use std::collections::HashSet;
use std::path::{Path, PathBuf};
const FILENAMES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
pub(crate) const DEFAULT_MAX_BYTES: usize = 32 * 1024;
#[derive(Clone, Debug)]
pub struct InstructionSources {
pub global: Vec<PathBuf>,
pub max_bytes: usize,
}
impl Default for InstructionSources {
fn default() -> Self {
Self { global: Vec::new(), max_bytes: DEFAULT_MAX_BYTES }
}
}
impl InstructionSources {
pub fn discover_global() -> Self {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Self::default();
};
Self {
global: vec![
home.join(".config/AGENTS.md"),
home.join(".codex/AGENTS.md"),
home.join(".claude/CLAUDE.md"),
],
..Self::default()
}
}
}
pub(crate) fn gather(cwd: &Path, sources: &InstructionSources) -> Option<String> {
let mut sections = Vec::new();
let mut remaining = sources.max_bytes;
for path in resolve(cwd, sources).into_iter().rev() {
if remaining == 0 {
break;
}
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let trimmed = content.trim();
if trimmed.is_empty() {
continue;
}
sections.push(take_within(trimmed, &mut remaining));
}
if sections.is_empty() {
return None;
}
sections.reverse(); Some(sections.join("\n\n"))
}
fn take_within(text: &str, remaining: &mut usize) -> String {
if text.len() <= *remaining {
*remaining -= text.len();
return text.to_owned();
}
let mut end = *remaining;
while !text.is_char_boundary(end) {
end -= 1;
}
*remaining = 0;
text[..end].to_owned()
}
fn resolve(cwd: &Path, sources: &InstructionSources) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut seen = HashSet::new();
if let Some(global) = sources.global.iter().find(|path| path.is_file()) {
files.push(global.clone());
seen.insert(global.clone());
}
for dir in project_dirs(cwd) {
if let Some(found) = first_in(&dir) {
if seen.insert(found.clone()) {
files.push(found);
}
}
}
files
}
fn first_in(dir: &Path) -> Option<PathBuf> {
FILENAMES.iter().map(|name| dir.join(name)).find(|path| path.is_file())
}
fn project_dirs(cwd: &Path) -> Vec<PathBuf> {
let mut chain = Vec::new();
let mut cur = cwd;
loop {
chain.push(cur.to_path_buf());
if cur.join(".git").exists() {
chain.reverse(); return chain;
}
match cur.parent() {
Some(parent) => cur = parent,
None => return vec![cwd.to_path_buf()], }
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn what_is_taken_is_a_prefix_of_what_was_offered(
text in "\\PC{0,64}",
budget in 0usize..192,
) {
let mut remaining = budget;
let taken = take_within(&text, &mut remaining);
prop_assert!(text.starts_with(&taken), "{taken:?} is not a prefix of {text:?}");
prop_assert!(taken.len() <= budget, "over budget");
prop_assert!(
budget - remaining >= taken.len(),
"charged less than it took",
);
}
#[test]
fn text_that_fits_is_taken_whole(text in "\\PC{0,64}", slack in 0usize..32) {
let budget = text.len() + slack;
let mut remaining = budget;
prop_assert_eq!(take_within(&text, &mut remaining), text.clone());
prop_assert_eq!(remaining, slack, "charged for more than the text");
}
#[test]
fn trimming_gives_up_no_more_than_one_character(
text in "\\PC{1,64}",
budget in 0usize..192,
) {
prop_assume!(text.len() > budget);
let mut remaining = budget;
let taken = take_within(&text, &mut remaining);
let widest = text.chars().map(char::len_utf8).max().unwrap_or(1);
prop_assert!(
taken.len() + widest > budget,
"took {} of a {budget} byte budget",
taken.len(),
);
}
}
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("hl-instr-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn project_only() -> InstructionSources {
InstructionSources::default()
}
#[test]
fn gathers_project_files_root_to_cwd() {
let root = scratch("proj");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "root rules").unwrap();
let sub = root.join("crate-a");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("CLAUDE.md"), "crate rules").unwrap();
let text = gather(&sub, &project_only()).expect("found instructions");
assert!(text.contains("root rules") && text.contains("crate rules"));
assert!(text.find("root rules") < text.find("crate rules"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_directory_contributes_one_file_not_both() {
let root = scratch("both");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "the standard").unwrap();
std::fs::write(root.join("CLAUDE.md"), "the fallback").unwrap();
let text = gather(&root, &project_only()).expect("found instructions");
assert!(text.contains("the standard"), "AGENTS.md is preferred: {text}");
assert!(!text.contains("the fallback"), "CLAUDE.md must not stack: {text}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn discover_global_names_the_conventional_files() {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return; };
let sources = InstructionSources::discover_global();
assert_eq!(
sources.global,
vec![
home.join(".config/AGENTS.md"),
home.join(".codex/AGENTS.md"),
home.join(".claude/CLAUDE.md"),
],
"order is precedence: our own convention first, then the agents that ship their own"
);
assert_eq!(sources.max_bytes, DEFAULT_MAX_BYTES, "opting in must not change the budget");
}
#[test]
fn the_default_budget_is_32_kib() {
assert_eq!(DEFAULT_MAX_BYTES, 32 * 1024);
assert_eq!(InstructionSources::default().max_bytes, DEFAULT_MAX_BYTES);
}
#[test]
fn a_file_exactly_on_budget_is_kept_whole() {
let root = scratch("exact");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "x".repeat(64)).unwrap();
let sources = InstructionSources { global: Vec::new(), max_bytes: 64 };
let text = gather(&root, &sources).expect("instructions");
assert_eq!(text.len(), 64, "a file the size of the budget is not truncated");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn nothing_outside_the_working_tree_is_read_by_default() {
let home = scratch("home");
std::fs::write(home.join("global.md"), "global rules").unwrap();
let root = scratch("no-global");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "project rules").unwrap();
let text = gather(&root, &InstructionSources::default()).expect("instructions");
assert!(!text.contains("global rules"), "default must not reach outside: {text}");
let opted_in = InstructionSources {
global: vec![home.join("global.md")],
..Default::default()
};
let text = gather(&root, &opted_in).expect("instructions");
assert!(text.contains("global rules"), "host opt-in must be honoured: {text}");
assert!(text.find("global rules") < text.find("project rules"), "global is least specific");
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_first_global_candidate_that_exists_wins() {
let home = scratch("globals");
std::fs::write(home.join("second.md"), "second choice").unwrap();
std::fs::write(home.join("third.md"), "third choice").unwrap();
let root = scratch("g-proj");
std::fs::create_dir_all(root.join(".git")).unwrap();
let sources = InstructionSources {
global: vec![home.join("first.md"), home.join("second.md"), home.join("third.md")],
..Default::default()
};
let text = gather(&root, &sources).expect("instructions");
assert!(text.contains("second choice"), "first existing candidate: {text}");
assert!(!text.contains("third choice"), "later candidates are ignored: {text}");
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_budget_truncates_and_spends_it_on_the_nearest_file() {
let home = scratch("budget-home");
std::fs::write(home.join("global.md"), "G".repeat(500)).unwrap();
let root = scratch("budget-proj");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "P".repeat(100)).unwrap();
let sources = InstructionSources { global: vec![home.join("global.md")], max_bytes: 300 };
let text = gather(&root, &sources).expect("instructions");
assert_eq!(text.matches('P').count(), 100, "the nearest file is kept whole: {}", text.len());
assert_eq!(text.matches('G').count(), 200, "the global file takes only what is left");
assert!(text.len() <= 300 + 2, "budget honoured, plus the joining separator");
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn truncation_never_splits_a_character() {
let root = scratch("utf8");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("AGENTS.md"), "é".repeat(10)).unwrap();
let sources = InstructionSources { global: Vec::new(), max_bytes: 5 };
let text = gather(&root, &sources).expect("instructions");
assert_eq!(text, "éé", "cut back to the boundary rather than panicking");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn none_when_absent() {
let dir = scratch("empty");
std::fs::create_dir_all(dir.join(".git")).unwrap();
assert!(gather(&dir, &project_only()).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}