use std::{
collections::HashSet,
path::{Path, PathBuf},
};
const AGENTS_FILE_NAME: &str = "AGENTS.md";
const MAX_SUBDIR_AGENTS_BYTES: u64 = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DiscoveredInstruction {
pub path: PathBuf,
pub content: String,
pub bytes: usize,
}
pub(crate) fn discover_subdir_instructions(
touched_path: &Path,
root_canonical: &Path,
already_loaded: &HashSet<PathBuf>,
) -> (Vec<DiscoveredInstruction>, Vec<String>) {
discover_subdir_instructions_with_diagnostics(touched_path, root_canonical, already_loaded)
}
fn discover_subdir_instructions_with_diagnostics(
touched_path: &Path,
root_canonical: &Path,
already_loaded: &HashSet<PathBuf>,
) -> (Vec<DiscoveredInstruction>, Vec<String>) {
let mut diagnostics = Vec::new();
let root = match root_canonical.canonicalize() {
Ok(root) => root,
Err(error) => {
diagnostics.push(format!("failed to canonicalize root: {error}"));
return (Vec::new(), diagnostics);
}
};
let touched = match touched_path.canonicalize() {
Ok(touched) => touched,
Err(error) => {
diagnostics.push(format!("failed to canonicalize touched path: {error}"));
return (Vec::new(), diagnostics);
}
};
if !touched.starts_with(&root) {
return (Vec::new(), diagnostics);
}
let mut cursor = if touched.is_dir() {
touched
} else {
touched.parent().unwrap_or(&touched).to_path_buf()
};
let mut discovered = Vec::new();
let mut seen = HashSet::new();
while cursor.starts_with(&root) && cursor != root {
let candidate = cursor.join(AGENTS_FILE_NAME);
if candidate.exists() {
match candidate.canonicalize() {
Ok(canonical) => {
if canonical.starts_with(&root)
&& !already_loaded.contains(&canonical)
&& seen.insert(canonical.clone())
{
match load_candidate(&canonical) {
Ok(instruction) => discovered.push(instruction),
Err(message) => {
diagnostics.push(format!("{}: {message}", canonical.display()))
}
}
}
}
Err(error) => diagnostics.push(format!(
"{}: failed to canonicalize candidate: {error}",
candidate.display()
)),
}
}
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent.to_path_buf();
}
(discovered, diagnostics)
}
fn load_candidate(path: &Path) -> Result<DiscoveredInstruction, String> {
let file = crate::prompt_file::read_prompt_file(path, MAX_SUBDIR_AGENTS_BYTES, false)
.map_err(|error| error.to_string())?;
Ok(DiscoveredInstruction {
path: path.to_path_buf(),
content: file.text,
bytes: file.bytes,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn touch_file(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, "content").unwrap();
}
fn discover(
touched_path: &Path,
root: &Path,
already_loaded: &HashSet<PathBuf>,
) -> Vec<DiscoveredInstruction> {
discover_subdir_instructions(touched_path, &root.canonicalize().unwrap(), already_loaded).0
}
#[test]
fn discovers_nearest_first() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("a/b/c")).unwrap();
fs::write(root.join("a/AGENTS.md"), "a").unwrap();
fs::write(root.join("a/b/AGENTS.md"), "b").unwrap();
let file = root.join("a/b/c/file.rs");
touch_file(&file);
let discovered = discover(&file, root, &HashSet::new());
assert_eq!(
discovered
.iter()
.map(|instruction| instruction.content.as_str())
.collect::<Vec<_>>(),
vec!["b", "a"]
);
}
#[test]
fn excludes_root_agents_md() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("a")).unwrap();
fs::write(root.join("AGENTS.md"), "root").unwrap();
let file = root.join("a/file.rs");
touch_file(&file);
let discovered = discover(&file, root, &HashSet::new());
assert!(discovered.is_empty());
}
#[test]
fn stops_before_above_root() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("root");
fs::create_dir_all(root.join("a")).unwrap();
fs::write(temp.path().join("AGENTS.md"), "above").unwrap();
let file = root.join("a/file.rs");
touch_file(&file);
let discovered = discover(&file, &root, &HashSet::new());
assert!(discovered.is_empty());
}
#[test]
fn outside_root_absolute_path_is_noop() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("AGENTS.md"), "outside").unwrap();
let file = outside.join("file.rs");
touch_file(&file);
let discovered = discover(&file, &root, &HashSet::new());
assert!(discovered.is_empty());
}
#[test]
fn skips_already_loaded_and_duplicate_spelling() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("a/b")).unwrap();
let agents = root.join("a/AGENTS.md");
fs::write(&agents, "a").unwrap();
let file = root.join("a/b/../b/file.rs");
touch_file(&root.join("a/b/file.rs"));
let mut already_loaded = HashSet::new();
already_loaded.insert(agents.canonicalize().unwrap());
let discovered = discover(&file, root, &already_loaded);
assert!(discovered.is_empty());
}
#[test]
#[cfg(unix)]
fn symlink_in_root_agents_md_is_loaded() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("a/b")).unwrap();
let target = root.join("shared.md");
fs::write(&target, "linked").unwrap();
symlink(&target, root.join("a/AGENTS.md")).unwrap();
let file = root.join("a/b/file.rs");
touch_file(&file);
let discovered = discover(&file, root, &HashSet::new());
assert_eq!(discovered.len(), 1);
assert_eq!(discovered[0].path, target.canonicalize().unwrap());
assert_eq!(discovered[0].content, "linked");
}
#[test]
#[cfg(unix)]
fn symlink_escape_agents_md_is_skipped() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside.md");
fs::create_dir_all(root.join("a/b")).unwrap();
fs::write(&outside, "escape").unwrap();
symlink(&outside, root.join("a/AGENTS.md")).unwrap();
let file = root.join("a/b/file.rs");
touch_file(&file);
let discovered = discover(&file, &root, &HashSet::new());
assert!(discovered.is_empty());
}
#[test]
fn invalid_utf8_and_oversized_files_produce_sanitized_diagnostics() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("invalid/deep")).unwrap();
fs::write(root.join("invalid/AGENTS.md"), [0xff, 0xfe]).unwrap();
let invalid_file = root.join("invalid/deep/file.rs");
touch_file(&invalid_file);
let (_loaded, diagnostics) = discover_subdir_instructions_with_diagnostics(
&invalid_file,
&root.canonicalize().unwrap(),
&HashSet::new(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0].contains("not valid UTF-8"),
"{diagnostics:?}"
);
assert!(!diagnostics[0].contains("0xff"), "{diagnostics:?}");
fs::create_dir_all(root.join("large/deep")).unwrap();
fs::write(
root.join("large/AGENTS.md"),
vec![b'x'; MAX_SUBDIR_AGENTS_BYTES as usize + 1],
)
.unwrap();
let large_file = root.join("large/deep/file.rs");
touch_file(&large_file);
let (_loaded, diagnostics) = discover_subdir_instructions_with_diagnostics(
&large_file,
&root.canonicalize().unwrap(),
&HashSet::new(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].contains("limit is"), "{diagnostics:?}");
assert!(!diagnostics[0].contains("xxxx"), "{diagnostics:?}");
}
}