1use std::collections::HashSet;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result};
26use chrono::{DateTime, Utc};
27use serde::Deserialize;
28use uuid::Uuid;
29
30use crate::storage::{Database, Memory};
31
32pub const CLAUDE_CODE_TOOL: &str = "claude-code";
34
35#[derive(Debug, Default, Deserialize)]
37struct Frontmatter {
38 #[serde(default)]
40 name: Option<String>,
41
42 #[serde(default)]
44 description: Option<String>,
45
46 #[serde(default)]
48 metadata: Option<FrontmatterMetadata>,
49}
50
51#[derive(Debug, Default, Deserialize)]
53struct FrontmatterMetadata {
54 #[serde(default, rename = "type")]
56 memory_type: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct ParsedMemory {
62 pub name: String,
64
65 pub description: Option<String>,
67
68 pub memory_type: Option<String>,
70
71 pub content: String,
73
74 pub file_path: String,
76
77 pub updated_at: DateTime<Utc>,
79}
80
81#[derive(Debug, Default, Clone, PartialEq, Eq)]
83pub struct MirrorStats {
84 pub upserted: usize,
86
87 pub removed: usize,
89}
90
91pub struct MemoryMirror {
96 base_dir: PathBuf,
98
99 source_tool: String,
101}
102
103impl MemoryMirror {
104 pub fn claude() -> Self {
106 Self {
107 base_dir: claude_projects_dir(),
108 source_tool: CLAUDE_CODE_TOOL.to_string(),
109 }
110 }
111
112 #[cfg(test)]
118 pub fn with_base_dir(base_dir: impl Into<PathBuf>, source_tool: impl Into<String>) -> Self {
119 Self {
120 base_dir: base_dir.into(),
121 source_tool: source_tool.into(),
122 }
123 }
124
125 pub fn memory_dir(&self, project_path: &Path) -> PathBuf {
130 self.base_dir
131 .join(project_slug(project_path))
132 .join("memory")
133 }
134
135 pub fn refresh(&self, db: &Database, project_path: &Path) -> Result<MirrorStats> {
145 let project_key = normalized_project_key(project_path);
146 let memory_dir = self.memory_dir(project_path);
147
148 let parsed = parse_memory_dir(&memory_dir)?;
149 let current_paths: HashSet<String> = parsed.iter().map(|m| m.file_path.clone()).collect();
150
151 let existing = db.get_memories(&project_key, &self.source_tool)?;
152
153 let mut stats = MirrorStats::default();
154
155 for memory in &existing {
157 if !current_paths.contains(&memory.file_path) && db.delete_memory(&memory.id)? {
158 stats.removed += 1;
159 }
160 }
161
162 for parsed_memory in &parsed {
164 let memory = Memory {
165 id: Uuid::new_v4(),
166 project_path: project_key.clone(),
167 source_tool: self.source_tool.clone(),
168 name: parsed_memory.name.clone(),
169 description: parsed_memory.description.clone(),
170 memory_type: parsed_memory.memory_type.clone(),
171 content: parsed_memory.content.clone(),
172 file_path: parsed_memory.file_path.clone(),
173 updated_at: parsed_memory.updated_at,
174 };
175 db.upsert_memory(&memory)?;
176 stats.upserted += 1;
177 }
178
179 Ok(stats)
180 }
181}
182
183fn claude_projects_dir() -> PathBuf {
185 dirs::home_dir()
186 .unwrap_or_else(|| PathBuf::from("."))
187 .join(".claude")
188 .join("projects")
189}
190
191fn project_slug(project_path: &Path) -> String {
198 let normalized = normalized_project_key(project_path);
199 normalized.replace(['/', '\\'], "-")
200}
201
202fn normalized_project_key(project_path: &Path) -> String {
208 let raw = project_path.to_string_lossy();
209 let trimmed = raw.trim_end_matches(['/', '\\']);
210 if trimmed.is_empty() {
211 raw.to_string()
212 } else {
213 trimmed.to_string()
214 }
215}
216
217pub fn resolve_project_path(explicit: Option<&str>) -> Result<PathBuf> {
224 let base = match explicit {
225 Some(p) => PathBuf::from(p),
226 None => std::env::current_dir().context("Failed to determine current directory")?,
227 };
228
229 let resolved = match crate::git::repo_info(&base) {
231 Ok(info) if !info.path.is_empty() => PathBuf::from(info.path),
232 _ => base,
233 };
234
235 Ok(PathBuf::from(normalized_project_key(&resolved)))
236}
237
238pub fn parse_memory_dir(memory_dir: &Path) -> Result<Vec<ParsedMemory>> {
244 if !memory_dir.exists() {
245 return Ok(Vec::new());
246 }
247
248 let mut memories = Vec::new();
249
250 for entry in fs::read_dir(memory_dir)
251 .with_context(|| format!("Failed to read memory directory {}", memory_dir.display()))?
252 {
253 let entry = entry?;
254 let path = entry.path();
255
256 if !path.is_file() {
257 continue;
258 }
259
260 match path.extension().and_then(|e| e.to_str()) {
261 Some("md") => {}
262 _ => continue,
263 }
264
265 match parse_memory_file(&path) {
266 Ok(memory) => memories.push(memory),
267 Err(e) => {
268 tracing::debug!("Skipping unreadable memory file {}: {}", path.display(), e);
269 }
270 }
271 }
272
273 memories.sort_by(|a, b| a.file_path.cmp(&b.file_path));
275
276 Ok(memories)
277}
278
279fn parse_memory_file(path: &Path) -> Result<ParsedMemory> {
281 let raw = fs::read_to_string(path)
282 .with_context(|| format!("Failed to read memory file {}", path.display()))?;
283
284 let (frontmatter, body) = split_frontmatter(&raw);
285
286 let file_stem = path
287 .file_stem()
288 .and_then(|s| s.to_str())
289 .unwrap_or("memory")
290 .to_string();
291
292 let is_index = path
293 .file_name()
294 .and_then(|n| n.to_str())
295 .map(|n| n.eq_ignore_ascii_case("MEMORY.md"))
296 .unwrap_or(false);
297
298 let name = frontmatter
299 .as_ref()
300 .and_then(|f| f.name.clone())
301 .unwrap_or(file_stem);
302
303 let description = frontmatter.as_ref().and_then(|f| f.description.clone());
304
305 let memory_type = frontmatter
306 .as_ref()
307 .and_then(|f| f.metadata.as_ref())
308 .and_then(|m| m.memory_type.clone())
309 .or_else(|| is_index.then(|| "index".to_string()));
310
311 let updated_at = file_modified_time(path);
312
313 Ok(ParsedMemory {
314 name,
315 description,
316 memory_type,
317 content: body,
318 file_path: path.to_string_lossy().to_string(),
319 updated_at,
320 })
321}
322
323fn file_modified_time(path: &Path) -> DateTime<Utc> {
327 fs::metadata(path)
328 .and_then(|m| m.modified())
329 .map(DateTime::<Utc>::from)
330 .unwrap_or_else(|_| Utc::now())
331}
332
333fn split_frontmatter(raw: &str) -> (Option<Frontmatter>, String) {
340 let raw = raw.trim_start_matches('\u{feff}');
341
342 let mut lines = raw.lines();
343 if lines.next().map(str::trim_end) != Some("---") {
344 return (None, raw.trim().to_string());
345 }
346
347 let mut yaml = String::new();
348 let mut body_lines: Vec<&str> = Vec::new();
349 let mut found_close = false;
350
351 for line in lines {
352 if !found_close && line.trim_end() == "---" {
353 found_close = true;
354 continue;
355 }
356 if found_close {
357 body_lines.push(line);
358 } else {
359 yaml.push_str(line);
360 yaml.push('\n');
361 }
362 }
363
364 if !found_close {
365 return (None, raw.trim().to_string());
367 }
368
369 let frontmatter = serde_saphyr::from_str::<Frontmatter>(&yaml).ok();
370 (frontmatter, body_lines.join("\n").trim().to_string())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::io::Write;
377 use tempfile::tempdir;
378
379 fn write_file(path: &Path, content: &str) {
381 if let Some(parent) = path.parent() {
382 fs::create_dir_all(parent).expect("Failed to create parent dirs");
383 }
384 let mut file = fs::File::create(path).expect("Failed to create file");
385 file.write_all(content.as_bytes())
386 .expect("Failed to write file");
387 }
388
389 fn create_test_db() -> (Database, tempfile::TempDir) {
391 let dir = tempdir().expect("Failed to create temp dir");
392 let db_path = dir.path().join("test.db");
393 let db = Database::open(&db_path).expect("Failed to open test database");
394 (db, dir)
395 }
396
397 #[test]
398 fn test_project_slug_replaces_separators() {
399 let slug = project_slug(Path::new("/Users/me/projects/lore"));
400 assert_eq!(slug, "-Users-me-projects-lore");
401 }
402
403 #[test]
404 fn test_project_slug_trims_trailing_slash() {
405 let with_slash = project_slug(Path::new("/Users/me/lore/"));
406 let without_slash = project_slug(Path::new("/Users/me/lore"));
407 assert_eq!(with_slash, without_slash);
408 }
409
410 #[test]
411 fn test_split_frontmatter_parses_fields() {
412 let raw = "---\nname: Prefer tabs\ndescription: Use tabs not spaces\nmetadata:\n type: user\n---\nThe user prefers tabs.\n";
413 let (fm, body) = split_frontmatter(raw);
414 let fm = fm.expect("Should parse frontmatter");
415 assert_eq!(fm.name.as_deref(), Some("Prefer tabs"));
416 assert_eq!(fm.description.as_deref(), Some("Use tabs not spaces"));
417 assert_eq!(
418 fm.metadata.and_then(|m| m.memory_type).as_deref(),
419 Some("user")
420 );
421 assert_eq!(body, "The user prefers tabs.");
422 }
423
424 #[test]
425 fn test_split_frontmatter_no_frontmatter() {
426 let raw = "Just some notes without frontmatter.";
427 let (fm, body) = split_frontmatter(raw);
428 assert!(fm.is_none());
429 assert_eq!(body, "Just some notes without frontmatter.");
430 }
431
432 #[test]
433 fn test_split_frontmatter_missing_close_is_body() {
434 let raw = "---\nname: broken\nno closing delimiter here";
435 let (fm, body) = split_frontmatter(raw);
436 assert!(fm.is_none());
437 assert!(body.contains("no closing delimiter"));
438 }
439
440 #[test]
441 fn test_parse_memory_dir_missing_folder_is_empty() {
442 let dir = tempdir().unwrap();
443 let missing = dir.path().join("does-not-exist");
444 let memories = parse_memory_dir(&missing).expect("Should not error");
445 assert!(memories.is_empty());
446 }
447
448 #[test]
449 fn test_parse_memory_dir_reads_facts_and_index() {
450 let dir = tempdir().unwrap();
451 let mem_dir = dir.path().join("memory");
452 write_file(&mem_dir.join("MEMORY.md"), "# Index\n- fact-1\n");
453 write_file(
454 &mem_dir.join("fact-1.md"),
455 "---\nname: API base URL\ndescription: Where the API lives\nmetadata:\n type: reference\n---\nThe API base URL is https://example.com.\n",
456 );
457
458 let memories = parse_memory_dir(&mem_dir).expect("Should parse");
459 assert_eq!(memories.len(), 2);
460
461 let fact = memories
462 .iter()
463 .find(|m| m.name == "API base URL")
464 .expect("Should find fact");
465 assert_eq!(fact.description.as_deref(), Some("Where the API lives"));
466 assert_eq!(fact.memory_type.as_deref(), Some("reference"));
467 assert!(fact.content.contains("https://example.com"));
468
469 let index = memories
470 .iter()
471 .find(|m| m.name == "MEMORY")
472 .expect("Should capture index");
473 assert_eq!(index.memory_type.as_deref(), Some("index"));
474 }
475
476 #[test]
477 fn test_refresh_captures_memories_scoped_to_project() {
478 let (db, _db_dir) = create_test_db();
479 let base = tempdir().unwrap();
480 let project = Path::new("/tmp/example-project");
481
482 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
483 let mem_dir = mirror.memory_dir(project);
484 write_file(
485 &mem_dir.join("fact-1.md"),
486 "---\nname: Fact one\ndescription: First fact\nmetadata:\n type: project\n---\nBody one.\n",
487 );
488
489 let stats = mirror
490 .refresh(&db, project)
491 .expect("Refresh should succeed");
492 assert_eq!(stats.upserted, 1);
493 assert_eq!(stats.removed, 0);
494
495 let memories = db
496 .get_memories("/tmp/example-project", CLAUDE_CODE_TOOL)
497 .expect("Should list memories");
498 assert_eq!(memories.len(), 1);
499 assert_eq!(memories[0].name, "Fact one");
500 assert_eq!(memories[0].memory_type.as_deref(), Some("project"));
501 assert_eq!(memories[0].project_path, "/tmp/example-project");
502
503 let other = db
505 .get_memories("/tmp/other-project", CLAUDE_CODE_TOOL)
506 .expect("Should query");
507 assert!(other.is_empty());
508 }
509
510 #[test]
511 fn test_refresh_removes_deleted_files() {
512 let (db, _db_dir) = create_test_db();
513 let base = tempdir().unwrap();
514 let project = Path::new("/tmp/mirror-remove");
515
516 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
517 let mem_dir = mirror.memory_dir(project);
518 let fact_a = mem_dir.join("a.md");
519 let fact_b = mem_dir.join("b.md");
520 write_file(&fact_a, "---\nname: A\n---\nBody A.\n");
521 write_file(&fact_b, "---\nname: B\n---\nBody B.\n");
522
523 mirror.refresh(&db, project).expect("Initial refresh");
524 assert_eq!(
525 db.get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL)
526 .unwrap()
527 .len(),
528 2
529 );
530
531 fs::remove_file(&fact_b).expect("Failed to remove file");
533 let stats = mirror.refresh(&db, project).expect("Second refresh");
534 assert_eq!(stats.removed, 1);
535
536 let remaining = db
537 .get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL)
538 .unwrap();
539 assert_eq!(remaining.len(), 1);
540 assert_eq!(remaining[0].name, "A");
541 }
542
543 #[test]
544 fn test_refresh_updates_changed_files() {
545 let (db, _db_dir) = create_test_db();
546 let base = tempdir().unwrap();
547 let project = Path::new("/tmp/mirror-update");
548
549 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
550 let mem_dir = mirror.memory_dir(project);
551 let fact = mem_dir.join("fact.md");
552 write_file(&fact, "---\nname: Original\n---\nOriginal body.\n");
553
554 mirror.refresh(&db, project).expect("Initial refresh");
555 let first = db
556 .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL)
557 .unwrap();
558 assert_eq!(first.len(), 1);
559 let original_id = first[0].id;
560
561 write_file(&fact, "---\nname: Updated\n---\nUpdated body.\n");
563 mirror.refresh(&db, project).expect("Second refresh");
564
565 let updated = db
566 .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL)
567 .unwrap();
568 assert_eq!(updated.len(), 1);
569 assert_eq!(updated[0].name, "Updated");
570 assert!(updated[0].content.contains("Updated body"));
571 assert_eq!(updated[0].id, original_id);
573 }
574
575 #[test]
576 fn test_refresh_adds_new_files() {
577 let (db, _db_dir) = create_test_db();
578 let base = tempdir().unwrap();
579 let project = Path::new("/tmp/mirror-add");
580
581 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
582 let mem_dir = mirror.memory_dir(project);
583 write_file(&mem_dir.join("a.md"), "---\nname: A\n---\nBody A.\n");
584 mirror.refresh(&db, project).expect("Initial refresh");
585
586 write_file(&mem_dir.join("b.md"), "---\nname: B\n---\nBody B.\n");
587 let stats = mirror.refresh(&db, project).expect("Second refresh");
588 assert_eq!(stats.upserted, 2);
589
590 let memories = db
591 .get_memories("/tmp/mirror-add", CLAUDE_CODE_TOOL)
592 .unwrap();
593 assert_eq!(memories.len(), 2);
594 }
595
596 #[test]
597 fn test_refresh_missing_folder_yields_no_memories() {
598 let (db, _db_dir) = create_test_db();
599 let base = tempdir().unwrap();
600 let project = Path::new("/tmp/mirror-empty");
601
602 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
603 let stats = mirror
604 .refresh(&db, project)
605 .expect("Refresh should not error on missing folder");
606 assert_eq!(stats.upserted, 0);
607 assert_eq!(stats.removed, 0);
608 assert!(db
609 .get_memories("/tmp/mirror-empty", CLAUDE_CODE_TOOL)
610 .unwrap()
611 .is_empty());
612 }
613
614 #[test]
615 fn test_search_memories_returns_matches() {
616 let (db, _db_dir) = create_test_db();
617 let base = tempdir().unwrap();
618 let project = Path::new("/tmp/mirror-search");
619
620 let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
621 let mem_dir = mirror.memory_dir(project);
622 write_file(
623 &mem_dir.join("auth.md"),
624 "---\nname: Auth flow\n---\nUse OAuth with PKCE for authentication.\n",
625 );
626 write_file(
627 &mem_dir.join("db.md"),
628 "---\nname: Database\n---\nThe project uses SQLite for storage.\n",
629 );
630 mirror.refresh(&db, project).expect("Refresh");
631
632 let results = db
633 .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "OAuth", 10)
634 .expect("Search should succeed");
635 assert_eq!(results.len(), 1);
636 assert_eq!(results[0].name, "Auth flow");
637
638 let none = db
639 .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "kubernetes", 10)
640 .expect("Search should succeed");
641 assert!(none.is_empty());
642 }
643}