1use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::{AGENTS_MD_SIZE_CAP, ContextSource};
8use super::{hash_content, scope_depth};
9
10pub const MAX_DISCOVERY_DEPTH: usize = 8;
12
13#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct InstructionDiagnostic {
16 pub path: Option<PathBuf>,
18 pub message: String,
20 pub severity: InstructionSeverity,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
26pub enum InstructionSeverity {
27 Info,
29 Warning,
31 Error,
33}
34
35impl InstructionDiagnostic {
36 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 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 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 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 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 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct InstructionInventory {
101 pub sources: Vec<ContextSource>,
105 pub diagnostics: Vec<InstructionDiagnostic>,
107}
108
109#[derive(Clone, Debug, Default, Eq, PartialEq)]
111pub struct InstructionSelection {
112 pub applicable: Vec<ContextSource>,
116 pub overridden: Vec<ContextSource>,
119}
120
121#[derive(Clone, Debug, Default, Eq, PartialEq)]
123pub struct InstructionSnapshot {
124 pub entries: Vec<(PathBuf, u64)>,
126}
127
128impl InstructionSnapshot {
129 pub fn from_sources(sources: &[ContextSource]) -> Self {
131 InstructionSnapshot { entries: sources.iter().map(|s| (s.path.clone(), s.content_hash)).collect() }
132 }
133
134 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
157pub 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
192fn 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
232fn 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
259pub 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
284fn 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
300fn 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}