1use serde_json::{Map, Value};
4
5use crate::limits::{DEFAULT_REMOTE_READ_TIMEOUT_MS, MAX_CACHE_TTL_SEC, MAX_DEADLINE_MS};
6use crate::observe::DisabledReason;
7
8#[derive(Debug, Clone, PartialEq, Default)]
10pub struct ShadowPolicy {
11 pub ramp: Option<f64>,
13 pub log_mismatches: Option<bool>,
18 pub mismatch_logging: Option<MismatchLogging>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub struct MismatchLogging {
27 pub value: Option<bool>,
29 pub diff: Option<MismatchDiff>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
37pub enum MismatchDiff {
38 #[default]
40 Off,
41 Paths,
43 Values,
45}
46
47impl MismatchDiff {
48 pub fn as_str(self) -> &'static str {
50 match self {
51 MismatchDiff::Off => "off",
52 MismatchDiff::Paths => "paths",
53 MismatchDiff::Values => "values",
54 }
55 }
56
57 fn from_json(value: &Value) -> Option<MismatchDiff> {
58 match value.as_str()? {
59 "off" => Some(MismatchDiff::Off),
60 "paths" => Some(MismatchDiff::Paths),
61 "values" => Some(MismatchDiff::Values),
62 _ => None,
63 }
64 }
65}
66
67impl std::fmt::Display for MismatchDiff {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.write_str(self.as_str())
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Default)]
76pub struct Policy {
77 pub request_local: Option<bool>,
79 pub coalesce: Option<bool>,
81 pub local_ttl_sec: Option<u64>,
83 pub remote_ttl_sec: Option<u64>,
85 pub local_ramp: Option<f64>,
87 pub remote_ramp: Option<f64>,
89 pub stale_on_error_max_age_sec: Option<u64>,
91 pub remote_read_timeout_ms: Option<u64>,
93 pub shadow: Option<ShadowPolicy>,
95}
96
97impl Policy {
98 pub fn enabled(ttl_sec: u64) -> Self {
100 Policy {
101 local_ttl_sec: Some(ttl_sec),
102 remote_ttl_sec: Some(ttl_sec),
103 local_ramp: Some(100.0),
104 remote_ramp: Some(100.0),
105 ..Policy::default()
106 }
107 }
108
109 pub fn disabled() -> Self {
113 Policy {
114 request_local: Some(false),
115 stale_on_error_max_age_sec: Some(0),
116 local_ramp: Some(0.0),
117 remote_ramp: Some(0.0),
118 shadow: Some(ShadowPolicy {
119 ramp: Some(0.0),
120 log_mismatches: None,
121 mismatch_logging: Some(MismatchLogging {
122 value: Some(false),
123 diff: Some(MismatchDiff::Off),
124 }),
125 }),
126 ..Policy::default()
127 }
128 }
129
130 pub fn request_local(mut self, enabled: bool) -> Self {
132 self.request_local = Some(enabled);
133 self
134 }
135
136 pub fn coalesce(mut self, enabled: bool) -> Self {
138 self.coalesce = Some(enabled);
139 self
140 }
141
142 pub fn local_ttl_sec(mut self, ttl_sec: u64) -> Self {
145 self.local_ttl_sec = Some(ttl_sec);
146 self
147 }
148
149 pub fn remote_ttl_sec(mut self, ttl_sec: u64) -> Self {
152 self.remote_ttl_sec = Some(ttl_sec);
153 self
154 }
155
156 pub fn local_ramp(mut self, ramp: f64) -> Self {
158 self.local_ramp = Some(ramp);
159 self
160 }
161
162 pub fn remote_ramp(mut self, ramp: f64) -> Self {
164 self.remote_ramp = Some(ramp);
165 self
166 }
167
168 pub fn stale_on_error_max_age_sec(mut self, seconds: u64) -> Self {
171 self.stale_on_error_max_age_sec = Some(seconds);
172 self
173 }
174
175 pub fn remote_read_timeout_ms(mut self, ms: u64) -> Self {
177 self.remote_read_timeout_ms = Some(ms);
178 self
179 }
180
181 pub fn shadow(mut self, shadow: ShadowPolicy) -> Self {
183 self.shadow = Some(shadow);
184 self
185 }
186
187 pub fn from_json(value: &Value) -> Result<Policy, PolicyError> {
195 let mut policy = Policy::default();
196 if value.is_null() {
197 return Ok(policy);
198 }
199 let config = policy_object(value, "defaultConfig")?;
200 reject_shadow_ramp(config)?;
201 if let Some(layers) = config.get("ttlSec") {
202 let layers = policy_object(layers, "ttlSec")?;
203 for (layer, slot) in [
204 ("local", &mut policy.local_ttl_sec),
205 ("remote", &mut policy.remote_ttl_sec),
206 ] {
207 if let Some(leaf) = layers.get(layer) {
208 let seconds = ttl_sec(leaf)
209 .ok_or_else(|| PolicyError(format!("invalid static ttlSec.{layer}")))?;
210 *slot = Some(seconds);
211 }
212 }
213 }
214 if let Some(layers) = config.get("ramp") {
215 let layers = policy_object(layers, "ramp")?;
216 for (layer, slot) in [
217 ("local", &mut policy.local_ramp),
218 ("remote", &mut policy.remote_ramp),
219 ] {
220 if let Some(leaf) = layers.get(layer) {
221 let ramp = finite_range(leaf, 0.0, 100.0, false)
222 .ok_or_else(|| PolicyError(format!("invalid static ramp.{layer}")))?;
223 *slot = Some(ramp);
224 }
225 }
226 }
227 for (field, slot) in [
228 ("requestLocal", &mut policy.request_local),
229 ("coalesce", &mut policy.coalesce),
230 ] {
231 if let Some(leaf) = config.get(field) {
232 let flag = leaf
233 .as_bool()
234 .ok_or_else(|| PolicyError(format!("{field} must be boolean")))?;
235 *slot = Some(flag);
236 }
237 }
238 if let Some(leaf) = config.get("staleOnErrorMaxAgeSec") {
239 let seconds = finite_range(leaf, 0.0, MAX_CACHE_TTL_SEC as f64, true)
240 .ok_or_else(|| PolicyError("invalid static staleOnErrorMaxAgeSec".to_string()))?;
241 policy.stale_on_error_max_age_sec = Some(seconds as u64);
242 }
243 if let Some(leaf) = config.get("remoteReadTimeoutMs") {
244 let ms = finite_range(leaf, 1.0, MAX_DEADLINE_MS as f64, true)
245 .ok_or_else(|| PolicyError("invalid remoteReadTimeoutMs".to_string()))?;
246 policy.remote_read_timeout_ms = Some(ms as u64);
247 }
248 if let Some(shadow) = config.get("shadow") {
249 let shadow = policy_object(shadow, "shadow")?;
250 let mut parsed = ShadowPolicy::default();
251 if let Some(leaf) = shadow.get("ramp") {
252 let ramp = finite_range(leaf, 0.0, 100.0, false)
253 .ok_or_else(|| PolicyError("invalid static shadow.ramp".to_string()))?;
254 parsed.ramp = Some(ramp);
255 }
256 if let Some(leaf) = shadow.get("logMismatches") {
257 let flag = leaf.as_bool().ok_or_else(|| {
258 PolicyError("shadow.logMismatches must be boolean".to_string())
259 })?;
260 parsed.log_mismatches = Some(flag);
261 }
262 if let Some(group) = shadow.get("mismatchLogging") {
263 let group = policy_object(group, "shadow mismatchLogging config")?;
264 let mut leaves = MismatchLogging::default();
265 if let Some(leaf) = group.get("value") {
266 let flag = leaf.as_bool().ok_or_else(|| {
267 PolicyError("shadow.mismatchLogging.value must be boolean".to_string())
268 })?;
269 leaves.value = Some(flag);
270 }
271 if let Some(leaf) = group.get("diff") {
272 let level = MismatchDiff::from_json(leaf).ok_or_else(|| {
273 PolicyError("invalid static shadow.mismatchLogging.diff".to_string())
274 })?;
275 leaves.diff = Some(level);
276 }
277 parsed.mismatch_logging = Some(leaves);
278 }
279 policy.shadow = Some(parsed);
280 }
281 policy.validate()?;
282 Ok(policy)
283 }
284
285 pub fn to_json(&self) -> Value {
291 let mut ttl = Map::new();
292 if let Some(seconds) = self.local_ttl_sec {
293 ttl.insert("local".to_string(), Value::from(seconds));
294 }
295 if let Some(seconds) = self.remote_ttl_sec {
296 ttl.insert("remote".to_string(), Value::from(seconds));
297 }
298 let mut ramp = Map::new();
299 if let Some(value) = self.local_ramp {
300 ramp.insert("local".to_string(), ramp_json(value));
301 }
302 if let Some(value) = self.remote_ramp {
303 ramp.insert("remote".to_string(), ramp_json(value));
304 }
305 let mut object = Map::new();
306 object.insert("ttlSec".to_string(), Value::Object(ttl));
307 object.insert("ramp".to_string(), Value::Object(ramp));
308 if let Some(enabled) = self.request_local {
309 object.insert("requestLocal".to_string(), Value::Bool(enabled));
310 }
311 if let Some(enabled) = self.coalesce {
312 object.insert("coalesce".to_string(), Value::Bool(enabled));
313 }
314 if let Some(seconds) = self.stale_on_error_max_age_sec {
315 object.insert("staleOnErrorMaxAgeSec".to_string(), Value::from(seconds));
316 }
317 if let Some(ms) = self.remote_read_timeout_ms {
318 object.insert("remoteReadTimeoutMs".to_string(), Value::from(ms));
319 }
320 if let Some(shadow) = &self.shadow {
321 let mut leaves = Map::new();
322 if let Some(value) = shadow.ramp {
323 leaves.insert("ramp".to_string(), ramp_json(value));
324 }
325 if let Some(flag) = shadow.log_mismatches {
326 leaves.insert("logMismatches".to_string(), Value::Bool(flag));
327 }
328 if let Some(group) = shadow.mismatch_logging {
329 let mut written = Map::new();
330 if let Some(flag) = group.value {
331 written.insert("value".to_string(), Value::Bool(flag));
332 }
333 if let Some(level) = group.diff {
334 written.insert("diff".to_string(), Value::from(level.as_str()));
335 }
336 leaves.insert("mismatchLogging".to_string(), Value::Object(written));
337 }
338 object.insert("shadow".to_string(), Value::Object(leaves));
339 }
340 Value::Object(object)
341 }
342
343 pub fn validate(&self) -> Result<(), PolicyError> {
345 for ttl in [self.local_ttl_sec, self.remote_ttl_sec]
346 .into_iter()
347 .flatten()
348 {
349 if !(1..=MAX_CACHE_TTL_SEC).contains(&ttl) {
350 return Err(PolicyError(
351 "static TTL must be whole seconds within 365 days".to_string(),
352 ));
353 }
354 }
355 for ramp in [self.local_ramp, self.remote_ramp].into_iter().flatten() {
356 if !ramp_in_domain(ramp) {
357 return Err(PolicyError(
358 "static ramp must be between zero and 100".to_string(),
359 ));
360 }
361 }
362 if let Some(age) = self.stale_on_error_max_age_sec {
363 let exceeds_remote = match self.remote_ttl_sec {
364 Some(remote) if remote > 0 => age > remote,
365 _ => false,
366 };
367 if age > MAX_CACHE_TTL_SEC || (age > 0 && !exceeds_remote) {
368 return Err(PolicyError(
369 "static recovery age must exceed a positive remote TTL".to_string(),
370 ));
371 }
372 }
373 if let Some(ms) = self.remote_read_timeout_ms {
374 if !(1..=MAX_DEADLINE_MS).contains(&ms) {
375 return Err(PolicyError("invalid remote read deadline".to_string()));
376 }
377 }
378 if let Some(ramp) = self.shadow.as_ref().and_then(|shadow| shadow.ramp) {
379 if !ramp_in_domain(ramp) {
380 return Err(PolicyError(
381 "static shadow ramp must be between zero and 100".to_string(),
382 ));
383 }
384 }
385 Ok(())
386 }
387}
388
389#[derive(Debug, Clone, PartialEq)]
396pub struct RuntimePolicy(pub Value);
397
398impl RuntimePolicy {
399 pub fn from_json(value: Value) -> Self {
402 RuntimePolicy(value)
403 }
404}
405
406impl From<Policy> for RuntimePolicy {
407 fn from(policy: Policy) -> Self {
408 RuntimePolicy(policy.to_json())
409 }
410}
411
412impl From<Value> for RuntimePolicy {
413 fn from(value: Value) -> Self {
414 RuntimePolicy(value)
415 }
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct PolicyDefaults {
421 pub remote_read_timeout_ms: u64,
425}
426
427impl Default for PolicyDefaults {
428 fn default() -> Self {
429 PolicyDefaults {
430 remote_read_timeout_ms: DEFAULT_REMOTE_READ_TIMEOUT_MS,
431 }
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
437#[error("{0}")]
438pub struct PolicyError(pub String);
439
440#[derive(Debug, Clone, Copy, PartialEq)]
442pub struct ResolvedLayer {
443 pub enabled: bool,
445 pub reason: Option<DisabledReason>,
447 pub configured: bool,
449 pub ttl_ms: u64,
451 pub ramp: f64,
453}
454
455impl ResolvedLayer {
456 fn disabled(reason: DisabledReason) -> Self {
457 ResolvedLayer {
458 enabled: false,
459 reason: Some(reason),
460 configured: false,
461 ttl_ms: 0,
462 ramp: 0.0,
463 }
464 }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Default)]
469pub struct ResolvedShadow {
470 pub enabled: bool,
472 pub ramp: f64,
474 pub mismatch_value: bool,
478 pub mismatch_diff: MismatchDiff,
482 pub config_error: bool,
487 pub logging_config_error: bool,
491}
492
493impl ResolvedShadow {
494 pub fn logs_mismatches(&self) -> bool {
497 self.mismatch_value || self.mismatch_diff != MismatchDiff::Off
498 }
499}
500
501#[derive(Debug, Clone, PartialEq)]
503pub struct ResolvedPolicy {
504 pub request_local: bool,
506 pub coalesce: bool,
508 pub local: ResolvedLayer,
510 pub remote: ResolvedLayer,
512 pub remote_read_timeout_ms: u64,
515 pub stale_on_error_max_age_ms: u64,
517 pub stale_on_error_config_error: bool,
521 pub shadow: ResolvedShadow,
523}
524
525pub fn resolve_policy(
531 base: &Policy,
532 overlay: Option<&RuntimePolicy>,
533 logical_key: &str,
534 defaults: PolicyDefaults,
535) -> Result<ResolvedPolicy, PolicyError> {
536 let mut remote_read_timeout_ms = defaults.remote_read_timeout_ms;
537 if remote_read_timeout_ms == 0 {
538 remote_read_timeout_ms = DEFAULT_REMOTE_READ_TIMEOUT_MS;
539 }
540 if remote_read_timeout_ms > MAX_DEADLINE_MS {
541 return Err(PolicyError("invalid instance read deadline".to_string()));
542 }
543 base.validate()?;
544 let mut merged = match base.to_json() {
545 Value::Object(object) => object,
546 _ => unreachable!("Policy::to_json always produces an object"),
547 };
548 merged.entry("requestLocal").or_insert(Value::Bool(false));
551 merged.entry("coalesce").or_insert(Value::Bool(true));
552 if let Some(RuntimePolicy(overlay)) = overlay {
553 if !overlay.is_null() {
554 merge_overlay(&mut merged, overlay)?;
555 }
556 }
557
558 let request_local = merged
559 .get("requestLocal")
560 .and_then(Value::as_bool)
561 .ok_or_else(|| PolicyError("runtime requestLocal must be boolean".to_string()))?;
562 let coalesce = merged
563 .get("coalesce")
564 .and_then(Value::as_bool)
565 .ok_or_else(|| PolicyError("runtime coalesce must be boolean".to_string()))?;
566 if let Some(leaf) = merged.get("remoteReadTimeoutMs") {
567 let ms = finite_range(leaf, 1.0, MAX_DEADLINE_MS as f64, true)
568 .ok_or_else(|| PolicyError("invalid runtime remoteReadTimeoutMs".to_string()))?;
569 remote_read_timeout_ms = ms as u64;
570 }
571
572 let empty = Map::new();
573 let ttls = merged
574 .get("ttlSec")
575 .and_then(Value::as_object)
576 .unwrap_or(&empty);
577 let ramps = merged
578 .get("ramp")
579 .and_then(Value::as_object)
580 .unwrap_or(&empty);
581 let local = resolve_layer(ttls, ramps, logical_key, "local");
582 let remote = resolve_layer(ttls, ramps, logical_key, "remote");
583
584 let mut stale_on_error_max_age_ms = 0;
585 let mut stale_on_error_config_error = false;
586 if let Some(age) = merged.get("staleOnErrorMaxAgeSec") {
587 let numeric_zero = policy_number(age) == Some(0.0);
588 if !remote.configured {
589 stale_on_error_config_error =
590 remote.reason == Some(DisabledReason::PolicyDisabled) && !numeric_zero;
591 } else if !numeric_zero {
592 match ttl_sec(age).map(|seconds| seconds * 1_000) {
593 Some(ms) if ms > remote.ttl_ms => stale_on_error_max_age_ms = ms,
594 _ => stale_on_error_config_error = true,
595 }
596 }
597 }
598
599 let mut shadow = ResolvedShadow::default();
600 if let Some(leaves) = merged.get("shadow").and_then(Value::as_object) {
601 if let Some(leaf) = leaves.get("ramp") {
602 match finite_range(leaf, 0.0, 100.0, false) {
603 Some(ramp) => {
604 shadow.ramp = ramp;
605 shadow.enabled = admits(ramp, logical_key, "shadow");
606 }
607 None => shadow.config_error = true,
608 }
609 }
610 let logging = resolve_mismatch_logging(leaves);
611 shadow.mismatch_value = logging.value;
612 shadow.mismatch_diff = logging.diff;
613 shadow.logging_config_error = logging.invalid;
614 }
615
616 Ok(ResolvedPolicy {
617 request_local,
618 coalesce,
619 local,
620 remote,
621 remote_read_timeout_ms,
622 stale_on_error_max_age_ms,
623 stale_on_error_config_error,
624 shadow,
625 })
626}
627
628fn merge_overlay(merged: &mut Map<String, Value>, overlay: &Value) -> Result<(), PolicyError> {
632 let overlay = policy_object(overlay, "runtime config")?;
633 reject_shadow_ramp(overlay)?;
634 for (name, leaves) in [
635 ("ttlSec", &["local", "remote"][..]),
636 ("ramp", &["local", "remote"][..]),
637 ] {
638 let Some(incoming) = overlay.get(name) else {
639 continue;
640 };
641 let incoming = policy_object(incoming, name)?;
642 let mut output = match merged.get(name) {
643 Some(Value::Object(existing)) => existing.clone(),
644 _ => Map::new(),
645 };
646 for leaf in leaves {
647 if let Some(value) = incoming.get(*leaf) {
648 output.insert((*leaf).to_string(), value.clone());
649 }
650 }
651 merged.insert(name.to_string(), Value::Object(output));
652 }
653 if let Some(incoming) = overlay.get("shadow") {
654 let incoming = policy_object(incoming, "shadow")?;
655 let inherited = match merged.get("shadow") {
656 Some(Value::Object(existing)) => existing.clone(),
657 _ => Map::new(),
658 };
659 merged.insert(
660 "shadow".to_string(),
661 Value::Object(merge_shadow(&inherited, incoming)?),
662 );
663 }
664 for name in [
665 "requestLocal",
666 "coalesce",
667 "staleOnErrorMaxAgeSec",
668 "remoteReadTimeoutMs",
669 ] {
670 if let Some(value) = overlay.get(name) {
671 merged.insert(name.to_string(), value.clone());
672 }
673 }
674 Ok(())
675}
676
677fn merge_shadow(
683 inherited: &Map<String, Value>,
684 incoming: &Map<String, Value>,
685) -> Result<Map<String, Value>, PolicyError> {
686 let mut output = Map::new();
687 if let Some(ramp) = incoming.get("ramp").or_else(|| inherited.get("ramp")) {
688 output.insert("ramp".to_string(), ramp.clone());
689 }
690 if let Some(group) = incoming.get("mismatchLogging") {
691 policy_object(group, "shadow mismatchLogging config")?;
692 }
693 let base = expand_mismatch_logging(inherited);
694 let written = expand_mismatch_logging(incoming);
695 if written.invalid {
699 if let Some(shorthand) = incoming.get("logMismatches") {
700 output.insert("logMismatches".to_string(), shorthand.clone());
701 }
702 }
703 let mut group = Map::new();
704 if let Some(value) = written.value.or(base.value) {
705 group.insert("value".to_string(), value);
706 }
707 if let Some(diff) = written.diff.or(base.diff) {
708 group.insert("diff".to_string(), diff);
709 }
710 if !group.is_empty() {
711 output.insert("mismatchLogging".to_string(), Value::Object(group));
712 }
713 Ok(output)
714}
715
716#[derive(Default)]
719struct MismatchLeaves {
720 value: Option<Value>,
721 diff: Option<Value>,
722 invalid: bool,
723}
724
725fn expand_mismatch_logging(shadow: &Map<String, Value>) -> MismatchLeaves {
731 let expanded = |value: bool, diff: MismatchDiff, invalid: bool| MismatchLeaves {
732 value: Some(Value::Bool(value)),
733 diff: Some(Value::from(diff.as_str())),
734 invalid,
735 };
736 let mut leaves = match shadow.get("logMismatches") {
737 None => MismatchLeaves::default(),
738 Some(Value::Bool(true)) => expanded(true, MismatchDiff::Values, false),
739 Some(Value::Bool(false)) => expanded(false, MismatchDiff::Off, false),
740 Some(_) => expanded(false, MismatchDiff::Off, true),
741 };
742 if let Some(group) = shadow.get("mismatchLogging").and_then(Value::as_object) {
743 if let Some(value) = group.get("value") {
744 leaves.value = Some(value.clone());
745 }
746 if let Some(diff) = group.get("diff") {
747 leaves.diff = Some(diff.clone());
748 }
749 }
750 leaves
751}
752
753struct MismatchLoggingResolution {
755 value: bool,
756 diff: MismatchDiff,
757 invalid: bool,
758}
759
760fn resolve_mismatch_logging(shadow: &Map<String, Value>) -> MismatchLoggingResolution {
764 let leaves = expand_mismatch_logging(shadow);
765 let value = match &leaves.value {
766 None => Some(false),
767 Some(leaf) => leaf.as_bool(),
768 };
769 let diff = match &leaves.diff {
770 None => Some(MismatchDiff::Off),
771 Some(leaf) => MismatchDiff::from_json(leaf),
772 };
773 MismatchLoggingResolution {
774 value: value.unwrap_or(false),
775 diff: diff.unwrap_or(MismatchDiff::Off),
776 invalid: leaves.invalid || value.is_none() || diff.is_none(),
777 }
778}
779
780fn resolve_layer(
781 ttls: &Map<String, Value>,
782 ramps: &Map<String, Value>,
783 logical_key: &str,
784 layer: &str,
785) -> ResolvedLayer {
786 let Some(ttl) = ttls.get(layer) else {
787 return ResolvedLayer::disabled(DisabledReason::PolicyDisabled);
788 };
789 let Some(ttl_ms) = ttl_sec(ttl).map(|seconds| seconds * 1_000) else {
790 return ResolvedLayer::disabled(DisabledReason::InvalidTtl);
791 };
792 let ramp = match ramps.get(layer) {
793 None => 100.0,
794 Some(leaf) => match finite_range(leaf, 0.0, 100.0, false) {
795 Some(ramp) => ramp,
796 None => return ResolvedLayer::disabled(DisabledReason::InvalidRamp),
797 },
798 };
799 let enabled = admits(ramp, logical_key, layer);
800 ResolvedLayer {
801 enabled,
802 reason: if enabled {
803 None
804 } else {
805 Some(DisabledReason::RampedDown)
806 },
807 configured: true,
808 ttl_ms,
809 ramp,
810 }
811}
812
813fn admits(ramp: f64, logical_key: &str, discriminator: &str) -> bool {
816 ramp >= 100.0 || (ramp > 0.0 && crate::identity::cohort(logical_key, discriminator) < ramp)
817}
818
819fn ramp_in_domain(ramp: f64) -> bool {
820 ramp.is_finite() && (0.0..=100.0).contains(&ramp)
821}
822
823fn ramp_json(ramp: f64) -> Value {
827 if ramp_in_domain(ramp) && ramp.trunc() == ramp {
828 Value::from(ramp as u64)
829 } else {
830 Value::from(ramp)
831 }
832}
833
834fn policy_number(value: &Value) -> Option<f64> {
836 value.as_number().and_then(serde_json::Number::as_f64)
837}
838
839fn finite_range(value: &Value, min: f64, max: f64, integer: bool) -> Option<f64> {
840 let n = policy_number(value)?;
841 (n.is_finite() && n >= min && n <= max && (!integer || n.trunc() == n)).then_some(n)
842}
843
844fn ttl_sec(value: &Value) -> Option<u64> {
846 finite_range(value, 1.0, MAX_CACHE_TTL_SEC as f64, true).map(|n| n as u64)
847}
848
849fn policy_object<'a>(value: &'a Value, name: &str) -> Result<&'a Map<String, Value>, PolicyError> {
850 value
851 .as_object()
852 .ok_or_else(|| PolicyError(format!("DialCache {name} must be an object")))
853}
854
855fn reject_shadow_ramp(object: &Map<String, Value>) -> Result<(), PolicyError> {
856 if object.contains_key("shadowRamp") {
857 return Err(PolicyError(
858 "shadowRamp was replaced by shadow.ramp".to_string(),
859 ));
860 }
861 Ok(())
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use serde_json::json;
868
869 const KEY: &str = "policy:item:one#lookup";
870 const MAX_TTL: u64 = MAX_CACHE_TTL_SEC;
871
872 fn resolve(base: &Policy, overlay: Option<Value>) -> Result<ResolvedPolicy, PolicyError> {
873 let overlay = overlay.map(RuntimePolicy);
874 resolve_policy(base, overlay.as_ref(), KEY, PolicyDefaults::default())
875 }
876
877 fn resolved(base: &Policy, overlay: Option<Value>) -> ResolvedPolicy {
878 resolve(base, overlay).expect("policy resolves")
879 }
880
881 #[test]
883 fn sparse_resolution_and_inheritance() {
884 let base = Policy::from_json(&json!({
885 "requestLocal": true, "coalesce": false,
886 "ttlSec": {"local": 1.0, "remote": 2.0},
887 "staleOnErrorMaxAgeSec": 5.0, "remoteReadTimeoutMs": 30.0,
888 "shadow": {"ramp": 100.0, "logMismatches": true},
889 }))
890 .unwrap();
891 let overlay = RuntimePolicy(json!({
892 "ttlSec": {"remote": 4.0},
893 "ramp": {"local": 0.0},
894 "shadow": {"logMismatches": false},
895 }));
896 let r = resolve_policy(
897 &base,
898 Some(&overlay),
899 KEY,
900 PolicyDefaults {
901 remote_read_timeout_ms: 20,
902 },
903 )
904 .unwrap();
905 assert!(r.request_local);
906 assert!(!r.coalesce);
907 assert!(!r.local.enabled);
908 assert_eq!(r.local.reason, Some(DisabledReason::RampedDown));
909 assert!(r.local.configured);
910 assert_eq!(r.local.ttl_ms, 1_000);
911 assert_eq!(r.local.ramp, 0.0);
912 assert!(r.remote.enabled);
913 assert_eq!(r.remote.reason, None);
914 assert_eq!(r.remote.ttl_ms, 4_000);
915 assert_eq!(r.remote.ramp, 100.0);
916 assert_eq!(r.stale_on_error_max_age_ms, 5_000);
917 assert!(!r.stale_on_error_config_error);
918 assert_eq!(r.remote_read_timeout_ms, 30);
919 assert!(r.shadow.enabled);
920 assert_eq!(r.shadow.ramp, 100.0);
921 assert!(!r.shadow.logs_mismatches());
922 assert!(!r.shadow.config_error);
923 assert!(!r.shadow.logging_config_error);
924
925 let snapshot = base.clone();
927 let mut mutated = base.clone();
928 mutated.remote_read_timeout_ms = Some(99);
929 mutated.shadow.as_mut().unwrap().ramp = Some(0.0);
930 assert_eq!(snapshot.remote_read_timeout_ms, Some(30));
931 assert_eq!(snapshot.shadow.as_ref().unwrap().ramp, Some(100.0));
932
933 for overlay in [None, Some(Value::Null)] {
935 let inherit = resolve_policy(
936 &snapshot,
937 overlay.map(RuntimePolicy).as_ref(),
938 KEY,
939 PolicyDefaults::default(),
940 )
941 .unwrap();
942 assert!(inherit.local.enabled);
943 assert!(inherit.remote.enabled);
944 assert_eq!(inherit.remote_read_timeout_ms, 30);
945 assert!(inherit.shadow.enabled);
946 assert!(inherit.shadow.mismatch_value);
947 assert_eq!(inherit.shadow.mismatch_diff, MismatchDiff::Values);
948 }
949 }
950
951 #[test]
953 fn static_validation_rejects_malformed_configs() {
954 let invalid = [
955 json!(true),
956 json!([]),
957 json!("policy"),
958 json!(1),
959 json!({"shadowRamp": 1}),
960 json!({"ttlSec": null}),
961 json!({"ramp": null}),
962 json!({"shadow": null}),
963 json!({"ttlSec": []}),
964 json!({"ttlSec": 60}),
965 json!({"shadow": true}),
966 json!({"requestLocal": null}),
967 json!({"coalesce": null}),
968 json!({"coalesce": "false"}),
969 json!({"requestLocal": 1}),
970 json!({"ttlSec": {"local": null}}),
971 json!({"ttlSec": {"local": 0}}),
972 json!({"ttlSec": {"local": -1}}),
973 json!({"ttlSec": {"local": 1.5}}),
974 json!({"ttlSec": {"local": "60"}}),
975 json!({"ttlSec": {"remote": 31_536_001}}),
976 json!({"ramp": {"remote": null}}),
977 json!({"ramp": {"remote": "nan"}}),
978 json!({"ramp": {"remote": -0.5}}),
979 json!({"ramp": {"local": 100.5}}),
980 json!({"ramp": {"local": true}}),
981 json!({"remoteReadTimeoutMs": null}),
982 json!({"remoteReadTimeoutMs": 0}),
983 json!({"remoteReadTimeoutMs": 1.5}),
984 json!({"remoteReadTimeoutMs": 2_147_483_648u64}),
985 json!({"staleOnErrorMaxAgeSec": null}),
986 json!({"staleOnErrorMaxAgeSec": -1}),
987 json!({"staleOnErrorMaxAgeSec": 2.5}),
988 json!({"staleOnErrorMaxAgeSec": 2}),
989 json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 2}),
990 json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 1}),
991 json!({"ttlSec": {"local": 2}, "staleOnErrorMaxAgeSec": 3}),
992 json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 31_536_001}),
993 json!({"shadow": {"ramp": null}}),
994 json!({"shadow": {"ramp": 101}}),
995 json!({"shadow": {"ramp": "50"}}),
996 json!({"shadow": {"logMismatches": null}}),
997 json!({"shadow": {"logMismatches": 1}}),
998 json!({"shadow": {"mismatchLogging": null}}),
999 json!({"shadow": {"mismatchLogging": []}}),
1000 json!({"shadow": {"mismatchLogging": true}}),
1001 json!({"shadow": {"mismatchLogging": {"value": null}}}),
1002 json!({"shadow": {"mismatchLogging": {"value": "true"}}}),
1003 json!({"shadow": {"mismatchLogging": {"diff": null}}}),
1004 json!({"shadow": {"mismatchLogging": {"diff": "full"}}}),
1005 json!({"shadow": {"mismatchLogging": {"diff": true}}}),
1006 json!({"shadow": {"mismatchLogging": {"diff": "Paths"}}}),
1007 ];
1008 for config in invalid {
1009 assert!(
1010 Policy::from_json(&config).is_err(),
1011 "accepted invalid static config: {config}"
1012 );
1013 }
1014
1015 let accepted = Policy::from_json(&json!({
1016 "ttlSec": {"remote": 31_536_000},
1017 "remoteReadTimeoutMs": 2_147_483_647u64,
1018 }))
1019 .unwrap();
1020 assert_eq!(accepted.remote_ttl_sec, Some(MAX_TTL));
1021 assert_eq!(accepted.remote_read_timeout_ms, Some(MAX_DEADLINE_MS));
1022 assert_eq!(accepted.local_ttl_sec, None);
1023
1024 assert!(Policy::from_json(&json!({"staleOnErrorMaxAgeSec": 0})).is_ok());
1026 assert!(
1027 Policy::from_json(&json!({"ttlSec": {"remote": 2}, "staleOnErrorMaxAgeSec": 3}))
1028 .is_ok()
1029 );
1030 let floats =
1032 Policy::from_json(&json!({"ttlSec": {"local": 60.0}, "ramp": {"local": 50}})).unwrap();
1033 assert_eq!(floats.local_ttl_sec, Some(60));
1034 assert_eq!(floats.local_ramp, Some(50.0));
1035 assert!(Policy::from_json(
1037 &json!({"ramp": {"local": 0, "remote": 100}, "shadow": {"ramp": 0}})
1038 )
1039 .is_ok());
1040
1041 assert!(Policy::default().local_ttl_sec(0).validate().is_err());
1043 assert!(Policy::default()
1044 .local_ttl_sec(MAX_TTL + 1)
1045 .validate()
1046 .is_err());
1047 assert!(Policy::default().remote_ttl_sec(MAX_TTL).validate().is_ok());
1048 assert!(Policy::default().local_ramp(f64::NAN).validate().is_err());
1049 assert!(Policy::default()
1050 .remote_ramp(f64::INFINITY)
1051 .validate()
1052 .is_err());
1053 assert!(Policy::default().remote_ramp(100.0001).validate().is_err());
1054 assert!(Policy::default().local_ramp(-0.1).validate().is_err());
1055 assert!(Policy::default()
1056 .remote_read_timeout_ms(0)
1057 .validate()
1058 .is_err());
1059 assert!(Policy::default()
1060 .remote_read_timeout_ms(MAX_DEADLINE_MS + 1)
1061 .validate()
1062 .is_err());
1063 assert!(Policy::default()
1064 .remote_read_timeout_ms(MAX_DEADLINE_MS)
1065 .validate()
1066 .is_ok());
1067 assert!(Policy::default()
1068 .stale_on_error_max_age_sec(1)
1069 .validate()
1070 .is_err());
1071 assert!(Policy::default()
1072 .stale_on_error_max_age_sec(0)
1073 .validate()
1074 .is_ok());
1075 assert!(Policy::default()
1076 .remote_ttl_sec(2)
1077 .stale_on_error_max_age_sec(2)
1078 .validate()
1079 .is_err());
1080 assert!(Policy::default()
1081 .remote_ttl_sec(2)
1082 .stale_on_error_max_age_sec(3)
1083 .validate()
1084 .is_ok());
1085 assert!(Policy::default()
1086 .remote_ttl_sec(2)
1087 .stale_on_error_max_age_sec(MAX_TTL + 1)
1088 .validate()
1089 .is_err());
1090 let nan_shadow = ShadowPolicy {
1091 ramp: Some(f64::NAN),
1092 ..ShadowPolicy::default()
1093 };
1094 assert!(Policy::default().shadow(nan_shadow).validate().is_err());
1095 let high_shadow = ShadowPolicy {
1096 ramp: Some(100.5),
1097 ..ShadowPolicy::default()
1098 };
1099 assert!(Policy::default().shadow(high_shadow).validate().is_err());
1100 let flag_only = ShadowPolicy {
1101 log_mismatches: Some(true),
1102 mismatch_logging: Some(MismatchLogging {
1103 value: Some(false),
1104 diff: Some(MismatchDiff::Paths),
1105 }),
1106 ..ShadowPolicy::default()
1107 };
1108 assert!(Policy::default().shadow(flag_only).validate().is_ok());
1109 assert!(Policy::enabled(60).validate().is_ok());
1110 assert!(Policy::disabled().validate().is_ok());
1111 }
1112
1113 #[test]
1114 fn from_json_treats_absent_and_null_wholes_as_empty() {
1115 assert_eq!(Policy::from_json(&Value::Null).unwrap(), Policy::default());
1116 assert_eq!(Policy::from_json(&json!({})).unwrap(), Policy::default());
1117 assert_eq!(
1119 Policy::from_json(&json!({"unknown": 1})).unwrap(),
1120 Policy::default()
1121 );
1122 let empty = Policy::from_json(&json!({"ttlSec": {}, "ramp": {}, "shadow": {}})).unwrap();
1124 assert_eq!(empty.local_ttl_sec, None);
1125 assert_eq!(empty.shadow, Some(ShadowPolicy::default()));
1126 }
1127
1128 #[test]
1129 fn from_json_round_trips_through_to_json() {
1130 let source = json!({
1131 "ttlSec": {"local": 5, "remote": 60},
1132 "ramp": {"local": 12.5, "remote": 100},
1133 "requestLocal": true,
1134 "coalesce": false,
1135 "staleOnErrorMaxAgeSec": 120,
1136 "remoteReadTimeoutMs": 25,
1137 "shadow": {
1138 "ramp": 3.5,
1139 "logMismatches": true,
1140 "mismatchLogging": {"value": false, "diff": "paths"},
1141 },
1142 });
1143 let policy = Policy::from_json(&source).unwrap();
1144 assert_eq!(
1145 policy,
1146 Policy {
1147 request_local: Some(true),
1148 coalesce: Some(false),
1149 local_ttl_sec: Some(5),
1150 remote_ttl_sec: Some(60),
1151 local_ramp: Some(12.5),
1152 remote_ramp: Some(100.0),
1153 stale_on_error_max_age_sec: Some(120),
1154 remote_read_timeout_ms: Some(25),
1155 shadow: Some(ShadowPolicy {
1156 ramp: Some(3.5),
1157 log_mismatches: Some(true),
1158 mismatch_logging: Some(MismatchLogging {
1159 value: Some(false),
1160 diff: Some(MismatchDiff::Paths),
1161 }),
1162 }),
1163 }
1164 );
1165 assert_eq!(policy.to_json(), source);
1166 assert_eq!(Policy::from_json(&policy.to_json()).unwrap(), policy);
1167 }
1168
1169 #[test]
1170 fn to_json_matches_the_static_policy_map_shape() {
1171 assert_eq!(
1173 Policy::default().to_json(),
1174 json!({"ttlSec": {}, "ramp": {}})
1175 );
1176 assert_eq!(
1178 Policy::default()
1179 .local_ttl_sec(7)
1180 .remote_ramp(40.0)
1181 .to_json(),
1182 json!({"ttlSec": {"local": 7}, "ramp": {"remote": 40}})
1183 );
1184 assert_eq!(
1185 Policy::default().local_ramp(12.5).to_json(),
1186 json!({"ttlSec": {}, "ramp": {"local": 12.5}})
1187 );
1188 assert_eq!(
1190 Policy::default()
1191 .request_local(false)
1192 .coalesce(true)
1193 .to_json(),
1194 json!({"ttlSec": {}, "ramp": {}, "requestLocal": false, "coalesce": true})
1195 );
1196 assert_eq!(
1198 Policy::default().shadow(ShadowPolicy::default()).to_json(),
1199 json!({"ttlSec": {}, "ramp": {}, "shadow": {}})
1200 );
1201 assert_eq!(
1202 Policy::default()
1203 .shadow(ShadowPolicy {
1204 log_mismatches: Some(false),
1205 ..ShadowPolicy::default()
1206 })
1207 .to_json(),
1208 json!({
1209 "ttlSec": {}, "ramp": {},
1210 "shadow": {"logMismatches": false},
1211 })
1212 );
1213 assert_eq!(
1215 Policy::default()
1216 .shadow(ShadowPolicy {
1217 mismatch_logging: Some(MismatchLogging {
1218 value: None,
1219 diff: Some(MismatchDiff::Values),
1220 }),
1221 ..ShadowPolicy::default()
1222 })
1223 .to_json(),
1224 json!({
1225 "ttlSec": {}, "ramp": {},
1226 "shadow": {"mismatchLogging": {"diff": "values"}},
1227 })
1228 );
1229 assert_eq!(
1231 Policy::default()
1232 .stale_on_error_max_age_sec(0)
1233 .remote_read_timeout_ms(75)
1234 .to_json(),
1235 json!({
1236 "ttlSec": {}, "ramp": {},
1237 "staleOnErrorMaxAgeSec": 0, "remoteReadTimeoutMs": 75,
1238 })
1239 );
1240 assert_eq!(
1241 Policy::enabled(60).to_json(),
1242 json!({
1243 "ttlSec": {"local": 60, "remote": 60},
1244 "ramp": {"local": 100, "remote": 100},
1245 })
1246 );
1247 assert_eq!(
1248 Policy::disabled().to_json(),
1249 json!({
1250 "ttlSec": {},
1251 "ramp": {"local": 0, "remote": 0},
1252 "requestLocal": false,
1253 "staleOnErrorMaxAgeSec": 0,
1254 "shadow": {"ramp": 0, "mismatchLogging": {"value": false, "diff": "off"}},
1255 })
1256 );
1257 }
1258
1259 #[test]
1260 fn enabled_and_disabled_presets_resolve_as_documented() {
1261 let enabled = resolved(&Policy::enabled(60), None);
1262 assert!(!enabled.request_local);
1263 assert!(enabled.coalesce);
1264 assert!(enabled.local.enabled && enabled.remote.enabled);
1265 assert_eq!(
1266 (enabled.local.ttl_ms, enabled.remote.ttl_ms),
1267 (60_000, 60_000)
1268 );
1269 assert_eq!((enabled.local.ramp, enabled.remote.ramp), (100.0, 100.0));
1270 assert_eq!(
1271 enabled.remote_read_timeout_ms,
1272 DEFAULT_REMOTE_READ_TIMEOUT_MS
1273 );
1274 assert_eq!(enabled.stale_on_error_max_age_ms, 0);
1275 assert!(!enabled.stale_on_error_config_error);
1276 assert_eq!(enabled.shadow, ResolvedShadow::default());
1277
1278 let alone = resolved(&Policy::disabled(), None);
1281 assert_eq!(alone.local.reason, Some(DisabledReason::PolicyDisabled));
1282 assert_eq!(alone.remote.reason, Some(DisabledReason::PolicyDisabled));
1283 assert!(!alone.stale_on_error_config_error);
1284 assert!(!alone.shadow.enabled && !alone.shadow.config_error);
1285 assert!(!alone.shadow.logs_mismatches() && !alone.shadow.logging_config_error);
1286
1287 let base = Policy::enabled(60)
1289 .request_local(true)
1290 .stale_on_error_max_age_sec(120)
1291 .shadow(ShadowPolicy {
1292 ramp: Some(100.0),
1293 log_mismatches: Some(true),
1294 mismatch_logging: Some(MismatchLogging {
1295 value: Some(true),
1296 diff: Some(MismatchDiff::Values),
1297 }),
1298 });
1299 let killed = resolved(&base, Some(RuntimePolicy::from(Policy::disabled()).0));
1300 assert!(!killed.request_local);
1301 assert!(killed.coalesce);
1302 for layer in [killed.local, killed.remote] {
1303 assert!(!layer.enabled);
1304 assert_eq!(layer.reason, Some(DisabledReason::RampedDown));
1305 assert!(layer.configured);
1306 assert_eq!(layer.ttl_ms, 60_000);
1307 assert_eq!(layer.ramp, 0.0);
1308 }
1309 assert_eq!(killed.stale_on_error_max_age_ms, 0);
1310 assert!(!killed.stale_on_error_config_error);
1311 assert_eq!(killed.shadow, ResolvedShadow::default());
1312 }
1313
1314 #[test]
1316 fn runtime_failure_scopes() {
1317 let base = Policy::default()
1318 .request_local(true)
1319 .local_ttl_sec(1)
1320 .remote_ttl_sec(2);
1321 for overlay in [
1322 json!(false),
1323 json!([]),
1324 json!("config"),
1325 json!(0),
1326 json!({"shadowRamp": 5}),
1327 json!({"ttlSec": null}),
1328 json!({"ttlSec": []}),
1329 json!({"ttlSec": 5}),
1330 json!({"ramp": null}),
1331 json!({"shadow": null}),
1332 json!({"shadow": "off"}),
1333 json!({"requestLocal": null}),
1334 json!({"requestLocal": "true"}),
1335 json!({"coalesce": 1}),
1336 json!({"coalesce": null}),
1337 json!({"remoteReadTimeoutMs": 0}),
1338 json!({"remoteReadTimeoutMs": null}),
1339 json!({"remoteReadTimeoutMs": 1.5}),
1340 json!({"remoteReadTimeoutMs": "30"}),
1341 json!({"remoteReadTimeoutMs": 2_147_483_648u64}),
1342 ] {
1343 assert!(
1344 resolve(&base, Some(overlay.clone())).is_err(),
1345 "accepted invalid invocation policy: {overlay}"
1346 );
1347 }
1348
1349 struct Case {
1350 overlay: Value,
1351 local: Option<DisabledReason>,
1352 remote: Option<DisabledReason>,
1353 recovery_error: bool,
1354 }
1355 let invalid_ttl = Some(DisabledReason::InvalidTtl);
1356 let invalid_ramp = Some(DisabledReason::InvalidRamp);
1357 let cases = [
1358 Case {
1359 overlay: json!({"ttlSec": {"local": null}}),
1360 local: invalid_ttl,
1361 remote: None,
1362 recovery_error: false,
1363 },
1364 Case {
1365 overlay: json!({"ttlSec": {"local": "1"}}),
1366 local: invalid_ttl,
1367 remote: None,
1368 recovery_error: false,
1369 },
1370 Case {
1371 overlay: json!({"ttlSec": {"local": 0}}),
1372 local: invalid_ttl,
1373 remote: None,
1374 recovery_error: false,
1375 },
1376 Case {
1377 overlay: json!({"ttlSec": {"local": 1.5}}),
1378 local: invalid_ttl,
1379 remote: None,
1380 recovery_error: false,
1381 },
1382 Case {
1383 overlay: json!({"ttlSec": {"local": 31_536_001}}),
1384 local: invalid_ttl,
1385 remote: None,
1386 recovery_error: false,
1387 },
1388 Case {
1389 overlay: json!({"ramp": {"remote": true}}),
1390 local: None,
1391 remote: invalid_ramp,
1392 recovery_error: false,
1393 },
1394 Case {
1395 overlay: json!({"ramp": {"remote": null}}),
1396 local: None,
1397 remote: invalid_ramp,
1398 recovery_error: false,
1399 },
1400 Case {
1401 overlay: json!({"ramp": {"remote": 100.1}}),
1402 local: None,
1403 remote: invalid_ramp,
1404 recovery_error: false,
1405 },
1406 Case {
1407 overlay: json!({"ramp": {"local": -1}}),
1408 local: invalid_ramp,
1409 remote: None,
1410 recovery_error: false,
1411 },
1412 Case {
1413 overlay: json!({"staleOnErrorMaxAgeSec": null}),
1414 local: None,
1415 remote: None,
1416 recovery_error: true,
1417 },
1418 Case {
1419 overlay: json!({"staleOnErrorMaxAgeSec": 1}),
1420 local: None,
1421 remote: None,
1422 recovery_error: true,
1423 },
1424 Case {
1425 overlay: json!({"staleOnErrorMaxAgeSec": 2}),
1426 local: None,
1427 remote: None,
1428 recovery_error: true,
1429 },
1430 Case {
1431 overlay: json!({"staleOnErrorMaxAgeSec": "3"}),
1432 local: None,
1433 remote: None,
1434 recovery_error: true,
1435 },
1436 Case {
1437 overlay: json!({"staleOnErrorMaxAgeSec": 2.5}),
1438 local: None,
1439 remote: None,
1440 recovery_error: true,
1441 },
1442 Case {
1443 overlay: json!({"staleOnErrorMaxAgeSec": 0}),
1444 local: None,
1445 remote: None,
1446 recovery_error: false,
1447 },
1448 Case {
1449 overlay: json!({"staleOnErrorMaxAgeSec": 0.0}),
1450 local: None,
1451 remote: None,
1452 recovery_error: false,
1453 },
1454 Case {
1455 overlay: json!({"staleOnErrorMaxAgeSec": 3}),
1456 local: None,
1457 remote: None,
1458 recovery_error: false,
1459 },
1460 Case {
1461 overlay: json!({"ttlSec": {"remote": -1}, "staleOnErrorMaxAgeSec": -1}),
1462 local: None,
1463 remote: invalid_ttl,
1464 recovery_error: false,
1465 },
1466 Case {
1467 overlay: json!({"ramp": {"remote": "bad"}, "staleOnErrorMaxAgeSec": 5}),
1468 local: None,
1469 remote: invalid_ramp,
1470 recovery_error: false,
1471 },
1472 ];
1473 for case in cases {
1474 let r = resolved(&base, Some(case.overlay.clone()));
1475 assert!(r.request_local, "{}", case.overlay);
1476 assert!(r.coalesce, "{}", case.overlay);
1477 assert_eq!(
1478 r.local.reason, case.local,
1479 "local reason for {}",
1480 case.overlay
1481 );
1482 assert_eq!(
1483 r.remote.reason, case.remote,
1484 "remote reason for {}",
1485 case.overlay
1486 );
1487 assert_eq!(r.local.enabled, case.local.is_none(), "{}", case.overlay);
1488 assert_eq!(r.remote.enabled, case.remote.is_none(), "{}", case.overlay);
1489 assert_eq!(
1490 r.stale_on_error_config_error, case.recovery_error,
1491 "recovery for {}",
1492 case.overlay
1493 );
1494 if case.recovery_error {
1495 assert_eq!(r.stale_on_error_max_age_ms, 0, "{}", case.overlay);
1496 }
1497 }
1498 assert_eq!(
1500 resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 3}))).stale_on_error_max_age_ms,
1501 3_000
1502 );
1503
1504 let missing = resolved(
1506 &Policy::default(),
1507 Some(json!({"staleOnErrorMaxAgeSec": 3})),
1508 );
1509 assert!(missing.stale_on_error_config_error);
1510 assert_eq!(missing.remote.reason, Some(DisabledReason::PolicyDisabled));
1511 assert!(!missing.remote.configured);
1512 assert_eq!(missing.stale_on_error_max_age_ms, 0);
1513 let zero = resolved(
1515 &Policy::default(),
1516 Some(json!({"staleOnErrorMaxAgeSec": 0})),
1517 );
1518 assert!(!zero.stale_on_error_config_error);
1519 let text = resolved(
1521 &Policy::default(),
1522 Some(json!({"staleOnErrorMaxAgeSec": "0"})),
1523 );
1524 assert!(text.stale_on_error_config_error);
1525 }
1526
1527 #[test]
1529 fn recovery_retention_and_shadow_diagnostics_remain_independent() {
1530 let base = Policy::default()
1531 .remote_ttl_sec(1)
1532 .remote_ramp(0.0)
1533 .stale_on_error_max_age_sec(86_400);
1534 let r = resolved(
1535 &base,
1536 Some(json!({"shadow": {"ramp": 100, "logMismatches": "invalid"}})),
1537 );
1538 assert!(!r.remote.enabled);
1539 assert!(r.remote.configured);
1540 assert_eq!(r.remote.reason, Some(DisabledReason::RampedDown));
1541 assert_eq!(r.stale_on_error_max_age_ms, 86_400_000);
1542 assert!(!r.stale_on_error_config_error);
1543 assert!(r.shadow.enabled);
1544 assert_eq!(r.shadow.ramp, 100.0);
1545 assert!(!r.shadow.config_error);
1546 assert!(r.shadow.logging_config_error);
1547 assert!(!r.shadow.logs_mismatches());
1548
1549 let r = resolved(&base, Some(json!({"shadow": {"ramp": null}})));
1550 assert!(r.shadow.config_error);
1551 assert!(!r.shadow.enabled);
1552 assert_eq!(r.shadow.ramp, 0.0);
1553 assert!(r.remote.configured);
1554 assert_eq!(r.stale_on_error_max_age_ms, 86_400_000);
1555
1556 for invalid in [json!("50"), json!(-1), json!(100.5), json!(true)] {
1557 let r = resolved(&base, Some(json!({"shadow": {"ramp": invalid}})));
1558 assert!(r.shadow.config_error, "{invalid}");
1559 assert!(!r.shadow.enabled, "{invalid}");
1560 }
1561 let off = resolved(&base, Some(json!({"shadow": {"ramp": 0}})));
1563 assert!(!off.shadow.enabled && !off.shadow.config_error);
1564 let logging = resolved(
1566 &base,
1567 Some(json!({"shadow": {"ramp": 100, "logMismatches": null}})),
1568 );
1569 assert!(
1570 logging.shadow.enabled
1571 && logging.shadow.logging_config_error
1572 && !logging.shadow.logs_mismatches()
1573 );
1574 }
1575
1576 #[test]
1577 fn overlay_semantics_follow_the_portable_table() {
1578 let base = Policy::enabled(10)
1579 .request_local(true)
1580 .coalesce(false)
1581 .stale_on_error_max_age_sec(30)
1582 .remote_read_timeout_ms(40)
1583 .shadow(ShadowPolicy {
1584 ramp: Some(100.0),
1585 log_mismatches: Some(true),
1586 mismatch_logging: None,
1587 });
1588
1589 let inherited = resolved(&base, None);
1591 assert_eq!(resolved(&base, Some(json!({}))), inherited);
1592 assert_eq!(
1593 resolved(&base, Some(json!({"ttlSec": {}, "ramp": {}, "shadow": {}}))),
1594 inherited
1595 );
1596 assert_eq!(resolved(&base, Some(Value::Null)), inherited);
1597 assert!(inherited.request_local && !inherited.coalesce);
1598 assert_eq!(inherited.remote_read_timeout_ms, 40);
1599 assert_eq!(inherited.stale_on_error_max_age_ms, 30_000);
1600 assert!(inherited.shadow.enabled && inherited.shadow.mismatch_value);
1601 assert_eq!(inherited.shadow.mismatch_diff, MismatchDiff::Values);
1602
1603 let patched = resolved(
1605 &base,
1606 Some(json!({"ttlSec": {"local": 3}, "ramp": {"remote": 0}})),
1607 );
1608 assert_eq!(patched.local.ttl_ms, 3_000);
1609 assert!(patched.local.enabled);
1610 assert_eq!(patched.remote.ttl_ms, 10_000);
1611 assert_eq!(patched.remote.reason, Some(DisabledReason::RampedDown));
1612 assert!(patched.remote.configured);
1613 assert_eq!(patched.stale_on_error_max_age_ms, 30_000);
1615
1616 let nulled = resolved(&base, Some(json!({"ttlSec": {"remote": null}})));
1618 assert!(nulled.local.enabled);
1619 assert_eq!(nulled.remote.reason, Some(DisabledReason::InvalidTtl));
1620 assert!(!nulled.remote.configured);
1621 assert!(!nulled.stale_on_error_config_error);
1623 assert_eq!(nulled.stale_on_error_max_age_ms, 0);
1624
1625 assert!(resolve(&base, Some(json!({"coalesce": "yes"}))).is_err());
1627 assert!(resolve(&base, Some(json!({"coalesce": null}))).is_err());
1628 assert!(resolve(&base, Some(json!({"requestLocal": 0}))).is_err());
1629 assert!(resolve(&base, Some(json!({"remoteReadTimeoutMs": -5}))).is_err());
1631 let flipped = resolved(
1633 &base,
1634 Some(json!({"coalesce": true, "requestLocal": false, "remoteReadTimeoutMs": 7})),
1635 );
1636 assert!(flipped.coalesce && !flipped.request_local);
1637 assert_eq!(flipped.remote_read_timeout_ms, 7);
1638
1639 let recovery = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 10})));
1641 assert!(recovery.remote.enabled);
1642 assert_eq!(recovery.stale_on_error_max_age_ms, 0);
1643 assert!(recovery.stale_on_error_config_error);
1644 let recovery = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": "30"})));
1645 assert!(recovery.remote.enabled && recovery.stale_on_error_config_error);
1646 let off = resolved(&base, Some(json!({"staleOnErrorMaxAgeSec": 0})));
1648 assert!(off.remote.enabled);
1649 assert_eq!(off.stale_on_error_max_age_ms, 0);
1650 assert!(!off.stale_on_error_config_error);
1651 let raised = resolved(&base, Some(json!({"ttlSec": {"remote": 30}})));
1653 assert!(raised.remote.enabled && raised.stale_on_error_config_error);
1654 assert_eq!(raised.stale_on_error_max_age_ms, 0);
1655
1656 let shadow = resolved(&base, Some(json!({"shadow": {"logMismatches": false}})));
1658 assert!(shadow.shadow.enabled && !shadow.shadow.logs_mismatches());
1659 let shadow = resolved(&base, Some(json!({"shadow": {"ramp": 0}})));
1660 assert!(
1661 !shadow.shadow.enabled
1662 && shadow.shadow.logs_mismatches()
1663 && !shadow.shadow.config_error
1664 );
1665 assert!(shadow.local.enabled && shadow.remote.enabled);
1666 }
1667
1668 #[test]
1669 fn runtime_leaves_can_configure_paths_the_operation_omitted() {
1670 let r = resolved(
1672 &Policy::default(),
1673 Some(json!({"ttlSec": {"local": 5, "remote": 6}})),
1674 );
1675 assert!(r.local.enabled && r.remote.enabled);
1676 assert_eq!((r.local.ramp, r.remote.ramp), (100.0, 100.0));
1677 assert_eq!((r.local.ttl_ms, r.remote.ttl_ms), (5_000, 6_000));
1678 let r = resolved(&Policy::default(), Some(json!({"ramp": {"local": 100}})));
1680 assert_eq!(r.local.reason, Some(DisabledReason::PolicyDisabled));
1681 assert!(!r.local.configured);
1682 let r = resolved(
1684 &Policy::default(),
1685 Some(
1686 json!({"ttlSec": {"remote": 6}, "staleOnErrorMaxAgeSec": 7, "shadow": {"ramp": 100}}),
1687 ),
1688 );
1689 assert_eq!(r.stale_on_error_max_age_ms, 7_000);
1690 assert!(r.shadow.enabled && !r.shadow.logs_mismatches());
1691 let overlay = RuntimePolicy::from(Policy::default().local_ttl_sec(9));
1693 let r = resolve_policy(
1694 &Policy::default(),
1695 Some(&overlay),
1696 KEY,
1697 PolicyDefaults::default(),
1698 )
1699 .unwrap();
1700 assert!(r.local.enabled);
1701 assert_eq!(r.local.ttl_ms, 9_000);
1702 assert_eq!(r.remote.reason, Some(DisabledReason::PolicyDisabled));
1703 }
1704
1705 #[test]
1706 fn typed_runtime_overlays_preserve_omitted_flags() {
1707 let base = Policy::default().request_local(true).coalesce(false);
1708 for overlay in [Policy::default(), Policy::default().local_ttl_sec(9)] {
1709 let r = resolve_policy(
1710 &base,
1711 Some(&RuntimePolicy::from(overlay)),
1712 KEY,
1713 PolicyDefaults::default(),
1714 )
1715 .unwrap();
1716 assert!(r.request_local, "an omitted flag must inherit the baseline");
1717 assert!(!r.coalesce, "an omitted flag must inherit the baseline");
1718 }
1719
1720 let explicit = resolved(
1721 &base,
1722 Some(RuntimePolicy::from(Policy::default().request_local(false).coalesce(true)).0),
1723 );
1724 assert!(!explicit.request_local);
1725 assert!(explicit.coalesce);
1726
1727 let disabled = resolved(&base, Some(RuntimePolicy::from(Policy::disabled()).0));
1728 assert!(!disabled.request_local);
1729 assert!(
1730 !disabled.coalesce,
1731 "the kill switch leaves coalescing unset"
1732 );
1733 }
1734
1735 #[test]
1736 fn json_round_trip_preserves_omitted_and_explicit_flags() {
1737 for request_local in [None, Some(false), Some(true)] {
1738 for coalesce in [None, Some(false), Some(true)] {
1739 let policy = Policy {
1740 request_local,
1741 coalesce,
1742 ..Policy::default()
1743 };
1744 assert_eq!(Policy::from_json(&policy.to_json()).unwrap(), policy);
1745 }
1746 }
1747 }
1748
1749 #[test]
1750 fn instance_defaults_and_deadline_precedence() {
1751 let base = Policy::enabled(1);
1752 let r = resolve_policy(
1754 &base,
1755 None,
1756 KEY,
1757 PolicyDefaults {
1758 remote_read_timeout_ms: 0,
1759 },
1760 )
1761 .unwrap();
1762 assert_eq!(r.remote_read_timeout_ms, DEFAULT_REMOTE_READ_TIMEOUT_MS);
1763 let r = resolve_policy(
1765 &base,
1766 None,
1767 KEY,
1768 PolicyDefaults {
1769 remote_read_timeout_ms: 20,
1770 },
1771 )
1772 .unwrap();
1773 assert_eq!(r.remote_read_timeout_ms, 20);
1774 let op = base.clone().remote_read_timeout_ms(30);
1776 let r = resolve_policy(
1777 &op,
1778 None,
1779 KEY,
1780 PolicyDefaults {
1781 remote_read_timeout_ms: 20,
1782 },
1783 )
1784 .unwrap();
1785 assert_eq!(r.remote_read_timeout_ms, 30);
1786 let overlay = RuntimePolicy(json!({"remoteReadTimeoutMs": 40}));
1787 let r = resolve_policy(
1788 &op,
1789 Some(&overlay),
1790 KEY,
1791 PolicyDefaults {
1792 remote_read_timeout_ms: 20,
1793 },
1794 )
1795 .unwrap();
1796 assert_eq!(r.remote_read_timeout_ms, 40);
1797 let err = resolve_policy(
1799 &base,
1800 None,
1801 KEY,
1802 PolicyDefaults {
1803 remote_read_timeout_ms: MAX_DEADLINE_MS + 1,
1804 },
1805 );
1806 assert_eq!(
1807 err.unwrap_err(),
1808 PolicyError("invalid instance read deadline".to_string())
1809 );
1810 assert!(resolve_policy(
1811 &base,
1812 None,
1813 KEY,
1814 PolicyDefaults {
1815 remote_read_timeout_ms: MAX_DEADLINE_MS
1816 }
1817 )
1818 .is_ok());
1819 let invalid = Policy::default().local_ttl_sec(0);
1821 assert!(resolve(&invalid, Some(json!({"ttlSec": {"local": 5}}))).is_err());
1822 }
1823
1824 #[test]
1825 fn error_messages_name_the_offending_container() {
1826 assert_eq!(
1827 Policy::from_json(&json!([])).unwrap_err(),
1828 PolicyError("DialCache defaultConfig must be an object".to_string())
1829 );
1830 assert_eq!(
1831 Policy::from_json(&json!({"ttlSec": null})).unwrap_err(),
1832 PolicyError("DialCache ttlSec must be an object".to_string())
1833 );
1834 assert_eq!(
1835 Policy::from_json(&json!({"shadowRamp": 1})).unwrap_err(),
1836 PolicyError("shadowRamp was replaced by shadow.ramp".to_string())
1837 );
1838 assert_eq!(
1839 resolve(&Policy::default(), Some(json!(1))).unwrap_err(),
1840 PolicyError("DialCache runtime config must be an object".to_string())
1841 );
1842 assert_eq!(
1843 resolve(&Policy::default(), Some(json!({"shadow": 1}))).unwrap_err(),
1844 PolicyError("DialCache shadow must be an object".to_string())
1845 );
1846 assert_eq!(
1847 resolve(&Policy::default(), Some(json!({"shadowRamp": null}))).unwrap_err(),
1848 PolicyError("shadowRamp was replaced by shadow.ramp".to_string())
1849 );
1850 assert_eq!(
1851 resolve(&Policy::default(), Some(json!({"coalesce": null}))).unwrap_err(),
1852 PolicyError("runtime coalesce must be boolean".to_string())
1853 );
1854 assert_eq!(
1855 resolve(&Policy::default(), Some(json!({"requestLocal": "x"}))).unwrap_err(),
1856 PolicyError("runtime requestLocal must be boolean".to_string())
1857 );
1858 assert_eq!(
1859 resolve(&Policy::default(), Some(json!({"remoteReadTimeoutMs": 0}))).unwrap_err(),
1860 PolicyError("invalid runtime remoteReadTimeoutMs".to_string())
1861 );
1862 }
1863
1864 fn shadow_logging(base: &Policy, overlay: Option<Value>) -> (bool, MismatchDiff, bool) {
1865 let shadow = resolved(base, overlay).shadow;
1866 (
1867 shadow.mismatch_value,
1868 shadow.mismatch_diff,
1869 shadow.logging_config_error,
1870 )
1871 }
1872
1873 fn shadow_base(log_mismatches: Option<bool>, group: Option<MismatchLogging>) -> Policy {
1874 Policy::default().shadow(ShadowPolicy {
1875 ramp: Some(100.0),
1876 log_mismatches,
1877 mismatch_logging: group,
1878 })
1879 }
1880
1881 #[test]
1882 fn mismatch_logging_resolves_the_recorded_four_row_table() {
1883 use MismatchDiff::{Off, Paths, Values};
1884 let leaves = |value, diff| Some(MismatchLogging { value, diff });
1885 let row = shadow_base(Some(true), leaves(Some(false), None));
1887 assert_eq!(shadow_logging(&row, None), (false, Values, false));
1888 assert_eq!(
1889 shadow_logging(
1890 &shadow_base(None, None),
1891 Some(
1892 json!({"shadow": {"logMismatches": true, "mismatchLogging": {"value": false}}})
1893 )
1894 ),
1895 (false, Values, false)
1896 );
1897 let row = shadow_base(Some(false), leaves(None, Some(Paths)));
1899 assert_eq!(shadow_logging(&row, None), (false, Paths, false));
1900 assert_eq!(
1901 shadow_logging(
1902 &shadow_base(None, None),
1903 Some(
1904 json!({"shadow": {"logMismatches": false, "mismatchLogging": {"diff": "paths"}}})
1905 )
1906 ),
1907 (false, Paths, false)
1908 );
1909 let paths = shadow_base(None, leaves(None, Some(Paths)));
1911 assert_eq!(
1912 shadow_logging(&paths, Some(json!({"shadow": {"logMismatches": false}}))),
1913 (false, Off, false)
1914 );
1915 let value_off = shadow_base(None, leaves(Some(false), None));
1916 assert_eq!(
1917 shadow_logging(&value_off, Some(json!({"shadow": {"logMismatches": true}}))),
1918 (true, Values, false)
1919 );
1920 }
1921
1922 #[test]
1923 fn mismatch_logging_leaves_merge_after_each_layer_expands() {
1924 use MismatchDiff::{Off, Paths, Values};
1925 let leaves = |value, diff| Some(MismatchLogging { value, diff });
1926 let base = shadow_base(None, leaves(Some(true), Some(Paths)));
1928 assert_eq!(
1929 shadow_logging(
1930 &base,
1931 Some(json!({"shadow": {"mismatchLogging": {"diff": "values"}}}))
1932 ),
1933 (true, Values, false)
1934 );
1935 assert_eq!(
1936 shadow_logging(
1937 &base,
1938 Some(json!({"shadow": {"mismatchLogging": {"value": false}}}))
1939 ),
1940 (false, Paths, false)
1941 );
1942 let shorthand = shadow_base(Some(true), None);
1944 assert_eq!(
1945 shadow_logging(
1946 &shorthand,
1947 Some(json!({"shadow": {"mismatchLogging": {"diff": "paths"}}}))
1948 ),
1949 (true, Paths, false)
1950 );
1951 for overlay in [
1953 json!({}),
1954 json!({"shadow": {}}),
1955 json!({"shadow": {"ramp": 50}}),
1956 ] {
1957 assert_eq!(
1958 shadow_logging(&shorthand, Some(overlay)),
1959 (true, Values, false)
1960 );
1961 }
1962 let all_on = shadow_base(Some(true), leaves(Some(true), Some(Values)));
1964 assert_eq!(
1965 shadow_logging(&all_on, Some(RuntimePolicy::from(Policy::disabled()).0)),
1966 (false, Off, false)
1967 );
1968 }
1969
1970 #[test]
1971 fn a_malformed_runtime_shorthand_stays_raw_for_one_config_resolution() {
1972 let inherited = shadow_base(
1973 None,
1974 Some(MismatchLogging {
1975 value: Some(false),
1976 diff: Some(MismatchDiff::Values),
1977 }),
1978 );
1979 let written = json!({"logMismatches": "yes", "mismatchLogging": {"value": true}});
1980 let base = match inherited.to_json() {
1981 Value::Object(object) => object["shadow"].as_object().unwrap().clone(),
1982 _ => unreachable!(),
1983 };
1984 let merged = merge_shadow(&base, written.as_object().unwrap()).unwrap();
1987 assert_eq!(
1988 Value::Object(merged),
1989 json!({
1990 "ramp": 100,
1991 "logMismatches": "yes",
1992 "mismatchLogging": {"value": true, "diff": "off"},
1993 })
1994 );
1995 assert_eq!(
1996 shadow_logging(&inherited, Some(json!({"shadow": written}))),
1997 (true, MismatchDiff::Off, true)
1998 );
1999 assert_eq!(
2001 shadow_logging(
2002 &inherited,
2003 Some(json!({"shadow": {
2004 "logMismatches": null,
2005 "mismatchLogging": {"value": "yes", "diff": "full"},
2006 }}))
2007 ),
2008 (false, MismatchDiff::Off, true)
2009 );
2010 }
2011
2012 #[test]
2013 fn mismatch_logging_resolves_each_leaf_independently() {
2014 use MismatchDiff::{Off, Paths, Values};
2015 let cases = [
2016 (json!({}), (false, Off, false)),
2017 (json!({"mismatchLogging": {}}), (false, Off, false)),
2018 (
2019 json!({"mismatchLogging": {"value": true, "diff": "paths"}}),
2020 (true, Paths, false),
2021 ),
2022 (
2023 json!({"mismatchLogging": {"value": "true", "diff": "paths"}}),
2024 (false, Paths, true),
2025 ),
2026 (
2027 json!({"mismatchLogging": {"value": null}}),
2028 (false, Off, true),
2029 ),
2030 (
2031 json!({"mismatchLogging": {"value": true, "diff": "full"}}),
2032 (true, Off, true),
2033 ),
2034 (json!({"mismatchLogging": {"diff": 1}}), (false, Off, true)),
2035 (
2036 json!({"mismatchLogging": {"diff": null}}),
2037 (false, Off, true),
2038 ),
2039 (
2040 json!({"mismatchLogging": {"diff": "toString"}}),
2041 (false, Off, true),
2042 ),
2043 (
2044 json!({"mismatchLogging": {"value": true, "diff": "constructor"}}),
2045 (true, Off, true),
2046 ),
2047 (
2048 json!({"mismatchLogging": {"diff": "Values"}}),
2049 (false, Off, true),
2050 ),
2051 (json!({"logMismatches": "yes"}), (false, Off, true)),
2052 (json!({"logMismatches": null}), (false, Off, true)),
2053 (json!({"logMismatches": 0}), (false, Off, true)),
2054 (
2055 json!({"logMismatches": "yes", "mismatchLogging": {"value": true, "diff": "values"}}),
2056 (true, Values, true),
2057 ),
2058 (
2060 json!({"mismatchLogging": {"diff": "paths", "unknown": 1}, "unknown": true}),
2061 (false, Paths, false),
2062 ),
2063 ];
2064 for (shadow, expected) in cases {
2065 assert_eq!(
2066 shadow_logging(&Policy::default(), Some(json!({"shadow": shadow.clone()}))),
2067 expected,
2068 "{shadow}"
2069 );
2070 }
2071 }
2072
2073 #[test]
2074 fn a_non_object_runtime_mismatch_logging_group_fails_resolution() {
2075 for group in [
2076 json!(null),
2077 json!([]),
2078 json!(1),
2079 json!("paths"),
2080 json!(true),
2081 ] {
2082 for base in [Policy::default(), shadow_base(Some(true), None)] {
2083 assert_eq!(
2084 resolve(
2085 &base,
2086 Some(json!({"shadow": {"logMismatches": true, "mismatchLogging": group.clone()}}))
2087 )
2088 .unwrap_err(),
2089 PolicyError("DialCache shadow mismatchLogging config must be an object".to_string()),
2090 "{group}"
2091 );
2092 }
2093 }
2094 }
2095
2096 #[test]
2099 fn every_combination_of_shorthand_and_leaves_resolves_as_each_layer_expanded_first() {
2100 fn layer_leaves(
2101 shorthand: Option<&Value>,
2102 group: Option<&Value>,
2103 ) -> (Option<Value>, Option<Value>) {
2104 let (mut value, mut diff) = (None, None);
2105 if let Some(shorthand) = shorthand {
2106 let on = shorthand == &Value::Bool(true);
2107 value = Some(Value::Bool(on));
2108 diff = Some(Value::from(if on { "values" } else { "off" }));
2109 }
2110 if let Some(group) = group.and_then(Value::as_object) {
2111 if let Some(leaf) = group.get("value") {
2112 value = Some(leaf.clone());
2113 }
2114 if let Some(leaf) = group.get("diff") {
2115 diff = Some(leaf.clone());
2116 }
2117 }
2118 (value, diff)
2119 }
2120 let static_shorthands = [None, Some(true), Some(false)];
2121 let static_groups = [
2122 None,
2123 Some((None, None)),
2124 Some((Some(true), None)),
2125 Some((Some(false), None)),
2126 Some((None, Some(MismatchDiff::Paths))),
2127 Some((None, Some(MismatchDiff::Off))),
2128 Some((Some(true), Some(MismatchDiff::Values))),
2129 ];
2130 let runtime_shorthands = [
2131 None,
2132 Some(json!(true)),
2133 Some(json!(false)),
2134 Some(json!("yes")),
2135 Some(json!(null)),
2136 Some(json!(0)),
2137 ];
2138 let runtime_groups = [
2139 None,
2140 Some(json!({})),
2141 Some(json!({"value": true})),
2142 Some(json!({"value": false})),
2143 Some(json!({"diff": "paths"})),
2144 Some(json!({"diff": "values"})),
2145 Some(json!({"value": true, "diff": "off"})),
2146 Some(json!({"value": "yes"})),
2147 Some(json!({"value": null})),
2148 Some(json!({"diff": "full"})),
2149 Some(json!({"diff": "toString"})),
2150 ];
2151 let mut combinations = 0;
2152 for static_shorthand in static_shorthands {
2153 for static_group in static_groups {
2154 let base = shadow_base(
2155 static_shorthand,
2156 static_group.map(|(value, diff)| MismatchLogging { value, diff }),
2157 );
2158 let static_shadow = base.to_json()["shadow"].clone();
2159 let inherited = layer_leaves(
2160 static_shadow.get("logMismatches"),
2161 static_shadow.get("mismatchLogging"),
2162 );
2163 for runtime_shorthand in &runtime_shorthands {
2164 for runtime_group in &runtime_groups {
2165 let mut shadow = Map::new();
2166 if let Some(shorthand) = runtime_shorthand {
2167 shadow.insert("logMismatches".to_string(), shorthand.clone());
2168 }
2169 if let Some(group) = runtime_group {
2170 shadow.insert("mismatchLogging".to_string(), group.clone());
2171 }
2172 let written =
2173 layer_leaves(runtime_shorthand.as_ref(), runtime_group.as_ref());
2174 let value = written.0.or(inherited.0.clone());
2175 let diff = written.1.or(inherited.1.clone());
2176 let valid_value = value.as_ref().is_none_or(Value::is_boolean);
2177 let level = diff.as_ref().and_then(MismatchDiff::from_json);
2178 let valid_diff = diff.is_none() || level.is_some();
2179 let malformed = runtime_shorthand.as_ref().is_some_and(|s| !s.is_boolean());
2180 let expected = (
2181 valid_value && value == Some(Value::Bool(true)),
2182 level.unwrap_or(MismatchDiff::Off),
2183 malformed || !valid_value || !valid_diff,
2184 );
2185 let overlay = json!({"shadow": Value::Object(shadow)});
2186 assert_eq!(
2187 shadow_logging(&base, Some(overlay.clone())),
2188 expected,
2189 "static {static_shadow} runtime {overlay}"
2190 );
2191 combinations += 1;
2192 }
2193 }
2194 }
2195 }
2196 assert_eq!(combinations, 3 * 7 * 6 * 11);
2197 }
2198}