use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const BUDGET: usize = 32 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instruction {
pub path: String,
pub sha256: String,
pub bytes: usize,
pub cut: bool,
pub text: String,
}
fn chain(leaf: &Path) -> Vec<PathBuf> {
let mut out = vec![PathBuf::new()];
let mut at = PathBuf::new();
for c in leaf.components() {
if let std::path::Component::Normal(part) = c {
at.push(part);
out.push(at.clone());
}
}
out
}
#[must_use]
pub fn collect(
repo: &Path,
project: &Path,
named: &[String],
names: &[String],
) -> Vec<Instruction> {
if names.is_empty() {
return Vec::new();
}
let Ok(repo) = std::fs::canonicalize(repo) else {
return Vec::new();
};
let project = std::fs::canonicalize(project).unwrap_or_else(|_| repo.clone());
let rel = project
.strip_prefix(&repo)
.map(Path::to_path_buf)
.unwrap_or_default();
let leaves: Vec<PathBuf> = if named.is_empty() {
vec![rel.clone()]
} else {
named
.iter()
.map(|p| {
rel.join(p)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
})
.collect()
};
let mut dirs: BTreeSet<(usize, PathBuf)> = BTreeSet::new();
for leaf in &leaves {
for d in chain(leaf) {
dirs.insert((d.components().count(), d));
}
}
let mut found: Vec<(usize, String, String)> = Vec::new();
for (depth, dir) in &dirs {
for name in names {
let file = repo.join(dir).join(name);
let Ok(real) = std::fs::canonicalize(&file) else {
continue;
};
if !real.starts_with(&repo) || !real.is_file() {
continue;
}
let Ok(text) = std::fs::read_to_string(&real) else {
continue; };
if text.trim().is_empty() {
continue;
}
let path = dir.join(name).to_string_lossy().replace('\\', "/");
found.push((*depth, path, text));
break;
}
}
fit(found)
}
fn fit(mut found: Vec<(usize, String, String)>) -> Vec<Instruction> {
found.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let mut left = BUDGET;
let mut out: Vec<Instruction> = Vec::with_capacity(found.len());
for (_, path, text) in found.into_iter().rev() {
let sha256 = crate::integrity::sha256_hex(text.as_bytes());
let bytes = text.len();
let (text, cut) = if bytes <= left {
left -= bytes;
(text, false)
} else {
let mut end = left;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
left = 0;
(
format!(
"{}\n\n[… cut: the repository's instructions are limited to {} KiB, and nearer files come first]\n",
&text[..end],
BUDGET / 1024
),
true,
)
};
out.push(Instruction {
path,
sha256,
bytes,
cut,
text,
});
}
out.reverse();
out
}
#[must_use]
pub fn section(instructions: &[Instruction]) -> Option<String> {
if instructions.is_empty() {
return None;
}
let mut out = String::from(
"# Repository instructions\n\nThe repository's own conventions for agents (AGENTS.md), root first, the nearest last — a nearer file overrides a farther one. Follow them; where they conflict with the task, the spec, the checks or the rules above, those win.\n",
);
for i in instructions {
out.push_str(&format!("\n## {}\n\n{}\n", i.path, i.text.trim_end()));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (p, text) in files {
let f = dir.path().join(p);
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(f, text).unwrap();
}
dir
}
fn paths(v: &[Instruction]) -> Vec<&str> {
v.iter().map(|i| i.path.as_str()).collect()
}
const AGENTS: &[&str] = &[];
#[test]
fn the_nearest_file_comes_last_and_others_are_not_read() {
let t = tree(&[
("AGENTS.md", "root"),
("services/api/AGENTS.md", "api"),
("web/AGENTS.md", "web"),
]);
let names = vec!["AGENTS.md".to_owned()];
let got = collect(
t.path(),
t.path(),
&["services/api/src/handler.rs".to_owned()],
&names,
);
assert_eq!(paths(&got), ["AGENTS.md", "services/api/AGENTS.md"]);
let none = collect(t.path(), t.path(), &[], &names);
assert_eq!(paths(&none), ["AGENTS.md"]);
let both = collect(
t.path(),
t.path(),
&["web/x.ts".to_owned(), "services/api/y.rs".to_owned()],
&names,
);
assert_eq!(
paths(&both),
["AGENTS.md", "web/AGENTS.md", "services/api/AGENTS.md"],
"by depth"
);
assert!(
collect(
t.path(),
t.path(),
&[],
&AGENTS.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>()
)
.is_empty()
);
}
#[test]
fn fallback_names_are_read_one_per_directory() {
let t = tree(&[
("CLAUDE.md", "claude"),
("pkg/AGENTS.md", "a"),
("pkg/CLAUDE.md", "c"),
]);
let names = vec!["AGENTS.md".to_owned(), "CLAUDE.md".to_owned()];
let got = collect(t.path(), t.path(), &["pkg/lib.rs".to_owned()], &names);
assert_eq!(paths(&got), ["CLAUDE.md", "pkg/AGENTS.md"]);
}
#[test]
fn a_project_in_a_subdirectory_gets_the_root_and_its_own() {
let t = tree(&[
("AGENTS.md", "root"),
("app/AGENTS.md", "app"),
("app/specs/x", ""),
]);
let got = collect(
t.path(),
&t.path().join("app"),
&[],
&["AGENTS.md".to_owned()],
);
assert_eq!(paths(&got), ["AGENTS.md", "app/AGENTS.md"]);
}
#[test]
fn the_budget_keeps_the_nearest_and_says_what_was_cut() {
let big = "x".repeat(BUDGET);
let t = tree(&[("AGENTS.md", &big), ("a/AGENTS.md", "near")]);
let got = collect(
t.path(),
t.path(),
&["a/f.rs".to_owned()],
&["AGENTS.md".to_owned()],
);
assert_eq!(paths(&got), ["AGENTS.md", "a/AGENTS.md"]);
assert!(!got[1].cut && got[1].text == "near");
assert!(got[0].cut && got[0].text.contains("cut:"));
assert_eq!(got[0].bytes, BUDGET);
let total: usize = got.iter().map(|i| i.text.len()).sum();
assert!(total <= BUDGET + 200, "{total}");
}
#[test]
fn symlinks_out_of_the_repository_and_binary_files_are_not_read() {
let outside = tree(&[("secret.md", "outside")]);
let t = tree(&[("pkg/AGENTS.md", "")]);
std::os::unix::fs::symlink(outside.path().join("secret.md"), t.path().join("AGENTS.md"))
.unwrap();
std::fs::write(t.path().join("pkg/AGENTS.md"), [0xff, 0xfe, 0x00]).unwrap();
assert!(
collect(
t.path(),
t.path(),
&["pkg/x".to_owned()],
&["AGENTS.md".to_owned()]
)
.is_empty()
);
}
#[test]
fn the_section_ranks_the_instructions_below_the_task() {
let i = Instruction {
path: "AGENTS.md".to_owned(),
sha256: String::new(),
bytes: 3,
cut: false,
text: "use nextest".to_owned(),
};
let s = section(&[i]).unwrap();
assert!(s.contains("## AGENTS.md\n\nuse nextest") && s.contains("those win"));
assert!(section(&[]).is_none());
}
}