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    let mut current = start.to_path_buf();
101    for _ in 0..MAX_WALK_DEPTH {
102        let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
103            .iter()
104            .map(|name| current.join(name))
105            .filter(|candidate| candidate.is_file())
106            .collect();
107        if !found.is_empty() {
108            return found;
109        }
110        // Stop at the git root (the .git entry itself ends the walk;
111        // most projects vendor instruction files at the repo root).
112        if current.join(".git").exists() {
113            return Vec::new();
114        }
115        // Stop at $HOME — don't search the user's home directory or
116        // anything above it. Avoids accidentally picking up a
117        // long-forgotten instruction file from a sibling project.
118        if let Some(ref h) = home
119            && current == *h
120        {
121            return Vec::new();
122        }
123        // Move up one level. If we're at the filesystem root, stop.
124        match current.parent() {
125            Some(parent) if parent != current => current = parent.to_path_buf(),
126            _ => return Vec::new(),
127        }
128    }
129    Vec::new()
130}
131
132/// Read the file at `path`, truncate to `MAX_INSTRUCTIONS_BYTES` if
133/// oversized, and return a `LoadedInstructions`. Returns `None` if the
134/// file can't be read or doesn't exist.
135pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
136    load_from_paths(&[path.to_path_buf()])
137}
138
139/// Read and combine the instruction files at `paths`, truncating the
140/// combined body to `MAX_INSTRUCTIONS_BYTES` if needed.
141pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
142    let mut sources = Vec::new();
143    let mut bodies = Vec::new();
144    let mut total_byte_len = 0usize;
145    let mut latest_mtime = UNIX_EPOCH;
146
147    for path in paths {
148        let metadata = std::fs::metadata(path).ok()?;
149        let mtime = metadata.modified().ok()?;
150        let raw = std::fs::read_to_string(path).ok()?;
151        total_byte_len = total_byte_len.saturating_add(raw.len());
152        if mtime > latest_mtime {
153            latest_mtime = mtime;
154        }
155        sources.push(InstructionSource {
156            path: path.to_path_buf(),
157            mtime,
158            byte_len: raw.len(),
159        });
160        bodies.push((path.to_path_buf(), raw));
161    }
162    let primary = sources.first()?.path.clone();
163    let raw = combine_instruction_bodies(bodies);
164    let byte_len = total_byte_len;
165    let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
166        // Char-boundary-safe truncation. `floor_char_boundary` stabilized
167        // in Rust 1.91.0 — matches the crate MSRV pinned in `Cargo.toml`.
168        let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
169        let mut clipped = raw[..cut].to_string();
170        clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
171        (clipped, true)
172    } else {
173        (raw, false)
174    };
175    Some(LoadedInstructions {
176        path: primary,
177        content,
178        mtime: latest_mtime,
179        byte_len,
180        truncated,
181        sources,
182    })
183}
184
185/// Per-turn auto-reload check. Compares the previously-loaded mtime to
186/// the current mtime on disk; reloads only when they differ. The hot
187/// path (file unchanged) is one `stat()` syscall — no I/O.
188///
189/// `cwd` is used to re-discover MERMAID.md when `current` is `None`
190/// (handles "user created the file mid-session" by re-running the walk).
191pub fn refresh(
192    current: Option<LoadedInstructions>,
193    cwd: &Path,
194) -> (Option<LoadedInstructions>, ReloadOutcome) {
195    match current {
196        Some(prior) => {
197            // Stat the previously-loaded path to detect edits or removal.
198            let paths: Vec<PathBuf> = if prior.sources.is_empty() {
199                vec![prior.path.clone()]
200            } else {
201                prior
202                    .sources
203                    .iter()
204                    .map(|source| source.path.clone())
205                    .collect()
206            };
207            let changed = if prior.sources.is_empty() {
208                std::fs::metadata(&prior.path)
209                    .and_then(|m| m.modified())
210                    .map(|mtime| mtime != prior.mtime)
211                    .unwrap_or(true)
212            } else {
213                prior.sources.iter().any(|source| {
214                    std::fs::metadata(&source.path)
215                        .and_then(|m| m.modified())
216                        .map(|mtime| mtime != source.mtime)
217                        .unwrap_or(true)
218                })
219            };
220            if !changed {
221                return (Some(prior), ReloadOutcome::Unchanged);
222            }
223            let old_tokens = prior.approx_tokens();
224            match load_from_paths(&paths) {
225                Some(reloaded) => {
226                    let new_tokens = reloaded.approx_tokens();
227                    (
228                        Some(reloaded),
229                        ReloadOutcome::Reloaded {
230                            old_tokens,
231                            new_tokens,
232                        },
233                    )
234                },
235                None => {
236                    // mtime moved but read failed (race or permission)
237                    // — treat as removed for safety.
238                    (None, ReloadOutcome::Removed)
239                },
240            }
241        },
242        None => {
243            // No prior load — re-walk in case the user created
244            // instruction files after session start.
245            match load_from_paths(&find_instruction_files(cwd)) {
246                Some(loaded) => {
247                    let tokens = loaded.approx_tokens();
248                    (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
249                },
250                None => (None, ReloadOutcome::Unchanged),
251            }
252        },
253    }
254}
255
256fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
257    if bodies.len() == 1 {
258        return bodies
259            .into_iter()
260            .next()
261            .map(|(_, body)| body)
262            .unwrap_or_default();
263    }
264    bodies
265        .into_iter()
266        .map(|(path, body)| {
267            let name = path
268                .file_name()
269                .and_then(|name| name.to_str())
270                .unwrap_or("instructions");
271            format!("# Project Instructions: {}\n\n{}", name, body)
272        })
273        .collect::<Vec<_>>()
274        .join("\n\n---\n\n")
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::fs;
281    use std::sync::Mutex;
282
283    /// Tests touch the filesystem; serialize them so concurrent test
284    /// runs don't see each other's temp files.
285    static FS_LOCK: Mutex<()> = Mutex::new(());
286
287    fn temp_dir(name: &str) -> PathBuf {
288        let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
289        let _ = fs::remove_dir_all(&p);
290        fs::create_dir_all(&p).expect("create temp dir");
291        p
292    }
293
294    #[test]
295    fn find_instruction_files_finds_in_cwd() {
296        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
297        let dir = temp_dir("cwd");
298        fs::write(dir.join("MERMAID.md"), "rules").unwrap();
299        let found = find_instruction_files(&dir);
300        assert_eq!(found, vec![dir.join("MERMAID.md")]);
301        let _ = fs::remove_dir_all(&dir);
302    }
303
304    #[test]
305    fn find_instruction_files_loads_both_in_precedence_order() {
306        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
307        let dir = temp_dir("both");
308        fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
309        fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
310        let found = find_instruction_files(&dir);
311        // AGENTS.md first, MERMAID.md last (last wins on conflict).
312        assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
313        let loaded = load_from_paths(&found).expect("load combined");
314        assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
315        assert!(loaded.content.contains("agent rules"));
316        assert!(
317            loaded
318                .content
319                .contains("# Project Instructions: MERMAID.md")
320        );
321        assert!(loaded.content.contains("mermaid rules"));
322        // MERMAID.md body must appear AFTER AGENTS.md so it overrides.
323        assert!(
324            loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
325            "MERMAID.md must come last so its guidance overrides AGENTS.md"
326        );
327        assert_eq!(loaded.sources.len(), 2);
328        let _ = fs::remove_dir_all(&dir);
329    }
330
331    #[test]
332    fn find_instruction_files_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_instruction_files(&sub);
340        assert_eq!(found, vec![root.join("MERMAID.md")]);
341        let _ = fs::remove_dir_all(&root);
342    }
343
344    #[test]
345    fn find_instruction_files_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_instruction_files(&sub);
357        assert!(found.is_empty(), "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_instruction_files_returns_empty_if_absent() {
364        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
365        let dir = temp_dir("absent");
366        // No instruction file anywhere. Plant a .git so the walk stops
367        // here deterministically rather than climbing the real tree.
368        fs::create_dir(dir.join(".git")).unwrap();
369        let found = find_instruction_files(&dir);
370        assert!(found.is_empty());
371        let _ = fs::remove_dir_all(&dir);
372    }
373
374    #[test]
375    fn load_from_path_truncates_oversized_file() {
376        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
377        let dir = temp_dir("oversized");
378        let path = dir.join("MERMAID.md");
379        // Write 50 KB — over the 40 KB cap.
380        let big = "a".repeat(50_000);
381        fs::write(&path, &big).unwrap();
382        let loaded = load_from_path(&path).expect("load");
383        assert!(loaded.truncated);
384        assert_eq!(loaded.byte_len, 50_000); // original size preserved
385        assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
386        // Content should be exactly cap + marker length.
387        assert_eq!(
388            loaded.content.len(),
389            MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
390        );
391        let _ = fs::remove_dir_all(&dir);
392    }
393
394    #[test]
395    fn load_from_path_returns_none_when_missing() {
396        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
397        let dir = temp_dir("missing");
398        assert!(load_from_path(&dir.join("nope.md")).is_none());
399        let _ = fs::remove_dir_all(&dir);
400    }
401
402    #[test]
403    fn refresh_returns_unchanged_when_mtime_stable() {
404        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
405        let dir = temp_dir("stable");
406        let path = dir.join("MERMAID.md");
407        fs::write(&path, "v1").unwrap();
408        let prior = load_from_path(&path).unwrap();
409        let (after, outcome) = refresh(Some(prior.clone()), &dir);
410        assert_eq!(outcome, ReloadOutcome::Unchanged);
411        assert!(after.is_some());
412        let _ = fs::remove_dir_all(&dir);
413    }
414
415    #[test]
416    fn refresh_returns_reloaded_on_content_change() {
417        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
418        let dir = temp_dir("changed");
419        let path = dir.join("MERMAID.md");
420        fs::write(&path, "v1").unwrap();
421        let prior = load_from_path(&path).unwrap();
422        // Sleep briefly to ensure mtime resolution registers a change.
423        // Most filesystems track mtime at second granularity or finer.
424        std::thread::sleep(std::time::Duration::from_millis(1100));
425        fs::write(&path, "v2 longer content here").unwrap();
426        let (after, outcome) = refresh(Some(prior), &dir);
427        assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
428        assert_eq!(after.unwrap().content, "v2 longer content here");
429        let _ = fs::remove_dir_all(&dir);
430    }
431
432    #[test]
433    fn refresh_returns_removed_when_file_deleted() {
434        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
435        let dir = temp_dir("removed");
436        let path = dir.join("MERMAID.md");
437        fs::write(&path, "v1").unwrap();
438        let prior = load_from_path(&path).unwrap();
439        fs::remove_file(&path).unwrap();
440        let (after, outcome) = refresh(Some(prior), &dir);
441        assert_eq!(outcome, ReloadOutcome::Removed);
442        assert!(after.is_none());
443        let _ = fs::remove_dir_all(&dir);
444    }
445
446    #[test]
447    fn refresh_returns_loaded_first_on_initial_discovery() {
448        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
449        let dir = temp_dir("first");
450        // Plant .git so the walk stays inside `dir`.
451        fs::create_dir(dir.join(".git")).unwrap();
452        // No prior load. Call refresh — should discover the new file.
453        fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
454        let (after, outcome) = refresh(None, &dir);
455        assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
456        assert_eq!(after.unwrap().content, "fresh");
457        let _ = fs::remove_dir_all(&dir);
458    }
459}