1use crate::engine::message::{AuditTrail, Change, Message};
14use crate::engine::utils::strip_hash_prefix;
15use chrono::{DateTime, Utc};
16use datavalue::OwnedDataValue;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::sync::Arc;
20
21const NODE_SIZE: usize = std::mem::size_of::<usize>();
24
25#[inline]
28fn is_false(b: &bool) -> bool {
29 !*b
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum StepResult {
41 Executed,
43 Skipped,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AuditTrailScope {
55 #[default]
58 Full,
59 Own,
63 None,
65}
66
67#[derive(Clone, Debug)]
78pub struct TraceOptions {
79 pub snapshots: bool,
85
86 pub mapping_contexts: bool,
90
91 pub changes: bool,
102
103 pub max_snapshot_bytes: usize,
113
114 pub redact_paths: Vec<String>,
131
132 pub snapshot_audit_trail: AuditTrailScope,
135}
136
137impl Default for TraceOptions {
138 fn default() -> Self {
139 Self {
140 snapshots: true,
141 mapping_contexts: true,
142 changes: false,
143 max_snapshot_bytes: 0,
144 redact_paths: Vec::new(),
145 snapshot_audit_trail: AuditTrailScope::Full,
146 }
147 }
148}
149
150impl TraceOptions {
151 pub fn timings_only() -> Self {
158 Self {
159 snapshots: false,
160 mapping_contexts: false,
161 changes: true,
162 ..Default::default()
163 }
164 }
165
166 fn redact_segments(&self) -> Vec<Vec<String>> {
172 self.redact_paths
173 .iter()
174 .filter(|p| !p.is_empty())
175 .map(|p| p.split('.').map(str::to_string).collect())
176 .collect()
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
186#[non_exhaustive]
187pub struct ExecutionStep {
188 pub workflow_id: String,
190 pub task_id: Option<String>,
192 pub result: StepResult,
194 #[serde(skip_serializing_if = "Option::is_none")]
198 pub message: Option<Message>,
199 #[serde(skip_serializing_if = "Option::is_none")]
202 pub mapping_contexts: Option<Vec<Value>>,
203 #[serde(skip_serializing_if = "Option::is_none")]
208 pub started_at: Option<DateTime<Utc>>,
209 #[serde(skip_serializing_if = "Option::is_none")]
214 pub duration_us: Option<u64>,
215 #[serde(skip_serializing_if = "Option::is_none")]
219 pub changes: Option<Vec<Change>>,
220}
221
222impl ExecutionStep {
223 pub fn executed(workflow_id: &str, task_id: &str, message: &Message) -> Self {
225 Self {
226 workflow_id: workflow_id.to_string(),
227 task_id: Some(task_id.to_string()),
228 result: StepResult::Executed,
229 message: Some(message.clone()),
230 mapping_contexts: None,
231 started_at: None,
232 duration_us: None,
233 changes: None,
234 }
235 }
236
237 pub fn task_skipped(workflow_id: &str, task_id: &str) -> Self {
239 Self {
240 workflow_id: workflow_id.to_string(),
241 task_id: Some(task_id.to_string()),
242 result: StepResult::Skipped,
243 message: None,
244 mapping_contexts: None,
245 started_at: None,
246 duration_us: None,
247 changes: None,
248 }
249 }
250
251 pub fn workflow_skipped(workflow_id: &str) -> Self {
253 Self {
254 workflow_id: workflow_id.to_string(),
255 task_id: None,
256 result: StepResult::Skipped,
257 message: None,
258 mapping_contexts: None,
259 started_at: None,
260 duration_us: None,
261 changes: None,
262 }
263 }
264
265 pub fn with_mapping_contexts(mut self, contexts: Vec<Value>) -> Self {
267 self.mapping_contexts = Some(contexts);
268 self
269 }
270
271 pub fn with_timing(mut self, started_at: DateTime<Utc>, duration_us: u64) -> Self {
273 self.started_at = Some(started_at);
274 self.duration_us = Some(duration_us);
275 self
276 }
277
278 pub fn with_changes(mut self, changes: Vec<Change>) -> Self {
280 self.changes = Some(changes);
281 self
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
290#[non_exhaustive]
291pub struct ExecutionTrace {
292 pub steps: Vec<ExecutionStep>,
294
295 #[serde(default, skip_serializing_if = "is_false")]
298 truncated: bool,
299
300 #[serde(skip)]
304 options: TraceOptions,
305
306 #[serde(skip)]
308 redact_segments: Vec<Vec<String>>,
309
310 #[serde(skip)]
312 snapshot_bytes: usize,
313}
314
315impl ExecutionTrace {
316 pub fn new() -> Self {
318 Self::with_options(TraceOptions::default())
319 }
320
321 pub fn with_options(options: TraceOptions) -> Self {
323 Self {
324 steps: Vec::new(),
325 truncated: false,
326 redact_segments: options.redact_segments(),
327 options,
328 snapshot_bytes: 0,
329 }
330 }
331
332 pub fn options(&self) -> &TraceOptions {
334 &self.options
335 }
336
337 pub fn truncated(&self) -> bool {
346 self.truncated
347 }
348
349 pub fn add_step(&mut self, step: ExecutionStep) {
351 self.steps.push(step);
352 }
353
354 pub(crate) fn add_executed_step(
360 &mut self,
361 workflow_id: &str,
362 task_id: &str,
363 message: &Message,
364 started_at: DateTime<Utc>,
365 duration_us: u64,
366 mapping_contexts: Option<Vec<Value>>,
367 ) {
368 let mut step = ExecutionStep {
369 workflow_id: workflow_id.to_string(),
370 task_id: Some(task_id.to_string()),
371 result: StepResult::Executed,
372 message: None,
373 mapping_contexts: None,
374 started_at: Some(started_at),
375 duration_us: Some(duration_us),
376 changes: if self.options.changes {
377 Some(
383 own_audit_entry(message, workflow_id, task_id)
384 .map(|e| e.changes.clone())
385 .unwrap_or_default(),
386 )
387 } else {
388 None
389 },
390 };
391
392 if self.options.snapshots {
393 let projected = self.projected_snapshot_size(message, workflow_id, task_id);
397 if self.would_exceed(projected) {
398 self.truncated = true;
399 } else {
400 self.snapshot_bytes += projected;
401 step.message = Some(self.build_snapshot(message, workflow_id, task_id));
402 }
403 }
404
405 if self.options.mapping_contexts {
406 if let Some(mut contexts) = mapping_contexts {
407 for ctx in &mut contexts {
410 redact_json_in_place(ctx, &self.redact_segments);
411 }
412 let size: usize = contexts.iter().map(approx_json_size).sum();
413 if self.would_exceed(size) {
414 self.truncated = true;
415 } else {
416 self.snapshot_bytes += size;
417 step.mapping_contexts = Some(contexts);
418 }
419 }
420 }
421
422 self.steps.push(step);
423 }
424
425 #[inline]
427 fn would_exceed(&self, additional: usize) -> bool {
428 self.options.max_snapshot_bytes != 0
429 && self.snapshot_bytes + additional > self.options.max_snapshot_bytes
430 }
431
432 fn projected_snapshot_size(
438 &self,
439 message: &Message,
440 workflow_id: &str,
441 task_id: &str,
442 ) -> usize {
443 let mut size = redacted_size(&message.context, &self.redact_segments);
444 for entry in self.scoped_audit_trail(message, workflow_id, task_id) {
445 size += NODE_SIZE;
446 for change in &entry.changes {
447 size += change.path.len()
448 + approx_owned_size(&change.old_value)
449 + approx_owned_size(&change.new_value);
450 }
451 }
452 size
453 }
454
455 fn scoped_audit_trail<'m>(
458 &self,
459 message: &'m Message,
460 workflow_id: &str,
461 task_id: &str,
462 ) -> Vec<&'m AuditTrail> {
463 match self.options.snapshot_audit_trail {
464 AuditTrailScope::Full => message.audit_trail.iter().collect(),
465 AuditTrailScope::Own => own_audit_entry(message, workflow_id, task_id)
466 .map(|e| vec![e])
467 .unwrap_or_default(),
468 AuditTrailScope::None => Vec::new(),
469 }
470 }
471
472 fn build_snapshot(&self, message: &Message, workflow_id: &str, task_id: &str) -> Message {
479 let (context, _) = redacting_clone(&message.context, &self.redact_segments);
480 let audit_trail: Vec<AuditTrail> = self
481 .scoped_audit_trail(message, workflow_id, task_id)
482 .into_iter()
483 .cloned()
484 .collect();
485
486 Message {
487 id: message.id.clone(),
488 payload: Arc::clone(&message.payload),
489 context,
490 audit_trail,
491 errors: message.errors.clone(),
492 capture_changes: message.capture_changes,
493 routing_bucket: message.routing_bucket,
494 }
495 }
496
497 pub fn final_message(&self) -> Option<&Message> {
502 self.steps
503 .iter()
504 .rev()
505 .find(|s| s.result == StepResult::Executed)
506 .and_then(|s| s.message.as_ref())
507 }
508
509 pub fn is_success(&self) -> bool {
515 self.final_message()
516 .map(|m| m.errors.is_empty())
517 .unwrap_or(true)
518 }
519
520 pub fn executed_count(&self) -> usize {
522 self.steps
523 .iter()
524 .filter(|s| s.result == StepResult::Executed)
525 .count()
526 }
527
528 pub fn skipped_count(&self) -> usize {
530 self.steps
531 .iter()
532 .filter(|s| s.result == StepResult::Skipped)
533 .count()
534 }
535}
536
537impl Default for ExecutionTrace {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543#[inline]
554fn own_audit_entry<'m>(
555 message: &'m Message,
556 workflow_id: &str,
557 task_id: &str,
558) -> Option<&'m AuditTrail> {
559 match message.audit_trail.last() {
560 Some(entry)
561 if entry.task_id.as_ref() == task_id && entry.workflow_id.as_ref() == workflow_id =>
562 {
563 Some(entry)
564 }
565 _ => None,
566 }
567}
568
569#[inline]
574pub(crate) fn duration_us_between(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
575 (end - start)
576 .num_microseconds()
577 .unwrap_or(0)
578 .max(0)
579 .try_into()
580 .unwrap_or(0)
581}
582
583fn redacting_clone(value: &OwnedDataValue, paths: &[Vec<String>]) -> (OwnedDataValue, usize) {
591 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
592 redacting_clone_inner(value, &refs)
593}
594
595fn narrow_for_object_key<'a>(paths: &[&'a [String]], key: &str) -> Vec<&'a [String]> {
601 paths
602 .iter()
603 .filter(|p| strip_hash_prefix(&p[0]) == key)
604 .map(|p| &p[1..])
605 .collect()
606}
607
608fn narrow_for_array_index<'a>(paths: &[&'a [String]], idx: usize) -> Vec<&'a [String]> {
611 paths
612 .iter()
613 .filter(|p| p[0].parse::<usize>() == Ok(idx))
614 .map(|p| &p[1..])
615 .collect()
616}
617
618fn redacting_clone_inner(value: &OwnedDataValue, paths: &[&[String]]) -> (OwnedDataValue, usize) {
619 if paths.iter().any(|p| p.is_empty()) {
621 return (OwnedDataValue::Null, NODE_SIZE);
622 }
623
624 match value {
625 OwnedDataValue::Object(pairs) => {
626 let mut out = Vec::with_capacity(pairs.len());
627 let mut size = NODE_SIZE;
628 for (key, child) in pairs {
629 let sub = narrow_for_object_key(paths, key);
630 let (cloned, child_size) = redacting_clone_inner(child, &sub);
631 size += key.len() + child_size;
632 out.push((key.clone(), cloned));
633 }
634 (OwnedDataValue::Object(out), size)
635 }
636 OwnedDataValue::Array(items) => {
637 let mut out = Vec::with_capacity(items.len());
638 let mut size = NODE_SIZE;
639 for (idx, child) in items.iter().enumerate() {
640 let sub = narrow_for_array_index(paths, idx);
641 let (cloned, child_size) = redacting_clone_inner(child, &sub);
642 size += child_size;
643 out.push(cloned);
644 }
645 (OwnedDataValue::Array(out), size)
646 }
647 OwnedDataValue::String(s) => (value.clone(), NODE_SIZE + s.len()),
649 other => (other.clone(), NODE_SIZE),
650 }
651}
652
653fn redacted_size(value: &OwnedDataValue, paths: &[Vec<String>]) -> usize {
659 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
660 redacted_size_inner(value, &refs)
661}
662
663fn redacted_size_inner(value: &OwnedDataValue, paths: &[&[String]]) -> usize {
664 if paths.iter().any(|p| p.is_empty()) {
665 return NODE_SIZE;
666 }
667 match value {
668 OwnedDataValue::Object(pairs) => {
669 let mut size = NODE_SIZE;
670 for (key, child) in pairs {
671 let sub = narrow_for_object_key(paths, key);
672 size += key.len() + redacted_size_inner(child, &sub);
673 }
674 size
675 }
676 OwnedDataValue::Array(items) => {
677 let mut size = NODE_SIZE;
678 for (idx, child) in items.iter().enumerate() {
679 let sub = narrow_for_array_index(paths, idx);
680 size += redacted_size_inner(child, &sub);
681 }
682 size
683 }
684 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
685 _ => NODE_SIZE,
686 }
687}
688
689fn approx_owned_size(value: &OwnedDataValue) -> usize {
692 match value {
693 OwnedDataValue::Object(pairs) => {
694 NODE_SIZE
695 + pairs
696 .iter()
697 .map(|(k, v)| k.len() + approx_owned_size(v))
698 .sum::<usize>()
699 }
700 OwnedDataValue::Array(items) => {
701 NODE_SIZE + items.iter().map(approx_owned_size).sum::<usize>()
702 }
703 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
704 _ => NODE_SIZE,
705 }
706}
707
708fn redact_json_in_place(value: &mut Value, paths: &[Vec<String>]) {
714 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
715 redact_json_inner(value, &refs);
716}
717
718fn redact_json_inner(value: &mut Value, paths: &[&[String]]) {
719 if paths.is_empty() {
720 return;
721 }
722 if paths.iter().any(|p| p.is_empty()) {
723 *value = Value::Null;
724 return;
725 }
726
727 match value {
728 Value::Object(map) => {
729 for (key, child) in map.iter_mut() {
730 let sub = narrow_for_object_key(paths, key);
731 redact_json_inner(child, &sub);
732 }
733 }
734 Value::Array(items) => {
735 for (idx, child) in items.iter_mut().enumerate() {
736 let sub = narrow_for_array_index(paths, idx);
737 redact_json_inner(child, &sub);
738 }
739 }
740 _ => {}
741 }
742}
743
744fn approx_json_size(value: &Value) -> usize {
747 match value {
748 Value::Object(map) => {
749 NODE_SIZE
750 + map
751 .iter()
752 .map(|(k, v)| k.len() + approx_json_size(v))
753 .sum::<usize>()
754 }
755 Value::Array(items) => NODE_SIZE + items.iter().map(approx_json_size).sum::<usize>(),
756 Value::String(s) => NODE_SIZE + s.len(),
757 _ => NODE_SIZE,
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use serde_json::json;
765
766 fn dv(v: serde_json::Value) -> OwnedDataValue {
767 OwnedDataValue::from(&v)
768 }
769
770 fn segments(paths: &[&str]) -> Vec<Vec<String>> {
771 TraceOptions {
772 redact_paths: paths.iter().map(|s| s.to_string()).collect(),
773 ..Default::default()
774 }
775 .redact_segments()
776 }
777
778 #[test]
779 fn test_step_result_serialization() {
780 assert_eq!(
781 serde_json::to_string(&StepResult::Executed).unwrap(),
782 "\"executed\""
783 );
784 assert_eq!(
785 serde_json::to_string(&StepResult::Skipped).unwrap(),
786 "\"skipped\""
787 );
788 }
789
790 #[test]
791 fn test_execution_step_executed() {
792 let message = Message::from_value(&json!({"test": "data"}));
793 let step = ExecutionStep::executed("workflow1", "task1", &message);
794
795 assert_eq!(step.workflow_id, "workflow1");
796 assert_eq!(step.task_id, Some("task1".to_string()));
797 assert_eq!(step.result, StepResult::Executed);
798 assert!(step.message.is_some());
799 }
800
801 #[test]
802 fn test_execution_step_task_skipped() {
803 let step = ExecutionStep::task_skipped("workflow1", "task1");
804
805 assert_eq!(step.workflow_id, "workflow1");
806 assert_eq!(step.task_id, Some("task1".to_string()));
807 assert_eq!(step.result, StepResult::Skipped);
808 assert!(step.message.is_none());
809 }
810
811 #[test]
812 fn test_execution_step_workflow_skipped() {
813 let step = ExecutionStep::workflow_skipped("workflow1");
814
815 assert_eq!(step.workflow_id, "workflow1");
816 assert_eq!(step.task_id, None);
817 assert_eq!(step.result, StepResult::Skipped);
818 assert!(step.message.is_none());
819 }
820
821 #[test]
822 fn test_execution_step_with_mapping_contexts() {
823 let message = Message::from_value(&json!({"test": "data"}));
824 let contexts = vec![json!({"data": {"a": 1}}), json!({"data": {"a": 1, "b": 2}})];
825
826 let step = ExecutionStep::executed("workflow1", "task1", &message)
827 .with_mapping_contexts(contexts.clone());
828
829 assert_eq!(step.mapping_contexts, Some(contexts));
830
831 let serialized = serde_json::to_value(&step).unwrap();
833 assert!(serialized.get("mapping_contexts").is_some());
834 assert_eq!(serialized["mapping_contexts"].as_array().unwrap().len(), 2);
835 }
836
837 #[test]
838 fn test_execution_step_without_mapping_contexts_serialization() {
839 let message = Message::from_value(&json!({"test": "data"}));
840 let step = ExecutionStep::executed("workflow1", "task1", &message);
841
842 let serialized = serde_json::to_value(&step).unwrap();
844 assert!(serialized.get("mapping_contexts").is_none());
845 assert!(serialized.get("started_at").is_none());
846 assert!(serialized.get("duration_us").is_none());
847 assert!(serialized.get("changes").is_none());
848 }
849
850 #[test]
851 fn test_execution_trace() {
852 let mut trace = ExecutionTrace::new();
853 let message = Message::from_value(&json!({"test": "data"}));
854
855 trace.add_step(ExecutionStep::workflow_skipped("workflow0"));
856 trace.add_step(ExecutionStep::executed("workflow1", "task1", &message));
857 trace.add_step(ExecutionStep::task_skipped("workflow1", "task2"));
858
859 assert_eq!(trace.steps.len(), 3);
860 assert_eq!(trace.executed_count(), 1);
861 assert_eq!(trace.skipped_count(), 2);
862 assert!(trace.final_message().is_some());
863 assert!(trace.is_success());
864 }
865
866 #[test]
867 fn default_options_reproduce_historical_capture() {
868 let o = TraceOptions::default();
869 assert!(o.snapshots);
870 assert!(o.mapping_contexts);
871 assert!(!o.changes);
872 assert_eq!(o.max_snapshot_bytes, 0);
873 assert!(o.redact_paths.is_empty());
874 assert_eq!(o.snapshot_audit_trail, AuditTrailScope::Full);
875 }
876
877 #[test]
878 fn timings_only_drops_snapshots_and_keeps_the_diff() {
879 let o = TraceOptions::timings_only();
880 assert!(!o.snapshots);
881 assert!(!o.mapping_contexts);
882 assert!(o.changes);
883 }
884
885 #[test]
886 fn a_complete_trace_does_not_serialize_the_truncated_flag() {
887 let trace = ExecutionTrace::new();
888 let serialized = serde_json::to_value(&trace).unwrap();
889 assert!(
890 serialized.get("truncated").is_none(),
891 "a complete trace keeps the historical wire shape"
892 );
893 assert!(!trace.truncated());
894 }
895
896 #[test]
897 fn a_trace_deserializes_from_a_payload_without_the_truncated_flag() {
898 let trace: ExecutionTrace = serde_json::from_value(json!({ "steps": [] })).unwrap();
899 assert!(!trace.truncated());
900 }
901
902 #[test]
903 fn duration_clamps_a_backward_clock_step_to_zero() {
904 let start = Utc::now();
905 let earlier = start - chrono::Duration::seconds(5);
906 assert_eq!(duration_us_between(start, earlier), 0);
907 assert_eq!(duration_us_between(start, start), 0);
908 assert_eq!(
909 duration_us_between(start, start + chrono::Duration::microseconds(1500)),
910 1500
911 );
912 }
913
914 #[test]
915 fn redaction_nulls_only_the_named_subtree() {
916 let ctx = dv(json!({"data": {"secret": {"k": "v"}, "keep": 1}}));
917 let (out, _) = redacting_clone(&ctx, &segments(&["data.secret"]));
918 assert_eq!(
919 serde_json::Value::from(&out),
920 json!({"data": {"secret": null, "keep": 1}})
921 );
922 }
923
924 #[test]
925 fn redaction_of_an_unresolvable_path_creates_nothing() {
926 let ctx = dv(json!({"data": {"items": [1, 2, 3]}}));
929 let (out, _) = redacting_clone(&ctx, &segments(&["data.items.99"]));
930 assert_eq!(
931 serde_json::Value::from(&out),
932 json!({"data": {"items": [1, 2, 3]}})
933 );
934 }
935
936 #[test]
937 fn redaction_through_a_non_container_is_a_noop() {
938 let ctx = dv(json!({"data": {"name": "alice"}}));
939 let (out, _) = redacting_clone(&ctx, &segments(&["data.name.first"]));
940 assert_eq!(
941 serde_json::Value::from(&out),
942 json!({"data": {"name": "alice"}})
943 );
944 }
945
946 #[test]
947 fn an_empty_redact_path_is_ignored() {
948 let ctx = dv(json!({"data": {"a": 1}}));
949 let (out, _) = redacting_clone(&ctx, &segments(&[""]));
950 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": 1}}));
951 }
952
953 #[test]
954 fn redaction_honours_the_hash_escape() {
955 let obj = dv(json!({"data": {"20": "secret", "other": 1}}));
957 let (out, _) = redacting_clone(&obj, &segments(&["data.#20"]));
958 assert_eq!(
959 serde_json::Value::from(&out),
960 json!({"data": {"20": null, "other": 1}})
961 );
962
963 let arr = dv(json!({"data": [0, 1, 2]}));
964 let (out, _) = redacting_clone(&arr, &segments(&["data.1"]));
965 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, null, 2]}));
966
967 let (out, _) = redacting_clone(&arr, &segments(&["data.#1"]));
969 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, 1, 2]}));
970 }
971
972 #[test]
973 fn nested_and_duplicated_redact_paths_are_safe() {
974 let ctx = dv(json!({"data": {"a": {"b": 1, "c": 2}}}));
975
976 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a.b"]));
977 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
978
979 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a"]));
980 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
981 }
982
983 #[test]
984 fn redaction_matches_unicode_keys_and_sizes_strings_by_bytes() {
985 let ctx = dv(json!({"data": {"café": "secret", "keep": "née"}}));
986 let (out, size) = redacting_clone(&ctx, &segments(&["data.café"]));
987 assert_eq!(
988 serde_json::Value::from(&out),
989 json!({"data": {"café": null, "keep": "née"}})
990 );
991
992 let (_, unredacted) = redacting_clone(&ctx, &segments(&[]));
994 assert!(unredacted > size, "redacting must lower the counted size");
995 assert!(
996 approx_owned_size(&dv(json!("née"))) == NODE_SIZE + 4,
997 "str::len() bytes, not chars().count()"
998 );
999 }
1000
1001 #[test]
1002 fn redacted_size_agrees_with_redacting_clone() {
1003 let shapes = [
1006 json!({}),
1007 json!({"data": {"a": 1, "b": "hello"}}),
1008 json!({"data": {"items": [1, "two", {"three": 3}], "nested": {"x": {"y": "z"}}}}),
1009 json!({"data": {"secret": {"deep": [1, 2, 3]}, "keep": "café"}}),
1010 ];
1011 let path_sets: [&[&str]; 4] = [&[], &["data.secret"], &["data.items.1"], &["data.nope"]];
1012
1013 for shape in &shapes {
1014 for paths in path_sets {
1015 let v = dv(shape.clone());
1016 let segs = segments(paths);
1017 let (_, cloned_size) = redacting_clone(&v, &segs);
1018 assert_eq!(
1019 redacted_size(&v, &segs),
1020 cloned_size,
1021 "probe and clone disagree for {shape:?} with {paths:?}"
1022 );
1023 }
1024 }
1025 }
1026
1027 #[test]
1028 fn redaction_applies_to_mapping_contexts_too() {
1029 let mut ctx = json!({"data": {"secret": {"k": "v"}, "keep": 1}});
1030 redact_json_in_place(&mut ctx, &segments(&["data.secret"]));
1031 assert_eq!(ctx, json!({"data": {"secret": null, "keep": 1}}));
1032 }
1033
1034 #[test]
1035 fn json_redaction_shares_the_owned_path_semantics() {
1036 let mut arr = json!({"data": {"items": [1, 2, 3]}});
1037 redact_json_in_place(&mut arr, &segments(&["data.items.99"]));
1038 assert_eq!(arr, json!({"data": {"items": [1, 2, 3]}}));
1039
1040 let mut scalar = json!({"data": {"name": "alice"}});
1041 redact_json_in_place(&mut scalar, &segments(&["data.name.first"]));
1042 assert_eq!(scalar, json!({"data": {"name": "alice"}}));
1043
1044 let mut hash = json!({"data": {"20": "secret"}});
1045 redact_json_in_place(&mut hash, &segments(&["data.#20"]));
1046 assert_eq!(hash, json!({"data": {"20": null}}));
1047 }
1048}