1use 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
12pub 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 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 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 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 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; }
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 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 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 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#[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 #[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 #[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 #[test]
334 fn test_parse_mem_multiline_value_joined_with_newlines() {
335 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 #[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 #[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 #[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 #[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 #[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 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 #[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 let result = parse_mem(&input);
488 assert!(
489 result.is_ok(),
490 "expected Ok with warnings, got: {:?}",
491 result
492 );
493 }
494
495 #[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 #[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 #[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 #[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 #[test]
593 fn test_parse_mem_multiline_value_two_spaces_stripped() {
594 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 #[test]
609 fn test_parse_mem_empty_input_returns_error() {
610 let result = parse_mem("");
611 assert!(result.is_err());
612 }
613}