1use std::fs;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use crate::text::floor_char_boundary;
22
23use crate::file_lock::FileLock;
24use crate::memory::{Memory, MemoryType};
25use crate::memory_parser::parse_memories;
26
27pub const DEFAULT_MEMORIES_PATH: &str = ".ralph/agent/memories.md";
29
30#[derive(Debug, Clone)]
41pub struct MarkdownMemoryStore {
42 path: PathBuf,
43}
44
45impl MarkdownMemoryStore {
46 #[must_use]
51 pub fn new(path: impl AsRef<Path>) -> Self {
52 Self {
53 path: path.as_ref().to_path_buf(),
54 }
55 }
56
57 #[must_use]
59 pub fn with_default_path(root: impl AsRef<Path>) -> Self {
60 Self::new(root.as_ref().join(DEFAULT_MEMORIES_PATH))
61 }
62
63 #[must_use]
65 pub fn path(&self) -> &Path {
66 &self.path
67 }
68
69 #[must_use]
71 pub fn exists(&self) -> bool {
72 self.path.exists()
73 }
74
75 pub fn init(&self, force: bool) -> io::Result<()> {
80 let lock = FileLock::new(&self.path)?;
81 let _guard = lock.exclusive()?;
82
83 if self.exists() && !force {
84 return Err(io::Error::new(
85 io::ErrorKind::AlreadyExists,
86 format!("Memories file already exists: {}", self.path.display()),
87 ));
88 }
89
90 if let Some(parent) = self.path.parent() {
92 fs::create_dir_all(parent)?;
93 }
94
95 fs::write(&self.path, self.template())
96 }
97
98 pub fn load(&self) -> io::Result<Vec<Memory>> {
103 if !self.exists() {
104 return Ok(Vec::new());
105 }
106
107 let lock = FileLock::new(&self.path)?;
108 let _guard = lock.shared()?;
109
110 let content = fs::read_to_string(&self.path)?;
111 Ok(parse_memories(&content))
112 }
113
114 pub fn append(&self, memory: &Memory) -> io::Result<()> {
120 let lock = FileLock::new(&self.path)?;
121 let _guard = lock.exclusive()?;
122
123 let content = if self.exists() {
124 fs::read_to_string(&self.path)?
125 } else {
126 if let Some(parent) = self.path.parent() {
128 fs::create_dir_all(parent)?;
129 }
130 self.template()
131 };
132
133 let section = format!("## {}", memory.memory_type.section_name());
134 let memory_block = self.format_memory(memory);
135
136 let new_content = if let Some(pos) = self.find_section_insert_point(&content, §ion) {
137 format!("{}{}{}", &content[..pos], memory_block, &content[pos..])
138 } else {
139 format!("{}\n{}\n{}", content.trim_end(), section, memory_block)
141 };
142
143 fs::write(&self.path, new_content)
144 }
145
146 pub fn delete(&self, id: &str) -> io::Result<bool> {
152 if !self.exists() {
153 return Ok(false);
154 }
155
156 let lock = FileLock::new(&self.path)?;
157 let _guard = lock.exclusive()?;
158
159 let content = fs::read_to_string(&self.path)?;
160 let memories = parse_memories(&content);
161
162 if !memories.iter().any(|m| m.id == id) {
163 return Ok(false);
164 }
165
166 let remaining: Vec<_> = memories.into_iter().filter(|m| m.id != id).collect();
168 self.write_all_internal(&remaining)?;
169
170 Ok(true)
171 }
172
173 pub fn get(&self, id: &str) -> io::Result<Option<Memory>> {
175 let memories = self.load()?;
176 Ok(memories.into_iter().find(|m| m.id == id))
177 }
178
179 pub fn search(&self, query: &str) -> io::Result<Vec<Memory>> {
183 let memories = self.load()?;
184 Ok(memories
185 .into_iter()
186 .filter(|m| m.matches_query(query))
187 .collect())
188 }
189
190 pub fn filter_by_type(&self, memory_type: MemoryType) -> io::Result<Vec<Memory>> {
192 let memories = self.load()?;
193 Ok(memories
194 .into_iter()
195 .filter(|m| m.memory_type == memory_type)
196 .collect())
197 }
198
199 pub fn filter_by_tags(&self, tags: &[String]) -> io::Result<Vec<Memory>> {
201 let memories = self.load()?;
202 Ok(memories
203 .into_iter()
204 .filter(|m| m.has_any_tag(tags))
205 .collect())
206 }
207
208 fn write_all_internal(&self, memories: &[Memory]) -> io::Result<()> {
213 if let Some(parent) = self.path.parent() {
215 fs::create_dir_all(parent)?;
216 }
217
218 let mut content = String::from("# Memories\n");
219
220 for memory_type in MemoryType::all() {
222 let type_memories: Vec<_> = memories
223 .iter()
224 .filter(|m| m.memory_type == *memory_type)
225 .collect();
226
227 content.push_str(&format!("\n## {}\n", memory_type.section_name()));
228
229 for memory in type_memories {
230 content.push_str(&self.format_memory(memory));
231 }
232 }
233
234 fs::write(&self.path, content)
235 }
236
237 fn format_memory(&self, memory: &Memory) -> String {
239 let content_lines: Vec<_> = memory
241 .content
242 .lines()
243 .map(|line| format!("> {}", line))
244 .collect();
245
246 format!(
247 "\n### {}\n{}\n<!-- tags: {} | created: {} -->\n",
248 memory.id,
249 content_lines.join("\n"),
250 memory.tags.join(", "),
251 memory.created,
252 )
253 }
254
255 fn find_section_insert_point(&self, content: &str, section: &str) -> Option<usize> {
260 let section_start = content.find(section)?;
261 let after_section = section_start + section.len();
263 let newline_pos = content[after_section..].find('\n')?;
265 Some(after_section + newline_pos + 1)
266 }
267
268 fn template(&self) -> String {
270 "# Memories\n\n## Patterns\n\n## Decisions\n\n## Fixes\n\n## Context\n".to_string()
271 }
272}
273
274#[must_use]
288pub fn format_memories_as_markdown(memories: &[Memory]) -> String {
289 if memories.is_empty() {
290 return String::new();
291 }
292
293 let mut output = String::from("# Memories\n");
294
295 for memory_type in MemoryType::all() {
297 let type_memories: Vec<_> = memories
298 .iter()
299 .filter(|m| m.memory_type == *memory_type)
300 .collect();
301
302 if type_memories.is_empty() {
303 continue;
304 }
305
306 output.push_str(&format!("\n## {}\n", memory_type.section_name()));
307
308 for memory in type_memories {
309 output.push_str(&format!(
310 "\n### {}\n> {}\n<!-- tags: {} | created: {} -->\n",
311 memory.id,
312 memory.content.replace('\n', "\n> "),
313 memory.tags.join(", "),
314 memory.created
315 ));
316 }
317 }
318
319 output
320}
321
322#[must_use]
334pub fn truncate_to_budget(content: &str, budget: usize) -> String {
335 if budget == 0 || content.is_empty() {
336 return content.to_string();
337 }
338
339 let char_budget = budget * 4;
341
342 if content.len() <= char_budget {
343 return content.to_string();
344 }
345
346 let safe_budget = floor_char_boundary(content, char_budget);
348
349 let truncated = &content[..safe_budget];
351
352 if let Some(last_complete) = truncated.rfind("-->") {
354 let end = last_complete + 3;
355 let final_end = truncated[end..].find('\n').map_or(end, |n| end + n + 1);
357 format!(
358 "{}\n\n<!-- truncated: budget {} tokens exceeded -->",
359 &content[..final_end],
360 budget
361 )
362 } else {
363 format!(
364 "{}\n\n<!-- truncated: budget {} tokens exceeded -->",
365 truncated, budget
366 )
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use tempfile::TempDir;
374
375 fn create_temp_store() -> (TempDir, MarkdownMemoryStore) {
376 let temp_dir = TempDir::new().unwrap();
377 let store = MarkdownMemoryStore::with_default_path(temp_dir.path());
378 (temp_dir, store)
379 }
380
381 #[test]
382 fn test_init_creates_file() {
383 let (_temp_dir, store) = create_temp_store();
384
385 assert!(!store.exists());
386 store.init(false).unwrap();
387 assert!(store.exists());
388
389 let content = fs::read_to_string(store.path()).unwrap();
390 assert!(content.contains("# Memories"));
391 assert!(content.contains("## Patterns"));
392 assert!(content.contains("## Decisions"));
393 assert!(content.contains("## Fixes"));
394 assert!(content.contains("## Context"));
395 }
396
397 #[test]
398 fn test_init_fails_if_exists_without_force() {
399 let (_temp_dir, store) = create_temp_store();
400
401 store.init(false).unwrap();
402 let result = store.init(false);
403 assert!(result.is_err());
404 assert!(result.unwrap_err().kind() == io::ErrorKind::AlreadyExists);
405 }
406
407 #[test]
408 fn test_init_with_force_overwrites() {
409 let (_temp_dir, store) = create_temp_store();
410
411 store.init(false).unwrap();
412
413 let memory = Memory::new(
415 MemoryType::Pattern,
416 "Test content".to_string(),
417 vec!["test".to_string()],
418 );
419 store.append(&memory).unwrap();
420
421 store.init(true).unwrap();
423
424 let memories = store.load().unwrap();
426 assert!(memories.is_empty());
427 }
428
429 #[test]
430 fn test_append_creates_file_if_missing() {
431 let (_temp_dir, store) = create_temp_store();
432
433 let memory = Memory::new(
434 MemoryType::Pattern,
435 "Uses barrel exports".to_string(),
436 vec!["imports".to_string()],
437 );
438
439 assert!(!store.exists());
440 store.append(&memory).unwrap();
441 assert!(store.exists());
442
443 let memories = store.load().unwrap();
444 assert_eq!(memories.len(), 1);
445 assert_eq!(memories[0].content, "Uses barrel exports");
446 }
447
448 #[test]
449 fn test_append_to_existing_section() {
450 let (_temp_dir, store) = create_temp_store();
451 store.init(false).unwrap();
452
453 let memory1 = Memory::new(
454 MemoryType::Pattern,
455 "First pattern".to_string(),
456 vec!["first".to_string()],
457 );
458 let memory2 = Memory::new(
459 MemoryType::Pattern,
460 "Second pattern".to_string(),
461 vec!["second".to_string()],
462 );
463
464 store.append(&memory1).unwrap();
465 store.append(&memory2).unwrap();
466
467 let memories = store.load().unwrap();
468 assert_eq!(memories.len(), 2);
469 assert!(
471 memories
472 .iter()
473 .all(|m| m.memory_type == MemoryType::Pattern)
474 );
475 }
476
477 #[test]
478 fn test_append_to_different_sections() {
479 let (_temp_dir, store) = create_temp_store();
480 store.init(false).unwrap();
481
482 let pattern = Memory::new(MemoryType::Pattern, "A pattern".to_string(), vec![]);
483 let decision = Memory::new(MemoryType::Decision, "A decision".to_string(), vec![]);
484 let fix = Memory::new(MemoryType::Fix, "A fix".to_string(), vec![]);
485
486 store.append(&pattern).unwrap();
487 store.append(&decision).unwrap();
488 store.append(&fix).unwrap();
489
490 let memories = store.load().unwrap();
491 assert_eq!(memories.len(), 3);
492
493 assert!(
495 memories
496 .iter()
497 .any(|m| m.memory_type == MemoryType::Pattern)
498 );
499 assert!(
500 memories
501 .iter()
502 .any(|m| m.memory_type == MemoryType::Decision)
503 );
504 assert!(memories.iter().any(|m| m.memory_type == MemoryType::Fix));
505 }
506
507 #[test]
508 fn test_delete_removes_memory() {
509 let (_temp_dir, store) = create_temp_store();
510 store.init(false).unwrap();
511
512 let memory = Memory::new(MemoryType::Pattern, "To be deleted".to_string(), vec![]);
513 let id = memory.id.clone();
514
515 store.append(&memory).unwrap();
516 assert_eq!(store.load().unwrap().len(), 1);
517
518 let deleted = store.delete(&id).unwrap();
519 assert!(deleted);
520 assert!(store.load().unwrap().is_empty());
521 }
522
523 #[test]
524 fn test_delete_returns_false_for_nonexistent() {
525 let (_temp_dir, store) = create_temp_store();
526 store.init(false).unwrap();
527
528 let deleted = store.delete("mem-nonexistent-0000").unwrap();
529 assert!(!deleted);
530 }
531
532 #[test]
533 fn test_get_finds_memory() {
534 let (_temp_dir, store) = create_temp_store();
535
536 let memory = Memory::new(
537 MemoryType::Decision,
538 "Important decision".to_string(),
539 vec!["important".to_string()],
540 );
541 let id = memory.id.clone();
542
543 store.append(&memory).unwrap();
544
545 let found = store.get(&id).unwrap();
546 assert!(found.is_some());
547 assert_eq!(found.unwrap().content, "Important decision");
548 }
549
550 #[test]
551 fn test_get_returns_none_for_nonexistent() {
552 let (_temp_dir, store) = create_temp_store();
553 store.init(false).unwrap();
554
555 let found = store.get("mem-nonexistent-0000").unwrap();
556 assert!(found.is_none());
557 }
558
559 #[test]
560 fn test_search_matches_content() {
561 let (_temp_dir, store) = create_temp_store();
562
563 let memory1 = Memory::new(
564 MemoryType::Pattern,
565 "Uses barrel exports".to_string(),
566 vec![],
567 );
568 let memory2 = Memory::new(
569 MemoryType::Pattern,
570 "Uses named exports".to_string(),
571 vec![],
572 );
573
574 store.append(&memory1).unwrap();
575 store.append(&memory2).unwrap();
576
577 let results = store.search("barrel").unwrap();
578 assert_eq!(results.len(), 1);
579 assert!(results[0].content.contains("barrel"));
580 }
581
582 #[test]
583 fn test_search_matches_tags() {
584 let (_temp_dir, store) = create_temp_store();
585
586 let memory = Memory::new(
587 MemoryType::Fix,
588 "Docker fix".to_string(),
589 vec!["docker".to_string(), "debugging".to_string()],
590 );
591
592 store.append(&memory).unwrap();
593
594 let results = store.search("docker").unwrap();
595 assert_eq!(results.len(), 1);
596 }
597
598 #[test]
599 fn test_filter_by_type() {
600 let (_temp_dir, store) = create_temp_store();
601
602 store
603 .append(&Memory::new(MemoryType::Pattern, "P1".to_string(), vec![]))
604 .unwrap();
605 store
606 .append(&Memory::new(MemoryType::Decision, "D1".to_string(), vec![]))
607 .unwrap();
608 store
609 .append(&Memory::new(MemoryType::Pattern, "P2".to_string(), vec![]))
610 .unwrap();
611
612 let patterns = store.filter_by_type(MemoryType::Pattern).unwrap();
613 assert_eq!(patterns.len(), 2);
614
615 let decisions = store.filter_by_type(MemoryType::Decision).unwrap();
616 assert_eq!(decisions.len(), 1);
617 }
618
619 #[test]
620 fn test_filter_by_tags() {
621 let (_temp_dir, store) = create_temp_store();
622
623 store
624 .append(&Memory::new(
625 MemoryType::Pattern,
626 "M1".to_string(),
627 vec!["rust".to_string(), "async".to_string()],
628 ))
629 .unwrap();
630 store
631 .append(&Memory::new(
632 MemoryType::Pattern,
633 "M2".to_string(),
634 vec!["python".to_string()],
635 ))
636 .unwrap();
637 store
638 .append(&Memory::new(
639 MemoryType::Pattern,
640 "M3".to_string(),
641 vec!["rust".to_string()],
642 ))
643 .unwrap();
644
645 let rust_memories = store.filter_by_tags(&["rust".to_string()]).unwrap();
646 assert_eq!(rust_memories.len(), 2);
647
648 let python_or_async = store
649 .filter_by_tags(&["python".to_string(), "async".to_string()])
650 .unwrap();
651 assert_eq!(python_or_async.len(), 2);
652 }
653
654 #[test]
655 fn test_load_empty_file() {
656 let (_temp_dir, store) = create_temp_store();
657
658 let memories = store.load().unwrap();
660 assert!(memories.is_empty());
661 }
662
663 #[test]
664 fn test_multiline_content_roundtrip() {
665 let (_temp_dir, store) = create_temp_store();
666
667 let memory = Memory::new(
668 MemoryType::Pattern,
669 "Line 1\nLine 2\nLine 3".to_string(),
670 vec!["multiline".to_string()],
671 );
672 let id = memory.id.clone();
673
674 store.append(&memory).unwrap();
675
676 let loaded = store.get(&id).unwrap().unwrap();
677 assert_eq!(loaded.content, "Line 1\nLine 2\nLine 3");
678 }
679
680 #[test]
681 fn test_format_memories_as_markdown_empty() {
682 let output = format_memories_as_markdown(&[]);
683 assert!(output.is_empty());
684 }
685
686 #[test]
687 fn test_format_memories_as_markdown_single() {
688 let memory = Memory {
689 id: "mem-123-abcd".to_string(),
690 memory_type: MemoryType::Pattern,
691 content: "Use barrel exports".to_string(),
692 tags: vec!["imports".to_string()],
693 created: "2025-01-20".to_string(),
694 };
695
696 let output = format_memories_as_markdown(&[memory]);
697
698 assert!(output.contains("# Memories"));
699 assert!(output.contains("## Patterns"));
700 assert!(output.contains("### mem-123-abcd"));
701 assert!(output.contains("> Use barrel exports"));
702 assert!(output.contains("tags: imports"));
703 }
704
705 #[test]
706 fn test_format_memories_as_markdown_grouped_by_type() {
707 let pattern = Memory {
708 id: "mem-1-p".to_string(),
709 memory_type: MemoryType::Pattern,
710 content: "A pattern".to_string(),
711 tags: vec![],
712 created: "2025-01-20".to_string(),
713 };
714 let decision = Memory {
715 id: "mem-2-d".to_string(),
716 memory_type: MemoryType::Decision,
717 content: "A decision".to_string(),
718 tags: vec![],
719 created: "2025-01-20".to_string(),
720 };
721
722 let output = format_memories_as_markdown(&[pattern, decision]);
723
724 assert!(output.contains("## Patterns"));
726 assert!(output.contains("## Decisions"));
727
728 let patterns_pos = output.find("## Patterns").unwrap();
730 let decisions_pos = output.find("## Decisions").unwrap();
731 assert!(patterns_pos < decisions_pos);
732 }
733
734 #[test]
735 fn test_truncate_to_budget_no_truncation_needed() {
736 let content = "Short content";
737 let result = truncate_to_budget(content, 100);
738 assert_eq!(result, content);
739 }
740
741 #[test]
742 fn test_truncate_to_budget_zero_means_unlimited() {
743 let content = "This is some long content that would normally be truncated";
744 let result = truncate_to_budget(content, 0);
745 assert_eq!(result, content);
746 }
747
748 #[test]
749 fn test_truncate_to_budget_adds_notice() {
750 let content = "x".repeat(1000); let result = truncate_to_budget(&content, 10); assert!(result.len() < content.len());
754 assert!(result.contains("<!-- truncated:"));
755 }
756}