Skip to main content

mermaid_cli/app/
memory.rs

1//! Durable semantic memory (v0.10.0).
2//!
3//! Plain-Markdown, agent-managed long-term memory: one fact per file with
4//! YAML frontmatter (`name`, `description`, `scope`, `created`, `tags`) and a
5//! body. Three scopes, all machine-local except shared:
6//!   - **global**     `<data_dir>/memory/`                       (all projects)
7//!   - **project-private** `<data_dir>/projects/<key>/memory/`   (default; not committed)
8//!   - **project-shared**  `<git-root>/.mermaid/memory/`         (opt-in; committed)
9//!
10//! Retrieval is an always-loaded auto-derived INDEX (name + description + path
11//! per file, grouped by scope) plus on-demand reads of the full files via the
12//! normal `read_file` tool. The index is generated from the files, so it can
13//! never drift from them. No database, no vectors, no embeddings.
14//!
15//! This module owns the on-disk format, scope resolution, index generation,
16//! load/refresh, and the write/delete primitives the memory tool and slash
17//! commands build on.
18
19use mermaid_domain::{LoadedMemory, MemoryEntry, MemoryScope};
20use std::hash::{Hash, Hasher};
21use std::path::{Path, PathBuf};
22use std::time::UNIX_EPOCH;
23
24use mermaid_domain::MemoryConfig;
25use mermaid_model::constants::MEMORY_INDEX_TRUNCATION_MARKER;
26
27/// Hard cap on directory levels `find_git_root` walks up (symlink-loop guard).
28const MAX_WALK_DEPTH: usize = 32;
29
30/// Per-file byte cap when reading a memory `.md` during the per-turn index
31/// refresh. A single fact is tiny; this only bounds a pathological/huge file so
32/// `refresh()` can't be made to slurp unbounded bytes every turn (F47).
33const MAX_MEMORY_FILE_BYTES: usize = 64_000;
34
35/// Outcome of a per-turn `refresh()`, for optional status reporting.
36#[derive(Debug, PartialEq, Eq)]
37pub enum MemoryReloadOutcome {
38    Unchanged,
39    Reloaded,
40    LoadedFirst,
41    Removed,
42}
43
44/// Walk UP from `start` to the nearest directory containing a `.git` entry
45/// (file or dir, so worktrees resolve), or `None` if not inside a repo.
46#[must_use]
47pub fn find_git_root(start: &Path) -> Option<PathBuf> {
48    let mut current = start.to_path_buf();
49    for _ in 0..MAX_WALK_DEPTH {
50        if current.join(".git").exists() {
51            return Some(current);
52        }
53        match current.parent() {
54            Some(parent) if parent != current => current = parent.to_path_buf(),
55            _ => return None,
56        }
57    }
58    None
59}
60
61/// The memory roots for `cwd`, in injection order (global → private → shared).
62/// Shared is omitted when `cwd` isn't in a git repo. Returns an empty vec only
63/// if the machine data dir can't be resolved.
64#[must_use]
65pub fn memory_roots(cwd: &Path) -> Vec<(PathBuf, MemoryScope)> {
66    let Ok(data) = mermaid_runtime::data_dir() else {
67        return Vec::new();
68    };
69    let mut roots = vec![(data.join("memory"), MemoryScope::Global)];
70    match find_git_root(cwd) {
71        Some(git_root) => {
72            roots.push((
73                data.join("projects")
74                    .join(project_key(&git_root))
75                    .join("memory"),
76                MemoryScope::ProjectPrivate,
77            ));
78            roots.push((
79                git_root.join(".mermaid").join("memory"),
80                MemoryScope::ProjectShared,
81            ));
82        },
83        None => {
84            // Not a repo: key private memory off the canonical cwd; no shared.
85            roots.push((
86                data.join("projects").join(project_key(cwd)).join("memory"),
87                MemoryScope::ProjectPrivate,
88            ));
89        },
90    }
91    roots
92}
93
94/// Resolve the on-disk directory for a scope at `cwd`, if available.
95#[must_use]
96pub fn dir_for(scope: MemoryScope, cwd: &Path) -> Option<PathBuf> {
97    memory_roots(cwd)
98        .into_iter()
99        .find(|(_, s)| *s == scope)
100        .map(|(dir, _)| dir)
101}
102
103/// Stable machine-local key for a project path: `<slug>-<hash8>`. The slug is
104/// human-debuggable; the hash disambiguates same-named dirs. Only keys the
105/// machine-local private store, so cross-machine stability is irrelevant.
106fn project_key(path: &Path) -> String {
107    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
108    let slug: String = canonical
109        .file_name()
110        .and_then(|n| n.to_str())
111        .unwrap_or("project")
112        .chars()
113        .map(|c| {
114            if c.is_ascii_alphanumeric() {
115                c.to_ascii_lowercase()
116            } else {
117                '-'
118            }
119        })
120        .take(32)
121        .collect();
122    let slug = slug.trim_matches('-');
123    let mut hasher = std::collections::hash_map::DefaultHasher::new();
124    canonical.to_string_lossy().hash(&mut hasher);
125    let hash = hasher.finish() as u32;
126    let slug = if slug.is_empty() { "project" } else { slug };
127    format!("{slug}-{hash:08x}")
128}
129
130/// kebab-case slug for a memory name → filename stem.
131#[must_use]
132pub fn slugify(name: &str) -> String {
133    let mut out = String::new();
134    let mut prev_dash = false;
135    for ch in name.trim().chars() {
136        if ch.is_ascii_alphanumeric() {
137            out.push(ch.to_ascii_lowercase());
138            prev_dash = false;
139        } else if !prev_dash {
140            out.push('-');
141            prev_dash = true;
142        }
143    }
144    let slug = out.trim_matches('-').to_string();
145    if slug.is_empty() {
146        "memory".to_string()
147    } else {
148        slug
149    }
150}
151
152#[derive(Debug, Default)]
153struct Frontmatter {
154    name: Option<String>,
155    description: Option<String>,
156}
157
158/// Split a memory file into its (name, description) frontmatter and body. A
159/// file without a leading `---` fence is treated as all-body. Tolerant: a
160/// malformed/unclosed fence falls back to the whole content as body.
161fn parse_frontmatter(raw: &str) -> (Frontmatter, String) {
162    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
163    let mut lines = raw.lines();
164    if lines.next().map(str::trim) != Some("---") {
165        return (Frontmatter::default(), raw.to_string());
166    }
167    let mut fm = Frontmatter::default();
168    let mut body_lines: Vec<&str> = Vec::new();
169    let mut in_fm = true;
170    for line in lines {
171        if in_fm {
172            if line.trim() == "---" {
173                in_fm = false;
174                continue;
175            }
176            if let Some((key, value)) = line.split_once(':') {
177                let value = value.trim().trim_matches('"').to_string();
178                match key.trim() {
179                    "name" => fm.name = Some(value),
180                    "description" => fm.description = Some(value),
181                    _ => {},
182                }
183            }
184        } else {
185            body_lines.push(line);
186        }
187    }
188    if in_fm {
189        // Unclosed fence — not real frontmatter.
190        return (Frontmatter::default(), raw.to_string());
191    }
192    (fm, body_lines.join("\n").trim().to_string())
193}
194
195/// Load all `*.md` memories in `dir` (non-recursive) as index entries. Missing
196/// dir ⇒ empty. Sorted by name for a deterministic index.
197fn load_root(dir: &Path, scope: MemoryScope) -> Vec<MemoryEntry> {
198    let mut entries = Vec::new();
199    let Ok(read) = std::fs::read_dir(dir) else {
200        return entries;
201    };
202    for entry in read.flatten() {
203        let path = entry.path();
204        if path.extension().and_then(|e| e.to_str()) != Some("md") {
205            continue;
206        }
207        let Ok(meta) = entry.metadata() else { continue };
208        if !meta.is_file() {
209            continue;
210        }
211        let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
212        // Bounded read: this dir is re-scanned every turn by `refresh()`, so
213        // never slurp a pathologically large `.md` whole — and surface (not
214        // silently swallow) a read error instead of indexing an empty stub (F47).
215        let raw = match mermaid_model::utils::read_file_capped(&path, MAX_MEMORY_FILE_BYTES) {
216            Ok((bytes, _truncated)) => String::from_utf8_lossy(&bytes).into_owned(),
217            Err(e) => {
218                tracing::warn!(path = %path.display(), error = %e, "memory: skipping unreadable file");
219                continue;
220            },
221        };
222        let (fm, body) = parse_frontmatter(&raw);
223        let stem = path
224            .file_stem()
225            .and_then(|s| s.to_str())
226            .unwrap_or("memory")
227            .to_string();
228        let name = fm.name.filter(|s| !s.is_empty()).unwrap_or(stem);
229        let description = fm.description.filter(|s| !s.is_empty()).unwrap_or_else(|| {
230            body.lines()
231                .find(|l| !l.trim().is_empty())
232                .unwrap_or("")
233                .to_string()
234        });
235        entries.push(MemoryEntry {
236            name,
237            description,
238            path,
239            scope,
240            mtime,
241        });
242    }
243    entries.sort_by(|a, b| a.name.cmp(&b.name));
244    entries
245}
246
247/// Render the always-loaded index from entries, grouped global → private →
248/// shared, clipped to `cap` bytes with a marker if oversized.
249fn render_index(entries: &[MemoryEntry], cap: usize) -> (String, bool) {
250    if entries.is_empty() {
251        return (String::new(), false);
252    }
253    let mut out = String::from(
254        "# 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",
255    );
256    for scope in [
257        MemoryScope::Global,
258        MemoryScope::ProjectPrivate,
259        MemoryScope::ProjectShared,
260    ] {
261        let mut first = true;
262        for entry in entries.iter().filter(|e| e.scope == scope) {
263            if first {
264                out.push_str(&format!("\n## {}\n", scope.label()));
265                first = false;
266            }
267            out.push_str(&format!(
268                "- [{}] {} — {}\n",
269                entry.name,
270                entry.description,
271                entry.path.display()
272            ));
273        }
274    }
275    if out.len() > cap {
276        let cut = out.floor_char_boundary(cap);
277        let mut clipped = out[..cut].to_string();
278        clipped.push_str(MEMORY_INDEX_TRUNCATION_MARKER);
279        (clipped, true)
280    } else {
281        (out, false)
282    }
283}
284
285/// Load all memory for `cwd` into a snapshot, or `None` when disabled or empty.
286#[must_use]
287pub fn load(cwd: &Path, cfg: &MemoryConfig) -> Option<LoadedMemory> {
288    if !cfg.enabled {
289        return None;
290    }
291    let mut entries = Vec::new();
292    for (dir, scope) in memory_roots(cwd) {
293        entries.extend(load_root(&dir, scope));
294    }
295    if entries.is_empty() {
296        return None;
297    }
298    let (index, truncated) = render_index(&entries, cfg.index_cap_bytes);
299    Some(LoadedMemory {
300        entries,
301        index,
302        truncated,
303    })
304}
305
306/// Per-turn refresh: re-scan the roots (cheap — a few `read_dir`s + stats) and
307/// report whether anything changed since `current`. Picks up the agent's own
308/// mid-session writes and hand edits with no filesystem watcher.
309#[must_use]
310pub fn refresh(
311    current: Option<LoadedMemory>,
312    cwd: &Path,
313    cfg: &MemoryConfig,
314) -> (Option<LoadedMemory>, MemoryReloadOutcome) {
315    let fresh = load(cwd, cfg);
316    let outcome = match (&current, &fresh) {
317        (None, None) => MemoryReloadOutcome::Unchanged,
318        (None, Some(_)) => MemoryReloadOutcome::LoadedFirst,
319        (Some(_), None) => MemoryReloadOutcome::Removed,
320        (Some(prev), Some(next)) => {
321            if same_entries(prev, next) {
322                MemoryReloadOutcome::Unchanged
323            } else {
324                MemoryReloadOutcome::Reloaded
325            }
326        },
327    };
328    (fresh, outcome)
329}
330
331fn same_entries(a: &LoadedMemory, b: &LoadedMemory) -> bool {
332    a.entries.len() == b.entries.len()
333        && a.entries
334            .iter()
335            .zip(&b.entries)
336            .all(|(x, y)| x.path == y.path && x.mtime == y.mtime)
337}
338
339/// Render a memory file's content (frontmatter + body).
340fn render_file(
341    name: &str,
342    description: &str,
343    scope: MemoryScope,
344    tags: &[String],
345    body: &str,
346) -> String {
347    let created = chrono::Utc::now().to_rfc3339();
348    let tags = tags
349        .iter()
350        .map(|t| t.trim())
351        .filter(|t| !t.is_empty())
352        .collect::<Vec<_>>()
353        .join(", ");
354    format!(
355        "---\nname: {name}\ndescription: {description}\nscope: {scope}\ncreated: {created}\ntags: [{tags}]\n---\n\n{body}\n",
356        scope = scope.as_str(),
357        body = body.trim_end(),
358    )
359}
360
361/// Write a memory into `dir` (created if needed). Returns the file path.
362/// Testable core of `write_memory`.
363///
364/// # Errors
365///
366/// Creating `dir` and writing the file. A `name` that slugifies to an existing
367/// file is not an error — the memory is overwritten, which is how an update
368/// works.
369pub fn write_to_dir(
370    dir: &Path,
371    name: &str,
372    description: &str,
373    scope: MemoryScope,
374    tags: &[String],
375    body: &str,
376) -> std::io::Result<PathBuf> {
377    std::fs::create_dir_all(dir)?;
378    // Redact credential-shaped strings before persisting model-written memory:
379    // a fact that summarizes a `.env` the model read would otherwise store a
380    // key in the durable (and always-index-loaded) memory file (#69). Scrub all
381    // four fields — the `name` re-enters the always-loaded system-prompt index
382    // and `tags` ride along in frontmatter, so redacting only description+body
383    // would still leak a secret pasted into the name/tags (F9). Redact the name
384    // BEFORE slugifying so a credential can't survive in the on-disk filename.
385    let name = mermaid_model::utils::redact_secrets(name);
386    let description = mermaid_model::utils::redact_secrets(description);
387    let tags: Vec<String> = tags
388        .iter()
389        .map(|t| mermaid_model::utils::redact_secrets(t))
390        .collect();
391    let body = mermaid_model::utils::redact_secrets(body);
392    let path = dir.join(format!("{}.md", slugify(&name)));
393    std::fs::write(&path, render_file(&name, &description, scope, &tags, &body))?;
394    Ok(path)
395}
396
397/// Write a memory at the resolved directory for `scope`/`cwd`.
398///
399/// # Errors
400///
401/// `scope` resolving to no directory — `NotFound`, e.g. a project scope with
402/// no project — then [`write_to_dir`]'s.
403pub fn write_memory(
404    cwd: &Path,
405    scope: MemoryScope,
406    name: &str,
407    description: &str,
408    tags: &[String],
409    body: &str,
410) -> std::io::Result<PathBuf> {
411    let dir = dir_for(scope, cwd).ok_or_else(|| {
412        std::io::Error::new(
413            std::io::ErrorKind::NotFound,
414            "could not resolve a memory directory",
415        )
416    })?;
417    write_to_dir(&dir, name, description, scope, tags, body)
418}
419
420/// Find a memory by name or file-stem id across all scopes.
421#[must_use]
422pub fn find(cwd: &Path, id_or_name: &str) -> Option<MemoryEntry> {
423    for (dir, scope) in memory_roots(cwd) {
424        for entry in load_root(&dir, scope) {
425            let stem = entry.path.file_stem().and_then(|s| s.to_str());
426            if entry.name == id_or_name || stem == Some(id_or_name) {
427                return Some(entry);
428            }
429        }
430    }
431    None
432}
433
434/// Delete a memory by name or file-stem id. Returns the deleted path, or
435/// `None` if no match.
436///
437/// # Errors
438///
439/// Removing the file once a match is found. No match is `Ok(None)`, not an
440/// error.
441pub fn delete_memory(cwd: &Path, id_or_name: &str) -> std::io::Result<Option<PathBuf>> {
442    match find(cwd, id_or_name) {
443        Some(entry) => {
444            std::fs::remove_file(&entry.path)?;
445            Ok(Some(entry.path))
446        },
447        None => Ok(None),
448    }
449}
450
451/// Load every memory's index entry paired with its full body text, across all
452/// scopes. Consolidation needs the bodies to judge duplicates/staleness.
453#[must_use]
454pub fn entries_with_bodies(cwd: &Path) -> Vec<(MemoryEntry, String)> {
455    let mut out = Vec::new();
456    for (dir, scope) in memory_roots(cwd) {
457        for entry in load_root(&dir, scope) {
458            let raw = std::fs::read_to_string(&entry.path).unwrap_or_default();
459            let (_, body) = parse_frontmatter(&raw);
460            out.push((entry, body));
461        }
462    }
463    out
464}
465
466/// One hit from a memory search: the matching entry plus a short excerpt of the
467/// line where the query matched (falling back to the description when the match
468/// is in the name/description rather than the body).
469#[derive(Debug, Clone)]
470pub struct MemorySearchHit {
471    pub entry: MemoryEntry,
472    pub snippet: String,
473}
474
475/// Search all memory across scopes for `query` — a case-insensitive substring
476/// match over each fact's name, description, and body. No embeddings or vectors
477/// (matches Mermaid's stated stance); a plain scan over the already-bounded
478/// memory corpus. Bodies on disk are redacted at write time, so snippets are
479/// safe to surface. Returns an empty vec for a blank query.
480#[must_use]
481pub fn search(cwd: &Path, query: &str) -> Vec<MemorySearchHit> {
482    search_entries(entries_with_bodies(cwd), query)
483}
484
485/// Core matcher for [`search`], split out so it can be tested over hand-built
486/// entries without touching the real per-user memory directories.
487fn search_entries(entries: Vec<(MemoryEntry, String)>, query: &str) -> Vec<MemorySearchHit> {
488    let needle = query.trim().to_lowercase();
489    if needle.is_empty() {
490        return Vec::new();
491    }
492    let mut out = Vec::new();
493    for (entry, body) in entries {
494        let body_line = body
495            .lines()
496            .find(|line| line.to_lowercase().contains(&needle));
497        let matches = entry.name.to_lowercase().contains(&needle)
498            || entry.description.to_lowercase().contains(&needle)
499            || body_line.is_some();
500        if !matches {
501            continue;
502        }
503        let raw_snippet = body_line
504            .map(str::trim)
505            .filter(|l| !l.is_empty())
506            .unwrap_or(entry.description.as_str());
507        let snippet = clip_chars(raw_snippet, 160);
508        out.push(MemorySearchHit { entry, snippet });
509    }
510    out
511}
512
513/// Clip `s` to at most `max` characters on a char boundary, appending a single
514/// ellipsis when truncated. Used for search snippets.
515fn clip_chars(s: &str, max: usize) -> String {
516    if s.chars().count() <= max {
517        return s.to_string();
518    }
519    let clipped: String = s.chars().take(max).collect();
520    format!("{clipped}…")
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use mermaid_model::constants::MAX_MEMORY_INDEX_BYTES;
527    use std::fs;
528    use std::sync::Mutex;
529    use std::time::SystemTime;
530
531    static FS_LOCK: Mutex<()> = Mutex::new(());
532
533    fn temp_dir(name: &str) -> PathBuf {
534        let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
535        let _ = fs::remove_dir_all(&p);
536        fs::create_dir_all(&p).expect("create temp dir");
537        p
538    }
539
540    #[test]
541    fn slugify_makes_safe_stems() {
542        assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
543        assert_eq!(slugify("  use   pnpm  "), "use-pnpm");
544        assert_eq!(slugify("***"), "memory");
545    }
546
547    #[test]
548    fn parse_frontmatter_extracts_name_and_description() {
549        let raw =
550            "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
551        let (fm, body) = parse_frontmatter(raw);
552        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
553        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
554        assert_eq!(body, "The body.");
555    }
556
557    #[test]
558    fn parse_frontmatter_without_fence_is_all_body() {
559        let (fm, body) = parse_frontmatter("just a note\nsecond line");
560        assert!(fm.name.is_none());
561        assert_eq!(body, "just a note\nsecond line");
562    }
563
564    #[test]
565    fn render_and_parse_round_trip() {
566        let content = render_file(
567            "prefer-rg",
568            "Use ripgrep",
569            MemoryScope::ProjectShared,
570            &["tooling".to_string()],
571            "Always reach for rg.",
572        );
573        assert!(content.contains("scope: project-shared"));
574        let (fm, body) = parse_frontmatter(&content);
575        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
576        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
577        assert_eq!(body, "Always reach for rg.");
578    }
579
580    #[test]
581    fn write_and_load_root_round_trip() {
582        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
583        let dir = temp_dir("root");
584        write_to_dir(
585            &dir,
586            "Test Fact",
587            "A description",
588            MemoryScope::Global,
589            &[],
590            "body",
591        )
592        .unwrap();
593        let entries = load_root(&dir, MemoryScope::Global);
594        assert_eq!(entries.len(), 1);
595        assert_eq!(entries[0].name, "Test Fact");
596        assert_eq!(entries[0].description, "A description");
597        assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
598        let _ = fs::remove_dir_all(&dir);
599    }
600
601    #[test]
602    fn search_entries_matches_name_description_and_body() {
603        let mk = |name: &str, desc: &str| MemoryEntry {
604            name: name.to_string(),
605            description: desc.to_string(),
606            path: PathBuf::from(format!("/tmp/{name}.md")),
607            scope: MemoryScope::ProjectPrivate,
608            mtime: SystemTime::UNIX_EPOCH,
609        };
610        let entries = vec![
611            (
612                mk("prefer-ripgrep", "Use rg for search"),
613                "Always reach for ripgrep over grep.".to_string(),
614            ),
615            (
616                mk("editor-choice", "Editor preference"),
617                "The user likes neovim.".to_string(),
618            ),
619            (
620                mk("ci-flow", "CI conventions"),
621                "Run just check before every PR.".to_string(),
622            ),
623        ];
624
625        // Body-only match returns the matching line as the snippet.
626        let hits = search_entries(entries.clone(), "neovim");
627        assert_eq!(hits.len(), 1);
628        assert_eq!(hits[0].entry.name, "editor-choice");
629        assert!(hits[0].snippet.contains("neovim"));
630
631        // Case-insensitive; matches in the name.
632        assert_eq!(search_entries(entries.clone(), "RIPGREP").len(), 1);
633
634        // Description match with no body hit falls back to the description.
635        let desc_hits = search_entries(entries.clone(), "conventions");
636        assert_eq!(desc_hits.len(), 1);
637        assert_eq!(desc_hits[0].snippet, "CI conventions");
638
639        // Blank query and unmatched query both return nothing.
640        assert!(search_entries(entries.clone(), "   ").is_empty());
641        assert!(search_entries(entries, "nonexistent-xyz").is_empty());
642    }
643
644    #[test]
645    fn clip_chars_truncates_on_char_boundary() {
646        assert_eq!(clip_chars("short", 10), "short");
647        let clipped = clip_chars(&"a".repeat(200), 160);
648        assert_eq!(clipped.chars().count(), 161); // 160 kept + one ellipsis
649        assert!(clipped.ends_with('…'));
650    }
651
652    #[test]
653    fn write_to_dir_redacts_name_and_tags() {
654        // F9: a credential pasted into the name or a tag must be scrubbed too —
655        // the name re-enters the always-loaded index, and tags persist in
656        // frontmatter. Redacting only description+body would still leak.
657        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
658        let dir = temp_dir("redact_name_tags");
659        let path = write_to_dir(
660            &dir,
661            "leaked key sk-ant-api03-abcdefghijklmnop",
662            "desc",
663            MemoryScope::Global,
664            &["env-OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string()],
665            "body",
666        )
667        .unwrap();
668        let raw = fs::read_to_string(&path).unwrap();
669        assert!(
670            !raw.contains("sk-ant-api03-abcdefghijklmnop"),
671            "name secret leaked: {raw}"
672        );
673        assert!(
674            !raw.contains("sk-abcdefghijklmnop1234"),
675            "tag secret leaked: {raw}"
676        );
677        assert!(
678            raw.contains("[REDACTED]"),
679            "expected redaction marker: {raw}"
680        );
681        // The credential must not survive in the on-disk filename either.
682        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
683        assert!(
684            !stem.contains("abcdefghijklmnop"),
685            "secret leaked into filename: {stem}"
686        );
687        let _ = fs::remove_dir_all(&dir);
688    }
689
690    #[test]
691    fn load_root_falls_back_to_stem_and_first_line() {
692        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
693        let dir = temp_dir("fallback");
694        fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
695        let entries = load_root(&dir, MemoryScope::Global);
696        assert_eq!(entries.len(), 1);
697        assert_eq!(entries[0].name, "bare-note");
698        assert_eq!(entries[0].description, "first meaningful line");
699        let _ = fs::remove_dir_all(&dir);
700    }
701
702    #[test]
703    fn render_index_groups_by_scope_and_excludes_body() {
704        let entries = vec![
705            MemoryEntry {
706                name: "g".into(),
707                description: "global fact".into(),
708                path: PathBuf::from("/g/g.md"),
709                scope: MemoryScope::Global,
710                mtime: UNIX_EPOCH,
711            },
712            MemoryEntry {
713                name: "p".into(),
714                description: "private fact".into(),
715                path: PathBuf::from("/p/p.md"),
716                scope: MemoryScope::ProjectPrivate,
717                mtime: UNIX_EPOCH,
718            },
719        ];
720        let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
721        assert!(!truncated);
722        assert!(index.contains("# Memory"));
723        assert!(index.contains("## Global"));
724        assert!(index.contains("## Project (private)"));
725        assert!(index.contains("[g] global fact"));
726        assert!(index.contains("[p] private fact"));
727        // Global section precedes private (most specific last).
728        assert!(index.find("global fact") < index.find("private fact"));
729    }
730
731    #[test]
732    fn render_index_truncates_when_oversized() {
733        let entries: Vec<MemoryEntry> = (0..200)
734            .map(|i| MemoryEntry {
735                name: format!("name-{i}"),
736                description: "a".repeat(80),
737                path: PathBuf::from(format!("/m/name-{i}.md")),
738                scope: MemoryScope::Global,
739                mtime: UNIX_EPOCH,
740            })
741            .collect();
742        let (index, truncated) = render_index(&entries, 1_000);
743        assert!(truncated);
744        assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
745    }
746
747    #[test]
748    fn find_git_root_detects_dot_git() {
749        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
750        let root = temp_dir("gitroot");
751        fs::create_dir(root.join(".git")).unwrap();
752        let sub = root.join("a/b");
753        fs::create_dir_all(&sub).unwrap();
754        assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
755        let _ = fs::remove_dir_all(&root);
756    }
757
758    #[test]
759    fn find_git_root_none_without_repo() {
760        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
761        let dir = temp_dir("norepo");
762        // No .git anywhere up to a sentinel; walk eventually returns None or a
763        // real ancestor repo. Assert it does not falsely claim `dir` is a root.
764        assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
765        let _ = fs::remove_dir_all(&dir);
766    }
767
768    #[test]
769    fn delete_in_dir_round_trip() {
770        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
771        let dir = temp_dir("delete");
772        let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
773        assert!(path.exists());
774        // Mirror delete_memory's match-then-remove against the single root.
775        let entries = load_root(&dir, MemoryScope::Global);
776        assert_eq!(entries.len(), 1);
777        fs::remove_file(&entries[0].path).unwrap();
778        assert!(load_root(&dir, MemoryScope::Global).is_empty());
779        let _ = fs::remove_dir_all(&dir);
780    }
781}