Skip to main content

locode_host/
instructions.rs

1//! Shared project-instruction (`AGENTS.md`) discovery (ADR-0023, Task 30).
2//!
3//! One shared loader — not pack-selectable — walks `cwd → project root` collecting
4//! `AGENTS.md` files (deepest wins on conflict), plus a global `~/.locode/AGENTS.md`,
5//! and returns a neutral [`ProjectInstructions`]. The engine renders that into a single
6//! `User`-role `<system-reminder>` message and injects it (ADR-0023 §2).
7//!
8//! This lives in `locode-host` — the trusted OS seam (ADR-0008) — and **reads the
9//! discovered files directly**, deliberately bypassing the tool path-jail: discovery
10//! legitimately spans ancestors *above* the cwd-rooted jail, and the jail governs
11//! *tools*, not engine machinery (ADR-0023 implementation note, 2026-07-23). Reads are
12//! bounded to the `AGENTS.md`/`AGENTS.override.md` names along the bounded ancestor walk.
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17/// The canonical project-instruction filename.
18const AGENTS_FILE: &str = "AGENTS.md";
19/// A per-directory, higher-precedence local override (Codex's `AGENTS.override.md`):
20/// same-directory, first-match-wins **replacement** of that directory's `AGENTS.md`
21/// (conventionally gitignored). Not additive, and never affects other directories.
22const OVERRIDE_FILE: &str = "AGENTS.override.md";
23
24/// Discovered project instructions: ordered entries, lowest-precedence first, so a later
25/// (deeper) entry wins on conflict. Neutral — the engine owns rendering/injection.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ProjectInstructions {
28    /// One entry per discovered file, in precedence order (last wins).
29    pub entries: Vec<InstructionEntry>,
30}
31
32impl ProjectInstructions {
33    /// Whether nothing was discovered.
34    #[must_use]
35    pub fn is_empty(&self) -> bool {
36        self.entries.is_empty()
37    }
38}
39
40/// One discovered instruction file: where it came from, and its contents.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InstructionEntry {
43    /// The file the content came from (labeled in the injected message so the model can
44    /// attribute conflicting rules).
45    pub source_path: PathBuf,
46    /// The file's text.
47    pub content: String,
48}
49
50/// How project-instruction discovery behaves. All fields have defaults; a caller (the CLI
51/// today, a settings layer later) overrides them.
52#[derive(Debug, Clone)]
53pub struct InstructionsConfig {
54    /// Master switch (default `true`). `--no-project-instructions` sets it `false`.
55    pub enabled: bool,
56    /// Byte budget for the *assembled* injected body (applied at render time, ADR-0023).
57    /// Default 64 KiB; `0` means unbounded.
58    pub byte_budget: usize,
59    /// Directory markers whose presence marks a project root (default `[".git"]`).
60    pub root_markers: Vec<String>,
61    /// ADR-0023 root-detection rule 2 (activated by ADR-0024 §1.4 / Task 31 S2):
62    /// a regex matched against each ancestor's **absolute path** during the ascent —
63    /// a match makes that directory the project root, exactly like a marker hit.
64    /// The escape hatch for VCS-less trees (monorepo segments, `/workspace/<p>`).
65    /// An invalid pattern degrades to no-pattern (the settings loader already
66    /// warned about it).
67    pub root_stop_pattern: Option<String>,
68    /// **Seam** (ADR-0023 implementation note): extra roots to also discover from. Honored
69    /// by the walk, but no `--add-dir` CLI flag ships until the tool-jail-widening task.
70    pub extra_roots: Vec<PathBuf>,
71    /// Read the global `~/.locode/AGENTS.md` (lowest precedence). Default `true`.
72    pub global_file: bool,
73}
74
75impl Default for InstructionsConfig {
76    fn default() -> Self {
77        Self {
78            enabled: true,
79            byte_budget: 64 * 1024,
80            root_markers: vec![".git".to_string()],
81            root_stop_pattern: None,
82            extra_roots: Vec::new(),
83            global_file: true,
84        }
85    }
86}
87
88/// Discover project instructions for `cwd` under `cfg`.
89///
90/// Order (lowest precedence first, so the deepest project file wins on conflict):
91/// global `~/.locode/AGENTS.md`, then the primary chain **root→cwd**, then each
92/// `extra_roots` entry's own root→dir chain. Per directory, `AGENTS.override.md` replaces
93/// `AGENTS.md` (first-match-wins by existence). Empty/whitespace-only and gitignored files
94/// (primary chain only) are dropped; entries are deduped by canonical path.
95///
96/// Synchronous by design: the walk is bounded (at most root-deep) and the files are tiny,
97/// so this runs inline once per turn (ADR-0023 "per-turn rescan").
98#[must_use]
99pub fn load_project_instructions(cwd: &Path, cfg: &InstructionsConfig) -> ProjectInstructions {
100    // Resolve the global file from `HOME` here (the only env read); the assembly in
101    // `load_impl` takes it explicitly so tests can inject it without mutating process env.
102    let global = (cfg.enabled && cfg.global_file)
103        .then(global_instruction_path)
104        .flatten();
105    load_impl(cwd, cfg, global.as_deref())
106}
107
108/// The env-free core of [`load_project_instructions`]: `global` is the already-resolved
109/// global file path (or `None`).
110fn load_impl(cwd: &Path, cfg: &InstructionsConfig, global: Option<&Path>) -> ProjectInstructions {
111    if !cfg.enabled {
112        return ProjectInstructions::default();
113    }
114
115    let mut entries: Vec<InstructionEntry> = Vec::new();
116    let mut seen: HashSet<String> = HashSet::new();
117
118    // Global file — lowest precedence, first. Outside any repo, so not gitignore-filtered.
119    if let Some(global) = global {
120        push_dir_entry(&global_dir_of(global), &mut entries, &mut seen, None);
121    }
122
123    // Primary chain: root→cwd, deepest last (wins). Gitignore matcher rooted at the
124    // discovered project root (only when it is a real git root). The stop-pattern
125    // compiles here (invalid ⇒ None — the settings loader already warned).
126    let stop_pattern = cfg
127        .root_stop_pattern
128        .as_deref()
129        .and_then(|p| regex::Regex::new(p).ok());
130    let root = find_root_from_markers(cwd, &cfg.root_markers, stop_pattern.as_ref());
131    let ignore = build_gitignore(&root);
132    for dir in dirs_root_to_leaf(cwd, &root) {
133        push_dir_entry(&dir, &mut entries, &mut seen, ignore.as_ref());
134    }
135
136    // Extra roots (seam): each discovered the same way, appended after the primary chain.
137    for extra in &cfg.extra_roots {
138        let extra_root = find_root_from_markers(extra, &cfg.root_markers, stop_pattern.as_ref());
139        for dir in dirs_root_to_leaf(extra, &extra_root) {
140            push_dir_entry(&dir, &mut entries, &mut seen, None);
141        }
142    }
143
144    ProjectInstructions { entries }
145}
146
147/// The directory that holds the global file (so [`push_dir_entry`] can pick the override
148/// there too, mirroring project directories).
149fn global_dir_of(global_file: &Path) -> PathBuf {
150    global_file
151        .parent()
152        .map_or_else(|| global_file.to_path_buf(), Path::to_path_buf)
153}
154
155/// The global instruction file: `<locode home>/AGENTS.md` via the shared ADR-0024
156/// resolver (`$LOCODE_HOME` override — must exist + canonicalize — else
157/// `$HOME/.locode`); `None` when no home resolves. Dependency-free — the shipped
158/// targets are macOS/Linux.
159fn global_instruction_path() -> Option<PathBuf> {
160    crate::home::locode_home()
161        .ok()
162        .map(|dir| dir.join(AGENTS_FILE))
163}
164
165/// The env-free core of [`global_instruction_path`] (tests inject the values — `HOME`/
166/// `LOCODE_HOME` are process-global and this crate forbids `unsafe` env mutation).
167#[cfg(test)]
168fn global_path_from(
169    locode_home: Option<std::ffi::OsString>,
170    home: Option<std::ffi::OsString>,
171) -> Option<PathBuf> {
172    crate::home::resolve_home_from(locode_home, home)
173        .ok()
174        .map(|dir| dir.join(AGENTS_FILE))
175}
176
177/// Ascend from `start`; the nearest ancestor containing any `markers` entry **or whose
178/// absolute path matches `stop_pattern`** is the root (ADR-0023 rules 1+2). No hit up
179/// to the filesystem root ⇒ cwd-only (returns `start`). The filesystem root is only a
180/// backstop, never itself the project root.
181///
182/// Shared with the settings loader (which passes `stop_pattern = None` — the settings
183/// files' own location is marker-detected only, avoiding a settings→pattern cycle).
184pub(crate) fn find_root_from_markers(
185    start: &Path,
186    markers: &[String],
187    stop_pattern: Option<&regex::Regex>,
188) -> PathBuf {
189    let mut dir = Some(start);
190    while let Some(d) = dir {
191        if markers.iter().any(|m| d.join(m).exists())
192            || stop_pattern.is_some_and(|re| re.is_match(&d.to_string_lossy()))
193        {
194            return d.to_path_buf();
195        }
196        dir = d.parent();
197    }
198    start.to_path_buf()
199}
200
201/// The directories from `root` down to `leaf`, inclusive, in root→leaf order (deepest
202/// last). `root` is always `leaf` or an ancestor of it, so the ascent always reaches it.
203fn dirs_root_to_leaf(leaf: &Path, root: &Path) -> Vec<PathBuf> {
204    let mut dirs = Vec::new();
205    let mut cur = Some(leaf);
206    while let Some(d) = cur {
207        dirs.push(d.to_path_buf());
208        if d == root {
209            break;
210        }
211        cur = d.parent();
212    }
213    dirs.reverse();
214    dirs
215}
216
217/// Pick a directory's instruction file (`AGENTS.override.md` before `AGENTS.md`,
218/// first-match-wins by existence), read it directly, and push a deduped, non-empty,
219/// non-gitignored entry.
220fn push_dir_entry(
221    dir: &Path,
222    entries: &mut Vec<InstructionEntry>,
223    seen: &mut HashSet<String>,
224    ignore: Option<&ignore::gitignore::Gitignore>,
225) {
226    for name in [OVERRIDE_FILE, AGENTS_FILE] {
227        let path = dir.join(name);
228        if !path.is_file() {
229            continue;
230        }
231        // The first existing candidate wins the directory (even if empty — an empty
232        // override still replaces the sibling AGENTS.md; it just yields no entry).
233        if let Some(gi) = ignore
234            && is_gitignored(gi, &path)
235        {
236            return;
237        }
238        let content = std::fs::read_to_string(&path).unwrap_or_default();
239        if content.trim().is_empty() {
240            return;
241        }
242        let key = canonical_key(&path);
243        if seen.insert(key) {
244            entries.push(InstructionEntry {
245                source_path: path,
246                content,
247            });
248        }
249        return;
250    }
251}
252
253/// A gitignore matcher for `root`, or `None` when `root` is not a git root (allow-all,
254/// grok's rule — `walk.rs`). Built from `root/.gitignore` + `root/.git/info/exclude`.
255fn build_gitignore(root: &Path) -> Option<ignore::gitignore::Gitignore> {
256    if !root.join(".git").exists() {
257        return None;
258    }
259    let base = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
260    let mut builder = ignore::gitignore::GitignoreBuilder::new(&base);
261    let _ = builder.add(base.join(".gitignore"));
262    let _ = builder.add(base.join(".git").join("info").join("exclude"));
263    builder.build().ok()
264}
265
266/// Whether `path` is gitignored under `gi` (canonicalized so the matcher's root prefix
267/// lines up).
268fn is_gitignored(gi: &ignore::gitignore::Gitignore, path: &Path) -> bool {
269    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
270    gi.matched_path_or_any_parents(&canon, /*is_dir*/ false)
271        .is_ignore()
272}
273
274/// A canonical, case-insensitive dedup key (symlink-resolved when the path exists).
275fn canonical_key(path: &Path) -> String {
276    std::fs::canonicalize(path)
277        .unwrap_or_else(|_| path.to_path_buf())
278        .to_string_lossy()
279        .to_lowercase()
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::fs;
286    use tempfile::TempDir;
287
288    /// A canonicalized tempdir (prod always passes a canonical cwd — macOS `/var` →
289    /// `/private/var`, so canonicalize for parity).
290    fn tmp() -> (TempDir, PathBuf) {
291        let dir = tempfile::tempdir().unwrap();
292        let root = fs::canonicalize(dir.path()).unwrap();
293        (dir, root)
294    }
295
296    fn write(path: &Path, body: &str) {
297        if let Some(parent) = path.parent() {
298            fs::create_dir_all(parent).unwrap();
299        }
300        fs::write(path, body).unwrap();
301    }
302
303    /// No `HOME` (or global disabled) unless a test opts in, so the real dev home is never
304    /// read. Returns a config with `global_file` off by default.
305    fn cfg_no_global() -> InstructionsConfig {
306        InstructionsConfig {
307            global_file: false,
308            ..Default::default()
309        }
310    }
311
312    #[test]
313    fn walks_root_to_cwd_deepest_last() {
314        let (_g, root) = tmp();
315        fs::create_dir(root.join(".git")).unwrap();
316        write(&root.join(AGENTS_FILE), "root rules");
317        let sub = root.join("a/b");
318        write(&sub.join(AGENTS_FILE), "leaf rules");
319
320        let got = load_project_instructions(&sub, &cfg_no_global());
321        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
322        assert_eq!(
323            paths,
324            vec![root.join(AGENTS_FILE), sub.join(AGENTS_FILE)],
325            "root→cwd order, deepest last"
326        );
327        assert_eq!(got.entries[0].content, "root rules");
328        assert_eq!(got.entries[1].content, "leaf rules");
329    }
330
331    #[test]
332    fn no_git_falls_back_to_cwd_only() {
333        let (_g, root) = tmp();
334        // No `.git` anywhere.
335        write(&root.join(AGENTS_FILE), "parent rules");
336        let sub = root.join("child");
337        write(&sub.join(AGENTS_FILE), "cwd rules");
338
339        let got = load_project_instructions(&sub, &cfg_no_global());
340        assert_eq!(got.entries.len(), 1, "cwd-only: parent not walked");
341        assert_eq!(got.entries[0].source_path, sub.join(AGENTS_FILE));
342    }
343
344    #[test]
345    fn override_replaces_same_dir_agents_md_only() {
346        let (_g, root) = tmp();
347        fs::create_dir(root.join(".git")).unwrap();
348        write(&root.join(AGENTS_FILE), "root plain");
349        let sub = root.join("s");
350        write(&sub.join(AGENTS_FILE), "sub plain");
351        write(&sub.join(OVERRIDE_FILE), "sub override");
352
353        let got = load_project_instructions(&sub, &cfg_no_global());
354        // Root's plain AGENTS.md still loads; sub's override replaces sub's AGENTS.md.
355        assert_eq!(got.entries.len(), 2);
356        assert_eq!(got.entries[1].source_path, sub.join(OVERRIDE_FILE));
357        assert_eq!(got.entries[1].content, "sub override");
358        assert!(
359            got.entries
360                .iter()
361                .all(|e| e.source_path != sub.join(AGENTS_FILE)),
362            "sub's plain AGENTS.md is suppressed"
363        );
364    }
365
366    #[test]
367    fn empty_override_suppresses_agents_md_and_yields_nothing() {
368        let (_g, root) = tmp();
369        write(&root.join(AGENTS_FILE), "would-be content");
370        write(&root.join(OVERRIDE_FILE), "   \n  ");
371
372        let got = load_project_instructions(&root, &cfg_no_global());
373        assert!(
374            got.is_empty(),
375            "empty override wins the dir, yields no entry"
376        );
377    }
378
379    #[test]
380    fn empty_and_whitespace_files_dropped() {
381        let (_g, root) = tmp();
382        write(&root.join(AGENTS_FILE), "\n\t  \n");
383        let got = load_project_instructions(&root, &cfg_no_global());
384        assert!(got.is_empty());
385    }
386
387    #[test]
388    fn gitignored_agents_md_skipped() {
389        let (_g, root) = tmp();
390        fs::create_dir(root.join(".git")).unwrap();
391        write(&root.join(".gitignore"), "AGENTS.md\n");
392        write(&root.join(AGENTS_FILE), "secret");
393        let got = load_project_instructions(&root, &cfg_no_global());
394        assert!(got.is_empty(), "gitignored AGENTS.md is filtered");
395    }
396
397    #[test]
398    fn dedup_by_canonical_path() {
399        let (_g, root) = tmp();
400        fs::create_dir(root.join(".git")).unwrap();
401        write(&root.join(AGENTS_FILE), "rules");
402        // A symlinked view of the same dir would double-count without canonical dedup;
403        // here we assert a single tree yields no duplicates and the key is stable.
404        let got = load_project_instructions(&root, &cfg_no_global());
405        assert_eq!(got.entries.len(), 1);
406        // Same key for the same file.
407        assert_eq!(
408            canonical_key(&root.join(AGENTS_FILE)),
409            canonical_key(&root.join(AGENTS_FILE))
410        );
411    }
412
413    #[test]
414    fn extra_roots_appended_after_primary() {
415        let (_g, root) = tmp();
416        fs::create_dir(root.join(".git")).unwrap();
417        write(&root.join(AGENTS_FILE), "primary");
418        let (_g2, other) = tmp();
419        write(&other.join(AGENTS_FILE), "extra");
420
421        let cfg = InstructionsConfig {
422            extra_roots: vec![other.clone()],
423            ..cfg_no_global()
424        };
425        let got = load_project_instructions(&root, &cfg);
426        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
427        assert_eq!(paths, vec![root.join(AGENTS_FILE), other.join(AGENTS_FILE)]);
428    }
429
430    #[test]
431    fn disabled_returns_empty() {
432        let (_g, root) = tmp();
433        write(&root.join(AGENTS_FILE), "rules");
434        let cfg = InstructionsConfig {
435            enabled: false,
436            ..cfg_no_global()
437        };
438        assert!(load_project_instructions(&root, &cfg).is_empty());
439    }
440
441    #[test]
442    fn root_stop_pattern_stops_the_ascent() {
443        // ADR-0023 rule 2 (active since Task 31 S2): a path-matching ancestor IS
444        // the root, so files above it are not read.
445        let (_g, root) = tmp();
446        fs::create_dir(root.join(".git")).unwrap();
447        write(&root.join(AGENTS_FILE), "top rules");
448        let x = root.join("x");
449        write(&x.join(AGENTS_FILE), "x rules");
450        let sub = x.join("y");
451        fs::create_dir_all(&sub).unwrap();
452
453        // Without the pattern: the .git root wins — both files load.
454        let got = load_project_instructions(&sub, &cfg_no_global());
455        assert_eq!(got.entries.len(), 2);
456
457        // With the pattern matching `.../x`: x is the root — top is not read.
458        let mut cfg = cfg_no_global();
459        cfg.root_stop_pattern = Some("/x$".to_string());
460        let got = load_project_instructions(&sub, &cfg);
461        assert_eq!(got.entries.len(), 1);
462        assert_eq!(got.entries[0].source_path, x.join(AGENTS_FILE));
463        assert_eq!(got.entries[0].content, "x rules");
464    }
465
466    #[test]
467    fn invalid_root_stop_pattern_degrades_to_no_pattern() {
468        let (_g, root) = tmp();
469        fs::create_dir(root.join(".git")).unwrap();
470        write(&root.join(AGENTS_FILE), "rules");
471        let mut cfg = cfg_no_global();
472        cfg.root_stop_pattern = Some("[invalid".to_string());
473        let got = load_project_instructions(&root, &cfg);
474        assert_eq!(got.entries.len(), 1, "marker detection still works");
475    }
476
477    #[test]
478    fn global_file_is_lowest_precedence() {
479        // Inject the global path directly (no env mutation — the crate forbids `unsafe`,
480        // and `HOME` is process-global). Exercises the same assembly the public fn uses.
481        let (_home_g, home) = tmp();
482        let global = home.join(".locode").join(AGENTS_FILE);
483        write(&global, "global");
484        let (_g, root) = tmp();
485        fs::create_dir(root.join(".git")).unwrap();
486        write(&root.join(AGENTS_FILE), "project");
487
488        let cfg = InstructionsConfig {
489            global_file: true,
490            ..Default::default()
491        };
492        let got = load_impl(&root, &cfg, Some(&global));
493        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
494        assert_eq!(
495            paths,
496            vec![global.clone(), root.join(AGENTS_FILE)],
497            "global first (lowest precedence), project after"
498        );
499    }
500
501    #[test]
502    fn global_file_disabled_passes_none() {
503        // With `global_file=false` the public fn resolves `global = None`, so the real
504        // `HOME` is never read and only the project chain loads.
505        let (_g, root) = tmp();
506        write(&root.join(AGENTS_FILE), "project");
507        let got = load_project_instructions(&root, &cfg_no_global());
508        assert_eq!(got.entries.len(), 1);
509        assert_eq!(got.entries[0].source_path, root.join(AGENTS_FILE));
510    }
511
512    #[test]
513    fn global_instruction_path_shape() {
514        // Read-only: whatever HOME is, the global path is `<home>/.locode/AGENTS.md`.
515        if let Some(p) = global_instruction_path() {
516            assert!(p.ends_with("AGENTS.md"));
517        }
518    }
519
520    #[test]
521    fn global_path_prefers_locode_home_over_home() {
522        use std::ffi::OsString;
523        // LOCODE_HOME set → `<LOCODE_HOME>/AGENTS.md` (the dir IS the dotfolder).
524        // The ADR-0024 resolver requires an explicit override to exist.
525        let dir = tempfile::tempdir().unwrap();
526        let canon = fs::canonicalize(dir.path()).unwrap();
527        let p = global_path_from(Some(dir.path().as_os_str().to_owned()), None).unwrap();
528        assert_eq!(p, canon.join(AGENTS_FILE));
529        // A nonexistent explicit override fails the resolver → no global file.
530        assert!(global_path_from(Some(OsString::from("/definitely/not/here")), None).is_none());
531        // Empty LOCODE_HOME is treated as unset → falls back to `$HOME/.locode`
532        // (the default is deliberately unverified).
533        let p = global_path_from(Some(OsString::new()), Some(OsString::from("/home/u"))).unwrap();
534        assert_eq!(p, PathBuf::from("/home/u/.locode").join(AGENTS_FILE));
535        // Neither set → None.
536        assert!(global_path_from(None, None).is_none());
537        assert!(global_path_from(None, Some(OsString::new())).is_none());
538    }
539}