Skip to main content

agm_core/renderer/
state.rs

1//! Renderer for `.agm.state` sidecar files.
2
3use crate::model::state::StateFile;
4
5// ---------------------------------------------------------------------------
6// render_state (canonical text format)
7// ---------------------------------------------------------------------------
8
9/// Renders a [`StateFile`] to its canonical `.agm.state` text format.
10///
11/// The output is round-trippable: `parse_state(&render_state(sf)) == sf`.
12///
13/// Format:
14/// ```text
15/// # agm.state: {format_version}
16/// # package: {package}
17/// # version: {version}
18/// # session_id: {session_id}
19/// # started_at: {started_at}
20/// # updated_at: {updated_at}
21///
22/// state {node_id}
23/// execution_status: {status}
24/// [executed_by: {val}]    <- only if Some
25/// [executed_at: {val}]    <- only if Some
26/// retry_count: {val}      <- always emit
27/// [execution_log: {val}]  <- only if Some
28/// ```
29///
30/// Blocks are separated by a single blank line. No trailing blank line.
31#[must_use]
32pub fn render_state(state: &StateFile) -> String {
33    let mut out = String::new();
34
35    // Header
36    out.push_str(&format!("# agm.state: {}\n", state.format_version));
37    out.push_str(&format!("# package: {}\n", state.package));
38    out.push_str(&format!("# version: {}\n", state.version));
39    out.push_str(&format!("# session_id: {}\n", state.session_id));
40    out.push_str(&format!("# started_at: {}\n", state.started_at));
41    out.push_str(&format!("# updated_at: {}\n", state.updated_at));
42
43    // Node blocks
44    let nodes: Vec<_> = state.nodes.iter().collect();
45    for (i, (node_id, node_state)) in nodes.iter().enumerate() {
46        // Blank line separating header from first block, and between blocks
47        out.push('\n');
48
49        out.push_str(&format!("state {node_id}\n"));
50        out.push_str(&format!(
51            "execution_status: {}\n",
52            node_state.execution_status
53        ));
54
55        if let Some(ref by) = node_state.executed_by {
56            out.push_str(&format!("executed_by: {by}\n"));
57        }
58        if let Some(ref at) = node_state.executed_at {
59            out.push_str(&format!("executed_at: {at}\n"));
60        }
61
62        out.push_str(&format!("retry_count: {}\n", node_state.retry_count));
63
64        if let Some(ref log) = node_state.execution_log {
65            out.push_str(&format!("execution_log: {log}\n"));
66        }
67
68        // Blank line between blocks (but not after the last one)
69        let _ = i; // suppress unused variable warning
70    }
71
72    // Remove the trailing newline from any trailing blank line.
73    // The loop above already ends each block without a trailing blank,
74    // so nothing to trim — just ensure no double newline at very end.
75    // The format already produces no trailing blank by construction.
76    out
77}
78
79// ---------------------------------------------------------------------------
80// render_state_json
81// ---------------------------------------------------------------------------
82
83/// Renders a [`StateFile`] to pretty-printed JSON.
84#[must_use]
85pub fn render_state_json(state: &StateFile) -> String {
86    serde_json::to_string_pretty(state).expect("StateFile serialization cannot fail")
87}
88
89// ---------------------------------------------------------------------------
90// render_state_sql
91// ---------------------------------------------------------------------------
92
93/// Renders a [`StateFile`] to SQL INSERT statements.
94///
95/// Escapes single quotes by doubling them (`'` -> `''`). Uses `NULL` for
96/// `None` optional fields.
97#[must_use]
98pub fn render_state_sql(state: &StateFile) -> String {
99    fn escape(s: &str) -> String {
100        s.replace('\'', "''")
101    }
102
103    fn opt_str(opt: &Option<String>) -> String {
104        match opt {
105            Some(s) => format!("'{}'", escape(s)),
106            None => "NULL".to_owned(),
107        }
108    }
109
110    let mut out = String::new();
111
112    out.push_str(&format!(
113        "INSERT INTO agm_session (format_version, package, version, session_id, started_at, updated_at) VALUES ('{}', '{}', '{}', '{}', '{}', '{}');\n",
114        escape(&state.format_version),
115        escape(&state.package),
116        escape(&state.version),
117        escape(&state.session_id),
118        escape(&state.started_at),
119        escape(&state.updated_at),
120    ));
121
122    for (node_id, node_state) in &state.nodes {
123        out.push_str(&format!(
124            "INSERT INTO agm_node_state (session_id, node_id, execution_status, executed_by, executed_at, retry_count, execution_log) VALUES ('{}', '{}', '{}', {}, {}, {}, {});\n",
125            escape(&state.session_id),
126            escape(node_id),
127            escape(&node_state.execution_status.to_string()),
128            opt_str(&node_state.executed_by),
129            opt_str(&node_state.executed_at),
130            node_state.retry_count,
131            opt_str(&node_state.execution_log),
132        ));
133    }
134
135    out
136}
137
138// ---------------------------------------------------------------------------
139// Tests
140// ---------------------------------------------------------------------------
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::model::execution::ExecutionStatus;
146    use crate::model::state::{NodeState, StateFile};
147    use std::collections::BTreeMap;
148
149    fn minimal_state() -> StateFile {
150        StateFile {
151            format_version: "1.0".to_owned(),
152            package: "test.pkg".to_owned(),
153            version: "0.1.0".to_owned(),
154            session_id: "run-001".to_owned(),
155            started_at: "2026-04-08T10:00:00Z".to_owned(),
156            updated_at: "2026-04-08T10:00:00Z".to_owned(),
157            nodes: BTreeMap::new(),
158        }
159    }
160
161    fn full_state() -> StateFile {
162        let mut nodes = BTreeMap::new();
163        nodes.insert(
164            "migration.001".to_owned(),
165            NodeState {
166                execution_status: ExecutionStatus::Completed,
167                executed_by: Some("shell-agent".to_owned()),
168                executed_at: Some("2026-04-08T10:05:00Z".to_owned()),
169                execution_log: Some(".agm/logs/migration.001.log".to_owned()),
170                retry_count: 1,
171            },
172        );
173        nodes.insert(
174            "migration.002".to_owned(),
175            NodeState {
176                execution_status: ExecutionStatus::Pending,
177                executed_by: None,
178                executed_at: None,
179                execution_log: None,
180                retry_count: 0,
181            },
182        );
183        StateFile {
184            format_version: "1.0".to_owned(),
185            package: "acme.migration".to_owned(),
186            version: "1.0.0".to_owned(),
187            session_id: "run-2026-04-08".to_owned(),
188            started_at: "2026-04-08T15:32:00Z".to_owned(),
189            updated_at: "2026-04-08T15:35:00Z".to_owned(),
190            nodes,
191        }
192    }
193
194    // -----------------------------------------------------------------------
195    // A: Headers present in canonical output
196    // -----------------------------------------------------------------------
197
198    #[test]
199    fn test_render_state_headers_present() {
200        let output = render_state(&minimal_state());
201        assert!(output.contains("# agm.state: 1.0"));
202        assert!(output.contains("# package: test.pkg"));
203        assert!(output.contains("# version: 0.1.0"));
204        assert!(output.contains("# session_id: run-001"));
205        assert!(output.contains("# started_at: 2026-04-08T10:00:00Z"));
206        assert!(output.contains("# updated_at: 2026-04-08T10:00:00Z"));
207    }
208
209    // -----------------------------------------------------------------------
210    // B: Node block fields present
211    // -----------------------------------------------------------------------
212
213    #[test]
214    fn test_render_state_node_block_fields_present() {
215        let output = render_state(&full_state());
216        assert!(output.contains("state migration.001"));
217        assert!(output.contains("execution_status: completed"));
218        assert!(output.contains("executed_by: shell-agent"));
219        assert!(output.contains("executed_at: 2026-04-08T10:05:00Z"));
220        assert!(output.contains("execution_log: .agm/logs/migration.001.log"));
221        assert!(output.contains("retry_count: 1"));
222    }
223
224    // -----------------------------------------------------------------------
225    // C: Optional fields absent when None
226    // -----------------------------------------------------------------------
227
228    #[test]
229    fn test_render_state_optional_fields_absent_when_none() {
230        let output = render_state(&full_state());
231        // migration.002 has no executed_by/at/log
232        // We just check retry_count is present for migration.002
233        assert!(output.contains("state migration.002"));
234        assert!(output.contains("execution_status: pending"));
235    }
236
237    // -----------------------------------------------------------------------
238    // D: Roundtrip
239    // -----------------------------------------------------------------------
240
241    #[test]
242    fn test_render_state_roundtrip_full() {
243        use crate::parser::state::parse_state;
244
245        let state = full_state();
246        let rendered = render_state(&state);
247        let parsed = parse_state(&rendered).expect("roundtrip parse failed");
248        assert_eq!(state, parsed);
249    }
250
251    #[test]
252    fn test_render_state_roundtrip_minimal() {
253        use crate::parser::state::parse_state;
254
255        let state = minimal_state();
256        let rendered = render_state(&state);
257        let parsed = parse_state(&rendered).expect("roundtrip parse failed");
258        assert_eq!(state, parsed);
259    }
260
261    // -----------------------------------------------------------------------
262    // E: JSON output is valid
263    // -----------------------------------------------------------------------
264
265    #[test]
266    fn test_render_state_json_valid() {
267        let json = render_state_json(&full_state());
268        let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
269        assert_eq!(parsed["package"], "acme.migration");
270        assert!(parsed["nodes"].is_object());
271    }
272
273    // -----------------------------------------------------------------------
274    // F: SQL output
275    // -----------------------------------------------------------------------
276
277    #[test]
278    fn test_render_state_sql_contains_session_insert() {
279        let sql = render_state_sql(&full_state());
280        assert!(sql.contains("INSERT INTO agm_session"));
281        assert!(sql.contains("acme.migration"));
282    }
283
284    #[test]
285    fn test_render_state_sql_contains_node_insert() {
286        let sql = render_state_sql(&full_state());
287        assert!(sql.contains("INSERT INTO agm_node_state"));
288        assert!(sql.contains("completed"));
289    }
290
291    #[test]
292    fn test_render_state_sql_null_for_none_fields() {
293        let sql = render_state_sql(&full_state());
294        // migration.002 has no executed_by — should use NULL
295        assert!(sql.contains("NULL"));
296    }
297
298    #[test]
299    fn test_render_state_sql_escapes_single_quotes() {
300        let mut state = minimal_state();
301        state.package = "it's.pkg".to_owned();
302        let sql = render_state_sql(&state);
303        assert!(sql.contains("it''s.pkg"));
304    }
305
306    // -----------------------------------------------------------------------
307    // G: Snapshot tests
308    // -----------------------------------------------------------------------
309
310    #[test]
311    fn test_render_state_snapshot_minimal() {
312        let output = render_state(&minimal_state());
313        insta::assert_snapshot!("render_state_minimal", output);
314    }
315
316    #[test]
317    fn test_render_state_snapshot_full() {
318        let output = render_state(&full_state());
319        insta::assert_snapshot!("render_state_full", output);
320    }
321}