1use std::hash::{Hash, Hasher};
20use std::path::{Path, PathBuf};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use crate::app::MemoryConfig;
24use crate::constants::MEMORY_INDEX_TRUNCATION_MARKER;
25
26const MAX_WALK_DEPTH: usize = 32;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum MemoryScope {
34 Global,
35 ProjectPrivate,
36 ProjectShared,
37}
38
39impl MemoryScope {
40 pub fn as_str(self) -> &'static str {
42 match self {
43 MemoryScope::Global => "global",
44 MemoryScope::ProjectPrivate => "project-private",
45 MemoryScope::ProjectShared => "project-shared",
46 }
47 }
48
49 fn label(self) -> &'static str {
51 match self {
52 MemoryScope::Global => "Global (all projects)",
53 MemoryScope::ProjectPrivate => "Project (private)",
54 MemoryScope::ProjectShared => "Project (shared)",
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
63pub struct MemoryEntry {
64 pub name: String,
65 pub description: String,
66 pub path: PathBuf,
67 pub scope: MemoryScope,
68 pub mtime: SystemTime,
69}
70
71#[derive(Debug, Clone)]
73pub struct LoadedMemory {
74 pub entries: Vec<MemoryEntry>,
75 pub index: String,
77 pub truncated: bool,
79}
80
81impl LoadedMemory {
82 pub fn approx_tokens(&self) -> usize {
83 self.index.len() / 4
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.entries.is_empty()
88 }
89}
90
91#[derive(Debug, PartialEq, Eq)]
93pub enum MemoryReloadOutcome {
94 Unchanged,
95 Reloaded,
96 LoadedFirst,
97 Removed,
98}
99
100pub fn find_git_root(start: &Path) -> Option<PathBuf> {
103 let mut current = start.to_path_buf();
104 for _ in 0..MAX_WALK_DEPTH {
105 if current.join(".git").exists() {
106 return Some(current);
107 }
108 match current.parent() {
109 Some(parent) if parent != current => current = parent.to_path_buf(),
110 _ => return None,
111 }
112 }
113 None
114}
115
116pub fn memory_roots(cwd: &Path) -> Vec<(PathBuf, MemoryScope)> {
120 let Ok(data) = crate::runtime::data_dir() else {
121 return Vec::new();
122 };
123 let mut roots = vec![(data.join("memory"), MemoryScope::Global)];
124 match find_git_root(cwd) {
125 Some(git_root) => {
126 roots.push((
127 data.join("projects")
128 .join(project_key(&git_root))
129 .join("memory"),
130 MemoryScope::ProjectPrivate,
131 ));
132 roots.push((
133 git_root.join(".mermaid").join("memory"),
134 MemoryScope::ProjectShared,
135 ));
136 },
137 None => {
138 roots.push((
140 data.join("projects").join(project_key(cwd)).join("memory"),
141 MemoryScope::ProjectPrivate,
142 ));
143 },
144 }
145 roots
146}
147
148pub fn dir_for(scope: MemoryScope, cwd: &Path) -> Option<PathBuf> {
150 memory_roots(cwd)
151 .into_iter()
152 .find(|(_, s)| *s == scope)
153 .map(|(dir, _)| dir)
154}
155
156fn project_key(path: &Path) -> String {
160 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
161 let slug: String = canonical
162 .file_name()
163 .and_then(|n| n.to_str())
164 .unwrap_or("project")
165 .chars()
166 .map(|c| {
167 if c.is_ascii_alphanumeric() {
168 c.to_ascii_lowercase()
169 } else {
170 '-'
171 }
172 })
173 .take(32)
174 .collect();
175 let slug = slug.trim_matches('-');
176 let mut hasher = std::collections::hash_map::DefaultHasher::new();
177 canonical.to_string_lossy().hash(&mut hasher);
178 let hash = hasher.finish() as u32;
179 let slug = if slug.is_empty() { "project" } else { slug };
180 format!("{slug}-{hash:08x}")
181}
182
183pub fn slugify(name: &str) -> String {
185 let mut out = String::new();
186 let mut prev_dash = false;
187 for ch in name.trim().chars() {
188 if ch.is_ascii_alphanumeric() {
189 out.push(ch.to_ascii_lowercase());
190 prev_dash = false;
191 } else if !prev_dash {
192 out.push('-');
193 prev_dash = true;
194 }
195 }
196 let slug = out.trim_matches('-').to_string();
197 if slug.is_empty() {
198 "memory".to_string()
199 } else {
200 slug
201 }
202}
203
204#[derive(Debug, Default)]
205struct Frontmatter {
206 name: Option<String>,
207 description: Option<String>,
208}
209
210fn parse_frontmatter(raw: &str) -> (Frontmatter, String) {
214 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
215 let mut lines = raw.lines();
216 if lines.next().map(str::trim) != Some("---") {
217 return (Frontmatter::default(), raw.to_string());
218 }
219 let mut fm = Frontmatter::default();
220 let mut body_lines: Vec<&str> = Vec::new();
221 let mut in_fm = true;
222 for line in lines {
223 if in_fm {
224 if line.trim() == "---" {
225 in_fm = false;
226 continue;
227 }
228 if let Some((key, value)) = line.split_once(':') {
229 let value = value.trim().trim_matches('"').to_string();
230 match key.trim() {
231 "name" => fm.name = Some(value),
232 "description" => fm.description = Some(value),
233 _ => {},
234 }
235 }
236 } else {
237 body_lines.push(line);
238 }
239 }
240 if in_fm {
241 return (Frontmatter::default(), raw.to_string());
243 }
244 (fm, body_lines.join("\n").trim().to_string())
245}
246
247fn load_root(dir: &Path, scope: MemoryScope) -> Vec<MemoryEntry> {
250 let mut entries = Vec::new();
251 let Ok(read) = std::fs::read_dir(dir) else {
252 return entries;
253 };
254 for entry in read.flatten() {
255 let path = entry.path();
256 if path.extension().and_then(|e| e.to_str()) != Some("md") {
257 continue;
258 }
259 let Ok(meta) = entry.metadata() else { continue };
260 if !meta.is_file() {
261 continue;
262 }
263 let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
264 let raw = std::fs::read_to_string(&path).unwrap_or_default();
265 let (fm, body) = parse_frontmatter(&raw);
266 let stem = path
267 .file_stem()
268 .and_then(|s| s.to_str())
269 .unwrap_or("memory")
270 .to_string();
271 let name = fm.name.filter(|s| !s.is_empty()).unwrap_or(stem);
272 let description = fm.description.filter(|s| !s.is_empty()).unwrap_or_else(|| {
273 body.lines()
274 .find(|l| !l.trim().is_empty())
275 .unwrap_or("")
276 .to_string()
277 });
278 entries.push(MemoryEntry {
279 name,
280 description,
281 path,
282 scope,
283 mtime,
284 });
285 }
286 entries.sort_by(|a, b| a.name.cmp(&b.name));
287 entries
288}
289
290fn render_index(entries: &[MemoryEntry], cap: usize) -> (String, bool) {
293 if entries.is_empty() {
294 return (String::new(), false);
295 }
296 let mut out = String::from(
297 "# Memory\n\nDurable facts you have saved across sessions. Read a file with `read_file` when its description is relevant; change memory with the `memory` tool.\n",
298 );
299 for scope in [
300 MemoryScope::Global,
301 MemoryScope::ProjectPrivate,
302 MemoryScope::ProjectShared,
303 ] {
304 let mut first = true;
305 for entry in entries.iter().filter(|e| e.scope == scope) {
306 if first {
307 out.push_str(&format!("\n## {}\n", scope.label()));
308 first = false;
309 }
310 out.push_str(&format!(
311 "- [{}] {} — {}\n",
312 entry.name,
313 entry.description,
314 entry.path.display()
315 ));
316 }
317 }
318 if out.len() > cap {
319 let cut = out.floor_char_boundary(cap);
320 let mut clipped = out[..cut].to_string();
321 clipped.push_str(MEMORY_INDEX_TRUNCATION_MARKER);
322 (clipped, true)
323 } else {
324 (out, false)
325 }
326}
327
328pub fn load(cwd: &Path, cfg: &MemoryConfig) -> Option<LoadedMemory> {
330 if !cfg.enabled {
331 return None;
332 }
333 let mut entries = Vec::new();
334 for (dir, scope) in memory_roots(cwd) {
335 entries.extend(load_root(&dir, scope));
336 }
337 if entries.is_empty() {
338 return None;
339 }
340 let (index, truncated) = render_index(&entries, cfg.index_cap_bytes);
341 Some(LoadedMemory {
342 entries,
343 index,
344 truncated,
345 })
346}
347
348pub fn refresh(
352 current: Option<LoadedMemory>,
353 cwd: &Path,
354 cfg: &MemoryConfig,
355) -> (Option<LoadedMemory>, MemoryReloadOutcome) {
356 let fresh = load(cwd, cfg);
357 let outcome = match (¤t, &fresh) {
358 (None, None) => MemoryReloadOutcome::Unchanged,
359 (None, Some(_)) => MemoryReloadOutcome::LoadedFirst,
360 (Some(_), None) => MemoryReloadOutcome::Removed,
361 (Some(prev), Some(next)) => {
362 if same_entries(prev, next) {
363 MemoryReloadOutcome::Unchanged
364 } else {
365 MemoryReloadOutcome::Reloaded
366 }
367 },
368 };
369 (fresh, outcome)
370}
371
372fn same_entries(a: &LoadedMemory, b: &LoadedMemory) -> bool {
373 a.entries.len() == b.entries.len()
374 && a.entries
375 .iter()
376 .zip(&b.entries)
377 .all(|(x, y)| x.path == y.path && x.mtime == y.mtime)
378}
379
380fn render_file(
382 name: &str,
383 description: &str,
384 scope: MemoryScope,
385 tags: &[String],
386 body: &str,
387) -> String {
388 let created = chrono::Utc::now().to_rfc3339();
389 let tags = tags
390 .iter()
391 .map(|t| t.trim())
392 .filter(|t| !t.is_empty())
393 .collect::<Vec<_>>()
394 .join(", ");
395 format!(
396 "---\nname: {name}\ndescription: {description}\nscope: {scope}\ncreated: {created}\ntags: [{tags}]\n---\n\n{body}\n",
397 scope = scope.as_str(),
398 body = body.trim_end(),
399 )
400}
401
402pub fn write_to_dir(
405 dir: &Path,
406 name: &str,
407 description: &str,
408 scope: MemoryScope,
409 tags: &[String],
410 body: &str,
411) -> std::io::Result<PathBuf> {
412 std::fs::create_dir_all(dir)?;
413 let path = dir.join(format!("{}.md", slugify(name)));
414 std::fs::write(&path, render_file(name, description, scope, tags, body))?;
415 Ok(path)
416}
417
418pub fn write_memory(
420 cwd: &Path,
421 scope: MemoryScope,
422 name: &str,
423 description: &str,
424 tags: &[String],
425 body: &str,
426) -> std::io::Result<PathBuf> {
427 let dir = dir_for(scope, cwd).ok_or_else(|| {
428 std::io::Error::new(
429 std::io::ErrorKind::NotFound,
430 "could not resolve a memory directory",
431 )
432 })?;
433 write_to_dir(&dir, name, description, scope, tags, body)
434}
435
436pub fn find(cwd: &Path, id_or_name: &str) -> Option<MemoryEntry> {
438 for (dir, scope) in memory_roots(cwd) {
439 for entry in load_root(&dir, scope) {
440 let stem = entry.path.file_stem().and_then(|s| s.to_str());
441 if entry.name == id_or_name || stem == Some(id_or_name) {
442 return Some(entry);
443 }
444 }
445 }
446 None
447}
448
449pub fn delete_memory(cwd: &Path, id_or_name: &str) -> std::io::Result<Option<PathBuf>> {
452 match find(cwd, id_or_name) {
453 Some(entry) => {
454 std::fs::remove_file(&entry.path)?;
455 Ok(Some(entry.path))
456 },
457 None => Ok(None),
458 }
459}
460
461pub fn entries_with_bodies(cwd: &Path) -> Vec<(MemoryEntry, String)> {
464 let mut out = Vec::new();
465 for (dir, scope) in memory_roots(cwd) {
466 for entry in load_root(&dir, scope) {
467 let raw = std::fs::read_to_string(&entry.path).unwrap_or_default();
468 let (_, body) = parse_frontmatter(&raw);
469 out.push((entry, body));
470 }
471 }
472 out
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::constants::MAX_MEMORY_INDEX_BYTES;
479 use std::fs;
480 use std::sync::Mutex;
481
482 static FS_LOCK: Mutex<()> = Mutex::new(());
483
484 fn temp_dir(name: &str) -> PathBuf {
485 let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
486 let _ = fs::remove_dir_all(&p);
487 fs::create_dir_all(&p).expect("create temp dir");
488 p
489 }
490
491 #[test]
492 fn slugify_makes_safe_stems() {
493 assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
494 assert_eq!(slugify(" use pnpm "), "use-pnpm");
495 assert_eq!(slugify("***"), "memory");
496 }
497
498 #[test]
499 fn parse_frontmatter_extracts_name_and_description() {
500 let raw =
501 "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
502 let (fm, body) = parse_frontmatter(raw);
503 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
504 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
505 assert_eq!(body, "The body.");
506 }
507
508 #[test]
509 fn parse_frontmatter_without_fence_is_all_body() {
510 let (fm, body) = parse_frontmatter("just a note\nsecond line");
511 assert!(fm.name.is_none());
512 assert_eq!(body, "just a note\nsecond line");
513 }
514
515 #[test]
516 fn render_and_parse_round_trip() {
517 let content = render_file(
518 "prefer-rg",
519 "Use ripgrep",
520 MemoryScope::ProjectShared,
521 &["tooling".to_string()],
522 "Always reach for rg.",
523 );
524 assert!(content.contains("scope: project-shared"));
525 let (fm, body) = parse_frontmatter(&content);
526 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
527 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
528 assert_eq!(body, "Always reach for rg.");
529 }
530
531 #[test]
532 fn write_and_load_root_round_trip() {
533 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
534 let dir = temp_dir("root");
535 write_to_dir(
536 &dir,
537 "Test Fact",
538 "A description",
539 MemoryScope::Global,
540 &[],
541 "body",
542 )
543 .unwrap();
544 let entries = load_root(&dir, MemoryScope::Global);
545 assert_eq!(entries.len(), 1);
546 assert_eq!(entries[0].name, "Test Fact");
547 assert_eq!(entries[0].description, "A description");
548 assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
549 let _ = fs::remove_dir_all(&dir);
550 }
551
552 #[test]
553 fn load_root_falls_back_to_stem_and_first_line() {
554 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
555 let dir = temp_dir("fallback");
556 fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
557 let entries = load_root(&dir, MemoryScope::Global);
558 assert_eq!(entries.len(), 1);
559 assert_eq!(entries[0].name, "bare-note");
560 assert_eq!(entries[0].description, "first meaningful line");
561 let _ = fs::remove_dir_all(&dir);
562 }
563
564 #[test]
565 fn render_index_groups_by_scope_and_excludes_body() {
566 let entries = vec![
567 MemoryEntry {
568 name: "g".into(),
569 description: "global fact".into(),
570 path: PathBuf::from("/g/g.md"),
571 scope: MemoryScope::Global,
572 mtime: UNIX_EPOCH,
573 },
574 MemoryEntry {
575 name: "p".into(),
576 description: "private fact".into(),
577 path: PathBuf::from("/p/p.md"),
578 scope: MemoryScope::ProjectPrivate,
579 mtime: UNIX_EPOCH,
580 },
581 ];
582 let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
583 assert!(!truncated);
584 assert!(index.contains("# Memory"));
585 assert!(index.contains("## Global"));
586 assert!(index.contains("## Project (private)"));
587 assert!(index.contains("[g] global fact"));
588 assert!(index.contains("[p] private fact"));
589 assert!(index.find("global fact") < index.find("private fact"));
591 }
592
593 #[test]
594 fn render_index_truncates_when_oversized() {
595 let entries: Vec<MemoryEntry> = (0..200)
596 .map(|i| MemoryEntry {
597 name: format!("name-{i}"),
598 description: "a".repeat(80),
599 path: PathBuf::from(format!("/m/name-{i}.md")),
600 scope: MemoryScope::Global,
601 mtime: UNIX_EPOCH,
602 })
603 .collect();
604 let (index, truncated) = render_index(&entries, 1_000);
605 assert!(truncated);
606 assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
607 }
608
609 #[test]
610 fn find_git_root_detects_dot_git() {
611 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
612 let root = temp_dir("gitroot");
613 fs::create_dir(root.join(".git")).unwrap();
614 let sub = root.join("a/b");
615 fs::create_dir_all(&sub).unwrap();
616 assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
617 let _ = fs::remove_dir_all(&root);
618 }
619
620 #[test]
621 fn find_git_root_none_without_repo() {
622 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
623 let dir = temp_dir("norepo");
624 assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
627 let _ = fs::remove_dir_all(&dir);
628 }
629
630 #[test]
631 fn delete_in_dir_round_trip() {
632 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
633 let dir = temp_dir("delete");
634 let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
635 assert!(path.exists());
636 let entries = load_root(&dir, MemoryScope::Global);
638 assert_eq!(entries.len(), 1);
639 fs::remove_file(&entries[0].path).unwrap();
640 assert!(load_root(&dir, MemoryScope::Global).is_empty());
641 let _ = fs::remove_dir_all(&dir);
642 }
643}