Skip to main content

agm_core/renderer/
mem.rs

1//! Renderer for `.agm.mem` sidecar files.
2
3use crate::model::mem_file::MemFile;
4
5// ---------------------------------------------------------------------------
6// render_mem (canonical text format)
7// ---------------------------------------------------------------------------
8
9/// Renders a [`MemFile`] to its canonical `.agm.mem` text format.
10///
11/// The output is round-trippable: `parse_mem(&render_mem(mf)) == mf`.
12///
13/// Multi-line values are serialised with the first line on the `value:` line,
14/// and subsequent lines indented with 2 spaces.
15///
16/// Format:
17/// ```text
18/// # agm.mem: {format_version}
19/// # package: {package}
20/// # updated_at: {updated_at}
21///
22/// entry {key}
23/// topic: {topic}
24/// scope: {scope}
25/// ttl: {ttl}
26/// value: {first_line}
27///   {continuation_line_2}
28///   {continuation_line_3}
29/// created_at: {ts}
30/// updated_at: {ts}
31/// ```
32///
33/// Blocks are separated by a single blank line. No trailing blank line.
34#[must_use]
35pub fn render_mem(mem: &MemFile) -> String {
36    let mut out = String::new();
37
38    // Header
39    out.push_str(&format!("# agm.mem: {}\n", mem.format_version));
40    out.push_str(&format!("# package: {}\n", mem.package));
41    out.push_str(&format!("# updated_at: {}\n", mem.updated_at));
42
43    // Entry blocks
44    for (key, entry) in &mem.entries {
45        out.push('\n');
46
47        out.push_str(&format!("entry {key}\n"));
48        out.push_str(&format!("topic: {}\n", entry.topic));
49        out.push_str(&format!("scope: {}\n", entry.scope));
50        out.push_str(&format!("ttl: {}\n", entry.ttl));
51
52        // Multi-line value: first line on `value:` line, rest indented 2 spaces
53        let mut value_lines = entry.value.split('\n');
54        if let Some(first) = value_lines.next() {
55            out.push_str(&format!("value: {first}\n"));
56            for cont in value_lines {
57                out.push_str(&format!("  {cont}\n"));
58            }
59        } else {
60            out.push_str("value: \n");
61        }
62
63        out.push_str(&format!("created_at: {}\n", entry.created_at));
64        out.push_str(&format!("updated_at: {}\n", entry.updated_at));
65    }
66
67    out
68}
69
70// ---------------------------------------------------------------------------
71// render_mem_json
72// ---------------------------------------------------------------------------
73
74/// Renders a [`MemFile`] to pretty-printed JSON.
75#[must_use]
76pub fn render_mem_json(mem: &MemFile) -> String {
77    serde_json::to_string_pretty(mem).expect("MemFile serialization cannot fail")
78}
79
80// ---------------------------------------------------------------------------
81// render_mem_sql
82// ---------------------------------------------------------------------------
83
84/// Renders a [`MemFile`] to SQL INSERT statements.
85///
86/// Escapes single quotes by doubling them (`'` -> `''`).
87#[must_use]
88pub fn render_mem_sql(mem: &MemFile) -> String {
89    fn escape(s: &str) -> String {
90        s.replace('\'', "''")
91    }
92
93    let mut out = String::new();
94
95    for (key, entry) in &mem.entries {
96        out.push_str(&format!(
97            "INSERT INTO agm_memory (package, entry_key, topic, scope, ttl, value, created_at, updated_at) VALUES ('{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}');\n",
98            escape(&mem.package),
99            escape(key),
100            escape(&entry.topic),
101            escape(&entry.scope.to_string()),
102            escape(&entry.ttl.to_string()),
103            escape(&entry.value),
104            escape(&entry.created_at),
105            escape(&entry.updated_at),
106        ));
107    }
108
109    out
110}
111
112// ---------------------------------------------------------------------------
113// Tests
114// ---------------------------------------------------------------------------
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::model::mem_file::{MemFile, MemFileEntry};
120    use crate::model::memory::{MemoryScope, MemoryTtl};
121    use std::collections::BTreeMap;
122
123    fn minimal_mem() -> MemFile {
124        MemFile {
125            format_version: "1.0".to_owned(),
126            package: "test.pkg".to_owned(),
127            updated_at: "2026-04-08T10:00:00Z".to_owned(),
128            entries: BTreeMap::new(),
129        }
130    }
131
132    fn make_entry(
133        key: &str,
134        value: &str,
135        ttl: MemoryTtl,
136        scope: MemoryScope,
137    ) -> (String, MemFileEntry) {
138        (
139            key.to_owned(),
140            MemFileEntry {
141                topic: "infrastructure".to_owned(),
142                scope,
143                ttl,
144                value: value.to_owned(),
145                created_at: "2026-04-08T10:00:00Z".to_owned(),
146                updated_at: "2026-04-08T10:00:00Z".to_owned(),
147            },
148        )
149    }
150
151    fn full_mem() -> MemFile {
152        let mut entries = BTreeMap::new();
153        let (k, e) = make_entry(
154            "project.db_version",
155            "PostgreSQL 15.2",
156            MemoryTtl::Permanent,
157            MemoryScope::Project,
158        );
159        entries.insert(k, e);
160        MemFile {
161            format_version: "1.0".to_owned(),
162            package: "acme.migration".to_owned(),
163            updated_at: "2026-04-08T15:30:00Z".to_owned(),
164            entries,
165        }
166    }
167
168    // -----------------------------------------------------------------------
169    // A: Headers present
170    // -----------------------------------------------------------------------
171
172    #[test]
173    fn test_render_mem_headers_present() {
174        let output = render_mem(&minimal_mem());
175        assert!(output.contains("# agm.mem: 1.0"));
176        assert!(output.contains("# package: test.pkg"));
177        assert!(output.contains("# updated_at: 2026-04-08T10:00:00Z"));
178    }
179
180    // -----------------------------------------------------------------------
181    // B: Entry fields present
182    // -----------------------------------------------------------------------
183
184    #[test]
185    fn test_render_mem_entry_fields_present() {
186        let output = render_mem(&full_mem());
187        assert!(output.contains("entry project.db_version"));
188        assert!(output.contains("topic: infrastructure"));
189        assert!(output.contains("scope: project"));
190        assert!(output.contains("ttl: permanent"));
191        assert!(output.contains("value: PostgreSQL 15.2"));
192        assert!(output.contains("created_at: 2026-04-08T10:00:00Z"));
193        assert!(output.contains("updated_at: 2026-04-08T10:00:00Z"));
194    }
195
196    // -----------------------------------------------------------------------
197    // C: Multi-line value rendered with 2-space indent
198    // -----------------------------------------------------------------------
199
200    #[test]
201    fn test_render_mem_multiline_value_indented() {
202        let mut entries = BTreeMap::new();
203        let (k, e) = make_entry(
204            "ml.entry",
205            "line one\nline two\nline three",
206            MemoryTtl::Permanent,
207            MemoryScope::Project,
208        );
209        entries.insert(k, e);
210        let mem = MemFile {
211            format_version: "1.0".to_owned(),
212            package: "test.pkg".to_owned(),
213            updated_at: "2026-04-08T10:00:00Z".to_owned(),
214            entries,
215        };
216        let output = render_mem(&mem);
217        assert!(output.contains("value: line one\n  line two\n  line three\n"));
218    }
219
220    // -----------------------------------------------------------------------
221    // D: Roundtrip — single-line value
222    // -----------------------------------------------------------------------
223
224    #[test]
225    fn test_render_mem_roundtrip_single_line_value() {
226        use crate::parser::mem::parse_mem;
227
228        let mem = full_mem();
229        let rendered = render_mem(&mem);
230        let parsed = parse_mem(&rendered).expect("roundtrip parse failed");
231        assert_eq!(mem, parsed);
232    }
233
234    // -----------------------------------------------------------------------
235    // E: Roundtrip — multi-line value
236    // -----------------------------------------------------------------------
237
238    #[test]
239    fn test_render_mem_roundtrip_multiline_value() {
240        use crate::parser::mem::parse_mem;
241
242        let mut entries = BTreeMap::new();
243        let (k, e) = make_entry(
244            "ml.entry",
245            "first line\nsecond line\nthird line",
246            MemoryTtl::Permanent,
247            MemoryScope::Project,
248        );
249        entries.insert(k, e);
250        let mem = MemFile {
251            format_version: "1.0".to_owned(),
252            package: "test.pkg".to_owned(),
253            updated_at: "2026-04-08T10:00:00Z".to_owned(),
254            entries,
255        };
256        let rendered = render_mem(&mem);
257        let parsed = parse_mem(&rendered).expect("roundtrip parse failed");
258        assert_eq!(mem, parsed);
259    }
260
261    // -----------------------------------------------------------------------
262    // F: Roundtrip — minimal (no entries)
263    // -----------------------------------------------------------------------
264
265    #[test]
266    fn test_render_mem_roundtrip_minimal() {
267        use crate::parser::mem::parse_mem;
268
269        let mem = minimal_mem();
270        let rendered = render_mem(&mem);
271        let parsed = parse_mem(&rendered).expect("roundtrip parse failed");
272        assert_eq!(mem, parsed);
273    }
274
275    // -----------------------------------------------------------------------
276    // G: JSON output is valid
277    // -----------------------------------------------------------------------
278
279    #[test]
280    fn test_render_mem_json_valid() {
281        let json = render_mem_json(&full_mem());
282        let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
283        assert_eq!(parsed["package"], "acme.migration");
284        assert!(parsed["entries"].is_object());
285    }
286
287    // -----------------------------------------------------------------------
288    // H: SQL output
289    // -----------------------------------------------------------------------
290
291    #[test]
292    fn test_render_mem_sql_contains_insert() {
293        let sql = render_mem_sql(&full_mem());
294        assert!(sql.contains("INSERT INTO agm_memory"));
295        assert!(sql.contains("acme.migration"));
296        assert!(sql.contains("project.db_version"));
297    }
298
299    #[test]
300    fn test_render_mem_sql_escapes_single_quotes() {
301        let mut entries = BTreeMap::new();
302        let (k, e) = make_entry(
303            "test.entry",
304            "it's a value",
305            MemoryTtl::Permanent,
306            MemoryScope::Project,
307        );
308        entries.insert(k, e);
309        let mem = MemFile {
310            format_version: "1.0".to_owned(),
311            package: "test.pkg".to_owned(),
312            updated_at: "2026-04-08T10:00:00Z".to_owned(),
313            entries,
314        };
315        let sql = render_mem_sql(&mem);
316        assert!(sql.contains("it''s a value"));
317    }
318
319    // -----------------------------------------------------------------------
320    // I: Snapshot tests
321    // -----------------------------------------------------------------------
322
323    #[test]
324    fn test_render_mem_snapshot_minimal() {
325        let output = render_mem(&minimal_mem());
326        insta::assert_snapshot!("render_mem_minimal", output);
327    }
328
329    #[test]
330    fn test_render_mem_snapshot_full() {
331        let output = render_mem(&full_mem());
332        insta::assert_snapshot!("render_mem_full", output);
333    }
334}