locode-instructions 0.1.11

Project-instruction (AGENTS.md) discovery, assembly, and injection for the locode coding agent
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
//! Shared project-instruction (`AGENTS.md`) discovery (ADR-0023, Task 30).
//!
//! One shared loader — not pack-selectable — walks `cwd → project root` collecting
//! `AGENTS.md` files (deepest wins on conflict), plus a global `~/.locode/AGENTS.md`,
//! and returns a neutral [`ProjectInstructions`]. The engine renders that into a single
//! `User`-role `<system-reminder>` message and injects it (ADR-0023 §2).
//!
//! Discovery **reads the files directly**, deliberately bypassing the tool path-jail:
//! it legitimately spans ancestors *above* the cwd-rooted jail, and the jail governs
//! *tools*, not engine machinery (ADR-0023 implementation note, 2026-07-23). Reads are
//! bounded to the `AGENTS.md`/`AGENTS.override.md` names along the bounded ancestor walk.
//! This module lived in `locode-host` until the crate split (ADR-0002 amendment
//! 2026-07-24); the one host primitive it still needs — the cwd→root marker walk, shared
//! with the settings loader — stays there as `locode_host::find_root_from_markers`.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

#[cfg(test)]
use locode_host::resolve_home_from;
use locode_host::{find_root_from_markers, locode_home};

/// The canonical project-instruction filename.
const AGENTS_FILE: &str = "AGENTS.md";
/// A per-directory, higher-precedence local override (Codex's `AGENTS.override.md`):
/// same-directory, first-match-wins **replacement** of that directory's `AGENTS.md`
/// (conventionally gitignored). Not additive, and never affects other directories.
const OVERRIDE_FILE: &str = "AGENTS.override.md";

/// Discovered project instructions: ordered entries, lowest-precedence first, so a later
/// (deeper) entry wins on conflict. Neutral — the engine owns rendering/injection.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectInstructions {
    /// One entry per discovered file, in precedence order (last wins).
    pub entries: Vec<InstructionEntry>,
}

impl ProjectInstructions {
    /// Whether nothing was discovered.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// One discovered instruction file: where it came from, and its contents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstructionEntry {
    /// The file the content came from (labeled in the injected message so the model can
    /// attribute conflicting rules).
    pub source_path: PathBuf,
    /// The file's text.
    pub content: String,
}

/// How project-instruction discovery behaves. All fields have defaults; a caller (the CLI
/// today, a settings layer later) overrides them.
#[derive(Debug, Clone)]
pub struct InstructionsConfig {
    /// Master switch (default `true`). `--no-project-instructions` sets it `false`.
    pub enabled: bool,
    /// Byte budget for the *assembled* injected body (applied at render time, ADR-0023).
    /// Default 64 KiB; `0` means unbounded.
    pub byte_budget: usize,
    /// Directory markers whose presence marks a project root (default `[".git"]`).
    pub root_markers: Vec<String>,
    /// ADR-0023 root-detection rule 2 (activated by ADR-0024 §1.4 / Task 31 S2):
    /// a regex matched against each ancestor's **absolute path** during the ascent —
    /// a match makes that directory the project root, exactly like a marker hit.
    /// The escape hatch for VCS-less trees (monorepo segments, `/workspace/<p>`).
    /// An invalid pattern degrades to no-pattern (the settings loader already
    /// warned about it).
    pub root_stop_pattern: Option<String>,
    /// **Seam** (ADR-0023 implementation note): extra roots to also discover from. Honored
    /// by the walk, but no `--add-dir` CLI flag ships until the tool-jail-widening task.
    pub extra_roots: Vec<PathBuf>,
    /// Read the global `~/.locode/AGENTS.md` (lowest precedence). Default `true`.
    pub global_file: bool,
    /// Resolved `extends` dotfolders from settings (ADR-0024 §1.2 amendment). Each
    /// contributes its `AGENTS.md` **below** the global file, in list order, so the
    /// user's own global file still wins and the repo chain wins over both
    /// (ADR-0023 amendment 2026-07-24).
    ///
    /// This field is why load order is an invariant rather than a convention
    /// (ADR-0025 §6.1): the value comes from `SettingsLoad::extends_dirs`, so
    /// discovery cannot run before settings resolve.
    pub extends_dirs: Vec<PathBuf>,
}

impl Default for InstructionsConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            byte_budget: 64 * 1024,
            root_markers: vec![".git".to_string()],
            root_stop_pattern: None,
            extra_roots: Vec::new(),
            global_file: true,
            extends_dirs: Vec::new(),
        }
    }
}

/// Discover project instructions for `cwd` under `cfg`.
///
/// Order (lowest precedence first, so the deepest project file wins on conflict):
/// global `~/.locode/AGENTS.md`, then the primary chain **root→cwd**, then each
/// `extra_roots` entry's own root→dir chain. Per directory, `AGENTS.override.md` replaces
/// `AGENTS.md` (first-match-wins by existence). Empty/whitespace-only and gitignored files
/// (primary chain only) are dropped; entries are deduped by canonical path.
///
/// Synchronous by design: the walk is bounded (at most root-deep) and the files are tiny,
/// so this runs inline once per turn (ADR-0023 "per-turn rescan").
#[must_use]
pub fn load_project_instructions(cwd: &Path, cfg: &InstructionsConfig) -> ProjectInstructions {
    // Resolve the global file from `HOME` here (the only env read); the assembly in
    // `load_impl` takes it explicitly so tests can inject it without mutating process env.
    let global = (cfg.enabled && cfg.global_file)
        .then(global_instruction_path)
        .flatten();
    load_impl(cwd, cfg, global.as_deref())
}

/// The env-free core of [`load_project_instructions`]: `global` is the already-resolved
/// global file path (or `None`).
fn load_impl(cwd: &Path, cfg: &InstructionsConfig, global: Option<&Path>) -> ProjectInstructions {
    if !cfg.enabled {
        return ProjectInstructions::default();
    }

    let mut entries: Vec<InstructionEntry> = Vec::new();
    let mut seen: HashSet<String> = HashSet::new();

    // `extends` dotfolders — the lowest precedence of all, so the user's own global
    // file (next) overrides a team's. Outside any repo ⇒ not gitignore-filtered.
    for dir in &cfg.extends_dirs {
        push_dir_entry(dir, &mut entries, &mut seen, None);
    }

    // Global file — below the repo chain, above `extends`. Outside any repo, so not
    // gitignore-filtered.
    if let Some(global) = global {
        push_dir_entry(&global_dir_of(global), &mut entries, &mut seen, None);
    }

    // Primary chain: root→cwd, deepest last (wins). Gitignore matcher rooted at the
    // discovered project root (only when it is a real git root). The stop-pattern
    // compiles here (invalid ⇒ None — the settings loader already warned).
    let stop_pattern = cfg
        .root_stop_pattern
        .as_deref()
        .and_then(|p| regex::Regex::new(p).ok());
    let root = find_root_from_markers(cwd, &cfg.root_markers, stop_pattern.as_ref());
    let ignore = build_gitignore(&root);
    for dir in dirs_root_to_leaf(cwd, &root) {
        push_dir_entry(&dir, &mut entries, &mut seen, ignore.as_ref());
    }

    // Extra roots (seam): each discovered the same way, appended after the primary chain.
    for extra in &cfg.extra_roots {
        let extra_root = find_root_from_markers(extra, &cfg.root_markers, stop_pattern.as_ref());
        for dir in dirs_root_to_leaf(extra, &extra_root) {
            push_dir_entry(&dir, &mut entries, &mut seen, None);
        }
    }

    ProjectInstructions { entries }
}

/// The directory that holds the global file (so [`push_dir_entry`] can pick the override
/// there too, mirroring project directories).
fn global_dir_of(global_file: &Path) -> PathBuf {
    global_file
        .parent()
        .map_or_else(|| global_file.to_path_buf(), Path::to_path_buf)
}

/// The global instruction file: `<locode home>/AGENTS.md` via the shared ADR-0024
/// resolver (`$LOCODE_HOME` override — must exist + canonicalize — else
/// `$HOME/.locode`); `None` when no home resolves. Dependency-free — the shipped
/// targets are macOS/Linux.
fn global_instruction_path() -> Option<PathBuf> {
    locode_home().ok().map(|dir| dir.join(AGENTS_FILE))
}

/// The env-free core of [`global_instruction_path`] (tests inject the values — `HOME`/
/// `LOCODE_HOME` are process-global and this crate forbids `unsafe` env mutation).
#[cfg(test)]
fn global_path_from(
    locode_home: Option<std::ffi::OsString>,
    home: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
    resolve_home_from(locode_home, home)
        .ok()
        .map(|dir| dir.join(AGENTS_FILE))
}

/// The directories from `root` down to `leaf`, inclusive, in root→leaf order (deepest
/// last). `root` is always `leaf` or an ancestor of it, so the ascent always reaches it.
fn dirs_root_to_leaf(leaf: &Path, root: &Path) -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    let mut cur = Some(leaf);
    while let Some(d) = cur {
        dirs.push(d.to_path_buf());
        if d == root {
            break;
        }
        cur = d.parent();
    }
    dirs.reverse();
    dirs
}

/// Pick a directory's instruction file (`AGENTS.override.md` before `AGENTS.md`,
/// first-match-wins by existence), read it directly, and push a deduped, non-empty,
/// non-gitignored entry.
fn push_dir_entry(
    dir: &Path,
    entries: &mut Vec<InstructionEntry>,
    seen: &mut HashSet<String>,
    ignore: Option<&ignore::gitignore::Gitignore>,
) {
    for name in [OVERRIDE_FILE, AGENTS_FILE] {
        let path = dir.join(name);
        if !path.is_file() {
            continue;
        }
        // The first existing candidate wins the directory (even if empty — an empty
        // override still replaces the sibling AGENTS.md; it just yields no entry).
        if let Some(gi) = ignore
            && is_gitignored(gi, &path)
        {
            return;
        }
        let content = std::fs::read_to_string(&path).unwrap_or_default();
        if content.trim().is_empty() {
            return;
        }
        let key = canonical_key(&path);
        if seen.insert(key) {
            entries.push(InstructionEntry {
                source_path: path,
                content,
            });
        }
        return;
    }
}

/// A gitignore matcher for `root`, or `None` when `root` is not a git root (allow-all,
/// grok's rule — `walk.rs`). Built from `root/.gitignore` + `root/.git/info/exclude`.
fn build_gitignore(root: &Path) -> Option<ignore::gitignore::Gitignore> {
    if !root.join(".git").exists() {
        return None;
    }
    let base = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
    let mut builder = ignore::gitignore::GitignoreBuilder::new(&base);
    let _ = builder.add(base.join(".gitignore"));
    let _ = builder.add(base.join(".git").join("info").join("exclude"));
    builder.build().ok()
}

/// Whether `path` is gitignored under `gi` (canonicalized so the matcher's root prefix
/// lines up).
fn is_gitignored(gi: &ignore::gitignore::Gitignore, path: &Path) -> bool {
    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    gi.matched_path_or_any_parents(&canon, /*is_dir*/ false)
        .is_ignore()
}

/// A canonical, case-insensitive dedup key (symlink-resolved when the path exists).
fn canonical_key(path: &Path) -> String {
    std::fs::canonicalize(path)
        .unwrap_or_else(|_| path.to_path_buf())
        .to_string_lossy()
        .to_lowercase()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// A canonicalized tempdir (prod always passes a canonical cwd — macOS `/var` →
    /// `/private/var`, so canonicalize for parity).
    fn tmp() -> (TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = fs::canonicalize(dir.path()).unwrap();
        (dir, root)
    }

    fn write(path: &Path, body: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    /// No `HOME` (or global disabled) unless a test opts in, so the real dev home is never
    /// read. Returns a config with `global_file` off by default.
    fn cfg_no_global() -> InstructionsConfig {
        InstructionsConfig {
            global_file: false,
            ..Default::default()
        }
    }

    #[test]
    fn walks_root_to_cwd_deepest_last() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "root rules");
        let sub = root.join("a/b");
        write(&sub.join(AGENTS_FILE), "leaf rules");

        let got = load_project_instructions(&sub, &cfg_no_global());
        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
        assert_eq!(
            paths,
            vec![root.join(AGENTS_FILE), sub.join(AGENTS_FILE)],
            "root→cwd order, deepest last"
        );
        assert_eq!(got.entries[0].content, "root rules");
        assert_eq!(got.entries[1].content, "leaf rules");
    }

    #[test]
    fn no_git_falls_back_to_cwd_only() {
        let (_g, root) = tmp();
        // No `.git` anywhere.
        write(&root.join(AGENTS_FILE), "parent rules");
        let sub = root.join("child");
        write(&sub.join(AGENTS_FILE), "cwd rules");

        let got = load_project_instructions(&sub, &cfg_no_global());
        assert_eq!(got.entries.len(), 1, "cwd-only: parent not walked");
        assert_eq!(got.entries[0].source_path, sub.join(AGENTS_FILE));
    }

    #[test]
    fn override_replaces_same_dir_agents_md_only() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "root plain");
        let sub = root.join("s");
        write(&sub.join(AGENTS_FILE), "sub plain");
        write(&sub.join(OVERRIDE_FILE), "sub override");

        let got = load_project_instructions(&sub, &cfg_no_global());
        // Root's plain AGENTS.md still loads; sub's override replaces sub's AGENTS.md.
        assert_eq!(got.entries.len(), 2);
        assert_eq!(got.entries[1].source_path, sub.join(OVERRIDE_FILE));
        assert_eq!(got.entries[1].content, "sub override");
        assert!(
            got.entries
                .iter()
                .all(|e| e.source_path != sub.join(AGENTS_FILE)),
            "sub's plain AGENTS.md is suppressed"
        );
    }

    #[test]
    fn empty_override_suppresses_agents_md_and_yields_nothing() {
        let (_g, root) = tmp();
        write(&root.join(AGENTS_FILE), "would-be content");
        write(&root.join(OVERRIDE_FILE), "   \n  ");

        let got = load_project_instructions(&root, &cfg_no_global());
        assert!(
            got.is_empty(),
            "empty override wins the dir, yields no entry"
        );
    }

    #[test]
    fn empty_and_whitespace_files_dropped() {
        let (_g, root) = tmp();
        write(&root.join(AGENTS_FILE), "\n\t  \n");
        let got = load_project_instructions(&root, &cfg_no_global());
        assert!(got.is_empty());
    }

    #[test]
    fn gitignored_agents_md_skipped() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(".gitignore"), "AGENTS.md\n");
        write(&root.join(AGENTS_FILE), "secret");
        let got = load_project_instructions(&root, &cfg_no_global());
        assert!(got.is_empty(), "gitignored AGENTS.md is filtered");
    }

    #[test]
    fn dedup_by_canonical_path() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "rules");
        // A symlinked view of the same dir would double-count without canonical dedup;
        // here we assert a single tree yields no duplicates and the key is stable.
        let got = load_project_instructions(&root, &cfg_no_global());
        assert_eq!(got.entries.len(), 1);
        // Same key for the same file.
        assert_eq!(
            canonical_key(&root.join(AGENTS_FILE)),
            canonical_key(&root.join(AGENTS_FILE))
        );
    }

    #[test]
    fn extra_roots_appended_after_primary() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "primary");
        let (_g2, other) = tmp();
        write(&other.join(AGENTS_FILE), "extra");

        let cfg = InstructionsConfig {
            extra_roots: vec![other.clone()],
            ..cfg_no_global()
        };
        let got = load_project_instructions(&root, &cfg);
        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
        assert_eq!(paths, vec![root.join(AGENTS_FILE), other.join(AGENTS_FILE)]);
    }

    #[test]
    fn disabled_returns_empty() {
        let (_g, root) = tmp();
        write(&root.join(AGENTS_FILE), "rules");
        let cfg = InstructionsConfig {
            enabled: false,
            ..cfg_no_global()
        };
        assert!(load_project_instructions(&root, &cfg).is_empty());
    }

    #[test]
    fn root_stop_pattern_stops_the_ascent() {
        // ADR-0023 rule 2 (active since Task 31 S2): a path-matching ancestor IS
        // the root, so files above it are not read.
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "top rules");
        let x = root.join("x");
        write(&x.join(AGENTS_FILE), "x rules");
        let sub = x.join("y");
        fs::create_dir_all(&sub).unwrap();

        // Without the pattern: the .git root wins — both files load.
        let got = load_project_instructions(&sub, &cfg_no_global());
        assert_eq!(got.entries.len(), 2);

        // With the pattern matching `.../x`: x is the root — top is not read.
        let mut cfg = cfg_no_global();
        cfg.root_stop_pattern = Some("/x$".to_string());
        let got = load_project_instructions(&sub, &cfg);
        assert_eq!(got.entries.len(), 1);
        assert_eq!(got.entries[0].source_path, x.join(AGENTS_FILE));
        assert_eq!(got.entries[0].content, "x rules");
    }

    #[test]
    fn invalid_root_stop_pattern_degrades_to_no_pattern() {
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "rules");
        let mut cfg = cfg_no_global();
        cfg.root_stop_pattern = Some("[invalid".to_string());
        let got = load_project_instructions(&root, &cfg);
        assert_eq!(got.entries.len(), 1, "marker detection still works");
    }

    #[test]
    fn global_file_is_lowest_precedence() {
        // Inject the global path directly (no env mutation — the crate forbids `unsafe`,
        // and `HOME` is process-global). Exercises the same assembly the public fn uses.
        let (_home_g, home) = tmp();
        let global = home.join(".locode").join(AGENTS_FILE);
        write(&global, "global");
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "project");

        let cfg = InstructionsConfig {
            global_file: true,
            ..Default::default()
        };
        let got = load_impl(&root, &cfg, Some(&global));
        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
        assert_eq!(
            paths,
            vec![global.clone(), root.join(AGENTS_FILE)],
            "global first (lowest precedence), project after"
        );
    }

    /// An `extends` dotfolder's `AGENTS.md` sits **below** the user's own global file,
    /// which sits below the repo chain (ADR-0023 amendment 2026-07-24) — so a team file
    /// is the weakest voice and the deepest project file still wins.
    #[test]
    fn extends_dotfolders_rank_below_the_global_file() {
        let (_home_g, home) = tmp();
        let global = home.join(".locode").join(AGENTS_FILE);
        write(&global, "global");
        let (_team_g, team) = tmp();
        write(&team.join(AGENTS_FILE), "team");
        let (_team2_g, team2) = tmp();
        write(&team2.join(AGENTS_FILE), "team2");
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "project");

        let cfg = InstructionsConfig {
            global_file: true,
            extends_dirs: vec![team.clone(), team2.clone()],
            ..Default::default()
        };
        let got = load_impl(&root, &cfg, Some(&global));
        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
        assert_eq!(
            paths,
            vec![
                team.join(AGENTS_FILE),
                team2.join(AGENTS_FILE),
                global.clone(),
                root.join(AGENTS_FILE),
            ],
            "extends (list order) → global → repo chain"
        );
    }

    /// A dotfolder that ships no `AGENTS.md` contributes nothing and is not an error.
    #[test]
    fn extends_dotfolder_without_agents_md_contributes_nothing() {
        let (_team_g, team) = tmp();
        let (_g, root) = tmp();
        fs::create_dir(root.join(".git")).unwrap();
        write(&root.join(AGENTS_FILE), "project");

        let cfg = InstructionsConfig {
            extends_dirs: vec![team],
            ..cfg_no_global()
        };
        let got = load_impl(&root, &cfg, None);
        let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
        assert_eq!(paths, vec![root.join(AGENTS_FILE)]);
    }

    #[test]
    fn global_file_disabled_passes_none() {
        // With `global_file=false` the public fn resolves `global = None`, so the real
        // `HOME` is never read and only the project chain loads.
        let (_g, root) = tmp();
        write(&root.join(AGENTS_FILE), "project");
        let got = load_project_instructions(&root, &cfg_no_global());
        assert_eq!(got.entries.len(), 1);
        assert_eq!(got.entries[0].source_path, root.join(AGENTS_FILE));
    }

    #[test]
    fn global_instruction_path_shape() {
        // Read-only: whatever HOME is, the global path is `<home>/.locode/AGENTS.md`.
        if let Some(p) = global_instruction_path() {
            assert!(p.ends_with("AGENTS.md"));
        }
    }

    #[test]
    fn global_path_prefers_locode_home_over_home() {
        use std::ffi::OsString;
        // LOCODE_HOME set → `<LOCODE_HOME>/AGENTS.md` (the dir IS the dotfolder).
        // The ADR-0024 resolver requires an explicit override to exist.
        let dir = tempfile::tempdir().unwrap();
        let canon = fs::canonicalize(dir.path()).unwrap();
        let p = global_path_from(Some(dir.path().as_os_str().to_owned()), None).unwrap();
        assert_eq!(p, canon.join(AGENTS_FILE));
        // A nonexistent explicit override fails the resolver → no global file.
        assert!(global_path_from(Some(OsString::from("/definitely/not/here")), None).is_none());
        // Empty LOCODE_HOME is treated as unset → falls back to `$HOME/.locode`
        // (the default is deliberately unverified).
        let p = global_path_from(Some(OsString::new()), Some(OsString::from("/home/u"))).unwrap();
        assert_eq!(p, PathBuf::from("/home/u/.locode").join(AGENTS_FILE));
        // Neither set → None.
        assert!(global_path_from(None, None).is_none());
        assert!(global_path_from(None, Some(OsString::new())).is_none());
    }
}