use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[cfg(test)]
use locode_host::resolve_home_from;
use locode_host::{find_root_from_markers, locode_home};
const AGENTS_FILE: &str = "AGENTS.md";
const OVERRIDE_FILE: &str = "AGENTS.override.md";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectInstructions {
pub entries: Vec<InstructionEntry>,
}
impl ProjectInstructions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstructionEntry {
pub source_path: PathBuf,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct InstructionsConfig {
pub enabled: bool,
pub byte_budget: usize,
pub root_markers: Vec<String>,
pub root_stop_pattern: Option<String>,
pub extra_roots: Vec<PathBuf>,
pub global_file: bool,
pub extends_dirs: Vec<PathBuf>,
}
impl Default for InstructionsConfig {
fn default() -> Self {
Self {
enabled: true,
byte_budget: 64 * 1024,
root_markers: vec![".git".to_string()],
root_stop_pattern: None,
extra_roots: Vec::new(),
global_file: true,
extends_dirs: Vec::new(),
}
}
}
#[must_use]
pub fn load_project_instructions(cwd: &Path, cfg: &InstructionsConfig) -> ProjectInstructions {
let global = (cfg.enabled && cfg.global_file)
.then(global_instruction_path)
.flatten();
load_impl(cwd, cfg, global.as_deref())
}
fn load_impl(cwd: &Path, cfg: &InstructionsConfig, global: Option<&Path>) -> ProjectInstructions {
if !cfg.enabled {
return ProjectInstructions::default();
}
let mut entries: Vec<InstructionEntry> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for dir in &cfg.extends_dirs {
push_dir_entry(dir, &mut entries, &mut seen, None);
}
if let Some(global) = global {
push_dir_entry(&global_dir_of(global), &mut entries, &mut seen, None);
}
let stop_pattern = cfg
.root_stop_pattern
.as_deref()
.and_then(|p| regex::Regex::new(p).ok());
let root = find_root_from_markers(cwd, &cfg.root_markers, stop_pattern.as_ref());
let ignore = build_gitignore(&root);
for dir in dirs_root_to_leaf(cwd, &root) {
push_dir_entry(&dir, &mut entries, &mut seen, ignore.as_ref());
}
for extra in &cfg.extra_roots {
let extra_root = find_root_from_markers(extra, &cfg.root_markers, stop_pattern.as_ref());
for dir in dirs_root_to_leaf(extra, &extra_root) {
push_dir_entry(&dir, &mut entries, &mut seen, None);
}
}
ProjectInstructions { entries }
}
fn global_dir_of(global_file: &Path) -> PathBuf {
global_file
.parent()
.map_or_else(|| global_file.to_path_buf(), Path::to_path_buf)
}
fn global_instruction_path() -> Option<PathBuf> {
locode_home().ok().map(|dir| dir.join(AGENTS_FILE))
}
#[cfg(test)]
fn global_path_from(
locode_home: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
resolve_home_from(locode_home, home)
.ok()
.map(|dir| dir.join(AGENTS_FILE))
}
fn dirs_root_to_leaf(leaf: &Path, root: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut cur = Some(leaf);
while let Some(d) = cur {
dirs.push(d.to_path_buf());
if d == root {
break;
}
cur = d.parent();
}
dirs.reverse();
dirs
}
fn push_dir_entry(
dir: &Path,
entries: &mut Vec<InstructionEntry>,
seen: &mut HashSet<String>,
ignore: Option<&ignore::gitignore::Gitignore>,
) {
for name in [OVERRIDE_FILE, AGENTS_FILE] {
let path = dir.join(name);
if !path.is_file() {
continue;
}
if let Some(gi) = ignore
&& is_gitignored(gi, &path)
{
return;
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
if content.trim().is_empty() {
return;
}
let key = canonical_key(&path);
if seen.insert(key) {
entries.push(InstructionEntry {
source_path: path,
content,
});
}
return;
}
}
fn build_gitignore(root: &Path) -> Option<ignore::gitignore::Gitignore> {
if !root.join(".git").exists() {
return None;
}
let base = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut builder = ignore::gitignore::GitignoreBuilder::new(&base);
let _ = builder.add(base.join(".gitignore"));
let _ = builder.add(base.join(".git").join("info").join("exclude"));
builder.build().ok()
}
fn is_gitignored(gi: &ignore::gitignore::Gitignore, path: &Path) -> bool {
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
gi.matched_path_or_any_parents(&canon, false)
.is_ignore()
}
fn canonical_key(path: &Path) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn tmp() -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = fs::canonicalize(dir.path()).unwrap();
(dir, root)
}
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
fn cfg_no_global() -> InstructionsConfig {
InstructionsConfig {
global_file: false,
..Default::default()
}
}
#[test]
fn walks_root_to_cwd_deepest_last() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "root rules");
let sub = root.join("a/b");
write(&sub.join(AGENTS_FILE), "leaf rules");
let got = load_project_instructions(&sub, &cfg_no_global());
let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
assert_eq!(
paths,
vec![root.join(AGENTS_FILE), sub.join(AGENTS_FILE)],
"root→cwd order, deepest last"
);
assert_eq!(got.entries[0].content, "root rules");
assert_eq!(got.entries[1].content, "leaf rules");
}
#[test]
fn no_git_falls_back_to_cwd_only() {
let (_g, root) = tmp();
write(&root.join(AGENTS_FILE), "parent rules");
let sub = root.join("child");
write(&sub.join(AGENTS_FILE), "cwd rules");
let got = load_project_instructions(&sub, &cfg_no_global());
assert_eq!(got.entries.len(), 1, "cwd-only: parent not walked");
assert_eq!(got.entries[0].source_path, sub.join(AGENTS_FILE));
}
#[test]
fn override_replaces_same_dir_agents_md_only() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "root plain");
let sub = root.join("s");
write(&sub.join(AGENTS_FILE), "sub plain");
write(&sub.join(OVERRIDE_FILE), "sub override");
let got = load_project_instructions(&sub, &cfg_no_global());
assert_eq!(got.entries.len(), 2);
assert_eq!(got.entries[1].source_path, sub.join(OVERRIDE_FILE));
assert_eq!(got.entries[1].content, "sub override");
assert!(
got.entries
.iter()
.all(|e| e.source_path != sub.join(AGENTS_FILE)),
"sub's plain AGENTS.md is suppressed"
);
}
#[test]
fn empty_override_suppresses_agents_md_and_yields_nothing() {
let (_g, root) = tmp();
write(&root.join(AGENTS_FILE), "would-be content");
write(&root.join(OVERRIDE_FILE), " \n ");
let got = load_project_instructions(&root, &cfg_no_global());
assert!(
got.is_empty(),
"empty override wins the dir, yields no entry"
);
}
#[test]
fn empty_and_whitespace_files_dropped() {
let (_g, root) = tmp();
write(&root.join(AGENTS_FILE), "\n\t \n");
let got = load_project_instructions(&root, &cfg_no_global());
assert!(got.is_empty());
}
#[test]
fn gitignored_agents_md_skipped() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(".gitignore"), "AGENTS.md\n");
write(&root.join(AGENTS_FILE), "secret");
let got = load_project_instructions(&root, &cfg_no_global());
assert!(got.is_empty(), "gitignored AGENTS.md is filtered");
}
#[test]
fn dedup_by_canonical_path() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "rules");
let got = load_project_instructions(&root, &cfg_no_global());
assert_eq!(got.entries.len(), 1);
assert_eq!(
canonical_key(&root.join(AGENTS_FILE)),
canonical_key(&root.join(AGENTS_FILE))
);
}
#[test]
fn extra_roots_appended_after_primary() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "primary");
let (_g2, other) = tmp();
write(&other.join(AGENTS_FILE), "extra");
let cfg = InstructionsConfig {
extra_roots: vec![other.clone()],
..cfg_no_global()
};
let got = load_project_instructions(&root, &cfg);
let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
assert_eq!(paths, vec![root.join(AGENTS_FILE), other.join(AGENTS_FILE)]);
}
#[test]
fn disabled_returns_empty() {
let (_g, root) = tmp();
write(&root.join(AGENTS_FILE), "rules");
let cfg = InstructionsConfig {
enabled: false,
..cfg_no_global()
};
assert!(load_project_instructions(&root, &cfg).is_empty());
}
#[test]
fn root_stop_pattern_stops_the_ascent() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "top rules");
let x = root.join("x");
write(&x.join(AGENTS_FILE), "x rules");
let sub = x.join("y");
fs::create_dir_all(&sub).unwrap();
let got = load_project_instructions(&sub, &cfg_no_global());
assert_eq!(got.entries.len(), 2);
let mut cfg = cfg_no_global();
cfg.root_stop_pattern = Some("/x$".to_string());
let got = load_project_instructions(&sub, &cfg);
assert_eq!(got.entries.len(), 1);
assert_eq!(got.entries[0].source_path, x.join(AGENTS_FILE));
assert_eq!(got.entries[0].content, "x rules");
}
#[test]
fn invalid_root_stop_pattern_degrades_to_no_pattern() {
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "rules");
let mut cfg = cfg_no_global();
cfg.root_stop_pattern = Some("[invalid".to_string());
let got = load_project_instructions(&root, &cfg);
assert_eq!(got.entries.len(), 1, "marker detection still works");
}
#[test]
fn global_file_is_lowest_precedence() {
let (_home_g, home) = tmp();
let global = home.join(".locode").join(AGENTS_FILE);
write(&global, "global");
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "project");
let cfg = InstructionsConfig {
global_file: true,
..Default::default()
};
let got = load_impl(&root, &cfg, Some(&global));
let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
assert_eq!(
paths,
vec![global.clone(), root.join(AGENTS_FILE)],
"global first (lowest precedence), project after"
);
}
#[test]
fn extends_dotfolders_rank_below_the_global_file() {
let (_home_g, home) = tmp();
let global = home.join(".locode").join(AGENTS_FILE);
write(&global, "global");
let (_team_g, team) = tmp();
write(&team.join(AGENTS_FILE), "team");
let (_team2_g, team2) = tmp();
write(&team2.join(AGENTS_FILE), "team2");
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "project");
let cfg = InstructionsConfig {
global_file: true,
extends_dirs: vec![team.clone(), team2.clone()],
..Default::default()
};
let got = load_impl(&root, &cfg, Some(&global));
let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
assert_eq!(
paths,
vec![
team.join(AGENTS_FILE),
team2.join(AGENTS_FILE),
global.clone(),
root.join(AGENTS_FILE),
],
"extends (list order) → global → repo chain"
);
}
#[test]
fn extends_dotfolder_without_agents_md_contributes_nothing() {
let (_team_g, team) = tmp();
let (_g, root) = tmp();
fs::create_dir(root.join(".git")).unwrap();
write(&root.join(AGENTS_FILE), "project");
let cfg = InstructionsConfig {
extends_dirs: vec![team],
..cfg_no_global()
};
let got = load_impl(&root, &cfg, None);
let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
assert_eq!(paths, vec![root.join(AGENTS_FILE)]);
}
#[test]
fn global_file_disabled_passes_none() {
let (_g, root) = tmp();
write(&root.join(AGENTS_FILE), "project");
let got = load_project_instructions(&root, &cfg_no_global());
assert_eq!(got.entries.len(), 1);
assert_eq!(got.entries[0].source_path, root.join(AGENTS_FILE));
}
#[test]
fn global_instruction_path_shape() {
if let Some(p) = global_instruction_path() {
assert!(p.ends_with("AGENTS.md"));
}
}
#[test]
fn global_path_prefers_locode_home_over_home() {
use std::ffi::OsString;
let dir = tempfile::tempdir().unwrap();
let canon = fs::canonicalize(dir.path()).unwrap();
let p = global_path_from(Some(dir.path().as_os_str().to_owned()), None).unwrap();
assert_eq!(p, canon.join(AGENTS_FILE));
assert!(global_path_from(Some(OsString::from("/definitely/not/here")), None).is_none());
let p = global_path_from(Some(OsString::new()), Some(OsString::from("/home/u"))).unwrap();
assert_eq!(p, PathBuf::from("/home/u/.locode").join(AGENTS_FILE));
assert!(global_path_from(None, None).is_none());
assert!(global_path_from(None, Some(OsString::new())).is_none());
}
}