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/// One hit from a memory search: the matching entry plus a short excerpt of the
504/// line where the query matched (falling back to the description when the match
505/// is in the name/description rather than the body).
506#[derive(Debug, Clone)]
507pub struct MemorySearchHit {
508    pub entry: MemoryEntry,
509    pub snippet: String,
510}
511
512/// Search all memory across scopes for `query` — a case-insensitive substring
513/// match over each fact's name, description, and body. No embeddings or vectors
514/// (matches Mermaid's stated stance); a plain scan over the already-bounded
515/// memory corpus. Bodies on disk are redacted at write time, so snippets are
516/// safe to surface. Returns an empty vec for a blank query.
517pub fn search(cwd: &Path, query: &str) -> Vec<MemorySearchHit> {
518    search_entries(entries_with_bodies(cwd), query)
519}
520
521/// Core matcher for [`search`], split out so it can be tested over hand-built
522/// entries without touching the real per-user memory directories.
523fn search_entries(entries: Vec<(MemoryEntry, String)>, query: &str) -> Vec<MemorySearchHit> {
524    let needle = query.trim().to_lowercase();
525    if needle.is_empty() {
526        return Vec::new();
527    }
528    let mut out = Vec::new();
529    for (entry, body) in entries {
530        let body_line = body
531            .lines()
532            .find(|line| line.to_lowercase().contains(&needle));
533        let matches = entry.name.to_lowercase().contains(&needle)
534            || entry.description.to_lowercase().contains(&needle)
535            || body_line.is_some();
536        if !matches {
537            continue;
538        }
539        let raw_snippet = body_line
540            .map(str::trim)
541            .filter(|l| !l.is_empty())
542            .unwrap_or(entry.description.as_str());
543        let snippet = clip_chars(raw_snippet, 160);
544        out.push(MemorySearchHit { entry, snippet });
545    }
546    out
547}
548
549/// Clip `s` to at most `max` characters on a char boundary, appending a single
550/// ellipsis when truncated. Used for search snippets.
551fn clip_chars(s: &str, max: usize) -> String {
552    if s.chars().count() <= max {
553        return s.to_string();
554    }
555    let clipped: String = s.chars().take(max).collect();
556    format!("{clipped}…")
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::constants::MAX_MEMORY_INDEX_BYTES;
563    use std::fs;
564    use std::sync::Mutex;
565
566    static FS_LOCK: Mutex<()> = Mutex::new(());
567
568    fn temp_dir(name: &str) -> PathBuf {
569        let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
570        let _ = fs::remove_dir_all(&p);
571        fs::create_dir_all(&p).expect("create temp dir");
572        p
573    }
574
575    #[test]
576    fn slugify_makes_safe_stems() {
577        assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
578        assert_eq!(slugify("  use   pnpm  "), "use-pnpm");
579        assert_eq!(slugify("***"), "memory");
580    }
581
582    #[test]
583    fn parse_frontmatter_extracts_name_and_description() {
584        let raw =
585            "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
586        let (fm, body) = parse_frontmatter(raw);
587        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
588        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
589        assert_eq!(body, "The body.");
590    }
591
592    #[test]
593    fn parse_frontmatter_without_fence_is_all_body() {
594        let (fm, body) = parse_frontmatter("just a note\nsecond line");
595        assert!(fm.name.is_none());
596        assert_eq!(body, "just a note\nsecond line");
597    }
598
599    #[test]
600    fn render_and_parse_round_trip() {
601        let content = render_file(
602            "prefer-rg",
603            "Use ripgrep",
604            MemoryScope::ProjectShared,
605            &["tooling".to_string()],
606            "Always reach for rg.",
607        );
608        assert!(content.contains("scope: project-shared"));
609        let (fm, body) = parse_frontmatter(&content);
610        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
611        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
612        assert_eq!(body, "Always reach for rg.");
613    }
614
615    #[test]
616    fn write_and_load_root_round_trip() {
617        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
618        let dir = temp_dir("root");
619        write_to_dir(
620            &dir,
621            "Test Fact",
622            "A description",
623            MemoryScope::Global,
624            &[],
625            "body",
626        )
627        .unwrap();
628        let entries = load_root(&dir, MemoryScope::Global);
629        assert_eq!(entries.len(), 1);
630        assert_eq!(entries[0].name, "Test Fact");
631        assert_eq!(entries[0].description, "A description");
632        assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
633        let _ = fs::remove_dir_all(&dir);
634    }
635
636    #[test]
637    fn search_entries_matches_name_description_and_body() {
638        let mk = |name: &str, desc: &str| MemoryEntry {
639            name: name.to_string(),
640            description: desc.to_string(),
641            path: PathBuf::from(format!("/tmp/{name}.md")),
642            scope: MemoryScope::ProjectPrivate,
643            mtime: SystemTime::UNIX_EPOCH,
644        };
645        let entries = vec![
646            (
647                mk("prefer-ripgrep", "Use rg for search"),
648                "Always reach for ripgrep over grep.".to_string(),
649            ),
650            (
651                mk("editor-choice", "Editor preference"),
652                "The user likes neovim.".to_string(),
653            ),
654            (
655                mk("ci-flow", "CI conventions"),
656                "Run just check before every PR.".to_string(),
657            ),
658        ];
659
660        // Body-only match returns the matching line as the snippet.
661        let hits = search_entries(entries.clone(), "neovim");
662        assert_eq!(hits.len(), 1);
663        assert_eq!(hits[0].entry.name, "editor-choice");
664        assert!(hits[0].snippet.contains("neovim"));
665
666        // Case-insensitive; matches in the name.
667        assert_eq!(search_entries(entries.clone(), "RIPGREP").len(), 1);
668
669        // Description match with no body hit falls back to the description.
670        let desc_hits = search_entries(entries.clone(), "conventions");
671        assert_eq!(desc_hits.len(), 1);
672        assert_eq!(desc_hits[0].snippet, "CI conventions");
673
674        // Blank query and unmatched query both return nothing.
675        assert!(search_entries(entries.clone(), "   ").is_empty());
676        assert!(search_entries(entries, "nonexistent-xyz").is_empty());
677    }
678
679    #[test]
680    fn clip_chars_truncates_on_char_boundary() {
681        assert_eq!(clip_chars("short", 10), "short");
682        let clipped = clip_chars(&"a".repeat(200), 160);
683        assert_eq!(clipped.chars().count(), 161); // 160 kept + one ellipsis
684        assert!(clipped.ends_with('…'));
685    }
686
687    #[test]
688    fn write_to_dir_redacts_name_and_tags() {
689        // F9: a credential pasted into the name or a tag must be scrubbed too —
690        // the name re-enters the always-loaded index, and tags persist in
691        // frontmatter. Redacting only description+body would still leak.
692        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
693        let dir = temp_dir("redact_name_tags");
694        let path = write_to_dir(
695            &dir,
696            "leaked key sk-ant-api03-abcdefghijklmnop",
697            "desc",
698            MemoryScope::Global,
699            &["env-OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string()],
700            "body",
701        )
702        .unwrap();
703        let raw = fs::read_to_string(&path).unwrap();
704        assert!(
705            !raw.contains("sk-ant-api03-abcdefghijklmnop"),
706            "name secret leaked: {raw}"
707        );
708        assert!(
709            !raw.contains("sk-abcdefghijklmnop1234"),
710            "tag secret leaked: {raw}"
711        );
712        assert!(
713            raw.contains("[REDACTED]"),
714            "expected redaction marker: {raw}"
715        );
716        // The credential must not survive in the on-disk filename either.
717        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
718        assert!(
719            !stem.contains("abcdefghijklmnop"),
720            "secret leaked into filename: {stem}"
721        );
722        let _ = fs::remove_dir_all(&dir);
723    }
724
725    #[test]
726    fn load_root_falls_back_to_stem_and_first_line() {
727        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
728        let dir = temp_dir("fallback");
729        fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
730        let entries = load_root(&dir, MemoryScope::Global);
731        assert_eq!(entries.len(), 1);
732        assert_eq!(entries[0].name, "bare-note");
733        assert_eq!(entries[0].description, "first meaningful line");
734        let _ = fs::remove_dir_all(&dir);
735    }
736
737    #[test]
738    fn render_index_groups_by_scope_and_excludes_body() {
739        let entries = vec![
740            MemoryEntry {
741                name: "g".into(),
742                description: "global fact".into(),
743                path: PathBuf::from("/g/g.md"),
744                scope: MemoryScope::Global,
745                mtime: UNIX_EPOCH,
746            },
747            MemoryEntry {
748                name: "p".into(),
749                description: "private fact".into(),
750                path: PathBuf::from("/p/p.md"),
751                scope: MemoryScope::ProjectPrivate,
752                mtime: UNIX_EPOCH,
753            },
754        ];
755        let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
756        assert!(!truncated);
757        assert!(index.contains("# Memory"));
758        assert!(index.contains("## Global"));
759        assert!(index.contains("## Project (private)"));
760        assert!(index.contains("[g] global fact"));
761        assert!(index.contains("[p] private fact"));
762        // Global section precedes private (most specific last).
763        assert!(index.find("global fact") < index.find("private fact"));
764    }
765
766    #[test]
767    fn render_index_truncates_when_oversized() {
768        let entries: Vec<MemoryEntry> = (0..200)
769            .map(|i| MemoryEntry {
770                name: format!("name-{i}"),
771                description: "a".repeat(80),
772                path: PathBuf::from(format!("/m/name-{i}.md")),
773                scope: MemoryScope::Global,
774                mtime: UNIX_EPOCH,
775            })
776            .collect();
777        let (index, truncated) = render_index(&entries, 1_000);
778        assert!(truncated);
779        assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
780    }
781
782    #[test]
783    fn find_git_root_detects_dot_git() {
784        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
785        let root = temp_dir("gitroot");
786        fs::create_dir(root.join(".git")).unwrap();
787        let sub = root.join("a/b");
788        fs::create_dir_all(&sub).unwrap();
789        assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
790        let _ = fs::remove_dir_all(&root);
791    }
792
793    #[test]
794    fn find_git_root_none_without_repo() {
795        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
796        let dir = temp_dir("norepo");
797        // No .git anywhere up to a sentinel; walk eventually returns None or a
798        // real ancestor repo. Assert it does not falsely claim `dir` is a root.
799        assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
800        let _ = fs::remove_dir_all(&dir);
801    }
802
803    #[test]
804    fn delete_in_dir_round_trip() {
805        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
806        let dir = temp_dir("delete");
807        let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
808        assert!(path.exists());
809        // Mirror delete_memory's match-then-remove against the single root.
810        let entries = load_root(&dir, MemoryScope::Global);
811        assert_eq!(entries.len(), 1);
812        fs::remove_file(&entries[0].path).unwrap();
813        assert!(load_root(&dir, MemoryScope::Global).is_empty());
814        let _ = fs::remove_dir_all(&dir);
815    }
816}