Skip to main content

mermaid_cli/app/
instructions.rs

1//! Project-instructions loader (Step 5h).
2//!
3//! On session start, walk UP from the current working directory looking
4//! for repo instruction files. Stop at the git root (any directory
5//! containing a `.git` entry) or at `$HOME`, whichever is reached first.
6//! Load every supported file in the nearest matching directory; cap the
7//! combined body at `MAX_INSTRUCTIONS_BYTES`; pass the content to the
8//! model as a dynamic suffix on the system prompt.
9//!
10//! Auto-reload: before every model call, `refresh()` stats the loaded
11//! file's path and compares mtime. If the mtime moved, re-read; if the
12//! file is gone, drop the instructions. One stat per turn is
13//! microseconds — no need for a filesystem watcher.
14
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::constants::{INSTRUCTIONS_TRUNCATION_MARKER, MAX_INSTRUCTIONS_BYTES};
19
20/// Instruction files Mermaid understands. Mermaid-specific guidance is
21/// read first, then interoperable files used by other local agents.
22pub const INSTRUCTION_FILENAMES: &[&str] = &["MERMAID.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md"];
23
24/// Hard cap on how many directory levels `find_mermaid_md` walks up
25/// before giving up. Guards against pathological symlink loops.
26const MAX_WALK_DEPTH: usize = 32;
27
28/// One loaded instruction file inside a combined project-instructions
29/// snapshot.
30#[derive(Debug, Clone)]
31pub struct InstructionSource {
32    pub path: PathBuf,
33    pub mtime: SystemTime,
34    pub byte_len: usize,
35}
36
37/// One-shot snapshot of loaded project instructions. Stored on `App` and
38/// `NonInteractiveRunner` so the per-turn auto-reload check has
39/// something to compare against.
40#[derive(Debug, Clone)]
41pub struct LoadedInstructions {
42    /// Primary absolute path the content was read from. Kept for
43    /// compatibility with older renderer/status code; `sources`
44    /// carries the full set.
45    pub path: PathBuf,
46    /// File body, possibly truncated. The truncation marker is
47    /// appended in-place so the model sees the elision.
48    pub content: String,
49    /// mtime at last read — compared against the next `stat()` to
50    /// decide whether to re-read.
51    pub mtime: SystemTime,
52    /// Original file size on disk (before any truncation).
53    pub byte_len: usize,
54    /// True when the file was larger than `MAX_INSTRUCTIONS_BYTES`
55    /// and the content was clipped + marker appended.
56    pub truncated: bool,
57    /// All files that contributed to `content`.
58    pub sources: Vec<InstructionSource>,
59}
60
61impl LoadedInstructions {
62    /// Approximate token count for status messages. ~4 chars/token is
63    /// the rule of thumb that's correct enough for user-facing display.
64    pub fn approx_tokens(&self) -> usize {
65        self.content.len() / 4
66    }
67}
68
69/// Outcome of a `refresh()` call. Used to decide whether to emit a
70/// status line so the user knows their context shifted.
71#[derive(Debug, PartialEq, Eq)]
72pub enum ReloadOutcome {
73    /// File still has the same mtime (or was/still is absent).
74    Unchanged,
75    /// File was loaded for the first time this session — handles "user
76    /// created MERMAID.md mid-session" gracefully.
77    LoadedFirst { tokens: usize },
78    /// File content changed since the last read.
79    Reloaded {
80        old_tokens: usize,
81        new_tokens: usize,
82    },
83    /// File was previously loaded but has been deleted from disk.
84    Removed,
85}
86
87/// Walk UP from `start` looking for any supported instruction file.
88/// Stops at the first of:
89/// - a directory containing `.git` (the git root)
90/// - `$HOME` (don't search above the user's home)
91/// - filesystem root
92/// - `MAX_WALK_DEPTH` levels (symlink-loop guard)
93///
94/// Returns all supported instruction files in the nearest matching
95/// directory, in precedence order, or an empty vec if none exist.
96pub fn find_instruction_files(start: &Path) -> Vec<PathBuf> {
97    let home = std::env::var_os("HOME").map(PathBuf::from);
98    let mut current = start.to_path_buf();
99    for _ in 0..MAX_WALK_DEPTH {
100        let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
101            .iter()
102            .map(|name| current.join(name))
103            .filter(|candidate| candidate.is_file())
104            .chain(
105                [current.join(".mermaid").join("memory").join("memory.jsonl")]
106                    .into_iter()
107                    .filter(|candidate| candidate.is_file()),
108            )
109            .collect();
110        if !found.is_empty() {
111            return found;
112        }
113        // Stop at the git root (the .git entry itself ends the walk;
114        // most projects vendor instruction files at the repo root).
115        if current.join(".git").exists() {
116            return Vec::new();
117        }
118        // Stop at $HOME — don't search the user's home directory or
119        // anything above it. Avoids accidentally picking up a
120        // long-forgotten instruction file from a sibling project.
121        if let Some(ref h) = home
122            && current == *h
123        {
124            return Vec::new();
125        }
126        // Move up one level. If we're at the filesystem root, stop.
127        match current.parent() {
128            Some(parent) if parent != current => current = parent.to_path_buf(),
129            _ => return Vec::new(),
130        }
131    }
132    Vec::new()
133}
134
135/// Backwards-compatible helper for callers that specifically want the
136/// nearest `MERMAID.md`.
137pub fn find_mermaid_md(start: &Path) -> Option<PathBuf> {
138    find_instruction_files(start)
139        .into_iter()
140        .find(|path| path.file_name().is_some_and(|name| name == "MERMAID.md"))
141}
142
143/// Read the file at `path`, truncate to `MAX_INSTRUCTIONS_BYTES` if
144/// oversized, and return a `LoadedInstructions`. Returns `None` if the
145/// file can't be read or doesn't exist.
146pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
147    load_from_paths(&[path.to_path_buf()])
148}
149
150/// Read and combine the instruction files at `paths`, truncating the
151/// combined body to `MAX_INSTRUCTIONS_BYTES` if needed.
152pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
153    let mut sources = Vec::new();
154    let mut bodies = Vec::new();
155    let mut total_byte_len = 0usize;
156    let mut latest_mtime = UNIX_EPOCH;
157
158    for path in paths {
159        let metadata = std::fs::metadata(path).ok()?;
160        let mtime = metadata.modified().ok()?;
161        let raw = std::fs::read_to_string(path).ok()?;
162        total_byte_len = total_byte_len.saturating_add(raw.len());
163        if mtime > latest_mtime {
164            latest_mtime = mtime;
165        }
166        sources.push(InstructionSource {
167            path: path.to_path_buf(),
168            mtime,
169            byte_len: raw.len(),
170        });
171        bodies.push((path.to_path_buf(), raw));
172    }
173    let primary = sources.first()?.path.clone();
174    let raw = combine_instruction_bodies(bodies);
175    let byte_len = total_byte_len;
176    let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
177        // Char-boundary-safe truncation. `floor_char_boundary` stabilized
178        // in Rust 1.91.0 — matches the crate MSRV pinned in `Cargo.toml`.
179        let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
180        let mut clipped = raw[..cut].to_string();
181        clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
182        (clipped, true)
183    } else {
184        (raw, false)
185    };
186    Some(LoadedInstructions {
187        path: primary,
188        content,
189        mtime: latest_mtime,
190        byte_len,
191        truncated,
192        sources,
193    })
194}
195
196/// Per-turn auto-reload check. Compares the previously-loaded mtime to
197/// the current mtime on disk; reloads only when they differ. The hot
198/// path (file unchanged) is one `stat()` syscall — no I/O.
199///
200/// `cwd` is used to re-discover MERMAID.md when `current` is `None`
201/// (handles "user created the file mid-session" by re-running the walk).
202pub fn refresh(
203    current: Option<LoadedInstructions>,
204    cwd: &Path,
205) -> (Option<LoadedInstructions>, ReloadOutcome) {
206    match current {
207        Some(prior) => {
208            // Stat the previously-loaded path to detect edits or removal.
209            let paths: Vec<PathBuf> = if prior.sources.is_empty() {
210                vec![prior.path.clone()]
211            } else {
212                prior
213                    .sources
214                    .iter()
215                    .map(|source| source.path.clone())
216                    .collect()
217            };
218            let changed = if prior.sources.is_empty() {
219                std::fs::metadata(&prior.path)
220                    .and_then(|m| m.modified())
221                    .map(|mtime| mtime != prior.mtime)
222                    .unwrap_or(true)
223            } else {
224                prior.sources.iter().any(|source| {
225                    std::fs::metadata(&source.path)
226                        .and_then(|m| m.modified())
227                        .map(|mtime| mtime != source.mtime)
228                        .unwrap_or(true)
229                })
230            };
231            if !changed {
232                return (Some(prior), ReloadOutcome::Unchanged);
233            }
234            let old_tokens = prior.approx_tokens();
235            match load_from_paths(&paths) {
236                Some(reloaded) => {
237                    let new_tokens = reloaded.approx_tokens();
238                    (
239                        Some(reloaded),
240                        ReloadOutcome::Reloaded {
241                            old_tokens,
242                            new_tokens,
243                        },
244                    )
245                },
246                None => {
247                    // mtime moved but read failed (race or permission)
248                    // — treat as removed for safety.
249                    (None, ReloadOutcome::Removed)
250                },
251            }
252        },
253        None => {
254            // No prior load — re-walk in case the user created
255            // instruction files after session start.
256            match load_from_paths(&find_instruction_files(cwd)) {
257                Some(loaded) => {
258                    let tokens = loaded.approx_tokens();
259                    (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
260                },
261                None => (None, ReloadOutcome::Unchanged),
262            }
263        },
264    }
265}
266
267fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
268    if bodies.len() == 1 {
269        return bodies
270            .into_iter()
271            .next()
272            .map(|(_, body)| body)
273            .unwrap_or_default();
274    }
275    bodies
276        .into_iter()
277        .map(|(path, body)| {
278            let name = path
279                .file_name()
280                .and_then(|name| name.to_str())
281                .unwrap_or("instructions");
282            format!("# Project Instructions: {}\n\n{}", name, body)
283        })
284        .collect::<Vec<_>>()
285        .join("\n\n---\n\n")
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::fs;
292    use std::sync::Mutex;
293
294    /// Tests touch the filesystem; serialize them so concurrent test
295    /// runs don't see each other's temp files.
296    static FS_LOCK: Mutex<()> = Mutex::new(());
297
298    fn temp_dir(name: &str) -> PathBuf {
299        let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
300        let _ = fs::remove_dir_all(&p);
301        fs::create_dir_all(&p).expect("create temp dir");
302        p
303    }
304
305    #[test]
306    fn find_mermaid_md_finds_in_cwd() {
307        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
308        let dir = temp_dir("cwd");
309        fs::write(dir.join("MERMAID.md"), "rules").unwrap();
310        let found = find_mermaid_md(&dir).expect("should find");
311        assert_eq!(found, dir.join("MERMAID.md"));
312        let _ = fs::remove_dir_all(&dir);
313    }
314
315    #[test]
316    fn find_instruction_files_loads_interoperable_files() {
317        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
318        let dir = temp_dir("agents");
319        fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
320        fs::write(dir.join("CLAUDE.md"), "claude rules").unwrap();
321        let found = find_instruction_files(&dir);
322        assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("CLAUDE.md")]);
323        let loaded = load_from_paths(&found).expect("load combined");
324        assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
325        assert!(loaded.content.contains("agent rules"));
326        assert!(loaded.content.contains("# Project Instructions: CLAUDE.md"));
327        assert_eq!(loaded.sources.len(), 2);
328        let _ = fs::remove_dir_all(&dir);
329    }
330
331    #[test]
332    fn find_mermaid_md_walks_up_to_git_root() {
333        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
334        let root = temp_dir("walkup");
335        fs::create_dir(root.join(".git")).unwrap();
336        fs::write(root.join("MERMAID.md"), "root rules").unwrap();
337        let sub = root.join("subdir/deeper");
338        fs::create_dir_all(&sub).unwrap();
339        let found = find_mermaid_md(&sub).expect("should walk up");
340        assert_eq!(found, root.join("MERMAID.md"));
341        let _ = fs::remove_dir_all(&root);
342    }
343
344    #[test]
345    fn find_mermaid_md_stops_at_git_root_without_file() {
346        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
347        let root = temp_dir("git_no_md");
348        fs::create_dir(root.join(".git")).unwrap();
349        // Place a MERMAID.md ABOVE the git root — should NOT be found
350        // because the walk stops at the .git boundary.
351        let parent = root.parent().unwrap();
352        let above_md = parent.join("MERMAID.md");
353        fs::write(&above_md, "outside").unwrap();
354        let sub = root.join("subdir");
355        fs::create_dir_all(&sub).unwrap();
356        let found = find_mermaid_md(&sub);
357        assert!(found.is_none(), "walk must stop at .git boundary");
358        let _ = fs::remove_dir_all(&root);
359        let _ = fs::remove_file(&above_md);
360    }
361
362    #[test]
363    fn find_mermaid_md_returns_none_if_absent() {
364        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
365        let dir = temp_dir("absent");
366        // No MERMAID.md anywhere — but also no .git, so the walk
367        // continues all the way up. As long as nothing UP the tree
368        // happens to have MERMAID.md, this returns None. To make the
369        // test deterministic, plant a .git so the walk stops here.
370        fs::create_dir(dir.join(".git")).unwrap();
371        let found = find_mermaid_md(&dir);
372        assert!(found.is_none());
373        let _ = fs::remove_dir_all(&dir);
374    }
375
376    #[test]
377    fn load_from_path_truncates_oversized_file() {
378        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
379        let dir = temp_dir("oversized");
380        let path = dir.join("MERMAID.md");
381        // Write 50 KB — over the 40 KB cap.
382        let big = "a".repeat(50_000);
383        fs::write(&path, &big).unwrap();
384        let loaded = load_from_path(&path).expect("load");
385        assert!(loaded.truncated);
386        assert_eq!(loaded.byte_len, 50_000); // original size preserved
387        assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
388        // Content should be exactly cap + marker length.
389        assert_eq!(
390            loaded.content.len(),
391            MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
392        );
393        let _ = fs::remove_dir_all(&dir);
394    }
395
396    #[test]
397    fn load_from_path_returns_none_when_missing() {
398        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
399        let dir = temp_dir("missing");
400        assert!(load_from_path(&dir.join("nope.md")).is_none());
401        let _ = fs::remove_dir_all(&dir);
402    }
403
404    #[test]
405    fn refresh_returns_unchanged_when_mtime_stable() {
406        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
407        let dir = temp_dir("stable");
408        let path = dir.join("MERMAID.md");
409        fs::write(&path, "v1").unwrap();
410        let prior = load_from_path(&path).unwrap();
411        let (after, outcome) = refresh(Some(prior.clone()), &dir);
412        assert_eq!(outcome, ReloadOutcome::Unchanged);
413        assert!(after.is_some());
414        let _ = fs::remove_dir_all(&dir);
415    }
416
417    #[test]
418    fn refresh_returns_reloaded_on_content_change() {
419        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
420        let dir = temp_dir("changed");
421        let path = dir.join("MERMAID.md");
422        fs::write(&path, "v1").unwrap();
423        let prior = load_from_path(&path).unwrap();
424        // Sleep briefly to ensure mtime resolution registers a change.
425        // Most filesystems track mtime at second granularity or finer.
426        std::thread::sleep(std::time::Duration::from_millis(1100));
427        fs::write(&path, "v2 longer content here").unwrap();
428        let (after, outcome) = refresh(Some(prior), &dir);
429        assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
430        assert_eq!(after.unwrap().content, "v2 longer content here");
431        let _ = fs::remove_dir_all(&dir);
432    }
433
434    #[test]
435    fn refresh_returns_removed_when_file_deleted() {
436        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
437        let dir = temp_dir("removed");
438        let path = dir.join("MERMAID.md");
439        fs::write(&path, "v1").unwrap();
440        let prior = load_from_path(&path).unwrap();
441        fs::remove_file(&path).unwrap();
442        let (after, outcome) = refresh(Some(prior), &dir);
443        assert_eq!(outcome, ReloadOutcome::Removed);
444        assert!(after.is_none());
445        let _ = fs::remove_dir_all(&dir);
446    }
447
448    #[test]
449    fn refresh_returns_loaded_first_on_initial_discovery() {
450        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
451        let dir = temp_dir("first");
452        // Plant .git so the walk stays inside `dir`.
453        fs::create_dir(dir.join(".git")).unwrap();
454        // No prior load. Call refresh — should discover the new file.
455        fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
456        let (after, outcome) = refresh(None, &dir);
457        assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
458        assert_eq!(after.unwrap().content, "fresh");
459        let _ = fs::remove_dir_all(&dir);
460    }
461}