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, in load order. `AGENTS.md` (the
21/// cross-tool open standard) is read first; `MERMAID.md` (mermaid-specific) is
22/// read last so its guidance overrides `AGENTS.md` on conflict. These are the
23/// only two recognized — there is intentionally no CLAUDE.md/GEMINI.md support.
24pub const INSTRUCTION_FILENAMES: &[&str] = &["AGENTS.md", "MERMAID.md"];
25
26/// Hard cap on how many directory levels `find_instruction_files` walks up
27/// before giving up. Guards against pathological symlink loops.
28const MAX_WALK_DEPTH: usize = 32;
29
30/// One loaded instruction file inside a combined project-instructions
31/// snapshot.
32#[derive(Debug, Clone)]
33pub struct InstructionSource {
34    pub path: PathBuf,
35    pub mtime: SystemTime,
36    pub byte_len: usize,
37}
38
39/// One-shot snapshot of loaded project instructions. Stored on `App` and
40/// `NonInteractiveRunner` so the per-turn auto-reload check has
41/// something to compare against.
42#[derive(Debug, Clone)]
43pub struct LoadedInstructions {
44    /// Primary absolute path the content was read from. Kept for
45    /// compatibility with older renderer/status code; `sources`
46    /// carries the full set.
47    pub path: PathBuf,
48    /// File body, possibly truncated. The truncation marker is
49    /// appended in-place so the model sees the elision.
50    pub content: String,
51    /// mtime at last read — compared against the next `stat()` to
52    /// decide whether to re-read.
53    pub mtime: SystemTime,
54    /// Original file size on disk (before any truncation).
55    pub byte_len: usize,
56    /// True when the file was larger than `MAX_INSTRUCTIONS_BYTES`
57    /// and the content was clipped + marker appended.
58    pub truncated: bool,
59    /// All files that contributed to `content`.
60    pub sources: Vec<InstructionSource>,
61}
62
63impl LoadedInstructions {
64    /// Approximate token count for status messages. ~4 chars/token is
65    /// the rule of thumb that's correct enough for user-facing display.
66    pub fn approx_tokens(&self) -> usize {
67        self.content.len() / 4
68    }
69}
70
71/// Outcome of a `refresh()` call. Used to decide whether to emit a
72/// status line so the user knows their context shifted.
73#[derive(Debug, PartialEq, Eq)]
74pub enum ReloadOutcome {
75    /// File still has the same mtime (or was/still is absent).
76    Unchanged,
77    /// File was loaded for the first time this session — handles "user
78    /// created MERMAID.md mid-session" gracefully.
79    LoadedFirst { tokens: usize },
80    /// File content changed since the last read.
81    Reloaded {
82        old_tokens: usize,
83        new_tokens: usize,
84    },
85    /// File was previously loaded but has been deleted from disk.
86    Removed,
87}
88
89/// Walk UP from `start` looking for any supported instruction file.
90/// Stops at the first of:
91/// - a directory containing `.git` (the git root)
92/// - `$HOME` (don't search above the user's home)
93/// - filesystem root
94/// - `MAX_WALK_DEPTH` levels (symlink-loop guard)
95///
96/// Returns all supported instruction files in the nearest matching
97/// directory, in precedence order, or an empty vec if none exist.
98pub fn find_instruction_files(start: &Path) -> Vec<PathBuf> {
99    let home = std::env::var_os("HOME").map(PathBuf::from);
100    find_instruction_files_bounded(start, home.as_deref())
101}
102
103/// Walk implementation with the `$HOME` boundary injected, so tests can
104/// exercise the "stop at home" rule (#108) without mutating the process-global
105/// `HOME` env var (which would race other threads' tests).
106fn find_instruction_files_bounded(start: &Path, home: Option<&Path>) -> Vec<PathBuf> {
107    let mut current = start.to_path_buf();
108    for _ in 0..MAX_WALK_DEPTH {
109        // Stop at $HOME *before* searching — don't load the user's home-dir
110        // instruction files (or anything above home). Checked first so a walk
111        // that climbs into home can't pick up `~/AGENTS.md` (#108); the old
112        // order searched, found, and returned it before this guard ran.
113        if home == Some(current.as_path()) {
114            return Vec::new();
115        }
116        let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
117            .iter()
118            .map(|name| current.join(name))
119            .filter(|candidate| candidate.is_file())
120            .collect();
121        if !found.is_empty() {
122            return found;
123        }
124        // Stop at the git root (the .git entry itself ends the walk; most
125        // projects vendor instruction files at the repo root). Checked *after*
126        // discovery so a file AT the git root still loads.
127        if current.join(".git").exists() {
128            return Vec::new();
129        }
130        // Move up one level. If we're at the filesystem root, stop.
131        match current.parent() {
132            Some(parent) if parent != current => current = parent.to_path_buf(),
133            _ => return Vec::new(),
134        }
135    }
136    Vec::new()
137}
138
139/// Read the file at `path`, truncate to `MAX_INSTRUCTIONS_BYTES` if
140/// oversized, and return a `LoadedInstructions`. Returns `None` if the
141/// file can't be read or doesn't exist.
142pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
143    load_from_paths(&[path.to_path_buf()])
144}
145
146/// Read and combine the instruction files at `paths`, truncating the
147/// combined body to `MAX_INSTRUCTIONS_BYTES` if needed.
148pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
149    let mut sources = Vec::new();
150    let mut bodies = Vec::new();
151    let mut total_byte_len = 0usize;
152    let mut latest_mtime = UNIX_EPOCH;
153
154    for path in paths {
155        let metadata = std::fs::metadata(path).ok()?;
156        let mtime = metadata.modified().ok()?;
157        let true_len = metadata.len() as usize;
158        // Bounded read: never slurp a giant MERMAID.md whole just to truncate it
159        // afterwards (#16). Read one byte past the cap so the combined-body
160        // truncation check below still detects an oversized single file; the
161        // true on-disk size comes from the stat above, so `byte_len` stays
162        // accurate rather than reflecting the capped read.
163        let (bytes, _truncated) =
164            crate::utils::read_file_capped(path, MAX_INSTRUCTIONS_BYTES.saturating_add(1)).ok()?;
165        let raw = String::from_utf8_lossy(&bytes).into_owned();
166        total_byte_len = total_byte_len.saturating_add(true_len);
167        if mtime > latest_mtime {
168            latest_mtime = mtime;
169        }
170        sources.push(InstructionSource {
171            path: path.to_path_buf(),
172            mtime,
173            byte_len: true_len,
174        });
175        bodies.push((path.to_path_buf(), raw));
176    }
177    let primary = sources.first()?.path.clone();
178    let raw = combine_instruction_bodies(bodies);
179    let byte_len = total_byte_len;
180    let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
181        // Char-boundary-safe truncation. `floor_char_boundary` stabilized
182        // in Rust 1.91.0 — matches the crate MSRV pinned in `Cargo.toml`.
183        let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
184        let mut clipped = raw[..cut].to_string();
185        clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
186        (clipped, true)
187    } else {
188        (raw, false)
189    };
190    Some(LoadedInstructions {
191        path: primary,
192        content,
193        mtime: latest_mtime,
194        byte_len,
195        truncated,
196        sources,
197    })
198}
199
200/// Per-turn auto-reload check. Compares the previously-loaded mtime to
201/// the current mtime on disk; reloads only when they differ. The hot
202/// path (file unchanged) is one `stat()` syscall — no I/O.
203///
204/// `cwd` is used to re-discover MERMAID.md when `current` is `None`
205/// (handles "user created the file mid-session" by re-running the walk).
206pub fn refresh(
207    current: Option<LoadedInstructions>,
208    cwd: &Path,
209) -> (Option<LoadedInstructions>, ReloadOutcome) {
210    match current {
211        Some(prior) => {
212            // Stat the previously-loaded path to detect edits or removal.
213            let paths: Vec<PathBuf> = if prior.sources.is_empty() {
214                vec![prior.path.clone()]
215            } else {
216                prior
217                    .sources
218                    .iter()
219                    .map(|source| source.path.clone())
220                    .collect()
221            };
222            let changed = if prior.sources.is_empty() {
223                std::fs::metadata(&prior.path)
224                    .and_then(|m| m.modified())
225                    .map(|mtime| mtime != prior.mtime)
226                    .unwrap_or(true)
227            } else {
228                prior.sources.iter().any(|source| {
229                    std::fs::metadata(&source.path)
230                        .and_then(|m| m.modified())
231                        .map(|mtime| mtime != source.mtime)
232                        .unwrap_or(true)
233                })
234            };
235            if !changed {
236                return (Some(prior), ReloadOutcome::Unchanged);
237            }
238            let old_tokens = prior.approx_tokens();
239            match load_from_paths(&paths) {
240                Some(reloaded) => {
241                    let new_tokens = reloaded.approx_tokens();
242                    (
243                        Some(reloaded),
244                        ReloadOutcome::Reloaded {
245                            old_tokens,
246                            new_tokens,
247                        },
248                    )
249                },
250                None => {
251                    // mtime moved but read failed (race or permission)
252                    // — treat as removed for safety.
253                    (None, ReloadOutcome::Removed)
254                },
255            }
256        },
257        None => {
258            // No prior load — re-walk in case the user created
259            // instruction files after session start.
260            match load_from_paths(&find_instruction_files(cwd)) {
261                Some(loaded) => {
262                    let tokens = loaded.approx_tokens();
263                    (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
264                },
265                None => (None, ReloadOutcome::Unchanged),
266            }
267        },
268    }
269}
270
271fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
272    // Always wrap each file in a labeled header — even a single file — so the
273    // content lands in the system prompt as clearly-bounded project data
274    // rather than blending into trusted system authority (#109). Matches the
275    // memory block's `# Memory` header and the multi-file labeling below.
276    bodies
277        .into_iter()
278        .map(|(path, body)| {
279            let name = path
280                .file_name()
281                .and_then(|name| name.to_str())
282                .unwrap_or("instructions");
283            format!("# Project Instructions: {}\n\n{}", name, body)
284        })
285        .collect::<Vec<_>>()
286        .join("\n\n---\n\n")
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::fs;
293    use std::sync::Mutex;
294
295    /// Tests touch the filesystem; serialize them so concurrent test
296    /// runs don't see each other's temp files.
297    static FS_LOCK: Mutex<()> = Mutex::new(());
298
299    fn temp_dir(name: &str) -> PathBuf {
300        let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
301        let _ = fs::remove_dir_all(&p);
302        fs::create_dir_all(&p).expect("create temp dir");
303        p
304    }
305
306    #[test]
307    fn find_instruction_files_finds_in_cwd() {
308        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
309        let dir = temp_dir("cwd");
310        fs::write(dir.join("MERMAID.md"), "rules").unwrap();
311        let found = find_instruction_files(&dir);
312        assert_eq!(found, vec![dir.join("MERMAID.md")]);
313        let _ = fs::remove_dir_all(&dir);
314    }
315
316    #[test]
317    fn find_instruction_files_loads_both_in_precedence_order() {
318        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
319        let dir = temp_dir("both");
320        fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
321        fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
322        let found = find_instruction_files(&dir);
323        // AGENTS.md first, MERMAID.md last (last wins on conflict).
324        assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
325        let loaded = load_from_paths(&found).expect("load combined");
326        assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
327        assert!(loaded.content.contains("agent rules"));
328        assert!(
329            loaded
330                .content
331                .contains("# Project Instructions: MERMAID.md")
332        );
333        assert!(loaded.content.contains("mermaid rules"));
334        // MERMAID.md body must appear AFTER AGENTS.md so it overrides.
335        assert!(
336            loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
337            "MERMAID.md must come last so its guidance overrides AGENTS.md"
338        );
339        assert_eq!(loaded.sources.len(), 2);
340        let _ = fs::remove_dir_all(&dir);
341    }
342
343    #[test]
344    fn find_instruction_files_walks_up_to_git_root() {
345        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346        let root = temp_dir("walkup");
347        fs::create_dir(root.join(".git")).unwrap();
348        fs::write(root.join("MERMAID.md"), "root rules").unwrap();
349        let sub = root.join("subdir/deeper");
350        fs::create_dir_all(&sub).unwrap();
351        let found = find_instruction_files(&sub);
352        assert_eq!(found, vec![root.join("MERMAID.md")]);
353        let _ = fs::remove_dir_all(&root);
354    }
355
356    #[test]
357    fn find_instruction_files_stops_at_git_root_without_file() {
358        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
359        let root = temp_dir("git_no_md");
360        fs::create_dir(root.join(".git")).unwrap();
361        // Place a MERMAID.md ABOVE the git root — should NOT be found
362        // because the walk stops at the .git boundary.
363        let parent = root.parent().unwrap();
364        let above_md = parent.join("MERMAID.md");
365        fs::write(&above_md, "outside").unwrap();
366        let sub = root.join("subdir");
367        fs::create_dir_all(&sub).unwrap();
368        let found = find_instruction_files(&sub);
369        assert!(found.is_empty(), "walk must stop at .git boundary");
370        let _ = fs::remove_dir_all(&root);
371        let _ = fs::remove_file(&above_md);
372    }
373
374    #[test]
375    fn find_instruction_files_returns_empty_if_absent() {
376        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
377        let dir = temp_dir("absent");
378        // No instruction file anywhere. Plant a .git so the walk stops
379        // here deterministically rather than climbing the real tree.
380        fs::create_dir(dir.join(".git")).unwrap();
381        let found = find_instruction_files(&dir);
382        assert!(found.is_empty());
383        let _ = fs::remove_dir_all(&dir);
384    }
385
386    #[test]
387    fn find_instruction_files_stops_at_home_boundary() {
388        // #108: a walk that climbs into $HOME must NOT pick up the home-dir
389        // AGENTS.md. The boundary is injected (no global env mutation), so the
390        // test is race-free. Without the fix the walk would search `home`,
391        // find AGENTS.md, and return it before the home guard ever ran.
392        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
393        let home = temp_dir("home_boundary");
394        fs::write(home.join("AGENTS.md"), "home rules").unwrap();
395        let child = home.join("project");
396        fs::create_dir_all(&child).unwrap();
397        // No .git anywhere between child and home, so only the home guard can
398        // stop the climb.
399        let found = find_instruction_files_bounded(&child, Some(home.as_path()));
400        assert!(
401            found.is_empty(),
402            "walk must stop at $HOME and not load ~/AGENTS.md, got {found:?}"
403        );
404        let _ = fs::remove_dir_all(&home);
405    }
406
407    #[test]
408    fn single_file_instructions_get_labeled_header() {
409        // #109: even a single instruction file is wrapped in a labeled
410        // boundary so it reaches the system prompt as clearly-bounded project
411        // data, not unlabeled trusted-system text.
412        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
413        let dir = temp_dir("single_header");
414        fs::write(dir.join("MERMAID.md"), "do the thing").unwrap();
415        let loaded = load_from_path(&dir.join("MERMAID.md")).expect("load");
416        assert!(
417            loaded
418                .content
419                .starts_with("# Project Instructions: MERMAID.md"),
420            "single-file instructions must carry a labeled header, got: {:?}",
421            loaded.content
422        );
423        assert!(loaded.content.contains("do the thing"));
424        let _ = fs::remove_dir_all(&dir);
425    }
426
427    #[test]
428    fn load_from_path_truncates_oversized_file() {
429        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
430        let dir = temp_dir("oversized");
431        let path = dir.join("MERMAID.md");
432        // Write 50 KB — over the 40 KB cap.
433        let big = "a".repeat(50_000);
434        fs::write(&path, &big).unwrap();
435        let loaded = load_from_path(&path).expect("load");
436        assert!(loaded.truncated);
437        assert_eq!(loaded.byte_len, 50_000); // original size preserved
438        assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
439        // Content should be exactly cap + marker length.
440        assert_eq!(
441            loaded.content.len(),
442            MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
443        );
444        let _ = fs::remove_dir_all(&dir);
445    }
446
447    #[test]
448    fn load_from_path_returns_none_when_missing() {
449        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
450        let dir = temp_dir("missing");
451        assert!(load_from_path(&dir.join("nope.md")).is_none());
452        let _ = fs::remove_dir_all(&dir);
453    }
454
455    #[test]
456    fn refresh_returns_unchanged_when_mtime_stable() {
457        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
458        let dir = temp_dir("stable");
459        let path = dir.join("MERMAID.md");
460        fs::write(&path, "v1").unwrap();
461        let prior = load_from_path(&path).unwrap();
462        let (after, outcome) = refresh(Some(prior.clone()), &dir);
463        assert_eq!(outcome, ReloadOutcome::Unchanged);
464        assert!(after.is_some());
465        let _ = fs::remove_dir_all(&dir);
466    }
467
468    #[test]
469    fn refresh_returns_reloaded_on_content_change() {
470        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
471        let dir = temp_dir("changed");
472        let path = dir.join("MERMAID.md");
473        fs::write(&path, "v1").unwrap();
474        let prior = load_from_path(&path).unwrap();
475        // Sleep briefly to ensure mtime resolution registers a change.
476        // Most filesystems track mtime at second granularity or finer.
477        std::thread::sleep(std::time::Duration::from_millis(1100));
478        fs::write(&path, "v2 longer content here").unwrap();
479        let (after, outcome) = refresh(Some(prior), &dir);
480        assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
481        let content = after.unwrap().content;
482        assert!(content.contains("# Project Instructions: MERMAID.md"));
483        assert!(content.contains("v2 longer content here"));
484        let _ = fs::remove_dir_all(&dir);
485    }
486
487    #[test]
488    fn refresh_returns_removed_when_file_deleted() {
489        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
490        let dir = temp_dir("removed");
491        let path = dir.join("MERMAID.md");
492        fs::write(&path, "v1").unwrap();
493        let prior = load_from_path(&path).unwrap();
494        fs::remove_file(&path).unwrap();
495        let (after, outcome) = refresh(Some(prior), &dir);
496        assert_eq!(outcome, ReloadOutcome::Removed);
497        assert!(after.is_none());
498        let _ = fs::remove_dir_all(&dir);
499    }
500
501    #[test]
502    fn refresh_returns_loaded_first_on_initial_discovery() {
503        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
504        let dir = temp_dir("first");
505        // Plant .git so the walk stays inside `dir`.
506        fs::create_dir(dir.join(".git")).unwrap();
507        // No prior load. Call refresh — should discover the new file.
508        fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
509        let (after, outcome) = refresh(None, &dir);
510        assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
511        let content = after.unwrap().content;
512        assert!(content.contains("# Project Instructions: MERMAID.md"));
513        assert!(content.contains("fresh"));
514        let _ = fs::remove_dir_all(&dir);
515    }
516}