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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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/// - the user's home directory (`$HOME`, or `%USERPROFILE%` on Windows)
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    find_instruction_files_bounded(start, home_dir_boundary().as_deref())
100}
101
102/// Resolve the user's home directory to use as the upward-walk boundary.
103///
104/// On Unix this is `$HOME`. On Windows `HOME` is usually unset — the home var is
105/// `%USERPROFILE%` (or `%HOMEDRIVE%%HOMEPATH%`) — so without consulting those the
106/// walk would have no home boundary on Windows and could climb above the user's
107/// profile, relying solely on `.git` / `MAX_WALK_DEPTH` (F63). An empty value is
108/// treated as unset.
109fn home_dir_boundary() -> Option<PathBuf> {
110    let home = std::env::var_os("HOME");
111    #[cfg(windows)]
112    let resolved = pick_home_boundary(
113        home.as_deref(),
114        std::env::var_os("USERPROFILE").as_deref(),
115        std::env::var_os("HOMEDRIVE").as_deref(),
116        std::env::var_os("HOMEPATH").as_deref(),
117    );
118    // Non-Windows: only `$HOME` bounds the walk.
119    #[cfg(not(windows))]
120    let resolved = pick_home_boundary(home.as_deref(), None, None, None);
121    resolved
122}
123
124/// Pick the home boundary from candidate env values in priority order: `HOME`,
125/// then `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH` (joined). The first present,
126/// non-empty value wins; empty values are ignored. Pure (takes its inputs rather
127/// than reading the environment) so the Windows fallback order is unit-testable
128/// without mutating process-global env (which would race other threads' tests).
129fn pick_home_boundary(
130    home: Option<&std::ffi::OsStr>,
131    userprofile: Option<&std::ffi::OsStr>,
132    homedrive: Option<&std::ffi::OsStr>,
133    homepath: Option<&std::ffi::OsStr>,
134) -> Option<PathBuf> {
135    fn nonempty(v: Option<&std::ffi::OsStr>) -> Option<&std::ffi::OsStr> {
136        v.filter(|s| !s.is_empty())
137    }
138    if let Some(home) = nonempty(home) {
139        return Some(PathBuf::from(home));
140    }
141    if let Some(profile) = nonempty(userprofile) {
142        return Some(PathBuf::from(profile));
143    }
144    if let (Some(drive), Some(path)) = (nonempty(homedrive), nonempty(homepath)) {
145        let mut combined = drive.to_os_string();
146        combined.push(path);
147        return Some(PathBuf::from(combined));
148    }
149    None
150}
151
152/// Walk implementation with the `$HOME` boundary injected, so tests can
153/// exercise the "stop at home" rule (#108) without mutating the process-global
154/// `HOME` env var (which would race other threads' tests).
155fn find_instruction_files_bounded(start: &Path, home: Option<&Path>) -> Vec<PathBuf> {
156    let mut current = start.to_path_buf();
157    for _ in 0..MAX_WALK_DEPTH {
158        // Stop at $HOME *before* searching — don't load the user's home-dir
159        // instruction files (or anything above home). Checked first so a walk
160        // that climbs into home can't pick up `~/AGENTS.md` (#108); the old
161        // order searched, found, and returned it before this guard ran.
162        if home == Some(current.as_path()) {
163            return Vec::new();
164        }
165        let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
166            .iter()
167            .map(|name| current.join(name))
168            .filter(|candidate| candidate.is_file())
169            .collect();
170        if !found.is_empty() {
171            return found;
172        }
173        // Stop at the git root (the .git entry itself ends the walk; most
174        // projects vendor instruction files at the repo root). Checked *after*
175        // discovery so a file AT the git root still loads.
176        if current.join(".git").exists() {
177            return Vec::new();
178        }
179        // Move up one level. If we're at the filesystem root, stop.
180        match current.parent() {
181            Some(parent) if parent != current => current = parent.to_path_buf(),
182            _ => return Vec::new(),
183        }
184    }
185    Vec::new()
186}
187
188/// Read the file at `path`, truncate to `MAX_INSTRUCTIONS_BYTES` if
189/// oversized, and return a `LoadedInstructions`. Returns `None` if the
190/// file can't be read or doesn't exist.
191pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
192    load_from_paths(&[path.to_path_buf()])
193}
194
195/// Read and combine the instruction files at `paths`, truncating the
196/// combined body to `MAX_INSTRUCTIONS_BYTES` if needed.
197pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
198    let mut sources = Vec::new();
199    let mut bodies = Vec::new();
200    let mut total_byte_len = 0usize;
201    let mut latest_mtime = UNIX_EPOCH;
202
203    for path in paths {
204        // Tolerate a per-file failure: if one path is missing or unreadable
205        // (e.g. MERMAID.md removed in the race between `find_instruction_files`
206        // and here), skip just that file and load the rest, rather than letting
207        // one stat/read failure drop the WHOLE multi-file set (F62). Only when
208        // EVERY file fails does the load return `None` (via `sources.first()?`).
209        let Ok(metadata) = std::fs::metadata(path) else {
210            continue;
211        };
212        let Ok(mtime) = metadata.modified() else {
213            continue;
214        };
215        let true_len = metadata.len() as usize;
216        // Bounded read: never slurp a giant MERMAID.md whole just to truncate it
217        // afterwards (#16). Read one byte past the cap so the combined-body
218        // truncation check below still detects an oversized single file; the
219        // true on-disk size comes from the stat above, so `byte_len` stays
220        // accurate rather than reflecting the capped read.
221        let Ok((bytes, _truncated)) =
222            crate::utils::read_file_capped(path, MAX_INSTRUCTIONS_BYTES.saturating_add(1))
223        else {
224            continue;
225        };
226        let raw = String::from_utf8_lossy(&bytes).into_owned();
227        total_byte_len = total_byte_len.saturating_add(true_len);
228        if mtime > latest_mtime {
229            latest_mtime = mtime;
230        }
231        sources.push(InstructionSource {
232            path: path.to_path_buf(),
233            mtime,
234            byte_len: true_len,
235        });
236        bodies.push((path.to_path_buf(), raw));
237    }
238    let primary = sources.first()?.path.clone();
239    let sections = label_instruction_bodies(bodies);
240    let byte_len = total_byte_len;
241    let (content, truncated) = combine_and_cap_sections(sections, MAX_INSTRUCTIONS_BYTES);
242    Some(LoadedInstructions {
243        path: primary,
244        content,
245        mtime: latest_mtime,
246        byte_len,
247        truncated,
248        sources,
249    })
250}
251
252/// Per-turn auto-reload check. Compares the previously-loaded mtime to
253/// the current mtime on disk; reloads only when they differ. The hot
254/// path (file unchanged) is one `stat()` syscall — no I/O.
255///
256/// `cwd` is used to re-discover MERMAID.md when `current` is `None`
257/// (handles "user created the file mid-session" by re-running the walk).
258pub fn refresh(
259    current: Option<LoadedInstructions>,
260    cwd: &Path,
261) -> (Option<LoadedInstructions>, ReloadOutcome) {
262    match current {
263        Some(prior) => {
264            // Stat the previously-loaded path to detect edits or removal.
265            let paths: Vec<PathBuf> = if prior.sources.is_empty() {
266                vec![prior.path.clone()]
267            } else {
268                prior
269                    .sources
270                    .iter()
271                    .map(|source| source.path.clone())
272                    .collect()
273            };
274            let changed = if prior.sources.is_empty() {
275                std::fs::metadata(&prior.path)
276                    .and_then(|m| m.modified())
277                    .map(|mtime| mtime != prior.mtime)
278                    .unwrap_or(true)
279            } else {
280                prior.sources.iter().any(|source| {
281                    std::fs::metadata(&source.path)
282                        .and_then(|m| m.modified())
283                        .map(|mtime| mtime != source.mtime)
284                        .unwrap_or(true)
285                })
286            };
287            if !changed {
288                return (Some(prior), ReloadOutcome::Unchanged);
289            }
290            let old_tokens = prior.approx_tokens();
291            match load_from_paths(&paths) {
292                Some(reloaded) => {
293                    let new_tokens = reloaded.approx_tokens();
294                    (
295                        Some(reloaded),
296                        ReloadOutcome::Reloaded {
297                            old_tokens,
298                            new_tokens,
299                        },
300                    )
301                },
302                None => {
303                    // mtime moved but read failed (race or permission)
304                    // — treat as removed for safety.
305                    (None, ReloadOutcome::Removed)
306                },
307            }
308        },
309        None => {
310            // No prior load — re-walk in case the user created
311            // instruction files after session start.
312            match load_from_paths(&find_instruction_files(cwd)) {
313                Some(loaded) => {
314                    let tokens = loaded.approx_tokens();
315                    (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
316                },
317                None => (None, ReloadOutcome::Unchanged),
318            }
319        },
320    }
321}
322
323/// Load project instructions + the durable memory index + the skills index
324/// for a one-shot, watcher-less run (`mermaid run` and subagents). The
325/// interactive TUI gets instructions/memory from the config watcher's first
326/// poll (and loads skills once at startup); the headless and subagent drivers
327/// have no watcher, so they must load synchronously before the first model
328/// call — otherwise the request goes out with no MERMAID.md/AGENTS.md, no
329/// memory index, and no skills, which is exactly the context `mermaid doctor`
330/// reports as loaded.
331pub fn load_project_context(
332    cwd: &Path,
333    mem_cfg: &crate::app::MemoryConfig,
334) -> (
335    Option<LoadedInstructions>,
336    Option<crate::app::memory::LoadedMemory>,
337    Option<crate::app::skills::LoadedSkills>,
338) {
339    let (instructions, _) = refresh(None, cwd);
340    let (memory, _) = crate::app::memory::refresh(None, cwd, mem_cfg);
341    let skills = crate::app::skills::load(cwd);
342    (instructions, memory, skills)
343}
344
345/// Separator inserted between labeled instruction sections in the combined body.
346const INSTRUCTION_SECTION_SEPARATOR: &str = "\n\n---\n\n";
347
348/// Wrap each `(path, body)` in a labeled header — even a single file — so the
349/// content lands in the system prompt as clearly-bounded project data rather
350/// than blending into trusted system authority (#109). Returns the labeled
351/// sections in load order: lowest precedence first, highest precedence LAST (so
352/// `MERMAID.md` lands after `AGENTS.md`).
353fn label_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> Vec<String> {
354    bodies
355        .into_iter()
356        .map(|(path, body)| {
357            let name = path
358                .file_name()
359                .and_then(|name| name.to_str())
360                .unwrap_or("instructions");
361            format!("# Project Instructions: {}\n\n{}", name, body)
362        })
363        .collect()
364}
365
366/// Join labeled `sections` into one body capped at `cap` bytes while PRESERVING
367/// the documented "MERMAID.md wins on conflict" contract under the cap (F61).
368///
369/// `sections` is in precedence order, **highest precedence last**. When the
370/// combined body fits, it is returned whole. When it overflows, the
371/// highest-precedence (last) section — `MERMAID.md` — is protected: the
372/// lower-precedence prefix (`AGENTS.md`) is head-truncated to fit, with the
373/// truncation marker at the elision point, so the winner survives intact and
374/// last (so it still overrides on conflict). Only when the winner *alone*
375/// overflows the cap is the winner itself head-clipped (lower-precedence
376/// sections dropped). The old code joined `[AGENTS, MERMAID]` then kept the
377/// HEAD, so a ≥ 40 KB AGENTS.md silently dropped the entire MERMAID.md tail —
378/// letting the lower-priority file win.
379///
380/// Truncation always lands on a UTF-8 char boundary (`floor_char_boundary`,
381/// stabilized in Rust 1.91.0 — matches the crate MSRV in `Cargo.toml`), and the
382/// result never exceeds `cap + INSTRUCTIONS_TRUNCATION_MARKER.len()` bytes.
383fn combine_and_cap_sections(sections: Vec<String>, cap: usize) -> (String, bool) {
384    const SEP: &str = INSTRUCTION_SECTION_SEPARATOR;
385    let marker = INSTRUCTIONS_TRUNCATION_MARKER;
386
387    let full = sections.join(SEP);
388    if full.len() <= cap {
389        return (full, false);
390    }
391    // Over cap. The winner (highest precedence = MERMAID.md) is the last section
392    // and must never be the file that's silently dropped.
393    let Some(winner) = sections.last() else {
394        return (String::new(), false);
395    };
396    // Even the winner alone exceeds the cap: there's no room for any
397    // lower-precedence content. Drop the rest and head-clip the winner — it
398    // still wins, merely truncated.
399    if winner.len() >= cap {
400        let cut = winner.floor_char_boundary(cap);
401        let mut clipped = winner[..cut].to_string();
402        clipped.push_str(marker);
403        return (clipped, true);
404    }
405    // The winner fits whole at the tail. Budget the remaining room for the
406    // lower-precedence prefix and head-truncate it, so the winner stays intact
407    // AND last (so it still overrides on conflict). `earlier_len >= 1` here: a
408    // lone section under the cap would have hit the fast path above.
409    let earlier_len = sections.len() - 1;
410    let earlier_full = sections[..earlier_len].join(SEP);
411    let avail = cap.saturating_sub(winner.len() + SEP.len());
412    let cut = earlier_full.floor_char_boundary(avail);
413    if cut == 0 {
414        // No lower-precedence content survives the budget: keep the winner whole
415        // (dropping the rest) with a trailing marker so the elision is visible.
416        let mut body = winner.clone();
417        body.push_str(marker);
418        return (body, true);
419    }
420    let mut body = String::with_capacity(cut + marker.len() + SEP.len() + winner.len());
421    body.push_str(&earlier_full[..cut]);
422    body.push_str(marker);
423    body.push_str(SEP);
424    body.push_str(winner.as_str());
425    (body, true)
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use std::fs;
432    use std::sync::Mutex;
433
434    /// Tests touch the filesystem; serialize them so concurrent test
435    /// runs don't see each other's temp files.
436    static FS_LOCK: Mutex<()> = Mutex::new(());
437
438    fn temp_dir(name: &str) -> PathBuf {
439        let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
440        let _ = fs::remove_dir_all(&p);
441        fs::create_dir_all(&p).expect("create temp dir");
442        p
443    }
444
445    #[test]
446    fn find_instruction_files_finds_in_cwd() {
447        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
448        let dir = temp_dir("cwd");
449        fs::write(dir.join("MERMAID.md"), "rules").unwrap();
450        let found = find_instruction_files(&dir);
451        assert_eq!(found, vec![dir.join("MERMAID.md")]);
452        let _ = fs::remove_dir_all(&dir);
453    }
454
455    #[test]
456    fn find_instruction_files_loads_both_in_precedence_order() {
457        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
458        let dir = temp_dir("both");
459        fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
460        fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
461        let found = find_instruction_files(&dir);
462        // AGENTS.md first, MERMAID.md last (last wins on conflict).
463        assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
464        let loaded = load_from_paths(&found).expect("load combined");
465        assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
466        assert!(loaded.content.contains("agent rules"));
467        assert!(
468            loaded
469                .content
470                .contains("# Project Instructions: MERMAID.md")
471        );
472        assert!(loaded.content.contains("mermaid rules"));
473        // MERMAID.md body must appear AFTER AGENTS.md so it overrides.
474        assert!(
475            loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
476            "MERMAID.md must come last so its guidance overrides AGENTS.md"
477        );
478        assert_eq!(loaded.sources.len(), 2);
479        let _ = fs::remove_dir_all(&dir);
480    }
481
482    #[test]
483    fn find_instruction_files_walks_up_to_git_root() {
484        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
485        let root = temp_dir("walkup");
486        fs::create_dir(root.join(".git")).unwrap();
487        fs::write(root.join("MERMAID.md"), "root rules").unwrap();
488        let sub = root.join("subdir/deeper");
489        fs::create_dir_all(&sub).unwrap();
490        let found = find_instruction_files(&sub);
491        assert_eq!(found, vec![root.join("MERMAID.md")]);
492        let _ = fs::remove_dir_all(&root);
493    }
494
495    #[test]
496    fn find_instruction_files_stops_at_git_root_without_file() {
497        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
498        let root = temp_dir("git_no_md");
499        fs::create_dir(root.join(".git")).unwrap();
500        // Place a MERMAID.md ABOVE the git root — should NOT be found
501        // because the walk stops at the .git boundary.
502        let parent = root.parent().unwrap();
503        let above_md = parent.join("MERMAID.md");
504        fs::write(&above_md, "outside").unwrap();
505        let sub = root.join("subdir");
506        fs::create_dir_all(&sub).unwrap();
507        let found = find_instruction_files(&sub);
508        assert!(found.is_empty(), "walk must stop at .git boundary");
509        let _ = fs::remove_dir_all(&root);
510        let _ = fs::remove_file(&above_md);
511    }
512
513    #[test]
514    fn find_instruction_files_returns_empty_if_absent() {
515        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
516        let dir = temp_dir("absent");
517        // No instruction file anywhere. Plant a .git so the walk stops
518        // here deterministically rather than climbing the real tree.
519        fs::create_dir(dir.join(".git")).unwrap();
520        let found = find_instruction_files(&dir);
521        assert!(found.is_empty());
522        let _ = fs::remove_dir_all(&dir);
523    }
524
525    #[test]
526    fn find_instruction_files_stops_at_home_boundary() {
527        // #108: a walk that climbs into $HOME must NOT pick up the home-dir
528        // AGENTS.md. The boundary is injected (no global env mutation), so the
529        // test is race-free. Without the fix the walk would search `home`,
530        // find AGENTS.md, and return it before the home guard ever ran.
531        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
532        let home = temp_dir("home_boundary");
533        fs::write(home.join("AGENTS.md"), "home rules").unwrap();
534        let child = home.join("project");
535        fs::create_dir_all(&child).unwrap();
536        // No .git anywhere between child and home, so only the home guard can
537        // stop the climb.
538        let found = find_instruction_files_bounded(&child, Some(home.as_path()));
539        assert!(
540            found.is_empty(),
541            "walk must stop at $HOME and not load ~/AGENTS.md, got {found:?}"
542        );
543        let _ = fs::remove_dir_all(&home);
544    }
545
546    #[test]
547    fn single_file_instructions_get_labeled_header() {
548        // #109: even a single instruction file is wrapped in a labeled
549        // boundary so it reaches the system prompt as clearly-bounded project
550        // data, not unlabeled trusted-system text.
551        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
552        let dir = temp_dir("single_header");
553        fs::write(dir.join("MERMAID.md"), "do the thing").unwrap();
554        let loaded = load_from_path(&dir.join("MERMAID.md")).expect("load");
555        assert!(
556            loaded
557                .content
558                .starts_with("# Project Instructions: MERMAID.md"),
559            "single-file instructions must carry a labeled header, got: {:?}",
560            loaded.content
561        );
562        assert!(loaded.content.contains("do the thing"));
563        let _ = fs::remove_dir_all(&dir);
564    }
565
566    #[test]
567    fn load_from_path_truncates_oversized_file() {
568        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
569        let dir = temp_dir("oversized");
570        let path = dir.join("MERMAID.md");
571        // Write 50 KB — over the 40 KB cap.
572        let big = "a".repeat(50_000);
573        fs::write(&path, &big).unwrap();
574        let loaded = load_from_path(&path).expect("load");
575        assert!(loaded.truncated);
576        assert_eq!(loaded.byte_len, 50_000); // original size preserved
577        assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
578        // Content should be exactly cap + marker length.
579        assert_eq!(
580            loaded.content.len(),
581            MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
582        );
583        let _ = fs::remove_dir_all(&dir);
584    }
585
586    #[test]
587    fn load_from_path_returns_none_when_missing() {
588        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
589        let dir = temp_dir("missing");
590        assert!(load_from_path(&dir.join("nope.md")).is_none());
591        let _ = fs::remove_dir_all(&dir);
592    }
593
594    #[test]
595    fn refresh_returns_unchanged_when_mtime_stable() {
596        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
597        let dir = temp_dir("stable");
598        let path = dir.join("MERMAID.md");
599        fs::write(&path, "v1").unwrap();
600        let prior = load_from_path(&path).unwrap();
601        let (after, outcome) = refresh(Some(prior.clone()), &dir);
602        assert_eq!(outcome, ReloadOutcome::Unchanged);
603        assert!(after.is_some());
604        let _ = fs::remove_dir_all(&dir);
605    }
606
607    #[test]
608    fn refresh_returns_reloaded_on_content_change() {
609        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
610        let dir = temp_dir("changed");
611        let path = dir.join("MERMAID.md");
612        fs::write(&path, "v1").unwrap();
613        let prior = load_from_path(&path).unwrap();
614        // Sleep briefly to ensure mtime resolution registers a change.
615        // Most filesystems track mtime at second granularity or finer.
616        std::thread::sleep(std::time::Duration::from_millis(1100));
617        fs::write(&path, "v2 longer content here").unwrap();
618        let (after, outcome) = refresh(Some(prior), &dir);
619        assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
620        let content = after.unwrap().content;
621        assert!(content.contains("# Project Instructions: MERMAID.md"));
622        assert!(content.contains("v2 longer content here"));
623        let _ = fs::remove_dir_all(&dir);
624    }
625
626    #[test]
627    fn refresh_returns_removed_when_file_deleted() {
628        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
629        let dir = temp_dir("removed");
630        let path = dir.join("MERMAID.md");
631        fs::write(&path, "v1").unwrap();
632        let prior = load_from_path(&path).unwrap();
633        fs::remove_file(&path).unwrap();
634        let (after, outcome) = refresh(Some(prior), &dir);
635        assert_eq!(outcome, ReloadOutcome::Removed);
636        assert!(after.is_none());
637        let _ = fs::remove_dir_all(&dir);
638    }
639
640    #[test]
641    fn refresh_returns_loaded_first_on_initial_discovery() {
642        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
643        let dir = temp_dir("first");
644        // Plant .git so the walk stays inside `dir`.
645        fs::create_dir(dir.join(".git")).unwrap();
646        // No prior load. Call refresh — should discover the new file.
647        fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
648        let (after, outcome) = refresh(None, &dir);
649        assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
650        let content = after.unwrap().content;
651        assert!(content.contains("# Project Instructions: MERMAID.md"));
652        assert!(content.contains("fresh"));
653        let _ = fs::remove_dir_all(&dir);
654    }
655
656    #[test]
657    fn load_project_context_loads_instructions_synchronously() {
658        // The one-shot paths (headless `mermaid run`, subagents) call this
659        // instead of relying on the config watcher, so it must surface MERMAID.md
660        // immediately — the bug was that headless runs saw no instructions at all.
661        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
662        let dir = temp_dir("project_context");
663        fs::create_dir(dir.join(".git")).unwrap();
664        fs::write(dir.join("MERMAID.md"), "sync-loaded instructions").unwrap();
665        let (instructions, _memory, _skills) =
666            load_project_context(&dir, &crate::app::MemoryConfig::default());
667        let content = instructions
668            .expect("instructions must load synchronously")
669            .content;
670        assert!(content.contains("sync-loaded instructions"));
671        let _ = fs::remove_dir_all(&dir);
672    }
673
674    #[test]
675    fn oversized_agents_does_not_drop_mermaid_winner() {
676        // F61: when AGENTS.md alone is huge, head-truncating the COMBINED body
677        // used to drop the entire MERMAID.md tail — silently letting the
678        // lower-priority file "win". MERMAID.md must survive intact and last (so
679        // it still overrides on conflict); AGENTS.md is the file truncated.
680        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
681        let dir = temp_dir("agents_huge");
682        fs::write(dir.join("AGENTS.md"), "A".repeat(60_000)).unwrap();
683        fs::write(dir.join("MERMAID.md"), "MERMAID_WINS_SENTINEL").unwrap();
684        let loaded =
685            load_from_paths(&[dir.join("AGENTS.md"), dir.join("MERMAID.md")]).expect("load");
686        assert!(loaded.truncated, "combined body exceeds the cap");
687        assert!(
688            loaded.content.contains("MERMAID_WINS_SENTINEL"),
689            "MERMAID.md (the winner) must survive the cap, not be dropped"
690        );
691        assert!(
692            loaded
693                .content
694                .contains("# Project Instructions: MERMAID.md")
695        );
696        assert!(loaded.content.contains(INSTRUCTIONS_TRUNCATION_MARKER));
697        // The winner lands AFTER the elision marker — AGENTS.md was the file
698        // truncated, and MERMAID.md still comes last so it overrides on conflict.
699        let marker_at = loaded.content.find(INSTRUCTIONS_TRUNCATION_MARKER).unwrap();
700        let winner_at = loaded.content.find("MERMAID_WINS_SENTINEL").unwrap();
701        assert!(
702            winner_at > marker_at,
703            "MERMAID.md must come after the truncated AGENTS.md"
704        );
705        // Bounded: never more than the cap plus a single marker.
706        assert!(
707            loaded.content.len() <= MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
708        );
709        let _ = fs::remove_dir_all(&dir);
710    }
711
712    #[test]
713    fn combine_and_cap_protects_the_last_section() {
714        // Lower-precedence section is large; the small winner must survive whole
715        // and land last, with the marker at the elision point.
716        let lower = format!("# Project Instructions: AGENTS.md\n\n{}", "L".repeat(200));
717        let winner = "# Project Instructions: MERMAID.md\n\nWIN".to_string();
718        let (body, truncated) = combine_and_cap_sections(vec![lower, winner], 100);
719        assert!(truncated);
720        assert!(body.contains("WIN"), "winner survives the cap");
721        assert!(body.contains(INSTRUCTIONS_TRUNCATION_MARKER));
722        let marker_at = body.find(INSTRUCTIONS_TRUNCATION_MARKER).unwrap();
723        assert!(
724            body.find("WIN").unwrap() > marker_at,
725            "winner stays last so it overrides on conflict"
726        );
727        assert!(body.len() <= 100 + INSTRUCTIONS_TRUNCATION_MARKER.len());
728    }
729
730    #[test]
731    fn combine_and_cap_clips_winner_when_it_alone_overflows() {
732        // When even the winner exceeds the cap, it's head-clipped (not dropped)
733        // and the lower-precedence section is dropped entirely.
734        let lower = "# Project Instructions: AGENTS.md\n\nlower-content".to_string();
735        let winner = format!("# Project Instructions: MERMAID.md\n\n{}", "W".repeat(300));
736        let (body, truncated) = combine_and_cap_sections(vec![lower, winner], 100);
737        assert!(truncated);
738        assert!(body.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
739        assert_eq!(body.len(), 100 + INSTRUCTIONS_TRUNCATION_MARKER.len());
740        assert!(
741            !body.contains("lower-content"),
742            "no room for the lower-precedence section"
743        );
744    }
745
746    #[test]
747    fn combine_and_cap_passes_through_when_it_fits() {
748        let a = "# Project Instructions: AGENTS.md\n\naye".to_string();
749        let b = "# Project Instructions: MERMAID.md\n\nbee".to_string();
750        let (body, truncated) = combine_and_cap_sections(vec![a, b], 10_000);
751        assert!(!truncated);
752        assert!(body.contains("aye") && body.contains("bee"));
753        assert!(
754            body.find("bee").unwrap() > body.find("aye").unwrap(),
755            "highest-precedence section stays last"
756        );
757    }
758
759    #[test]
760    fn load_from_paths_tolerates_a_missing_file() {
761        // F62: if one path is missing (e.g. MERMAID.md removed in the race
762        // between discovery and load), the present file(s) must still load
763        // rather than the whole multi-file set returning None.
764        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
765        let dir = temp_dir("partial_load");
766        fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
767        let missing = dir.join("MERMAID.md"); // never created
768        let loaded = load_from_paths(&[dir.join("AGENTS.md"), missing])
769            .expect("AGENTS.md must still load when MERMAID.md is absent");
770        assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
771        assert!(loaded.content.contains("agent rules"));
772        assert_eq!(loaded.sources.len(), 1, "only the present file is a source");
773        assert_eq!(loaded.path, dir.join("AGENTS.md"));
774        let _ = fs::remove_dir_all(&dir);
775    }
776
777    #[test]
778    fn load_from_paths_none_when_all_missing() {
779        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
780        let dir = temp_dir("all_missing");
781        assert!(
782            load_from_paths(&[dir.join("AGENTS.md"), dir.join("MERMAID.md")]).is_none(),
783            "no present files => None"
784        );
785        let _ = fs::remove_dir_all(&dir);
786    }
787
788    #[test]
789    fn pick_home_boundary_resolves_windows_home_vars() {
790        // F63: on Windows `HOME` is usually unset; the home boundary must fall
791        // back to `%USERPROFILE%`, then `%HOMEDRIVE%%HOMEPATH%`. Exercised here
792        // with synthetic values so it's verifiable on every platform.
793        use std::ffi::OsStr;
794        // HOME wins when present.
795        assert_eq!(
796            pick_home_boundary(
797                Some(OsStr::new("/home/me")),
798                Some(OsStr::new("C:\\Users\\me")),
799                None,
800                None
801            ),
802            Some(PathBuf::from("/home/me"))
803        );
804        // No HOME => USERPROFILE (the Windows home var) bounds the walk.
805        assert_eq!(
806            pick_home_boundary(None, Some(OsStr::new("C:\\Users\\me")), None, None),
807            Some(PathBuf::from("C:\\Users\\me"))
808        );
809        // No HOME/USERPROFILE => HOMEDRIVE + HOMEPATH joined.
810        assert_eq!(
811            pick_home_boundary(
812                None,
813                None,
814                Some(OsStr::new("C:")),
815                Some(OsStr::new("\\Users\\me"))
816            ),
817            Some(PathBuf::from("C:\\Users\\me"))
818        );
819        // Empty values are ignored (not a usable boundary).
820        assert_eq!(
821            pick_home_boundary(Some(OsStr::new("")), Some(OsStr::new("")), None, None),
822            None
823        );
824        // HOMEDRIVE without HOMEPATH (and vice-versa) => no boundary.
825        assert_eq!(
826            pick_home_boundary(None, None, Some(OsStr::new("C:")), None),
827            None
828        );
829        assert_eq!(
830            pick_home_boundary(None, None, None, Some(OsStr::new("\\Users\\me"))),
831            None
832        );
833    }
834}