1use noether_core::stage::StageId;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub struct CompositionTrace {
7 pub composition_id: String,
8 pub started_at: String,
9 pub duration_ms: u64,
10 pub status: TraceStatus,
11 pub stages: Vec<StageTrace>,
12 #[serde(default, skip_serializing_if = "Vec::is_empty")]
14 pub security_events: Vec<SecurityEvent>,
15 #[serde(default, skip_serializing_if = "Vec::is_empty")]
17 pub warnings: Vec<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct SecurityEvent {
23 pub stage_id: StageId,
24 pub capability: String,
25 pub message: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub enum TraceStatus {
30 Ok,
31 Failed,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct StageTrace {
36 pub stage_id: StageId,
37 pub step_index: usize,
38 pub status: StageStatus,
39 pub duration_ms: u64,
40 pub input_hash: Option<String>,
41 pub output_hash: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub enum StageStatus {
46 Ok,
47 Failed { code: String, message: String },
48 Skipped { reason: String },
49}
50
51#[derive(Debug, Default)]
53pub struct MemoryTraceStore {
54 traces: HashMap<String, CompositionTrace>,
55}
56
57impl MemoryTraceStore {
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn put(&mut self, trace: CompositionTrace) -> String {
63 let id = trace.composition_id.clone();
64 self.traces.insert(id.clone(), trace);
65 id
66 }
67
68 pub fn get(&self, composition_id: &str) -> Option<&CompositionTrace> {
69 self.traces.get(composition_id)
70 }
71
72 pub fn list(&self) -> Vec<&CompositionTrace> {
73 self.traces.values().collect()
74 }
75}
76
77#[cfg(feature = "native")]
79pub struct JsonFileTraceStore {
80 path: std::path::PathBuf,
81 traces: HashMap<String, CompositionTrace>,
82}
83
84#[cfg(feature = "native")]
85#[derive(Serialize, Deserialize)]
86struct TraceFile {
87 traces: Vec<CompositionTrace>,
88}
89
90#[cfg(feature = "native")]
91impl JsonFileTraceStore {
92 pub fn open(path: impl Into<std::path::PathBuf>) -> Result<Self, String> {
93 let path = path.into();
94 let traces = if path.exists() {
95 let content = std::fs::read_to_string(&path).map_err(|e| format!("read error: {e}"))?;
96 let file: TraceFile =
97 serde_json::from_str(&content).map_err(|e| format!("parse error: {e}"))?;
98 file.traces
99 .into_iter()
100 .map(|t| (t.composition_id.clone(), t))
101 .collect()
102 } else {
103 HashMap::new()
104 };
105 Ok(Self { path, traces })
106 }
107
108 pub fn put(&mut self, trace: CompositionTrace) -> String {
109 let id = trace.composition_id.clone();
110 self.traces.insert(id.clone(), trace);
111 let _ = self.save();
112 id
113 }
114
115 pub fn get(&self, composition_id: &str) -> Option<&CompositionTrace> {
116 self.traces.get(composition_id)
117 }
118
119 pub fn list(&self) -> Vec<&CompositionTrace> {
120 self.traces.values().collect()
121 }
122
123 fn save(&self) -> Result<(), String> {
124 if let Some(parent) = self.path.parent() {
125 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir error: {e}"))?;
126 }
127 let file = TraceFile {
128 traces: self.traces.values().cloned().collect(),
129 };
130 let json = serde_json::to_string_pretty(&file).map_err(|e| format!("json error: {e}"))?;
131 std::fs::write(&self.path, json).map_err(|e| format!("write error: {e}"))?;
132 Ok(())
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn sample_trace() -> CompositionTrace {
141 CompositionTrace {
142 composition_id: "abc123".into(),
143 started_at: "2026-04-05T10:00:00Z".into(),
144 duration_ms: 100,
145 status: TraceStatus::Ok,
146 stages: vec![StageTrace {
147 stage_id: StageId("stage1".into()),
148 step_index: 0,
149 status: StageStatus::Ok,
150 duration_ms: 50,
151 input_hash: Some("inhash".into()),
152 output_hash: Some("outhash".into()),
153 }],
154 security_events: Vec::new(),
155 warnings: Vec::new(),
156 }
157 }
158
159 #[test]
160 fn trace_store_put_get() {
161 let mut store = MemoryTraceStore::new();
162 let trace = sample_trace();
163 store.put(trace.clone());
164 let retrieved = store.get("abc123").unwrap();
165 assert_eq!(retrieved, &trace);
166 }
167
168 #[test]
169 fn trace_serde_round_trip() {
170 let trace = sample_trace();
171 let json = serde_json::to_string(&trace).unwrap();
172 let parsed: CompositionTrace = serde_json::from_str(&json).unwrap();
173 assert_eq!(trace, parsed);
174 }
175}