pub(crate) mod subdir;
use anyhow::Context;
use std::path::{Path, PathBuf};
const MAX_STARTUP_INSTRUCTION_BYTES: u64 = 256 * 1024;
const MAX_STARTUP_INSTRUCTION_TOTAL_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionSourceKind {
User,
Repository,
Configured,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstructionFile {
pub kind: InstructionSourceKind,
pub path: PathBuf,
pub content: String,
}
fn discover_agents(
user_agents: &Path,
project_root: &Path,
) -> anyhow::Result<Vec<InstructionFile>> {
let mut files = Vec::new();
load_optional(user_agents, InstructionSourceKind::User, &mut files)?;
load_optional(
&project_root.join("AGENTS.md"),
InstructionSourceKind::Repository,
&mut files,
)?;
Ok(files)
}
fn load_optional(
path: &Path,
kind: InstructionSourceKind,
files: &mut Vec<InstructionFile>,
) -> anyhow::Result<()> {
match crate::prompt_file::read_prompt_file(path, MAX_STARTUP_INSTRUCTION_BYTES, false) {
Ok(file) => append_with_budget(files, kind, path.to_path_buf(), file.text, file.bytes),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
Ok(())
}
Err(error) => Err(error)
.with_context(|| format!("failed to read instruction file: {}", path.display())),
}
}
fn append_with_budget(
files: &mut Vec<InstructionFile>,
kind: InstructionSourceKind,
path: PathBuf,
content: String,
bytes: usize,
) -> anyhow::Result<()> {
let current = files.iter().map(|file| file.content.len()).sum::<usize>();
let total = current
.checked_add(bytes)
.ok_or_else(|| anyhow::anyhow!("startup instruction byte count overflow"))?;
if total > MAX_STARTUP_INSTRUCTION_TOTAL_BYTES {
anyhow::bail!(
"startup instruction budget exceeded at {}: current total {current} bytes, source {bytes} bytes, ceiling {MAX_STARTUP_INSTRUCTION_TOTAL_BYTES} bytes",
path.display()
);
}
files.push(InstructionFile {
kind,
path,
content,
});
Ok(())
}
pub fn discover_agents_with_additional_markdown(
user_agents: &Path,
project_root: &Path,
additional_paths: &[PathBuf],
) -> anyhow::Result<Vec<InstructionFile>> {
let mut files = discover_agents(user_agents, project_root)?;
for (index, path) in additional_paths.iter().enumerate() {
let file = load_configured_markdown(index, path)?;
let bytes = file.content.len();
append_with_budget(&mut files, file.kind, file.path, file.content, bytes)?;
}
Ok(files)
}
fn load_configured_markdown(index: usize, path: &Path) -> anyhow::Result<InstructionFile> {
let key = format!("instructions.additional_markdown_paths[{index}]");
if !path.is_absolute() {
anyhow::bail!("{key} error: path must be absolute: {}", path.display());
}
if !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
anyhow::bail!("{key} error: expected .md file: {}", path.display());
}
let content = crate::prompt_file::read_prompt_file(path, MAX_STARTUP_INSTRUCTION_BYTES, false)
.with_context(|| {
format!(
"{key} error: failed to read UTF-8 markdown file: {}",
path.display()
)
})?;
Ok(InstructionFile {
kind: InstructionSourceKind::Configured,
path: path.to_path_buf(),
content: content.text,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn agents_discovery_loads_only_user_and_explicit_project_root() {
let temp = TempDir::new().unwrap();
let mc = temp.path().join("mc");
let repo = temp.path().join("repo");
let nested = repo.join("a/b");
fs::create_dir_all(&mc).unwrap();
fs::create_dir_all(&nested).unwrap();
fs::write(mc.join("AGENTS.md"), "user").unwrap();
fs::write(repo.join("AGENTS.md"), "repo agents").unwrap();
fs::write(repo.join("CLAUDE.md"), "repo claude").unwrap();
fs::write(repo.join("a").join("AGENTS.md"), "nested agents").unwrap();
let files = discover_agents(&mc.join("AGENTS.md"), &repo).unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].kind, InstructionSourceKind::User);
assert_eq!(files[0].content, "user");
assert_eq!(files[1].kind, InstructionSourceKind::Repository);
assert_eq!(files[1].content, "repo agents");
assert!(!files.iter().any(|file| file.content == "repo claude"));
assert!(!files.iter().any(|file| file.content == "nested agents"));
}
#[test]
fn configured_markdown_appends_after_agents_in_configured_order() {
let temp = TempDir::new().unwrap();
let mc = temp.path().join("mc");
let repo = temp.path().join("repo");
fs::create_dir_all(&mc).unwrap();
fs::create_dir_all(&repo).unwrap();
fs::write(mc.join("AGENTS.md"), "user").unwrap();
fs::write(repo.join("AGENTS.md"), "repo").unwrap();
let first = temp.path().join("first.md");
let second = temp.path().join("second.md");
fs::write(&first, "configured one").unwrap();
fs::write(&second, "configured two").unwrap();
let files = discover_agents_with_additional_markdown(
&mc.join("AGENTS.md"),
&repo,
&[first.clone(), second.clone(), first.clone()],
)
.unwrap();
assert_eq!(
files.iter().map(|file| &file.kind).collect::<Vec<_>>(),
vec![
&InstructionSourceKind::User,
&InstructionSourceKind::Repository,
&InstructionSourceKind::Configured,
&InstructionSourceKind::Configured,
&InstructionSourceKind::Configured,
]
);
assert_eq!(files[2].path, first);
assert_eq!(files[2].content, "configured one");
assert_eq!(files[3].path, second);
assert_eq!(files[3].content, "configured two");
assert_eq!(files[4].content, "configured one");
}
#[test]
fn configured_markdown_rejects_invalid_paths_with_setting_index() {
let temp = TempDir::new().unwrap();
let non_markdown = temp.path().join("notes.txt");
fs::write(&non_markdown, "notes").unwrap();
let missing = temp.path().join("missing.md");
let invalid_utf8 = temp.path().join("invalid.md");
fs::write(&invalid_utf8, [0xff, 0xfe]).unwrap();
for (paths, expected) in [
(
vec![PathBuf::from("relative.md")],
"instructions.additional_markdown_paths[0] error: path must be absolute",
),
(
vec![non_markdown],
"instructions.additional_markdown_paths[0] error: expected .md file",
),
(
vec![missing],
"instructions.additional_markdown_paths[0] error: failed to read UTF-8 markdown file",
),
(
vec![invalid_utf8],
"instructions.additional_markdown_paths[0] error: failed to read UTF-8 markdown file",
),
] {
let error = discover_agents_with_additional_markdown(
&temp.path().join("mc/AGENTS.md"),
temp.path(),
&paths,
)
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}");
assert!(error.contains(&paths[0].display().to_string()), "{error}");
}
}
}