Skip to main content

thndrs_lib/core/context/
instructions.rs

1//! Scoped project instructions: nested `AGENTS.md` discovery, selection, and
2//! turn-boundary reload diagnostics.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::{AGENTS_MD_SIZE_CAP, ContextSource};
8use super::{hash_content, scope_depth};
9
10/// Maximum directory depth for nested `AGENTS.md` discovery below the workspace root.
11pub const MAX_DISCOVERY_DEPTH: usize = 8;
12
13/// A diagnostic about a discovered instruction file.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct InstructionDiagnostic {
16    /// Absolute path to the file, when known.
17    pub path: Option<PathBuf>,
18    /// Human-readable explanation.
19    pub message: String,
20    /// Severity.
21    pub severity: InstructionSeverity,
22}
23
24/// Severity of an [`InstructionDiagnostic`].
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
26pub enum InstructionSeverity {
27    /// Informational note (e.g. a new file appeared since the previous turn).
28    Info,
29    /// Recoverable issue (e.g. unreadable nested file skipped).
30    Warning,
31    /// Blocks correct instruction loading.
32    Error,
33}
34
35impl InstructionDiagnostic {
36    /// A nested file could not be read.
37    pub fn unreadable(path: &Path, detail: &str) -> Self {
38        InstructionDiagnostic {
39            path: Some(path.to_path_buf()),
40            message: format!("failed to read AGENTS.md: {detail}"),
41            severity: InstructionSeverity::Warning,
42        }
43    }
44
45    /// A file exceeded the size cap and was truncated.
46    pub fn oversized(path: &Path, byte_count: usize) -> Self {
47        InstructionDiagnostic {
48            path: Some(path.to_path_buf()),
49            message: format!("AGENTS.md is {byte_count} bytes, truncated to {AGENTS_MD_SIZE_CAP}"),
50            severity: InstructionSeverity::Warning,
51        }
52    }
53
54    /// A file's content hash changed since the previous turn.
55    pub fn changed(path: &Path) -> Self {
56        InstructionDiagnostic {
57            path: Some(path.to_path_buf()),
58            message: "AGENTS.md changed since the previous turn".to_string(),
59            severity: InstructionSeverity::Warning,
60        }
61    }
62
63    /// A file present in the previous turn is now missing.
64    pub fn removed(path: &Path) -> Self {
65        InstructionDiagnostic {
66            path: Some(path.to_path_buf()),
67            message: "AGENTS.md removed since the previous turn".to_string(),
68            severity: InstructionSeverity::Warning,
69        }
70    }
71
72    /// A new file appeared since the previous turn.
73    pub fn added(path: &Path) -> Self {
74        InstructionDiagnostic {
75            path: Some(path.to_path_buf()),
76            message: "AGENTS.md added since the previous turn".to_string(),
77            severity: InstructionSeverity::Info,
78        }
79    }
80
81    /// Render a compact one-line summary.
82    pub fn summary(&self) -> String {
83        let location = self.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
84        format!("{}  {location}  {}", self.severity.label(), self.message)
85    }
86}
87
88impl InstructionSeverity {
89    pub fn label(self) -> &'static str {
90        match self {
91            InstructionSeverity::Info => "info",
92            InstructionSeverity::Warning => "warning",
93            InstructionSeverity::Error => "error",
94        }
95    }
96}
97
98/// Result of scoped instruction discovery.
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct InstructionInventory {
101    /// All discovered sources: root first, then nested ordered by scope depth
102    /// (shallowest first). Root content is loaded; nested content is loaded
103    /// too so the ledger can render it when selected.
104    pub sources: Vec<ContextSource>,
105    /// Diagnostics for unreadable or oversized files.
106    pub diagnostics: Vec<InstructionDiagnostic>,
107}
108
109/// Result of pure instruction selection for a turn.
110#[derive(Clone, Debug, Default, Eq, PartialEq)]
111pub struct InstructionSelection {
112    /// Sources applicable this turn, ordered closest-first.
113    ///
114    /// The closest applicable source wins for paths under its scope.
115    pub applicable: Vec<ContextSource>,
116    /// Sources discovered but not applicable, kept as metadata so the
117    /// ledger can show them as candidates/overridden.
118    pub overridden: Vec<ContextSource>,
119}
120
121/// A turn-boundary snapshot of instruction file hashes for change detection.
122#[derive(Clone, Debug, Default, Eq, PartialEq)]
123pub struct InstructionSnapshot {
124    /// `(path, content_hash)` pairs for every successfully loaded source.
125    pub entries: Vec<(PathBuf, u64)>,
126}
127
128impl InstructionSnapshot {
129    /// Build a snapshot from discovered sources.
130    pub fn from_sources(sources: &[ContextSource]) -> Self {
131        InstructionSnapshot { entries: sources.iter().map(|s| (s.path.clone(), s.content_hash)).collect() }
132    }
133
134    /// Diff against a previous snapshot, producing diagnostics for changed,
135    /// removed, and added files.
136    pub fn diff(&self, prev: &InstructionSnapshot) -> Vec<InstructionDiagnostic> {
137        let mut diagnostics = Vec::new();
138        let prev_map: std::collections::HashMap<&PathBuf, u64> = prev.entries.iter().map(|(p, h)| (p, *h)).collect();
139        let cur_map: std::collections::HashMap<&PathBuf, u64> = self.entries.iter().map(|(p, h)| (p, *h)).collect();
140
141        for (path, hash) in &self.entries {
142            match prev_map.get(path) {
143                Some(prev_hash) if prev_hash != hash => diagnostics.push(InstructionDiagnostic::changed(path)),
144                None => diagnostics.push(InstructionDiagnostic::added(path)),
145                _ => {}
146            }
147        }
148        for (path, _) in &prev.entries {
149            if !cur_map.contains_key(path) {
150                diagnostics.push(InstructionDiagnostic::removed(path));
151            }
152        }
153        diagnostics
154    }
155}
156
157/// Discover root and nested `AGENTS.md` files below `workspace_root`.
158///
159/// Root `AGENTS.md` is always loaded when present. Nested files are discovered
160/// below the root, skipping hidden, VCS, and build directories.
161///
162/// Each nested source is assigned a subtree scope (the relative directory path, or `"."` for root).
163///
164/// Unreadable nested files produce [`InstructionDiagnostic::unreadable`] warnings and are skipped.
165///
166/// Oversized files are truncated and produce [`InstructionDiagnostic::oversized`] warnings, matching root behavior.
167pub fn discover_instructions(workspace_root: &Path) -> InstructionInventory {
168    let mut sources = Vec::new();
169    let mut diagnostics = Vec::new();
170
171    if let Some(root_source) = super::load_agents_md(workspace_root) {
172        if root_source.truncated {
173            diagnostics.push(InstructionDiagnostic::oversized(
174                &root_source.path,
175                root_source.byte_count,
176            ));
177        }
178        sources.push(root_source);
179    }
180
181    discover_nested(workspace_root, workspace_root, 0, &mut sources, &mut diagnostics);
182
183    sources.sort_by(|a, b| {
184        let da = scope_depth(&a.scope);
185        let db = scope_depth(&b.scope);
186        da.cmp(&db).then_with(|| a.path.cmp(&b.path))
187    });
188
189    InstructionInventory { sources, diagnostics }
190}
191
192/// Recursively discover nested `AGENTS.md` files.
193fn discover_nested(
194    dir: &Path, workspace_root: &Path, depth: usize, sources: &mut Vec<ContextSource>,
195    diagnostics: &mut Vec<InstructionDiagnostic>,
196) {
197    if depth > MAX_DISCOVERY_DEPTH {
198        return;
199    }
200    let Ok(entries) = fs::read_dir(dir) else {
201        return;
202    };
203    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
204    entries.sort_by_key(|e| e.file_name());
205
206    for entry in entries {
207        let path = entry.path();
208        if !path.is_dir() {
209            continue;
210        }
211        let name = entry.file_name();
212        let name = name.to_string_lossy();
213        if should_skip_dir(&name) {
214            continue;
215        }
216        let agents_md = path.join("AGENTS.md");
217        if agents_md.is_file() {
218            match load_nested(&agents_md, workspace_root) {
219                Ok(source) => {
220                    if source.truncated {
221                        diagnostics.push(InstructionDiagnostic::oversized(&source.path, source.byte_count));
222                    }
223                    sources.push(source);
224                }
225                Err(detail) => diagnostics.push(InstructionDiagnostic::unreadable(&agents_md, &detail)),
226            }
227        }
228        discover_nested(&path, workspace_root, depth + 1, sources, diagnostics);
229    }
230}
231
232/// Load a nested `AGENTS.md`, assigning it a subtree scope relative to the workspace root.
233fn load_nested(path: &Path, workspace_root: &Path) -> Result<ContextSource, String> {
234    let metadata = fs::metadata(path).map_err(|e| e.to_string())?;
235    let byte_count = metadata.len() as usize;
236    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
237    let content_hash = hash_content(&content);
238
239    let scope = path
240        .parent()
241        .and_then(|p| p.strip_prefix(workspace_root).ok())
242        .map(|rel| rel.display().to_string())
243        .unwrap_or_else(|| ".".to_string());
244
245    let (content, truncated) = if byte_count > AGENTS_MD_SIZE_CAP {
246        let mut capped = content.into_bytes();
247        capped.truncate(AGENTS_MD_SIZE_CAP);
248        (
249            super::trim_to_char_boundary(&String::from_utf8_lossy(&capped), AGENTS_MD_SIZE_CAP),
250            true,
251        )
252    } else {
253        (content, false)
254    };
255
256    Ok(ContextSource { path: path.to_path_buf(), scope, content, content_hash, truncated, byte_count })
257}
258
259/// Select applicable instruction sources for a turn.
260pub fn select_instructions(
261    sources: &[ContextSource], mentioned_paths: &[PathBuf], pinned_paths: &[PathBuf],
262) -> InstructionSelection {
263    let mut applicable: Vec<ContextSource> = Vec::new();
264    let mut overridden: Vec<ContextSource> = Vec::new();
265
266    let references: Vec<&PathBuf> = mentioned_paths.iter().chain(pinned_paths.iter()).collect();
267
268    for source in sources {
269        let is_root = source.scope == ".";
270        let applies = is_root || references.iter().any(|p| path_under_scope(p, &source.scope));
271
272        if applies {
273            applicable.push(source.clone());
274        } else {
275            overridden.push(source.clone());
276        }
277    }
278
279    applicable.sort_by_key(|s| std::cmp::Reverse(scope_depth(&s.scope)));
280
281    InstructionSelection { applicable, overridden }
282}
283
284/// Whether `path` falls under the subtree `scope` (a relative directory path
285/// from the workspace root, or `"."` for root).
286fn path_under_scope(path: &Path, scope: &str) -> bool {
287    if scope == "." {
288        return true;
289    }
290
291    let scope_path = Path::new(scope);
292    let components: Vec<_> = path.components().collect();
293    let scope_components: Vec<_> = scope_path.components().collect();
294    if components.len() < scope_components.len() {
295        return false;
296    }
297    components[..scope_components.len()] == scope_components[..]
298}
299
300/// Whether a directory entry should be skipped during nested discovery.
301///
302/// Skips hidden directories (leading `.`), VCS metadata, and common build
303/// output directories so discovery stays fast and avoids noise.
304///
305/// TODO: use ignore crate?
306fn should_skip_dir(name: &str) -> bool {
307    name.starts_with('.') || matches!(name, "node_modules" | "target" | "dist" | "build" | "out")
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::io::Write;
314
315    fn temp_dir() -> tempfile::TempDir {
316        tempfile::tempdir().expect("create temp dir")
317    }
318
319    fn write_file(root: &Path, rel: &str, content: &str) {
320        let path = root.join(rel);
321        fs::create_dir_all(path.parent().unwrap()).expect("create parent");
322        let mut f = fs::File::create(&path).expect("create file");
323        f.write_all(content.as_bytes()).expect("write file");
324    }
325
326    #[test]
327    fn discover_loads_root_agents_md() {
328        let dir = temp_dir();
329        write_file(dir.path(), "AGENTS.md", "# Root\n");
330
331        let inventory = discover_instructions(dir.path());
332        assert_eq!(inventory.sources.len(), 1);
333        assert_eq!(inventory.sources[0].scope, ".");
334        assert!(inventory.sources[0].content.contains("# Root"));
335        assert!(inventory.diagnostics.is_empty());
336    }
337
338    #[test]
339    fn discover_no_root_returns_empty() {
340        let dir = temp_dir();
341        let inventory = discover_instructions(dir.path());
342        assert!(inventory.sources.is_empty());
343        assert!(inventory.diagnostics.is_empty());
344    }
345
346    #[test]
347    fn discover_finds_nested_agents_md_with_subtree_scope() {
348        let dir = temp_dir();
349        write_file(dir.path(), "AGENTS.md", "# Root\n");
350        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
351        write_file(dir.path(), "src/core/AGENTS.md", "# Core\n");
352
353        let inventory = discover_instructions(dir.path());
354        let scopes: Vec<&str> = inventory.sources.iter().map(|s| s.scope.as_str()).collect();
355        assert!(scopes.contains(&"."));
356        assert!(scopes.contains(&"src"));
357        assert!(scopes.contains(&"src/core"));
358        assert_eq!(inventory.sources[0].scope, ".");
359    }
360
361    #[test]
362    fn discover_skips_hidden_and_build_dirs() {
363        let dir = temp_dir();
364        write_file(dir.path(), "AGENTS.md", "# Root\n");
365        write_file(dir.path(), ".hidden/AGENTS.md", "# Hidden\n");
366        write_file(dir.path(), "target/AGENTS.md", "# Build\n");
367        write_file(dir.path(), "node_modules/AGENTS.md", "# Deps\n");
368
369        let inventory = discover_instructions(dir.path());
370        let scopes: Vec<&str> = inventory.sources.iter().map(|s| s.scope.as_str()).collect();
371        assert_eq!(scopes, vec!["."]);
372    }
373
374    #[test]
375    fn discover_diagnoses_oversized_root() {
376        let dir = temp_dir();
377        let big = "x".repeat(AGENTS_MD_SIZE_CAP + 10);
378        write_file(dir.path(), "AGENTS.md", &big);
379
380        let inventory = discover_instructions(dir.path());
381        assert_eq!(inventory.diagnostics.len(), 1);
382        assert!(inventory.diagnostics[0].message.contains("truncated"));
383        assert!(inventory.sources[0].truncated);
384    }
385
386    #[test]
387    fn discover_diagnoses_oversized_nested() {
388        let dir = temp_dir();
389        write_file(dir.path(), "AGENTS.md", "# Root\n");
390        let big = "x".repeat(AGENTS_MD_SIZE_CAP + 10);
391        write_file(dir.path(), "src/AGENTS.md", &big);
392
393        let inventory = discover_instructions(dir.path());
394        let oversized = inventory
395            .diagnostics
396            .iter()
397            .filter(|d| d.message.contains("truncated"))
398            .count();
399        assert_eq!(oversized, 1);
400        let nested = inventory.sources.iter().find(|s| s.scope == "src").expect("nested src");
401        assert!(nested.truncated);
402    }
403
404    #[test]
405    fn discover_diagnoses_unreadable_nested() {
406        let dir = temp_dir();
407        write_file(dir.path(), "AGENTS.md", "# Root\n");
408
409        let path = dir.path().join("src").join("AGENTS.md");
410        fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
411        let mut f = fs::File::create(&path).expect("create file");
412        f.write_all(&[0xFF, 0xFE, 0x00]).expect("write invalid utf8");
413
414        let inventory = discover_instructions(dir.path());
415        let unreadable = inventory
416            .diagnostics
417            .iter()
418            .filter(|d| d.message.contains("failed to read"))
419            .count();
420        assert_eq!(unreadable, 1);
421    }
422
423    #[test]
424    fn select_root_always_applicable() {
425        let dir = temp_dir();
426        write_file(dir.path(), "AGENTS.md", "# Root\n");
427        let inventory = discover_instructions(dir.path());
428
429        let selection = select_instructions(&inventory.sources, &[], &[]);
430        assert_eq!(selection.applicable.len(), 1);
431        assert_eq!(selection.applicable[0].scope, ".");
432        assert!(selection.overridden.is_empty());
433    }
434
435    #[test]
436    fn select_nested_applicable_when_mentioned_path_under_scope() {
437        let dir = temp_dir();
438        write_file(dir.path(), "AGENTS.md", "# Root\n");
439        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
440        let inventory = discover_instructions(dir.path());
441
442        let mentioned = vec![PathBuf::from("src/main.rs")];
443        let selection = select_instructions(&inventory.sources, &mentioned, &[]);
444        let applicable_scopes: Vec<&str> = selection.applicable.iter().map(|s| s.scope.as_str()).collect();
445        assert!(applicable_scopes.contains(&"."));
446        assert!(applicable_scopes.contains(&"src"));
447    }
448
449    #[test]
450    fn select_nested_not_applicable_when_path_outside_scope() {
451        let dir = temp_dir();
452        write_file(dir.path(), "AGENTS.md", "# Root\n");
453        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
454        let inventory = discover_instructions(dir.path());
455
456        let mentioned = vec![PathBuf::from("docs/readme.md")];
457        let selection = select_instructions(&inventory.sources, &mentioned, &[]);
458        assert!(selection.applicable.iter().any(|s| s.scope == "."));
459        assert!(!selection.applicable.iter().any(|s| s.scope == "src"));
460        assert!(selection.overridden.iter().any(|s| s.scope == "src"));
461    }
462
463    #[test]
464    fn select_closest_wins_first_in_applicable() {
465        let dir = temp_dir();
466        write_file(dir.path(), "AGENTS.md", "# Root\n");
467        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
468        write_file(dir.path(), "src/core/AGENTS.md", "# Core\n");
469        let inventory = discover_instructions(dir.path());
470
471        let mentioned = vec![PathBuf::from("src/core/context.rs")];
472        let selection = select_instructions(&inventory.sources, &mentioned, &[]);
473        assert_eq!(selection.applicable[0].scope, "src/core");
474        assert_eq!(selection.applicable[1].scope, "src");
475        assert_eq!(selection.applicable[2].scope, ".");
476    }
477
478    #[test]
479    fn select_pinned_path_triggers_nested_applicability() {
480        let dir = temp_dir();
481        write_file(dir.path(), "AGENTS.md", "# Root\n");
482        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
483        let inventory = discover_instructions(dir.path());
484
485        let pinned = vec![PathBuf::from("src/lib.rs")];
486        let selection = select_instructions(&inventory.sources, &[], &pinned);
487        assert!(selection.applicable.iter().any(|s| s.scope == "src"));
488    }
489
490    #[test]
491    fn select_preserves_root_only_behavior_when_no_nested() {
492        let dir = temp_dir();
493        write_file(dir.path(), "AGENTS.md", "# Root\n");
494        let inventory = discover_instructions(dir.path());
495        let selection = select_instructions(&inventory.sources, &[PathBuf::from("anything.rs")], &[]);
496        assert_eq!(selection.applicable.len(), 1);
497        assert_eq!(selection.overridden.len(), 0);
498    }
499
500    #[test]
501    fn select_scope_does_not_match_prefix_only_directory() {
502        let dir = temp_dir();
503        write_file(dir.path(), "AGENTS.md", "# Root\n");
504        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
505        write_file(dir.path(), "src-other/AGENTS.md", "# SrcOther\n");
506
507        let inventory = discover_instructions(dir.path());
508        let mentioned = vec![PathBuf::from("src/main.rs")];
509        let selection = select_instructions(&inventory.sources, &mentioned, &[]);
510        let applicable_scopes: Vec<&str> = selection.applicable.iter().map(|s| s.scope.as_str()).collect();
511        assert!(applicable_scopes.contains(&"src"));
512        assert!(!applicable_scopes.contains(&"src-other"));
513    }
514
515    #[test]
516    fn snapshot_diff_detects_changed_hash() {
517        let dir = temp_dir();
518        write_file(dir.path(), "AGENTS.md", "# v1\n");
519        let inv1 = discover_instructions(dir.path());
520        let snap1 = InstructionSnapshot::from_sources(&inv1.sources);
521
522        write_file(dir.path(), "AGENTS.md", "# v2\n");
523        let inv2 = discover_instructions(dir.path());
524        let snap2 = InstructionSnapshot::from_sources(&inv2.sources);
525
526        let diagnostics = snap2.diff(&snap1);
527        assert_eq!(diagnostics.len(), 1);
528        assert!(diagnostics[0].message.contains("changed"));
529    }
530
531    #[test]
532    fn snapshot_diff_detects_removed_file() {
533        let dir = temp_dir();
534        write_file(dir.path(), "AGENTS.md", "# Root\n");
535        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
536        let inv1 = discover_instructions(dir.path());
537        let snap1 = InstructionSnapshot::from_sources(&inv1.sources);
538
539        fs::remove_file(dir.path().join("src/AGENTS.md")).expect("remove");
540        let inv2 = discover_instructions(dir.path());
541        let snap2 = InstructionSnapshot::from_sources(&inv2.sources);
542
543        let diagnostics = snap2.diff(&snap1);
544        let removed = diagnostics.iter().filter(|d| d.message.contains("removed")).count();
545        assert_eq!(removed, 1);
546    }
547
548    #[test]
549    fn snapshot_diff_detects_added_file() {
550        let dir = temp_dir();
551        write_file(dir.path(), "AGENTS.md", "# Root\n");
552        let inv1 = discover_instructions(dir.path());
553        let snap1 = InstructionSnapshot::from_sources(&inv1.sources);
554
555        write_file(dir.path(), "src/AGENTS.md", "# Src\n");
556        let inv2 = discover_instructions(dir.path());
557        let snap2 = InstructionSnapshot::from_sources(&inv2.sources);
558
559        let diagnostics = snap2.diff(&snap1);
560        let added = diagnostics.iter().filter(|d| d.message.contains("added")).count();
561        assert_eq!(added, 1);
562    }
563
564    #[test]
565    fn snapshot_diff_empty_when_unchanged() {
566        let dir = temp_dir();
567        write_file(dir.path(), "AGENTS.md", "# Root\n");
568        let inv = discover_instructions(dir.path());
569        let snap = InstructionSnapshot::from_sources(&inv.sources);
570        assert!(snap.diff(&snap).is_empty());
571    }
572
573    #[test]
574    fn path_under_scope_matches_subdirectory() {
575        assert!(path_under_scope(Path::new("src/main.rs"), "src"));
576        assert!(path_under_scope(Path::new("src/core/context.rs"), "src"));
577        assert!(!path_under_scope(Path::new("docs/readme.md"), "src"));
578        assert!(path_under_scope(Path::new("anything"), "."));
579    }
580
581    #[test]
582    fn diagnostic_summary_includes_path_and_message() {
583        let d = InstructionDiagnostic::changed(Path::new("/repo/AGENTS.md"));
584        let s = d.summary();
585        assert!(s.contains("/repo/AGENTS.md"));
586        assert!(s.contains("changed"));
587    }
588}