1use crate::model::state::StateFile;
4
5#[must_use]
32pub fn render_state(state: &StateFile) -> String {
33 let mut out = String::new();
34
35 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 let nodes: Vec<_> = state.nodes.iter().collect();
45 for (i, (node_id, node_state)) in nodes.iter().enumerate() {
46 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 let _ = i; }
71
72 out
77}
78
79#[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#[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#[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 #[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 #[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 #[test]
229 fn test_render_state_optional_fields_absent_when_none() {
230 let output = render_state(&full_state());
231 assert!(output.contains("state migration.002"));
234 assert!(output.contains("execution_status: pending"));
235 }
236
237 #[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 #[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 #[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 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 #[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}