Skip to main content

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