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(Clone, Copy, Debug)]
188pub(crate) struct StepTiming {
189 pub started_at: DateTime<Utc>,
191 pub duration_us: u64,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
201#[non_exhaustive]
202pub struct ExecutionStep {
203 pub workflow_id: String,
205 pub task_id: Option<String>,
207 pub result: StepResult,
209 #[serde(skip_serializing_if = "Option::is_none")]
213 pub message: Option<Message>,
214 #[serde(skip_serializing_if = "Option::is_none")]
217 pub mapping_contexts: Option<Vec<Value>>,
218 #[serde(skip_serializing_if = "Option::is_none")]
223 pub started_at: Option<DateTime<Utc>>,
224 #[serde(skip_serializing_if = "Option::is_none")]
229 pub duration_us: Option<u64>,
230 #[serde(skip_serializing_if = "Option::is_none")]
234 pub changes: Option<Vec<Change>>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub loop_counter: Option<i64>,
244}
245
246impl ExecutionStep {
247 pub fn executed(workflow_id: &str, task_id: &str, message: &Message) -> Self {
249 Self {
250 workflow_id: workflow_id.to_string(),
251 task_id: Some(task_id.to_string()),
252 result: StepResult::Executed,
253 message: Some(message.clone()),
254 mapping_contexts: None,
255 started_at: None,
256 duration_us: None,
257 changes: None,
258 loop_counter: None,
259 }
260 }
261
262 pub fn task_skipped(workflow_id: &str, task_id: &str) -> Self {
264 Self {
265 workflow_id: workflow_id.to_string(),
266 task_id: Some(task_id.to_string()),
267 result: StepResult::Skipped,
268 message: None,
269 mapping_contexts: None,
270 started_at: None,
271 duration_us: None,
272 changes: None,
273 loop_counter: None,
274 }
275 }
276
277 pub fn workflow_skipped(workflow_id: &str) -> Self {
279 Self {
280 workflow_id: workflow_id.to_string(),
281 task_id: None,
282 result: StepResult::Skipped,
283 message: None,
284 mapping_contexts: None,
285 started_at: None,
286 duration_us: None,
287 changes: None,
288 loop_counter: None,
289 }
290 }
291
292 pub fn with_mapping_contexts(mut self, contexts: Vec<Value>) -> Self {
294 self.mapping_contexts = Some(contexts);
295 self
296 }
297
298 pub fn with_timing(mut self, started_at: DateTime<Utc>, duration_us: u64) -> Self {
300 self.started_at = Some(started_at);
301 self.duration_us = Some(duration_us);
302 self
303 }
304
305 pub fn with_changes(mut self, changes: Vec<Change>) -> Self {
307 self.changes = Some(changes);
308 self
309 }
310
311 pub fn with_loop_counter(mut self, loop_counter: Option<i64>) -> Self {
314 self.loop_counter = loop_counter;
315 self
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
324#[non_exhaustive]
325pub struct ExecutionTrace {
326 pub steps: Vec<ExecutionStep>,
328
329 #[serde(default, skip_serializing_if = "is_false")]
332 truncated: bool,
333
334 #[serde(skip)]
338 options: TraceOptions,
339
340 #[serde(skip)]
342 redact_segments: Vec<Vec<String>>,
343
344 #[serde(skip)]
346 snapshot_bytes: usize,
347}
348
349impl ExecutionTrace {
350 pub fn new() -> Self {
352 Self::with_options(TraceOptions::default())
353 }
354
355 pub fn with_options(options: TraceOptions) -> Self {
357 Self {
358 steps: Vec::new(),
359 truncated: false,
360 redact_segments: options.redact_segments(),
361 options,
362 snapshot_bytes: 0,
363 }
364 }
365
366 pub fn options(&self) -> &TraceOptions {
368 &self.options
369 }
370
371 pub fn truncated(&self) -> bool {
380 self.truncated
381 }
382
383 pub fn add_step(&mut self, step: ExecutionStep) {
385 self.steps.push(step);
386 }
387
388 pub(crate) fn add_executed_step(
394 &mut self,
395 workflow_id: &str,
396 task_id: &str,
397 message: &Message,
398 timing: StepTiming,
399 mapping_contexts: Option<Vec<Value>>,
400 loop_counter: Option<i64>,
401 ) {
402 let mut step = ExecutionStep {
403 workflow_id: workflow_id.to_string(),
404 task_id: Some(task_id.to_string()),
405 result: StepResult::Executed,
406 message: None,
407 mapping_contexts: None,
408 started_at: Some(timing.started_at),
409 duration_us: Some(timing.duration_us),
410 loop_counter,
411 changes: if self.options.changes {
412 Some(
418 own_audit_entry(message, workflow_id, task_id)
419 .map(|e| e.changes.clone())
420 .unwrap_or_default(),
421 )
422 } else {
423 None
424 },
425 };
426
427 if self.options.snapshots {
428 let projected = self.projected_snapshot_size(message, workflow_id, task_id);
432 if self.would_exceed(projected) {
433 self.truncated = true;
434 } else {
435 self.snapshot_bytes += projected;
436 step.message = Some(self.build_snapshot(message, workflow_id, task_id));
437 }
438 }
439
440 if self.options.mapping_contexts {
441 if let Some(mut contexts) = mapping_contexts {
442 for ctx in &mut contexts {
445 redact_json_in_place(ctx, &self.redact_segments);
446 }
447 let size: usize = contexts.iter().map(approx_json_size).sum();
448 if self.would_exceed(size) {
449 self.truncated = true;
450 } else {
451 self.snapshot_bytes += size;
452 step.mapping_contexts = Some(contexts);
453 }
454 }
455 }
456
457 self.steps.push(step);
458 }
459
460 #[inline]
462 fn would_exceed(&self, additional: usize) -> bool {
463 self.options.max_snapshot_bytes != 0
464 && self.snapshot_bytes + additional > self.options.max_snapshot_bytes
465 }
466
467 fn projected_snapshot_size(
473 &self,
474 message: &Message,
475 workflow_id: &str,
476 task_id: &str,
477 ) -> usize {
478 let mut size = redacted_size(&message.context, &self.redact_segments);
479 for entry in self.scoped_audit_trail(message, workflow_id, task_id) {
480 size += NODE_SIZE;
481 for change in &entry.changes {
482 size += change.path.len()
483 + approx_owned_size(&change.old_value)
484 + approx_owned_size(&change.new_value);
485 }
486 }
487 size
488 }
489
490 fn scoped_audit_trail<'m>(
493 &self,
494 message: &'m Message,
495 workflow_id: &str,
496 task_id: &str,
497 ) -> Vec<&'m AuditTrail> {
498 match self.options.snapshot_audit_trail {
499 AuditTrailScope::Full => message.audit_trail.iter().collect(),
500 AuditTrailScope::Own => own_audit_entry(message, workflow_id, task_id)
501 .map(|e| vec![e])
502 .unwrap_or_default(),
503 AuditTrailScope::None => Vec::new(),
504 }
505 }
506
507 fn build_snapshot(&self, message: &Message, workflow_id: &str, task_id: &str) -> Message {
514 let (context, _) = redacting_clone(&message.context, &self.redact_segments);
515 let audit_trail: Vec<AuditTrail> = self
516 .scoped_audit_trail(message, workflow_id, task_id)
517 .into_iter()
518 .cloned()
519 .collect();
520
521 Message {
522 id: message.id.clone(),
523 payload: Arc::clone(&message.payload),
524 context,
525 audit_trail,
526 errors: message.errors.clone(),
527 capture_changes: message.capture_changes,
528 routing_bucket: message.routing_bucket,
529 }
530 }
531
532 pub fn final_message(&self) -> Option<&Message> {
537 self.steps
538 .iter()
539 .rev()
540 .find(|s| s.result == StepResult::Executed)
541 .and_then(|s| s.message.as_ref())
542 }
543
544 pub fn is_success(&self) -> bool {
550 self.final_message()
551 .map(|m| m.errors.is_empty())
552 .unwrap_or(true)
553 }
554
555 pub fn executed_count(&self) -> usize {
557 self.steps
558 .iter()
559 .filter(|s| s.result == StepResult::Executed)
560 .count()
561 }
562
563 pub fn skipped_count(&self) -> usize {
565 self.steps
566 .iter()
567 .filter(|s| s.result == StepResult::Skipped)
568 .count()
569 }
570}
571
572impl Default for ExecutionTrace {
573 fn default() -> Self {
574 Self::new()
575 }
576}
577
578#[inline]
589fn own_audit_entry<'m>(
590 message: &'m Message,
591 workflow_id: &str,
592 task_id: &str,
593) -> Option<&'m AuditTrail> {
594 match message.audit_trail.last() {
595 Some(entry)
596 if entry.task_id.as_ref() == task_id && entry.workflow_id.as_ref() == workflow_id =>
597 {
598 Some(entry)
599 }
600 _ => None,
601 }
602}
603
604#[inline]
609pub(crate) fn duration_us_between(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
610 (end - start)
611 .num_microseconds()
612 .unwrap_or(0)
613 .max(0)
614 .try_into()
615 .unwrap_or(0)
616}
617
618fn redacting_clone(value: &OwnedDataValue, paths: &[Vec<String>]) -> (OwnedDataValue, usize) {
626 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
627 redacting_clone_inner(value, &refs)
628}
629
630fn narrow_for_object_key<'a>(paths: &[&'a [String]], key: &str) -> Vec<&'a [String]> {
636 paths
637 .iter()
638 .filter(|p| strip_hash_prefix(&p[0]) == key)
639 .map(|p| &p[1..])
640 .collect()
641}
642
643fn narrow_for_array_index<'a>(paths: &[&'a [String]], idx: usize) -> Vec<&'a [String]> {
646 paths
647 .iter()
648 .filter(|p| p[0].parse::<usize>() == Ok(idx))
649 .map(|p| &p[1..])
650 .collect()
651}
652
653fn redacting_clone_inner(value: &OwnedDataValue, paths: &[&[String]]) -> (OwnedDataValue, usize) {
654 if paths.iter().any(|p| p.is_empty()) {
656 return (OwnedDataValue::Null, NODE_SIZE);
657 }
658
659 match value {
660 OwnedDataValue::Object(pairs) => {
661 let mut out = Vec::with_capacity(pairs.len());
662 let mut size = NODE_SIZE;
663 for (key, child) in pairs {
664 let sub = narrow_for_object_key(paths, key);
665 let (cloned, child_size) = redacting_clone_inner(child, &sub);
666 size += key.len() + child_size;
667 out.push((key.clone(), cloned));
668 }
669 (OwnedDataValue::Object(out), size)
670 }
671 OwnedDataValue::Array(items) => {
672 let mut out = Vec::with_capacity(items.len());
673 let mut size = NODE_SIZE;
674 for (idx, child) in items.iter().enumerate() {
675 let sub = narrow_for_array_index(paths, idx);
676 let (cloned, child_size) = redacting_clone_inner(child, &sub);
677 size += child_size;
678 out.push(cloned);
679 }
680 (OwnedDataValue::Array(out), size)
681 }
682 OwnedDataValue::String(s) => (value.clone(), NODE_SIZE + s.len()),
684 other => (other.clone(), NODE_SIZE),
685 }
686}
687
688fn redacted_size(value: &OwnedDataValue, paths: &[Vec<String>]) -> usize {
694 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
695 redacted_size_inner(value, &refs)
696}
697
698fn redacted_size_inner(value: &OwnedDataValue, paths: &[&[String]]) -> usize {
699 if paths.iter().any(|p| p.is_empty()) {
700 return NODE_SIZE;
701 }
702 match value {
703 OwnedDataValue::Object(pairs) => {
704 let mut size = NODE_SIZE;
705 for (key, child) in pairs {
706 let sub = narrow_for_object_key(paths, key);
707 size += key.len() + redacted_size_inner(child, &sub);
708 }
709 size
710 }
711 OwnedDataValue::Array(items) => {
712 let mut size = NODE_SIZE;
713 for (idx, child) in items.iter().enumerate() {
714 let sub = narrow_for_array_index(paths, idx);
715 size += redacted_size_inner(child, &sub);
716 }
717 size
718 }
719 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
720 _ => NODE_SIZE,
721 }
722}
723
724fn approx_owned_size(value: &OwnedDataValue) -> usize {
727 match value {
728 OwnedDataValue::Object(pairs) => {
729 NODE_SIZE
730 + pairs
731 .iter()
732 .map(|(k, v)| k.len() + approx_owned_size(v))
733 .sum::<usize>()
734 }
735 OwnedDataValue::Array(items) => {
736 NODE_SIZE + items.iter().map(approx_owned_size).sum::<usize>()
737 }
738 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
739 _ => NODE_SIZE,
740 }
741}
742
743fn redact_json_in_place(value: &mut Value, paths: &[Vec<String>]) {
749 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
750 redact_json_inner(value, &refs);
751}
752
753fn redact_json_inner(value: &mut Value, paths: &[&[String]]) {
754 if paths.is_empty() {
755 return;
756 }
757 if paths.iter().any(|p| p.is_empty()) {
758 *value = Value::Null;
759 return;
760 }
761
762 match value {
763 Value::Object(map) => {
764 for (key, child) in map.iter_mut() {
765 let sub = narrow_for_object_key(paths, key);
766 redact_json_inner(child, &sub);
767 }
768 }
769 Value::Array(items) => {
770 for (idx, child) in items.iter_mut().enumerate() {
771 let sub = narrow_for_array_index(paths, idx);
772 redact_json_inner(child, &sub);
773 }
774 }
775 _ => {}
776 }
777}
778
779fn approx_json_size(value: &Value) -> usize {
782 match value {
783 Value::Object(map) => {
784 NODE_SIZE
785 + map
786 .iter()
787 .map(|(k, v)| k.len() + approx_json_size(v))
788 .sum::<usize>()
789 }
790 Value::Array(items) => NODE_SIZE + items.iter().map(approx_json_size).sum::<usize>(),
791 Value::String(s) => NODE_SIZE + s.len(),
792 _ => NODE_SIZE,
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use serde_json::json;
800
801 fn dv(v: serde_json::Value) -> OwnedDataValue {
802 OwnedDataValue::from(&v)
803 }
804
805 fn segments(paths: &[&str]) -> Vec<Vec<String>> {
806 TraceOptions {
807 redact_paths: paths.iter().map(|s| s.to_string()).collect(),
808 ..Default::default()
809 }
810 .redact_segments()
811 }
812
813 #[test]
814 fn test_step_result_serialization() {
815 assert_eq!(
816 serde_json::to_string(&StepResult::Executed).unwrap(),
817 "\"executed\""
818 );
819 assert_eq!(
820 serde_json::to_string(&StepResult::Skipped).unwrap(),
821 "\"skipped\""
822 );
823 }
824
825 #[test]
826 fn test_execution_step_executed() {
827 let message = Message::from_value(&json!({"test": "data"}));
828 let step = ExecutionStep::executed("workflow1", "task1", &message);
829
830 assert_eq!(step.workflow_id, "workflow1");
831 assert_eq!(step.task_id, Some("task1".to_string()));
832 assert_eq!(step.result, StepResult::Executed);
833 assert!(step.message.is_some());
834 }
835
836 #[test]
837 fn test_execution_step_task_skipped() {
838 let step = ExecutionStep::task_skipped("workflow1", "task1");
839
840 assert_eq!(step.workflow_id, "workflow1");
841 assert_eq!(step.task_id, Some("task1".to_string()));
842 assert_eq!(step.result, StepResult::Skipped);
843 assert!(step.message.is_none());
844 }
845
846 #[test]
847 fn test_execution_step_workflow_skipped() {
848 let step = ExecutionStep::workflow_skipped("workflow1");
849
850 assert_eq!(step.workflow_id, "workflow1");
851 assert_eq!(step.task_id, None);
852 assert_eq!(step.result, StepResult::Skipped);
853 assert!(step.message.is_none());
854 }
855
856 #[test]
857 fn test_execution_step_with_mapping_contexts() {
858 let message = Message::from_value(&json!({"test": "data"}));
859 let contexts = vec![json!({"data": {"a": 1}}), json!({"data": {"a": 1, "b": 2}})];
860
861 let step = ExecutionStep::executed("workflow1", "task1", &message)
862 .with_mapping_contexts(contexts.clone());
863
864 assert_eq!(step.mapping_contexts, Some(contexts));
865
866 let serialized = serde_json::to_value(&step).unwrap();
868 assert!(serialized.get("mapping_contexts").is_some());
869 assert_eq!(serialized["mapping_contexts"].as_array().unwrap().len(), 2);
870 }
871
872 #[test]
873 fn test_execution_step_without_mapping_contexts_serialization() {
874 let message = Message::from_value(&json!({"test": "data"}));
875 let step = ExecutionStep::executed("workflow1", "task1", &message);
876
877 let serialized = serde_json::to_value(&step).unwrap();
879 assert!(serialized.get("mapping_contexts").is_none());
880 assert!(serialized.get("started_at").is_none());
881 assert!(serialized.get("duration_us").is_none());
882 assert!(serialized.get("changes").is_none());
883 }
884
885 #[test]
886 fn test_execution_trace() {
887 let mut trace = ExecutionTrace::new();
888 let message = Message::from_value(&json!({"test": "data"}));
889
890 trace.add_step(ExecutionStep::workflow_skipped("workflow0"));
891 trace.add_step(ExecutionStep::executed("workflow1", "task1", &message));
892 trace.add_step(ExecutionStep::task_skipped("workflow1", "task2"));
893
894 assert_eq!(trace.steps.len(), 3);
895 assert_eq!(trace.executed_count(), 1);
896 assert_eq!(trace.skipped_count(), 2);
897 assert!(trace.final_message().is_some());
898 assert!(trace.is_success());
899 }
900
901 #[test]
902 fn default_options_reproduce_historical_capture() {
903 let o = TraceOptions::default();
904 assert!(o.snapshots);
905 assert!(o.mapping_contexts);
906 assert!(!o.changes);
907 assert_eq!(o.max_snapshot_bytes, 0);
908 assert!(o.redact_paths.is_empty());
909 assert_eq!(o.snapshot_audit_trail, AuditTrailScope::Full);
910 }
911
912 #[test]
913 fn timings_only_drops_snapshots_and_keeps_the_diff() {
914 let o = TraceOptions::timings_only();
915 assert!(!o.snapshots);
916 assert!(!o.mapping_contexts);
917 assert!(o.changes);
918 }
919
920 #[test]
921 fn a_complete_trace_does_not_serialize_the_truncated_flag() {
922 let trace = ExecutionTrace::new();
923 let serialized = serde_json::to_value(&trace).unwrap();
924 assert!(
925 serialized.get("truncated").is_none(),
926 "a complete trace keeps the historical wire shape"
927 );
928 assert!(!trace.truncated());
929 }
930
931 #[test]
932 fn a_trace_deserializes_from_a_payload_without_the_truncated_flag() {
933 let trace: ExecutionTrace = serde_json::from_value(json!({ "steps": [] })).unwrap();
934 assert!(!trace.truncated());
935 }
936
937 #[test]
938 fn duration_clamps_a_backward_clock_step_to_zero() {
939 let start = Utc::now();
940 let earlier = start - chrono::Duration::seconds(5);
941 assert_eq!(duration_us_between(start, earlier), 0);
942 assert_eq!(duration_us_between(start, start), 0);
943 assert_eq!(
944 duration_us_between(start, start + chrono::Duration::microseconds(1500)),
945 1500
946 );
947 }
948
949 #[test]
950 fn redaction_nulls_only_the_named_subtree() {
951 let ctx = dv(json!({"data": {"secret": {"k": "v"}, "keep": 1}}));
952 let (out, _) = redacting_clone(&ctx, &segments(&["data.secret"]));
953 assert_eq!(
954 serde_json::Value::from(&out),
955 json!({"data": {"secret": null, "keep": 1}})
956 );
957 }
958
959 #[test]
960 fn redaction_of_an_unresolvable_path_creates_nothing() {
961 let ctx = dv(json!({"data": {"items": [1, 2, 3]}}));
964 let (out, _) = redacting_clone(&ctx, &segments(&["data.items.99"]));
965 assert_eq!(
966 serde_json::Value::from(&out),
967 json!({"data": {"items": [1, 2, 3]}})
968 );
969 }
970
971 #[test]
972 fn redaction_through_a_non_container_is_a_noop() {
973 let ctx = dv(json!({"data": {"name": "alice"}}));
974 let (out, _) = redacting_clone(&ctx, &segments(&["data.name.first"]));
975 assert_eq!(
976 serde_json::Value::from(&out),
977 json!({"data": {"name": "alice"}})
978 );
979 }
980
981 #[test]
982 fn an_empty_redact_path_is_ignored() {
983 let ctx = dv(json!({"data": {"a": 1}}));
984 let (out, _) = redacting_clone(&ctx, &segments(&[""]));
985 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": 1}}));
986 }
987
988 #[test]
989 fn redaction_honours_the_hash_escape() {
990 let obj = dv(json!({"data": {"20": "secret", "other": 1}}));
992 let (out, _) = redacting_clone(&obj, &segments(&["data.#20"]));
993 assert_eq!(
994 serde_json::Value::from(&out),
995 json!({"data": {"20": null, "other": 1}})
996 );
997
998 let arr = dv(json!({"data": [0, 1, 2]}));
999 let (out, _) = redacting_clone(&arr, &segments(&["data.1"]));
1000 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, null, 2]}));
1001
1002 let (out, _) = redacting_clone(&arr, &segments(&["data.#1"]));
1004 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, 1, 2]}));
1005 }
1006
1007 #[test]
1008 fn nested_and_duplicated_redact_paths_are_safe() {
1009 let ctx = dv(json!({"data": {"a": {"b": 1, "c": 2}}}));
1010
1011 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a.b"]));
1012 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1013
1014 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a"]));
1015 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1016 }
1017
1018 #[test]
1019 fn redaction_matches_unicode_keys_and_sizes_strings_by_bytes() {
1020 let ctx = dv(json!({"data": {"café": "secret", "keep": "née"}}));
1021 let (out, size) = redacting_clone(&ctx, &segments(&["data.café"]));
1022 assert_eq!(
1023 serde_json::Value::from(&out),
1024 json!({"data": {"café": null, "keep": "née"}})
1025 );
1026
1027 let (_, unredacted) = redacting_clone(&ctx, &segments(&[]));
1029 assert!(unredacted > size, "redacting must lower the counted size");
1030 assert!(
1031 approx_owned_size(&dv(json!("née"))) == NODE_SIZE + 4,
1032 "str::len() bytes, not chars().count()"
1033 );
1034 }
1035
1036 #[test]
1037 fn redacted_size_agrees_with_redacting_clone() {
1038 let shapes = [
1041 json!({}),
1042 json!({"data": {"a": 1, "b": "hello"}}),
1043 json!({"data": {"items": [1, "two", {"three": 3}], "nested": {"x": {"y": "z"}}}}),
1044 json!({"data": {"secret": {"deep": [1, 2, 3]}, "keep": "café"}}),
1045 ];
1046 let path_sets: [&[&str]; 4] = [&[], &["data.secret"], &["data.items.1"], &["data.nope"]];
1047
1048 for shape in &shapes {
1049 for paths in path_sets {
1050 let v = dv(shape.clone());
1051 let segs = segments(paths);
1052 let (_, cloned_size) = redacting_clone(&v, &segs);
1053 assert_eq!(
1054 redacted_size(&v, &segs),
1055 cloned_size,
1056 "probe and clone disagree for {shape:?} with {paths:?}"
1057 );
1058 }
1059 }
1060 }
1061
1062 #[test]
1063 fn redaction_applies_to_mapping_contexts_too() {
1064 let mut ctx = json!({"data": {"secret": {"k": "v"}, "keep": 1}});
1065 redact_json_in_place(&mut ctx, &segments(&["data.secret"]));
1066 assert_eq!(ctx, json!({"data": {"secret": null, "keep": 1}}));
1067 }
1068
1069 #[test]
1070 fn json_redaction_shares_the_owned_path_semantics() {
1071 let mut arr = json!({"data": {"items": [1, 2, 3]}});
1072 redact_json_in_place(&mut arr, &segments(&["data.items.99"]));
1073 assert_eq!(arr, json!({"data": {"items": [1, 2, 3]}}));
1074
1075 let mut scalar = json!({"data": {"name": "alice"}});
1076 redact_json_in_place(&mut scalar, &segments(&["data.name.first"]));
1077 assert_eq!(scalar, json!({"data": {"name": "alice"}}));
1078
1079 let mut hash = json!({"data": {"20": "secret"}});
1080 redact_json_in_place(&mut hash, &segments(&["data.#20"]));
1081 assert_eq!(hash, json!({"data": {"20": null}}));
1082 }
1083
1084 #[test]
1085 fn execution_step_loop_counter_defaults_to_none_on_every_constructor() {
1086 assert_eq!(
1087 ExecutionStep::executed("w", "t", &Message::from_value(&json!({}))).loop_counter,
1088 None
1089 );
1090 assert_eq!(ExecutionStep::task_skipped("w", "t").loop_counter, None);
1091 assert_eq!(ExecutionStep::workflow_skipped("w").loop_counter, None);
1092 }
1093
1094 #[test]
1095 fn with_loop_counter_sets_and_clears_the_field() {
1096 let step = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(3));
1097 assert_eq!(step.loop_counter, Some(3));
1098 assert_eq!(step.with_loop_counter(None).loop_counter, None);
1101 }
1102
1103 #[test]
1104 fn execution_step_loop_counter_is_absent_from_json_when_none() {
1105 let step = ExecutionStep::task_skipped("w", "t");
1108 let json = serde_json::to_value(&step).expect("should serialize");
1109 assert!(json.get("loop_counter").is_none());
1110
1111 let looped = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(0));
1112 assert_eq!(
1113 serde_json::to_value(&looped).expect("should serialize")["loop_counter"],
1114 json!(0),
1115 "counter 0 must serialize, not be elided as a default"
1116 );
1117 }
1118
1119 #[test]
1120 fn execution_step_without_a_loop_counter_key_deserializes() {
1121 let step: ExecutionStep = serde_json::from_value(json!({
1123 "workflow_id": "w",
1124 "task_id": "t",
1125 "result": "skipped"
1126 }))
1127 .expect("legacy trace JSON should deserialize");
1128 assert_eq!(step.loop_counter, None);
1129 assert_eq!(step.workflow_id, "w");
1130 }
1131
1132 #[test]
1133 fn add_executed_step_records_the_timing_bundle_and_the_loop_counter() {
1134 let mut trace = ExecutionTrace::with_options(TraceOptions::timings_only());
1135 let started_at = Utc::now();
1136 trace.add_executed_step(
1137 "w",
1138 "t",
1139 &Message::from_value(&json!({})),
1140 StepTiming {
1141 started_at,
1142 duration_us: 42,
1143 },
1144 None,
1145 Some(7),
1146 );
1147
1148 let step = &trace.steps[0];
1149 assert_eq!(step.started_at, Some(started_at));
1150 assert_eq!(step.duration_us, Some(42));
1151 assert_eq!(step.loop_counter, Some(7));
1152 assert_eq!(step.result, StepResult::Executed);
1153 }
1154}