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 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub element_index: Option<usize>,
250}
251
252impl ExecutionStep {
253 pub fn executed(workflow_id: &str, task_id: &str, message: &Message) -> Self {
255 Self {
256 workflow_id: workflow_id.to_string(),
257 task_id: Some(task_id.to_string()),
258 result: StepResult::Executed,
259 message: Some(message.clone()),
260 mapping_contexts: None,
261 started_at: None,
262 duration_us: None,
263 changes: None,
264 loop_counter: None,
265 element_index: None,
266 }
267 }
268
269 pub fn task_skipped(workflow_id: &str, task_id: &str) -> Self {
271 Self {
272 workflow_id: workflow_id.to_string(),
273 task_id: Some(task_id.to_string()),
274 result: StepResult::Skipped,
275 message: None,
276 mapping_contexts: None,
277 started_at: None,
278 duration_us: None,
279 changes: None,
280 loop_counter: None,
281 element_index: None,
282 }
283 }
284
285 pub fn workflow_skipped(workflow_id: &str) -> Self {
287 Self {
288 workflow_id: workflow_id.to_string(),
289 task_id: None,
290 result: StepResult::Skipped,
291 message: None,
292 mapping_contexts: None,
293 started_at: None,
294 duration_us: None,
295 changes: None,
296 loop_counter: None,
297 element_index: None,
298 }
299 }
300
301 pub fn with_mapping_contexts(mut self, contexts: Vec<Value>) -> Self {
303 self.mapping_contexts = Some(contexts);
304 self
305 }
306
307 pub fn with_timing(mut self, started_at: DateTime<Utc>, duration_us: u64) -> Self {
309 self.started_at = Some(started_at);
310 self.duration_us = Some(duration_us);
311 self
312 }
313
314 pub fn with_changes(mut self, changes: Vec<Change>) -> Self {
316 self.changes = Some(changes);
317 self
318 }
319
320 pub fn with_loop_counter(mut self, loop_counter: Option<i64>) -> Self {
323 self.loop_counter = loop_counter;
324 self
325 }
326
327 pub fn with_element_index(mut self, element_index: Option<usize>) -> Self {
330 self.element_index = element_index;
331 self
332 }
333}
334
335#[derive(Clone, Copy, Debug, Default)]
341pub(crate) struct StepStamp {
342 pub loop_counter: Option<i64>,
343 pub element_index: Option<usize>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
351#[non_exhaustive]
352pub struct ExecutionTrace {
353 pub steps: Vec<ExecutionStep>,
355
356 #[serde(default, skip_serializing_if = "is_false")]
359 truncated: bool,
360
361 #[serde(skip)]
365 options: TraceOptions,
366
367 #[serde(skip)]
369 redact_segments: Vec<Vec<String>>,
370
371 #[serde(skip)]
373 snapshot_bytes: usize,
374}
375
376impl ExecutionTrace {
377 pub fn new() -> Self {
379 Self::with_options(TraceOptions::default())
380 }
381
382 pub fn with_options(options: TraceOptions) -> Self {
384 Self {
385 steps: Vec::new(),
386 truncated: false,
387 redact_segments: options.redact_segments(),
388 options,
389 snapshot_bytes: 0,
390 }
391 }
392
393 pub fn options(&self) -> &TraceOptions {
395 &self.options
396 }
397
398 pub fn truncated(&self) -> bool {
407 self.truncated
408 }
409
410 pub fn add_step(&mut self, step: ExecutionStep) {
412 self.steps.push(step);
413 }
414
415 pub(crate) fn add_executed_step(
421 &mut self,
422 workflow_id: &str,
423 task_id: &str,
424 message: &Message,
425 timing: StepTiming,
426 mapping_contexts: Option<Vec<Value>>,
427 stamp: StepStamp,
428 ) {
429 let mut step = ExecutionStep {
430 workflow_id: workflow_id.to_string(),
431 task_id: Some(task_id.to_string()),
432 result: StepResult::Executed,
433 message: None,
434 mapping_contexts: None,
435 started_at: Some(timing.started_at),
436 duration_us: Some(timing.duration_us),
437 loop_counter: stamp.loop_counter,
438 element_index: stamp.element_index,
439 changes: if self.options.changes {
440 Some(
446 own_audit_entry(message, workflow_id, task_id)
447 .map(|e| e.changes.clone())
448 .unwrap_or_default(),
449 )
450 } else {
451 None
452 },
453 };
454
455 if self.options.snapshots {
456 let projected = self.projected_snapshot_size(message, workflow_id, task_id);
460 if self.would_exceed(projected) {
461 self.truncated = true;
462 } else {
463 self.snapshot_bytes += projected;
464 step.message = Some(self.build_snapshot(message, workflow_id, task_id));
465 }
466 }
467
468 if self.options.mapping_contexts
469 && let Some(mut contexts) = mapping_contexts
470 {
471 for ctx in &mut contexts {
474 redact_json_in_place(ctx, &self.redact_segments);
475 }
476 let size: usize = contexts.iter().map(approx_json_size).sum();
477 if self.would_exceed(size) {
478 self.truncated = true;
479 } else {
480 self.snapshot_bytes += size;
481 step.mapping_contexts = Some(contexts);
482 }
483 }
484
485 self.steps.push(step);
486 }
487
488 #[inline]
490 fn would_exceed(&self, additional: usize) -> bool {
491 self.options.max_snapshot_bytes != 0
492 && self.snapshot_bytes + additional > self.options.max_snapshot_bytes
493 }
494
495 fn projected_snapshot_size(
501 &self,
502 message: &Message,
503 workflow_id: &str,
504 task_id: &str,
505 ) -> usize {
506 let mut size = redacted_size(&message.context, &self.redact_segments);
507 for entry in self.scoped_audit_trail(message, workflow_id, task_id) {
508 size += NODE_SIZE;
509 for change in &entry.changes {
510 size += change.path.len()
511 + approx_owned_size(&change.old_value)
512 + approx_owned_size(&change.new_value);
513 }
514 }
515 size
516 }
517
518 fn scoped_audit_trail<'m>(
521 &self,
522 message: &'m Message,
523 workflow_id: &str,
524 task_id: &str,
525 ) -> Vec<&'m AuditTrail> {
526 match self.options.snapshot_audit_trail {
527 AuditTrailScope::Full => message.audit_trail.iter().collect(),
528 AuditTrailScope::Own => own_audit_entry(message, workflow_id, task_id)
529 .map(|e| vec![e])
530 .unwrap_or_default(),
531 AuditTrailScope::None => Vec::new(),
532 }
533 }
534
535 fn build_snapshot(&self, message: &Message, workflow_id: &str, task_id: &str) -> Message {
542 let (context, _) = redacting_clone(&message.context, &self.redact_segments);
543 let audit_trail: Vec<AuditTrail> = self
544 .scoped_audit_trail(message, workflow_id, task_id)
545 .into_iter()
546 .cloned()
547 .collect();
548
549 Message {
550 id: message.id.clone(),
551 payload: Arc::clone(&message.payload),
552 context,
553 audit_trail,
554 errors: message.errors.clone(),
555 capture_changes: message.capture_changes,
556 routing_bucket: message.routing_bucket,
557 }
558 }
559
560 pub fn final_message(&self) -> Option<&Message> {
565 self.steps
566 .iter()
567 .rev()
568 .find(|s| s.result == StepResult::Executed)
569 .and_then(|s| s.message.as_ref())
570 }
571
572 pub fn is_success(&self) -> bool {
578 self.final_message()
579 .map(|m| m.errors.is_empty())
580 .unwrap_or(true)
581 }
582
583 pub fn executed_count(&self) -> usize {
585 self.steps
586 .iter()
587 .filter(|s| s.result == StepResult::Executed)
588 .count()
589 }
590
591 pub fn skipped_count(&self) -> usize {
593 self.steps
594 .iter()
595 .filter(|s| s.result == StepResult::Skipped)
596 .count()
597 }
598}
599
600impl Default for ExecutionTrace {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606#[inline]
617fn own_audit_entry<'m>(
618 message: &'m Message,
619 workflow_id: &str,
620 task_id: &str,
621) -> Option<&'m AuditTrail> {
622 match message.audit_trail.last() {
623 Some(entry)
624 if entry.task_id.as_ref() == task_id && entry.workflow_id.as_ref() == workflow_id =>
625 {
626 Some(entry)
627 }
628 _ => None,
629 }
630}
631
632#[inline]
637pub(crate) fn duration_us_between(start: DateTime<Utc>, end: DateTime<Utc>) -> u64 {
638 (end - start)
639 .num_microseconds()
640 .unwrap_or(0)
641 .max(0)
642 .try_into()
643 .unwrap_or(0)
644}
645
646fn redacting_clone(value: &OwnedDataValue, paths: &[Vec<String>]) -> (OwnedDataValue, usize) {
654 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
655 redacting_clone_inner(value, &refs)
656}
657
658fn narrow_for_object_key<'a>(paths: &[&'a [String]], key: &str) -> Vec<&'a [String]> {
664 paths
665 .iter()
666 .filter(|p| strip_hash_prefix(&p[0]) == key)
667 .map(|p| &p[1..])
668 .collect()
669}
670
671fn narrow_for_array_index<'a>(paths: &[&'a [String]], idx: usize) -> Vec<&'a [String]> {
674 paths
675 .iter()
676 .filter(|p| p[0].parse::<usize>() == Ok(idx))
677 .map(|p| &p[1..])
678 .collect()
679}
680
681fn redacting_clone_inner(value: &OwnedDataValue, paths: &[&[String]]) -> (OwnedDataValue, usize) {
682 if paths.iter().any(|p| p.is_empty()) {
684 return (OwnedDataValue::Null, NODE_SIZE);
685 }
686
687 match value {
688 OwnedDataValue::Object(pairs) => {
689 let mut out = Vec::with_capacity(pairs.len());
690 let mut size = NODE_SIZE;
691 for (key, child) in pairs {
692 let sub = narrow_for_object_key(paths, key);
693 let (cloned, child_size) = redacting_clone_inner(child, &sub);
694 size += key.len() + child_size;
695 out.push((key.clone(), cloned));
696 }
697 (OwnedDataValue::Object(out), size)
698 }
699 OwnedDataValue::Array(items) => {
700 let mut out = Vec::with_capacity(items.len());
701 let mut size = NODE_SIZE;
702 for (idx, child) in items.iter().enumerate() {
703 let sub = narrow_for_array_index(paths, idx);
704 let (cloned, child_size) = redacting_clone_inner(child, &sub);
705 size += child_size;
706 out.push(cloned);
707 }
708 (OwnedDataValue::Array(out), size)
709 }
710 OwnedDataValue::String(s) => (value.clone(), NODE_SIZE + s.len()),
712 other => (other.clone(), NODE_SIZE),
713 }
714}
715
716fn redacted_size(value: &OwnedDataValue, paths: &[Vec<String>]) -> usize {
722 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
723 redacted_size_inner(value, &refs)
724}
725
726fn redacted_size_inner(value: &OwnedDataValue, paths: &[&[String]]) -> usize {
727 if paths.iter().any(|p| p.is_empty()) {
728 return NODE_SIZE;
729 }
730 match value {
731 OwnedDataValue::Object(pairs) => {
732 let mut size = NODE_SIZE;
733 for (key, child) in pairs {
734 let sub = narrow_for_object_key(paths, key);
735 size += key.len() + redacted_size_inner(child, &sub);
736 }
737 size
738 }
739 OwnedDataValue::Array(items) => {
740 let mut size = NODE_SIZE;
741 for (idx, child) in items.iter().enumerate() {
742 let sub = narrow_for_array_index(paths, idx);
743 size += redacted_size_inner(child, &sub);
744 }
745 size
746 }
747 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
748 _ => NODE_SIZE,
749 }
750}
751
752fn approx_owned_size(value: &OwnedDataValue) -> usize {
755 match value {
756 OwnedDataValue::Object(pairs) => {
757 NODE_SIZE
758 + pairs
759 .iter()
760 .map(|(k, v)| k.len() + approx_owned_size(v))
761 .sum::<usize>()
762 }
763 OwnedDataValue::Array(items) => {
764 NODE_SIZE + items.iter().map(approx_owned_size).sum::<usize>()
765 }
766 OwnedDataValue::String(s) => NODE_SIZE + s.len(),
767 _ => NODE_SIZE,
768 }
769}
770
771fn redact_json_in_place(value: &mut Value, paths: &[Vec<String>]) {
777 let refs: Vec<&[String]> = paths.iter().map(|p| p.as_slice()).collect();
778 redact_json_inner(value, &refs);
779}
780
781fn redact_json_inner(value: &mut Value, paths: &[&[String]]) {
782 if paths.is_empty() {
783 return;
784 }
785 if paths.iter().any(|p| p.is_empty()) {
786 *value = Value::Null;
787 return;
788 }
789
790 match value {
791 Value::Object(map) => {
792 for (key, child) in map.iter_mut() {
793 let sub = narrow_for_object_key(paths, key);
794 redact_json_inner(child, &sub);
795 }
796 }
797 Value::Array(items) => {
798 for (idx, child) in items.iter_mut().enumerate() {
799 let sub = narrow_for_array_index(paths, idx);
800 redact_json_inner(child, &sub);
801 }
802 }
803 _ => {}
804 }
805}
806
807fn approx_json_size(value: &Value) -> usize {
810 match value {
811 Value::Object(map) => {
812 NODE_SIZE
813 + map
814 .iter()
815 .map(|(k, v)| k.len() + approx_json_size(v))
816 .sum::<usize>()
817 }
818 Value::Array(items) => NODE_SIZE + items.iter().map(approx_json_size).sum::<usize>(),
819 Value::String(s) => NODE_SIZE + s.len(),
820 _ => NODE_SIZE,
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use serde_json::json;
828
829 fn dv(v: serde_json::Value) -> OwnedDataValue {
830 OwnedDataValue::from(&v)
831 }
832
833 fn segments(paths: &[&str]) -> Vec<Vec<String>> {
834 TraceOptions {
835 redact_paths: paths.iter().map(|s| s.to_string()).collect(),
836 ..Default::default()
837 }
838 .redact_segments()
839 }
840
841 #[test]
842 fn test_step_result_serialization() {
843 assert_eq!(
844 serde_json::to_string(&StepResult::Executed).unwrap(),
845 "\"executed\""
846 );
847 assert_eq!(
848 serde_json::to_string(&StepResult::Skipped).unwrap(),
849 "\"skipped\""
850 );
851 }
852
853 #[test]
854 fn test_execution_step_executed() {
855 let message = Message::from_value(&json!({"test": "data"}));
856 let step = ExecutionStep::executed("workflow1", "task1", &message);
857
858 assert_eq!(step.workflow_id, "workflow1");
859 assert_eq!(step.task_id, Some("task1".to_string()));
860 assert_eq!(step.result, StepResult::Executed);
861 assert!(step.message.is_some());
862 }
863
864 #[test]
865 fn test_execution_step_task_skipped() {
866 let step = ExecutionStep::task_skipped("workflow1", "task1");
867
868 assert_eq!(step.workflow_id, "workflow1");
869 assert_eq!(step.task_id, Some("task1".to_string()));
870 assert_eq!(step.result, StepResult::Skipped);
871 assert!(step.message.is_none());
872 }
873
874 #[test]
875 fn test_execution_step_workflow_skipped() {
876 let step = ExecutionStep::workflow_skipped("workflow1");
877
878 assert_eq!(step.workflow_id, "workflow1");
879 assert_eq!(step.task_id, None);
880 assert_eq!(step.result, StepResult::Skipped);
881 assert!(step.message.is_none());
882 }
883
884 #[test]
885 fn test_execution_step_with_mapping_contexts() {
886 let message = Message::from_value(&json!({"test": "data"}));
887 let contexts = vec![json!({"data": {"a": 1}}), json!({"data": {"a": 1, "b": 2}})];
888
889 let step = ExecutionStep::executed("workflow1", "task1", &message)
890 .with_mapping_contexts(contexts.clone());
891
892 assert_eq!(step.mapping_contexts, Some(contexts));
893
894 let serialized = serde_json::to_value(&step).unwrap();
896 assert!(serialized.get("mapping_contexts").is_some());
897 assert_eq!(serialized["mapping_contexts"].as_array().unwrap().len(), 2);
898 }
899
900 #[test]
901 fn test_execution_step_without_mapping_contexts_serialization() {
902 let message = Message::from_value(&json!({"test": "data"}));
903 let step = ExecutionStep::executed("workflow1", "task1", &message);
904
905 let serialized = serde_json::to_value(&step).unwrap();
907 assert!(serialized.get("mapping_contexts").is_none());
908 assert!(serialized.get("started_at").is_none());
909 assert!(serialized.get("duration_us").is_none());
910 assert!(serialized.get("changes").is_none());
911 }
912
913 #[test]
914 fn test_execution_trace() {
915 let mut trace = ExecutionTrace::new();
916 let message = Message::from_value(&json!({"test": "data"}));
917
918 trace.add_step(ExecutionStep::workflow_skipped("workflow0"));
919 trace.add_step(ExecutionStep::executed("workflow1", "task1", &message));
920 trace.add_step(ExecutionStep::task_skipped("workflow1", "task2"));
921
922 assert_eq!(trace.steps.len(), 3);
923 assert_eq!(trace.executed_count(), 1);
924 assert_eq!(trace.skipped_count(), 2);
925 assert!(trace.final_message().is_some());
926 assert!(trace.is_success());
927 }
928
929 #[test]
930 fn default_options_reproduce_historical_capture() {
931 let o = TraceOptions::default();
932 assert!(o.snapshots);
933 assert!(o.mapping_contexts);
934 assert!(!o.changes);
935 assert_eq!(o.max_snapshot_bytes, 0);
936 assert!(o.redact_paths.is_empty());
937 assert_eq!(o.snapshot_audit_trail, AuditTrailScope::Full);
938 }
939
940 #[test]
941 fn timings_only_drops_snapshots_and_keeps_the_diff() {
942 let o = TraceOptions::timings_only();
943 assert!(!o.snapshots);
944 assert!(!o.mapping_contexts);
945 assert!(o.changes);
946 }
947
948 #[test]
949 fn a_complete_trace_does_not_serialize_the_truncated_flag() {
950 let trace = ExecutionTrace::new();
951 let serialized = serde_json::to_value(&trace).unwrap();
952 assert!(
953 serialized.get("truncated").is_none(),
954 "a complete trace keeps the historical wire shape"
955 );
956 assert!(!trace.truncated());
957 }
958
959 #[test]
960 fn a_trace_deserializes_from_a_payload_without_the_truncated_flag() {
961 let trace: ExecutionTrace = serde_json::from_value(json!({ "steps": [] })).unwrap();
962 assert!(!trace.truncated());
963 }
964
965 #[test]
966 fn duration_clamps_a_backward_clock_step_to_zero() {
967 let start = Utc::now();
968 let earlier = start - chrono::Duration::seconds(5);
969 assert_eq!(duration_us_between(start, earlier), 0);
970 assert_eq!(duration_us_between(start, start), 0);
971 assert_eq!(
972 duration_us_between(start, start + chrono::Duration::microseconds(1500)),
973 1500
974 );
975 }
976
977 #[test]
978 fn redaction_nulls_only_the_named_subtree() {
979 let ctx = dv(json!({"data": {"secret": {"k": "v"}, "keep": 1}}));
980 let (out, _) = redacting_clone(&ctx, &segments(&["data.secret"]));
981 assert_eq!(
982 serde_json::Value::from(&out),
983 json!({"data": {"secret": null, "keep": 1}})
984 );
985 }
986
987 #[test]
988 fn redaction_of_an_unresolvable_path_creates_nothing() {
989 let ctx = dv(json!({"data": {"items": [1, 2, 3]}}));
992 let (out, _) = redacting_clone(&ctx, &segments(&["data.items.99"]));
993 assert_eq!(
994 serde_json::Value::from(&out),
995 json!({"data": {"items": [1, 2, 3]}})
996 );
997 }
998
999 #[test]
1000 fn redaction_through_a_non_container_is_a_noop() {
1001 let ctx = dv(json!({"data": {"name": "alice"}}));
1002 let (out, _) = redacting_clone(&ctx, &segments(&["data.name.first"]));
1003 assert_eq!(
1004 serde_json::Value::from(&out),
1005 json!({"data": {"name": "alice"}})
1006 );
1007 }
1008
1009 #[test]
1010 fn an_empty_redact_path_is_ignored() {
1011 let ctx = dv(json!({"data": {"a": 1}}));
1012 let (out, _) = redacting_clone(&ctx, &segments(&[""]));
1013 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": 1}}));
1014 }
1015
1016 #[test]
1017 fn redaction_honours_the_hash_escape() {
1018 let obj = dv(json!({"data": {"20": "secret", "other": 1}}));
1020 let (out, _) = redacting_clone(&obj, &segments(&["data.#20"]));
1021 assert_eq!(
1022 serde_json::Value::from(&out),
1023 json!({"data": {"20": null, "other": 1}})
1024 );
1025
1026 let arr = dv(json!({"data": [0, 1, 2]}));
1027 let (out, _) = redacting_clone(&arr, &segments(&["data.1"]));
1028 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, null, 2]}));
1029
1030 let (out, _) = redacting_clone(&arr, &segments(&["data.#1"]));
1032 assert_eq!(serde_json::Value::from(&out), json!({"data": [0, 1, 2]}));
1033 }
1034
1035 #[test]
1036 fn nested_and_duplicated_redact_paths_are_safe() {
1037 let ctx = dv(json!({"data": {"a": {"b": 1, "c": 2}}}));
1038
1039 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a.b"]));
1040 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1041
1042 let (out, _) = redacting_clone(&ctx, &segments(&["data.a", "data.a"]));
1043 assert_eq!(serde_json::Value::from(&out), json!({"data": {"a": null}}));
1044 }
1045
1046 #[test]
1047 fn redaction_matches_unicode_keys_and_sizes_strings_by_bytes() {
1048 let ctx = dv(json!({"data": {"café": "secret", "keep": "née"}}));
1049 let (out, size) = redacting_clone(&ctx, &segments(&["data.café"]));
1050 assert_eq!(
1051 serde_json::Value::from(&out),
1052 json!({"data": {"café": null, "keep": "née"}})
1053 );
1054
1055 let (_, unredacted) = redacting_clone(&ctx, &segments(&[]));
1057 assert!(unredacted > size, "redacting must lower the counted size");
1058 assert!(
1059 approx_owned_size(&dv(json!("née"))) == NODE_SIZE + 4,
1060 "str::len() bytes, not chars().count()"
1061 );
1062 }
1063
1064 #[test]
1065 fn redacted_size_agrees_with_redacting_clone() {
1066 let shapes = [
1069 json!({}),
1070 json!({"data": {"a": 1, "b": "hello"}}),
1071 json!({"data": {"items": [1, "two", {"three": 3}], "nested": {"x": {"y": "z"}}}}),
1072 json!({"data": {"secret": {"deep": [1, 2, 3]}, "keep": "café"}}),
1073 ];
1074 let path_sets: [&[&str]; 4] = [&[], &["data.secret"], &["data.items.1"], &["data.nope"]];
1075
1076 for shape in &shapes {
1077 for paths in path_sets {
1078 let v = dv(shape.clone());
1079 let segs = segments(paths);
1080 let (_, cloned_size) = redacting_clone(&v, &segs);
1081 assert_eq!(
1082 redacted_size(&v, &segs),
1083 cloned_size,
1084 "probe and clone disagree for {shape:?} with {paths:?}"
1085 );
1086 }
1087 }
1088 }
1089
1090 #[test]
1091 fn redaction_applies_to_mapping_contexts_too() {
1092 let mut ctx = json!({"data": {"secret": {"k": "v"}, "keep": 1}});
1093 redact_json_in_place(&mut ctx, &segments(&["data.secret"]));
1094 assert_eq!(ctx, json!({"data": {"secret": null, "keep": 1}}));
1095 }
1096
1097 #[test]
1098 fn json_redaction_shares_the_owned_path_semantics() {
1099 let mut arr = json!({"data": {"items": [1, 2, 3]}});
1100 redact_json_in_place(&mut arr, &segments(&["data.items.99"]));
1101 assert_eq!(arr, json!({"data": {"items": [1, 2, 3]}}));
1102
1103 let mut scalar = json!({"data": {"name": "alice"}});
1104 redact_json_in_place(&mut scalar, &segments(&["data.name.first"]));
1105 assert_eq!(scalar, json!({"data": {"name": "alice"}}));
1106
1107 let mut hash = json!({"data": {"20": "secret"}});
1108 redact_json_in_place(&mut hash, &segments(&["data.#20"]));
1109 assert_eq!(hash, json!({"data": {"20": null}}));
1110 }
1111
1112 #[test]
1113 fn execution_step_loop_counter_defaults_to_none_on_every_constructor() {
1114 assert_eq!(
1115 ExecutionStep::executed("w", "t", &Message::from_value(&json!({}))).loop_counter,
1116 None
1117 );
1118 assert_eq!(ExecutionStep::task_skipped("w", "t").loop_counter, None);
1119 assert_eq!(ExecutionStep::workflow_skipped("w").loop_counter, None);
1120 }
1121
1122 #[test]
1123 fn with_loop_counter_sets_and_clears_the_field() {
1124 let step = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(3));
1125 assert_eq!(step.loop_counter, Some(3));
1126 assert_eq!(step.with_loop_counter(None).loop_counter, None);
1129 }
1130
1131 #[test]
1132 fn execution_step_loop_counter_is_absent_from_json_when_none() {
1133 let step = ExecutionStep::task_skipped("w", "t");
1136 let json = serde_json::to_value(&step).expect("should serialize");
1137 assert!(json.get("loop_counter").is_none());
1138
1139 let looped = ExecutionStep::task_skipped("w", "t").with_loop_counter(Some(0));
1140 assert_eq!(
1141 serde_json::to_value(&looped).expect("should serialize")["loop_counter"],
1142 json!(0),
1143 "counter 0 must serialize, not be elided as a default"
1144 );
1145 }
1146
1147 #[test]
1148 fn execution_step_without_a_loop_counter_key_deserializes() {
1149 let step: ExecutionStep = serde_json::from_value(json!({
1151 "workflow_id": "w",
1152 "task_id": "t",
1153 "result": "skipped"
1154 }))
1155 .expect("legacy trace JSON should deserialize");
1156 assert_eq!(step.loop_counter, None);
1157 assert_eq!(step.workflow_id, "w");
1158 }
1159
1160 #[test]
1161 fn add_executed_step_records_the_timing_bundle_and_the_loop_counter() {
1162 let mut trace = ExecutionTrace::with_options(TraceOptions::timings_only());
1163 let started_at = Utc::now();
1164 trace.add_executed_step(
1165 "w",
1166 "t",
1167 &Message::from_value(&json!({})),
1168 StepTiming {
1169 started_at,
1170 duration_us: 42,
1171 },
1172 None,
1173 StepStamp {
1174 loop_counter: Some(7),
1175 element_index: Some(2),
1176 },
1177 );
1178
1179 let step = &trace.steps[0];
1180 assert_eq!(step.started_at, Some(started_at));
1181 assert_eq!(step.duration_us, Some(42));
1182 assert_eq!(step.loop_counter, Some(7));
1183 assert_eq!(step.element_index, Some(2));
1184 assert_eq!(step.result, StepResult::Executed);
1185 }
1186
1187 #[test]
1188 fn execution_step_element_index_is_absent_from_json_when_none() {
1189 let plain = ExecutionStep::task_skipped("w", "t");
1190 let json = serde_json::to_value(&plain).expect("should serialize");
1191 assert!(
1192 json.get("element_index").is_none(),
1193 "an ordinary step keeps its wire shape"
1194 );
1195
1196 let fanned = ExecutionStep::task_skipped("w", "t").with_element_index(Some(3));
1197 assert_eq!(
1198 serde_json::to_value(&fanned).expect("should serialize")["element_index"],
1199 json!(3)
1200 );
1201
1202 let back: ExecutionStep = serde_json::from_value(json).expect("should deserialize");
1203 assert_eq!(back.element_index, None, "a step without the key loads");
1204 }
1205}