greentic_dev/dev_runner/
transcript.rs

1use std::collections::HashSet;
2use std::error::Error;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9use serde_yaml_bw::{Mapping, Value as YamlValue};
10
11use super::runner::ValidatedNode;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NodeTranscript {
15    pub node_name: String,
16    pub resolved_config: YamlValue,
17    pub schema_id: Option<String>,
18    pub run_log: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FlowTranscript {
23    pub flow_name: String,
24    pub flow_path: String,
25    pub generated_at: u64,
26    pub nodes: Vec<NodeTranscript>,
27}
28
29#[derive(Clone, Debug)]
30pub struct TranscriptStore {
31    root: PathBuf,
32}
33
34#[derive(Debug)]
35pub enum TranscriptError {
36    Io(std::io::Error),
37    Serialize(serde_yaml_bw::Error),
38}
39
40impl TranscriptStore {
41    pub fn with_root<P: Into<PathBuf>>(root: P) -> Self {
42        Self { root: root.into() }
43    }
44
45    pub fn write_transcript<P>(
46        &self,
47        flow_path: P,
48        transcript: &FlowTranscript,
49    ) -> Result<PathBuf, TranscriptError>
50    where
51        P: AsRef<Path>,
52    {
53        let flow_path = flow_path.as_ref();
54        let flow_stem = flow_path
55            .file_stem()
56            .and_then(|stem| stem.to_str())
57            .unwrap_or("flow");
58
59        let output_path = self
60            .root
61            .join(format!("{}-{}.yaml", flow_stem, transcript.generated_at));
62
63        if let Some(parent) = output_path.parent() {
64            fs::create_dir_all(parent)?;
65        }
66
67        let serialized = serde_yaml_bw::to_string(transcript)?;
68        fs::write(&output_path, serialized)?;
69
70        Ok(output_path)
71    }
72}
73
74impl Default for TranscriptStore {
75    fn default() -> Self {
76        Self::with_root(".greentic/transcripts")
77    }
78}
79
80impl FlowTranscript {
81    pub fn from_validated_nodes<P: AsRef<Path>>(flow_path: P, nodes: &[ValidatedNode]) -> Self {
82        let flow_path_ref = flow_path.as_ref();
83        let flow_name = flow_path_ref
84            .file_name()
85            .and_then(|name| name.to_str())
86            .unwrap_or("flow")
87            .to_string();
88
89        let generated_at = SystemTime::now()
90            .duration_since(UNIX_EPOCH)
91            .unwrap_or_default()
92            .as_secs();
93
94        let node_transcripts = nodes.iter().map(node_transcript_from_validated).collect();
95
96        Self {
97            flow_name,
98            flow_path: flow_path_ref.to_string_lossy().to_string(),
99            generated_at,
100            nodes: node_transcripts,
101        }
102    }
103}
104
105impl NodeTranscript {
106    pub fn merged_config(&self) -> &YamlValue {
107        &self.resolved_config
108    }
109}
110
111impl fmt::Display for TranscriptError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            TranscriptError::Io(error) => write!(f, "failed to write transcript: {error}"),
115            TranscriptError::Serialize(error) => {
116                write!(f, "failed to serialize transcript: {error}")
117            }
118        }
119    }
120}
121
122impl Error for TranscriptError {
123    fn source(&self) -> Option<&(dyn Error + 'static)> {
124        match self {
125            TranscriptError::Io(error) => Some(error),
126            TranscriptError::Serialize(error) => Some(error),
127        }
128    }
129}
130
131impl From<std::io::Error> for TranscriptError {
132    fn from(value: std::io::Error) -> Self {
133        TranscriptError::Io(value)
134    }
135}
136
137impl From<serde_yaml_bw::Error> for TranscriptError {
138    fn from(value: serde_yaml_bw::Error) -> Self {
139        TranscriptError::Serialize(value)
140    }
141}
142
143fn node_transcript_from_validated(node: &ValidatedNode) -> NodeTranscript {
144    let (resolved_config, run_log) = merge_with_defaults(node.defaults.as_ref(), &node.node_config);
145    let node_name = node_name(&node.node_config, &node.component);
146
147    NodeTranscript {
148        node_name,
149        resolved_config,
150        schema_id: node.schema_id.clone(),
151        run_log,
152    }
153}
154
155fn node_name(node_config: &YamlValue, fallback: &str) -> String {
156    node_config
157        .as_mapping()
158        .and_then(|mapping| mapping.get("id"))
159        .and_then(|value| value.as_str())
160        .unwrap_or(fallback)
161        .to_string()
162}
163
164fn merge_with_defaults(
165    defaults: Option<&YamlValue>,
166    overrides: &YamlValue,
167) -> (YamlValue, Vec<String>) {
168    let mut run_log = Vec::new();
169    let mut path = Vec::new();
170    let resolved = merge_node(defaults, overrides, &mut path, &mut run_log);
171
172    // Deduplicate logs while preserving insertion order.
173    let mut seen = HashSet::new();
174    run_log.retain(|entry| seen.insert(entry.clone()));
175
176    (resolved, run_log)
177}
178
179fn merge_node(
180    defaults: Option<&YamlValue>,
181    overrides: &YamlValue,
182    path: &mut Vec<String>,
183    run_log: &mut Vec<String>,
184) -> YamlValue {
185    match (defaults, overrides) {
186        (Some(YamlValue::Mapping(default_map)), YamlValue::Mapping(override_map)) => {
187            let mut result = Mapping::new();
188
189            for (key, default_value) in default_map {
190                let key_str = key_to_segment(key);
191                path.push(key_str.clone());
192                if let Some(override_value) = override_map.get(key) {
193                    let merged = merge_node(Some(default_value), override_value, path, run_log);
194                    result.insert(key.clone(), merged);
195                } else {
196                    log_default(path, run_log);
197                    result.insert(key.clone(), default_value.clone());
198                }
199                path.pop();
200            }
201
202            for (key, override_value) in override_map {
203                if default_map.contains_key(key) {
204                    continue;
205                }
206                let key_str = key_to_segment(key);
207                path.push(key_str.clone());
208                log_override(path, run_log);
209                let merged = merge_node(None, override_value, path, run_log);
210                result.insert(key.clone(), merged);
211                path.pop();
212            }
213
214            YamlValue::Mapping(result)
215        }
216        (Some(YamlValue::Sequence(default_seq)), YamlValue::Sequence(override_seq)) => {
217            if let Some(path_str) = path_string(path) {
218                if default_seq == override_seq {
219                    run_log.push(format!("default: {path_str}"));
220                } else {
221                    run_log.push(format!("override: {path_str}"));
222                }
223            }
224            YamlValue::Sequence(override_seq.clone())
225        }
226        (Some(default_value), override_value) => {
227            if let Some(path_str) = path_string(path) {
228                if default_value == override_value {
229                    run_log.push(format!("default: {path_str}"));
230                } else {
231                    run_log.push(format!("override: {path_str}"));
232                }
233            }
234            override_value.clone()
235        }
236        (None, override_value) => {
237            if let Some(path_str) = path_string(path) {
238                run_log.push(format!("override: {path_str}"));
239            }
240            override_value.clone()
241        }
242    }
243}
244
245fn log_default(path: &[String], run_log: &mut Vec<String>) {
246    if let Some(path_str) = path_string(path) {
247        run_log.push(format!("default: {path_str}"));
248    }
249}
250
251fn log_override(path: &[String], run_log: &mut Vec<String>) {
252    if let Some(path_str) = path_string(path) {
253        run_log.push(format!("override: {path_str}"));
254    }
255}
256
257fn path_string(path: &[String]) -> Option<String> {
258    if path.is_empty() {
259        None
260    } else {
261        Some(path.join("."))
262    }
263}
264
265fn key_to_segment(key: &YamlValue) -> String {
266    key.as_str()
267        .map(|s| s.to_string())
268        .or_else(|| key.as_u64().map(|n| n.to_string()))
269        .unwrap_or_else(|| "unknown".to_string())
270}