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