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