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