Skip to main content

agm_core/parser/
mem.rs

1//! Parser for `.agm.mem` sidecar files.
2
3use std::collections::BTreeMap;
4use std::str::FromStr;
5
6use crate::error::{AgmError, ErrorCode, ErrorLocation};
7use crate::model::mem_file::{MemFile, MemFileEntry};
8use crate::model::memory::{MemoryScope, MemoryTtl};
9use crate::parser::ParseResult;
10use crate::parser::sidecar::{SidecarLineKind, lex_sidecar};
11
12// ---------------------------------------------------------------------------
13// parse_mem
14// ---------------------------------------------------------------------------
15
16/// Parses raw `.agm.mem` text into a [`MemFile`].
17///
18/// Returns `Err(Vec<AgmError>)` if any Error-severity diagnostics are
19/// produced (missing required headers/fields, bad enum values, duplicate
20/// entry keys). Warnings (unknown fields) keep the parse alive.
21pub fn parse_mem(input: &str) -> ParseResult<MemFile> {
22    let lines = lex_sidecar(input)?;
23    let mut pos = 0;
24    let mut errors: Vec<AgmError> = Vec::new();
25
26    // ------------------------------------------------------------------
27    // 1. Consume header lines
28    // ------------------------------------------------------------------
29    let mut format_version: Option<String> = None;
30    let mut package: Option<String> = None;
31    let mut updated_at: Option<String> = None;
32
33    while pos < lines.len() {
34        match &lines[pos].kind {
35            SidecarLineKind::Blank | SidecarLineKind::Comment(_) => {
36                pos += 1;
37            }
38            SidecarLineKind::Header(key, value) => {
39                match key.as_str() {
40                    "agm.mem" => format_version = Some(value.clone()),
41                    "package" => package = Some(value.clone()),
42                    "updated_at" => updated_at = Some(value.clone()),
43                    _ => {
44                        errors.push(AgmError::new(
45                            ErrorCode::P009,
46                            format!("Unknown header field '{}' in mem file", key),
47                            ErrorLocation::new(None, Some(lines[pos].number), None),
48                        ));
49                    }
50                }
51                pos += 1;
52            }
53            _ => break,
54        }
55    }
56
57    // Validate required headers
58    for (field, present) in [
59        ("agm.mem", format_version.is_some()),
60        ("package", package.is_some()),
61        ("updated_at", updated_at.is_some()),
62    ] {
63        if !present {
64            errors.push(AgmError::new(
65                ErrorCode::P001,
66                format!("Missing required header field '{field}' in mem file"),
67                ErrorLocation::new(None, Some(1), None),
68            ));
69        }
70    }
71
72    // ------------------------------------------------------------------
73    // 2. Parse entry blocks
74    // ------------------------------------------------------------------
75    let mut entries: BTreeMap<String, MemFileEntry> = BTreeMap::new();
76
77    while pos < lines.len() {
78        match &lines[pos].kind {
79            SidecarLineKind::Blank | SidecarLineKind::Comment(_) => {
80                pos += 1;
81            }
82            SidecarLineKind::BlockDecl(keyword, entry_key) if keyword == "entry" => {
83                let entry_key = entry_key.clone();
84                let block_line = lines[pos].number;
85                pos += 1;
86
87                let mut topic: Option<String> = None;
88                let mut scope: Option<MemoryScope> = None;
89                let mut ttl: Option<MemoryTtl> = None;
90                let mut value: Option<String> = None;
91                let mut created_at: Option<String> = None;
92                let mut entry_updated_at: Option<String> = None;
93
94                while pos < lines.len() {
95                    match &lines[pos].kind {
96                        SidecarLineKind::Blank => break,
97                        SidecarLineKind::Comment(_) => {
98                            pos += 1;
99                        }
100                        SidecarLineKind::BlockDecl(_, _) => break,
101                        SidecarLineKind::Field(key, fvalue) => {
102                            let field_line = lines[pos].number;
103                            match key.as_str() {
104                                "topic" => topic = Some(fvalue.clone()),
105                                "scope" => match MemoryScope::from_str(fvalue) {
106                                    Ok(s) => scope = Some(s),
107                                    Err(_) => {
108                                        errors.push(AgmError::new(
109                                            ErrorCode::P003,
110                                            format!(
111                                                "Invalid scope value '{}' in entry '{}'",
112                                                fvalue, entry_key
113                                            ),
114                                            ErrorLocation::new(None, Some(field_line), None),
115                                        ));
116                                    }
117                                },
118                                "ttl" => match MemoryTtl::from_str(fvalue) {
119                                    Ok(t) => ttl = Some(t),
120                                    Err(_) => {
121                                        errors.push(AgmError::new(
122                                            ErrorCode::P003,
123                                            format!(
124                                                "Invalid ttl value '{}' in entry '{}'",
125                                                fvalue, entry_key
126                                            ),
127                                            ErrorLocation::new(None, Some(field_line), None),
128                                        ));
129                                    }
130                                },
131                                "value" => {
132                                    // Collect potential continuation lines
133                                    let mut collected = fvalue.clone();
134                                    pos += 1;
135                                    while pos < lines.len() {
136                                        if let SidecarLineKind::Continuation(cont) =
137                                            &lines[pos].kind
138                                        {
139                                            collected.push('\n');
140                                            collected.push_str(cont);
141                                            pos += 1;
142                                        } else {
143                                            break;
144                                        }
145                                    }
146                                    value = Some(collected);
147                                    continue; // pos already advanced
148                                }
149                                "created_at" => created_at = Some(fvalue.clone()),
150                                "updated_at" => entry_updated_at = Some(fvalue.clone()),
151                                unknown => {
152                                    errors.push(AgmError::new(
153                                        ErrorCode::P009,
154                                        format!(
155                                            "Unknown field '{}' in entry block '{}'",
156                                            unknown, entry_key
157                                        ),
158                                        ErrorLocation::new(None, Some(field_line), None),
159                                    ));
160                                }
161                            }
162                            pos += 1;
163                        }
164                        SidecarLineKind::Header(_, _) | SidecarLineKind::Continuation(_) => {
165                            pos += 1;
166                        }
167                    }
168                }
169
170                // Validate required fields
171                let mut entry_errors = false;
172                for (field, present) in [
173                    ("topic", topic.is_some()),
174                    ("scope", scope.is_some()),
175                    ("ttl", ttl.is_some()),
176                    ("value", value.is_some()),
177                    ("created_at", created_at.is_some()),
178                    ("updated_at", entry_updated_at.is_some()),
179                ] {
180                    if !present {
181                        errors.push(AgmError::new(
182                            ErrorCode::P001,
183                            format!(
184                                "Missing required field '{}' in entry block '{}'",
185                                field, entry_key
186                            ),
187                            ErrorLocation::new(None, Some(block_line), None),
188                        ));
189                        entry_errors = true;
190                    }
191                }
192
193                if !entry_errors {
194                    // Duplicate entry key check
195                    use std::collections::btree_map::Entry;
196                    match entries.entry(entry_key.clone()) {
197                        Entry::Occupied(_) => {
198                            errors.push(AgmError::new(
199                                ErrorCode::P006,
200                                format!("Duplicate entry key '{}' in mem file", entry_key),
201                                ErrorLocation::new(None, Some(block_line), None),
202                            ));
203                        }
204                        Entry::Vacant(slot) => {
205                            slot.insert(MemFileEntry {
206                                topic: topic.unwrap(),
207                                scope: scope.unwrap(),
208                                ttl: ttl.unwrap(),
209                                value: value.unwrap(),
210                                created_at: created_at.unwrap(),
211                                updated_at: entry_updated_at.unwrap(),
212                            });
213                        }
214                    }
215                }
216            }
217            SidecarLineKind::BlockDecl(keyword, _) => {
218                errors.push(AgmError::new(
219                    ErrorCode::P003,
220                    format!(
221                        "Unexpected block keyword '{}' in mem file (expected 'entry')",
222                        keyword
223                    ),
224                    ErrorLocation::new(None, Some(lines[pos].number), None),
225                ));
226                pos += 1;
227            }
228            SidecarLineKind::Field(key, _) => {
229                errors.push(AgmError::new(
230                    ErrorCode::P003,
231                    format!("Field '{}' outside of an 'entry' block", key),
232                    ErrorLocation::new(None, Some(lines[pos].number), None),
233                ));
234                pos += 1;
235            }
236            SidecarLineKind::Continuation(_) | SidecarLineKind::Header(_, _) => {
237                pos += 1;
238            }
239        }
240    }
241
242    // ------------------------------------------------------------------
243    // 3. Return
244    // ------------------------------------------------------------------
245    if errors.iter().any(|e| e.is_error()) {
246        Err(errors)
247    } else {
248        Ok(MemFile {
249            format_version: format_version.unwrap_or_default(),
250            package: package.unwrap_or_default(),
251            updated_at: updated_at.unwrap_or_default(),
252            entries,
253        })
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Tests
259// ---------------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::error::ErrorCode;
265    use crate::model::memory::{MemoryScope, MemoryTtl};
266
267    fn minimal_mem() -> &'static str {
268        "# agm.mem: 1.0\n\
269         # package: test.pkg\n\
270         # updated_at: 2026-04-08T10:00:00Z\n"
271    }
272
273    fn errors_contain(errors: &[AgmError], code: ErrorCode) -> bool {
274        errors.iter().any(|e| e.code == code)
275    }
276
277    fn full_entry(key: &str) -> String {
278        format!(
279            "entry {key}\n\
280             topic: infrastructure\n\
281             scope: project\n\
282             ttl: permanent\n\
283             value: some value\n\
284             created_at: 2026-04-08T10:00:00Z\n\
285             updated_at: 2026-04-08T10:00:00Z\n"
286        )
287    }
288
289    // -----------------------------------------------------------------------
290    // A: Valid minimal — no entries
291    // -----------------------------------------------------------------------
292
293    #[test]
294    fn test_parse_mem_minimal_valid_returns_ok() {
295        let result = parse_mem(minimal_mem());
296        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
297        let mf = result.unwrap();
298        assert_eq!(mf.format_version, "1.0");
299        assert_eq!(mf.package, "test.pkg");
300        assert!(mf.entries.is_empty());
301    }
302
303    // -----------------------------------------------------------------------
304    // B: Valid full — with entries
305    // -----------------------------------------------------------------------
306
307    #[test]
308    fn test_parse_mem_full_valid_returns_entries() {
309        let input = format!(
310            "{}\n\
311             entry project.db_version\n\
312             topic: infrastructure\n\
313             scope: project\n\
314             ttl: permanent\n\
315             value: PostgreSQL 15.2\n\
316             created_at: 2026-04-08T15:30:00Z\n\
317             updated_at: 2026-04-08T15:30:00Z\n",
318            minimal_mem()
319        );
320        let mf = parse_mem(&input).unwrap();
321        assert_eq!(mf.entries.len(), 1);
322        let entry = &mf.entries["project.db_version"];
323        assert_eq!(entry.topic, "infrastructure");
324        assert_eq!(entry.scope, MemoryScope::Project);
325        assert_eq!(entry.ttl, MemoryTtl::Permanent);
326        assert_eq!(entry.value, "PostgreSQL 15.2");
327    }
328
329    // -----------------------------------------------------------------------
330    // C: Multi-line value
331    // -----------------------------------------------------------------------
332
333    #[test]
334    fn test_parse_mem_multiline_value_joined_with_newlines() {
335        // Continuation lines must start with exactly 2 spaces in the raw text.
336        let input = format!(
337            "{base}\nentry project.notes\ntopic: documentation\nscope: project\nttl: permanent\nvalue: Primera linea del valor\n  Segunda linea continuada\n  Tercera linea continuada\ncreated_at: 2026-04-08T10:00:00Z\nupdated_at: 2026-04-08T10:00:00Z\n",
338            base = minimal_mem()
339        );
340        let mf = parse_mem(&input).unwrap();
341        let entry = &mf.entries["project.notes"];
342        assert!(entry.value.contains('\n'));
343        assert!(entry.value.contains("Primera linea"));
344        assert!(entry.value.contains("Segunda linea"));
345        assert!(entry.value.contains("Tercera linea"));
346    }
347
348    // -----------------------------------------------------------------------
349    // D: Missing required header
350    // -----------------------------------------------------------------------
351
352    #[test]
353    fn test_parse_mem_missing_agm_mem_header_returns_p001() {
354        let input = "# package: test.pkg\n# updated_at: 2026-04-08T10:00:00Z\n";
355        let errors = parse_mem(input).unwrap_err();
356        assert!(
357            errors
358                .iter()
359                .any(|e| e.code == ErrorCode::P001 && e.message.contains("agm.mem"))
360        );
361    }
362
363    #[test]
364    fn test_parse_mem_missing_package_returns_p001() {
365        let input = "# agm.mem: 1.0\n# updated_at: 2026-04-08T10:00:00Z\n";
366        let errors = parse_mem(input).unwrap_err();
367        assert!(
368            errors
369                .iter()
370                .any(|e| e.code == ErrorCode::P001 && e.message.contains("package"))
371        );
372    }
373
374    #[test]
375    fn test_parse_mem_missing_updated_at_returns_p001() {
376        let input = "# agm.mem: 1.0\n# package: test.pkg\n";
377        let errors = parse_mem(input).unwrap_err();
378        assert!(
379            errors
380                .iter()
381                .any(|e| e.code == ErrorCode::P001 && e.message.contains("updated_at"))
382        );
383    }
384
385    // -----------------------------------------------------------------------
386    // E: Duplicate entry key
387    // -----------------------------------------------------------------------
388
389    #[test]
390    fn test_parse_mem_duplicate_entry_key_returns_p006() {
391        let input = format!(
392            "{}\n{}\n{}",
393            minimal_mem(),
394            full_entry("dup.key"),
395            full_entry("dup.key")
396        );
397        let errors = parse_mem(&input).unwrap_err();
398        assert!(errors_contain(&errors, ErrorCode::P006));
399    }
400
401    // -----------------------------------------------------------------------
402    // F: Invalid scope
403    // -----------------------------------------------------------------------
404
405    #[test]
406    fn test_parse_mem_bad_scope_returns_p003() {
407        let input = format!(
408            "{}\n\
409             entry bad.scope\n\
410             topic: infra\n\
411             scope: workspace\n\
412             ttl: permanent\n\
413             value: test\n\
414             created_at: 2026-04-08T10:00:00Z\n\
415             updated_at: 2026-04-08T10:00:00Z\n",
416            minimal_mem()
417        );
418        let errors = parse_mem(&input).unwrap_err();
419        assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
420    }
421
422    // -----------------------------------------------------------------------
423    // G: Invalid TTL
424    // -----------------------------------------------------------------------
425
426    #[test]
427    fn test_parse_mem_bad_ttl_returns_p003() {
428        let input = format!(
429            "{}\n\
430             entry bad.ttl\n\
431             topic: infra\n\
432             scope: project\n\
433             ttl: forever\n\
434             value: test\n\
435             created_at: 2026-04-08T10:00:00Z\n\
436             updated_at: 2026-04-08T10:00:00Z\n",
437            minimal_mem()
438        );
439        let errors = parse_mem(&input).unwrap_err();
440        assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
441    }
442
443    // -----------------------------------------------------------------------
444    // H: Missing required entry field
445    // -----------------------------------------------------------------------
446
447    #[test]
448    fn test_parse_mem_missing_entry_field_returns_p001() {
449        let input = format!(
450            "{}\n\
451             entry missing.field\n\
452             topic: infra\n\
453             scope: project\n\
454             ttl: permanent\n\
455             created_at: 2026-04-08T10:00:00Z\n\
456             updated_at: 2026-04-08T10:00:00Z\n",
457            minimal_mem()
458        );
459        // value is missing
460        let errors = parse_mem(&input).unwrap_err();
461        assert!(
462            errors
463                .iter()
464                .any(|e| e.code == ErrorCode::P001 && e.message.contains("value"))
465        );
466    }
467
468    // -----------------------------------------------------------------------
469    // I: Unknown field produces P009 (warning — parse succeeds)
470    // -----------------------------------------------------------------------
471
472    #[test]
473    fn test_parse_mem_unknown_field_returns_p009_warning() {
474        let input = format!(
475            "{}\n\
476             entry ok.entry\n\
477             topic: infra\n\
478             scope: project\n\
479             ttl: permanent\n\
480             value: test\n\
481             created_at: 2026-04-08T10:00:00Z\n\
482             updated_at: 2026-04-08T10:00:00Z\n\
483             mystery_field: some value\n",
484            minimal_mem()
485        );
486        // P009 is warning — should parse OK
487        let result = parse_mem(&input);
488        assert!(
489            result.is_ok(),
490            "expected Ok with warnings, got: {:?}",
491            result
492        );
493    }
494
495    // -----------------------------------------------------------------------
496    // J: Duration TTL parsed correctly
497    // -----------------------------------------------------------------------
498
499    #[test]
500    fn test_parse_mem_duration_ttl_parsed_correctly() {
501        let input = format!(
502            "{}\n\
503             entry dur.entry\n\
504             topic: infra\n\
505             scope: session\n\
506             ttl: duration:P30D\n\
507             value: test\n\
508             created_at: 2026-04-08T10:00:00Z\n\
509             updated_at: 2026-04-08T10:00:00Z\n",
510            minimal_mem()
511        );
512        let mf = parse_mem(&input).unwrap();
513        assert_eq!(
514            mf.entries["dur.entry"].ttl,
515            MemoryTtl::Duration("P30D".to_owned())
516        );
517    }
518
519    // -----------------------------------------------------------------------
520    // K: All scopes accepted
521    // -----------------------------------------------------------------------
522
523    #[test]
524    fn test_parse_mem_all_scopes_accepted() {
525        for scope_str in ["node", "session", "project", "global"] {
526            let input = format!(
527                "{}\n\
528                 entry scope.test\n\
529                 topic: infra\n\
530                 scope: {}\n\
531                 ttl: permanent\n\
532                 value: test\n\
533                 created_at: 2026-04-08T10:00:00Z\n\
534                 updated_at: 2026-04-08T10:00:00Z\n",
535                minimal_mem(),
536                scope_str
537            );
538            let result = parse_mem(&input);
539            assert!(
540                result.is_ok(),
541                "failed for scope '{}': {:?}",
542                scope_str,
543                result
544            );
545        }
546    }
547
548    // -----------------------------------------------------------------------
549    // L: Multiple entries in one file
550    // -----------------------------------------------------------------------
551
552    #[test]
553    fn test_parse_mem_multiple_entries_all_parsed() {
554        let input = format!(
555            "{}\n{}\n{}",
556            minimal_mem(),
557            full_entry("key.one"),
558            full_entry("key.two")
559        );
560        let mf = parse_mem(&input).unwrap();
561        assert_eq!(mf.entries.len(), 2);
562        assert!(mf.entries.contains_key("key.one"));
563        assert!(mf.entries.contains_key("key.two"));
564    }
565
566    // -----------------------------------------------------------------------
567    // M: Comments inside entry block are ignored
568    // -----------------------------------------------------------------------
569
570    #[test]
571    fn test_parse_mem_comments_inside_block_ignored() {
572        let input = format!(
573            "{}\n\
574             entry commented.entry\n\
575             # this is a comment\n\
576             topic: infra\n\
577             scope: project\n\
578             ttl: permanent\n\
579             value: test\n\
580             created_at: 2026-04-08T10:00:00Z\n\
581             updated_at: 2026-04-08T10:00:00Z\n",
582            minimal_mem()
583        );
584        let mf = parse_mem(&input).unwrap();
585        assert_eq!(mf.entries["commented.entry"].topic, "infra");
586    }
587
588    // -----------------------------------------------------------------------
589    // N: Multiline value — continuation lines have 2-space prefix stripped
590    // -----------------------------------------------------------------------
591
592    #[test]
593    fn test_parse_mem_multiline_value_two_spaces_stripped() {
594        // Continuation line must start with exactly 2 spaces in the raw text.
595        let input = format!(
596            "{base}\nentry ml.entry\ntopic: docs\nscope: project\nttl: permanent\nvalue: line one\n  line two\ncreated_at: 2026-04-08T10:00:00Z\nupdated_at: 2026-04-08T10:00:00Z\n",
597            base = minimal_mem()
598        );
599        let mf = parse_mem(&input).unwrap();
600        let val = &mf.entries["ml.entry"].value;
601        assert_eq!(val, "line one\nline two");
602    }
603
604    // -----------------------------------------------------------------------
605    // O: Empty file is error
606    // -----------------------------------------------------------------------
607
608    #[test]
609    fn test_parse_mem_empty_input_returns_error() {
610        let result = parse_mem("");
611        assert!(result.is_err());
612    }
613}