use std::path::{Path, PathBuf};
pub const MAX_IMPORT_DEPTH: usize = 4;
pub const PROJECT_FILENAMES: &[&str] = &["APR.md", "CLAUDE.md"];
pub fn find_project_instructions(cwd: &Path) -> Option<PathBuf> {
PROJECT_FILENAMES.iter().map(|f| cwd.join(f)).find(|p| p.is_file())
}
pub fn find_user_global_instructions() -> Option<PathBuf> {
for layer in user_global_search_dirs() {
for fname in PROJECT_FILENAMES {
let p = layer.join(fname);
if p.is_file() {
return Some(p);
}
}
}
None
}
fn user_global_search_dirs() -> Vec<PathBuf> {
if let Ok(custom) = std::env::var("APR_CONFIG") {
if !custom.is_empty() {
return vec![PathBuf::from(custom)];
}
}
let mut out = Vec::new();
if let Some(cfg) = dirs::config_dir() {
out.push(cfg.join("apr"));
}
if let Some(home) = dirs::home_dir() {
out.push(home.join(".claude"));
}
out
}
pub fn expand_imports(content: &str, base_dir: &Path, warnings: &mut Vec<String>) -> String {
expand_imports_inner(content, base_dir, 0, warnings)
}
fn expand_imports_inner(
content: &str,
base_dir: &Path,
depth: usize,
warnings: &mut Vec<String>,
) -> String {
let mut out = String::with_capacity(content.len());
for line in content.lines() {
if let Some(import_path) = parse_import_line(line) {
if depth >= MAX_IMPORT_DEPTH {
warnings.push(format!(
"@{import_path}: import depth limit ({MAX_IMPORT_DEPTH}) exceeded; line kept verbatim"
));
out.push_str(line);
out.push('\n');
continue;
}
let resolved = resolve_import_path(import_path, base_dir);
match std::fs::read_to_string(&resolved) {
Ok(body) => {
let next_base = resolved
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| base_dir.to_path_buf());
let expanded = expand_imports_inner(&body, &next_base, depth + 1, warnings);
out.push_str(&expanded);
if !out.ends_with('\n') {
out.push('\n');
}
}
Err(e) => {
warnings.push(format!("@{import_path}: {e}"));
out.push_str(line);
out.push('\n');
}
}
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
fn parse_import_line(line: &str) -> Option<&str> {
let t = line.trim_start();
let after_at = t.strip_prefix('@')?;
let path = after_at.split_whitespace().next()?;
if path.is_empty() {
None
} else {
Some(path)
}
}
fn resolve_import_path(import_path: &str, base_dir: &Path) -> PathBuf {
if let Some(rest) = import_path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
let p = Path::new(import_path);
if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
}
}
pub fn truncate_to_budget(content: String, max_bytes: usize) -> Option<String> {
if max_bytes == 0 {
return None;
}
if content.len() <= max_bytes {
return Some(content);
}
let end = content
.char_indices()
.take_while(|(i, _)| *i < max_bytes)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(max_bytes.min(content.len()));
Some(format!("{}...\n(truncated from {} bytes)", &content[..end], content.len()))
}
pub fn load_layered_instructions(
cwd: &Path,
max_bytes: usize,
warnings: &mut Vec<String>,
) -> Option<String> {
if max_bytes == 0 {
return None;
}
let mut accumulated = String::new();
if let Some(user_path) = find_user_global_instructions() {
if let Ok(body) = std::fs::read_to_string(&user_path) {
let user_dir = user_path.parent().unwrap_or(Path::new("."));
let expanded = expand_imports(&body, user_dir, warnings);
accumulated.push_str("## User-global instructions (");
accumulated.push_str(&user_path.display().to_string());
accumulated.push_str(")\n\n");
accumulated.push_str(&expanded);
if !accumulated.ends_with("\n\n") {
accumulated.push('\n');
}
}
}
if let Some(project_path) = find_project_instructions(cwd) {
if let Ok(body) = std::fs::read_to_string(&project_path) {
let project_dir = project_path.parent().unwrap_or(cwd);
let expanded = expand_imports(&body, project_dir, warnings);
if !accumulated.is_empty() {
accumulated.push_str("\n## Project instructions (");
accumulated.push_str(&project_path.display().to_string());
accumulated.push_str(")\n\n");
}
accumulated.push_str(&expanded);
}
}
if accumulated.is_empty() {
return None;
}
truncate_to_budget(accumulated, max_bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, body: &str) {
if let Some(p) = path.parent() {
fs::create_dir_all(p).expect("mkdir");
}
fs::write(path, body).expect("write");
}
#[test]
fn import_line_simple() {
assert_eq!(parse_import_line("@./CONVENTIONS.md"), Some("./CONVENTIONS.md"));
}
#[test]
fn import_line_strips_indent() {
assert_eq!(parse_import_line(" @abs/path.md"), Some("abs/path.md"));
}
#[test]
fn import_line_stops_at_whitespace() {
assert_eq!(parse_import_line("@./README.md trailing comment"), Some("./README.md"));
}
#[test]
fn import_line_email_not_an_import() {
assert_eq!(parse_import_line("Email noah@paiml.com"), None);
}
#[test]
fn import_line_bare_at_is_not() {
assert_eq!(parse_import_line("@"), None);
assert_eq!(parse_import_line("@ "), None);
}
#[test]
fn import_line_inline_at_ignored() {
assert_eq!(parse_import_line("see @./foo.md inline"), None);
}
#[test]
fn resolve_relative_against_base() {
let p = resolve_import_path("./conventions.md", Path::new("/tmp/proj"));
assert_eq!(p, Path::new("/tmp/proj/./conventions.md"));
}
#[test]
fn resolve_absolute_passes_through() {
let p = resolve_import_path("/abs/file.md", Path::new("/tmp/proj"));
assert_eq!(p, Path::new("/abs/file.md"));
}
#[test]
fn resolve_tilde_expands_home() {
let p = resolve_import_path("~/CONVENTIONS.md", Path::new("/tmp/proj"));
if let Some(home) = dirs::home_dir() {
assert_eq!(p, home.join("CONVENTIONS.md"));
}
}
#[test]
fn expand_no_imports_returns_unchanged_modulo_newlines() {
let mut warns = Vec::new();
let out = expand_imports("hello\nworld\n", Path::new("/tmp"), &mut warns);
assert_eq!(out, "hello\nworld\n");
assert!(warns.is_empty());
}
#[test]
fn expand_single_import() {
let dir = tempfile::tempdir().expect("tempdir");
let imp = dir.path().join("conv.md");
write(&imp, "## Conventions\n- camelCase\n");
let body = format!("Top-level\n@{}\nBottom\n", imp.display());
let mut warns = Vec::new();
let out = expand_imports(&body, dir.path(), &mut warns);
assert!(out.contains("Top-level"));
assert!(out.contains("## Conventions"));
assert!(out.contains("camelCase"));
assert!(out.contains("Bottom"));
assert!(warns.is_empty());
}
#[test]
fn expand_relative_import_against_base() {
let dir = tempfile::tempdir().expect("tempdir");
let imp = dir.path().join("conv.md");
write(&imp, "imported-body");
let body = "@./conv.md\n";
let mut warns = Vec::new();
let out = expand_imports(body, dir.path(), &mut warns);
assert!(out.contains("imported-body"));
}
#[test]
fn expand_missing_import_keeps_line_and_warns() {
let dir = tempfile::tempdir().expect("tempdir");
let body = "@./not-there.md\n";
let mut warns = Vec::new();
let out = expand_imports(body, dir.path(), &mut warns);
assert!(out.contains("@./not-there.md"));
assert_eq!(warns.len(), 1);
assert!(warns[0].contains("not-there.md"));
}
#[test]
fn expand_recursive_imports() {
let dir = tempfile::tempdir().expect("tempdir");
let a = dir.path().join("a.md");
let b = dir.path().join("b.md");
let c = dir.path().join("c.md");
write(&a, "AAA\n@./b.md\n");
write(&b, "BBB\n@./c.md\n");
write(&c, "CCC\n");
let mut warns = Vec::new();
let out = expand_imports(&fs::read_to_string(&a).unwrap(), dir.path(), &mut warns);
assert!(out.contains("AAA"));
assert!(out.contains("BBB"));
assert!(out.contains("CCC"));
assert!(warns.is_empty());
}
#[test]
fn expand_recursive_path_resolves_against_importing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let sub = dir.path().join("sub");
let outer = dir.path().join("outer.md");
let mid = sub.join("mid.md");
let leaf = sub.join("leaf.md");
write(&outer, "TOP\n@./sub/mid.md\n");
write(&mid, "MID\n@./leaf.md\n"); write(&leaf, "LEAF\n");
let mut warns = Vec::new();
let out = expand_imports(&fs::read_to_string(&outer).unwrap(), dir.path(), &mut warns);
assert!(out.contains("TOP"));
assert!(out.contains("MID"));
assert!(out.contains("LEAF"), "leaf.md should resolve relative to sub/, got: {out:?}");
}
#[test]
fn expand_depth_limit_prevents_cycle_blowup() {
let dir = tempfile::tempdir().expect("tempdir");
let a = dir.path().join("a.md");
let b = dir.path().join("b.md");
write(&a, "@./b.md\n");
write(&b, "@./a.md\n");
let mut warns = Vec::new();
let out = expand_imports(&fs::read_to_string(&a).unwrap(), dir.path(), &mut warns);
assert!(out.len() < 100_000);
assert!(warns.iter().any(|w| w.contains("depth limit")), "warns: {warns:?}");
}
#[test]
fn truncate_zero_budget_yields_none() {
assert!(truncate_to_budget("xxx".into(), 0).is_none());
}
#[test]
fn truncate_under_budget_passthrough() {
let s = truncate_to_budget("short".into(), 100).expect("kept");
assert_eq!(s, "short");
}
#[test]
fn truncate_over_budget_appends_annotation() {
let big = "x".repeat(500);
let s = truncate_to_budget(big, 100).expect("truncated");
assert!(s.starts_with("x"));
assert!(s.contains("truncated from 500 bytes"));
}
#[test]
fn truncate_respects_utf8_boundary() {
let s = format!("{}é", "a".repeat(99));
let truncated = truncate_to_budget(s, 100).expect("truncated");
assert!(truncated.contains("truncated from"));
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn user_global_honors_apr_config_env_first() {
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("CLAUDE.md"), "user-global-content");
std::env::set_var("APR_CONFIG", dir.path());
let p = find_user_global_instructions().expect("found");
std::env::remove_var("APR_CONFIG");
assert_eq!(p, dir.path().join("CLAUDE.md"));
}
#[test]
fn user_global_prefers_apr_md_over_claude_md_within_layer() {
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
write(&dir.path().join("APR.md"), "apr-version");
write(&dir.path().join("CLAUDE.md"), "claude-version");
std::env::set_var("APR_CONFIG", dir.path());
let p = find_user_global_instructions().expect("found");
std::env::remove_var("APR_CONFIG");
assert_eq!(p, dir.path().join("APR.md"), "APR.md wins over CLAUDE.md within a layer");
}
#[test]
fn load_layered_returns_none_when_nothing_to_load() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let proj = tempfile::tempdir().expect("proj");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_layered_instructions(proj.path(), 4096, &mut warns);
std::env::remove_var("APR_CONFIG");
assert!(out.is_none());
}
#[test]
fn load_layered_concatenates_user_global_then_project() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let proj = tempfile::tempdir().expect("proj");
write(&cfg.path().join("CLAUDE.md"), "USER-GLOBAL-BODY\n");
write(&proj.path().join("CLAUDE.md"), "PROJECT-BODY\n");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_layered_instructions(proj.path(), 65536, &mut warns).expect("loaded");
std::env::remove_var("APR_CONFIG");
let user_idx = out.find("USER-GLOBAL-BODY").expect("user-global present");
let proj_idx = out.find("PROJECT-BODY").expect("project present");
assert!(
user_idx < proj_idx,
"user-global must come before project so project wins context-wise"
);
assert!(out.contains("User-global instructions"));
assert!(out.contains("Project instructions"));
}
#[test]
fn load_layered_resolves_imports_in_each_layer() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let proj = tempfile::tempdir().expect("proj");
write(&cfg.path().join("CLAUDE.md"), "USER\n@./shared.md\n");
write(&cfg.path().join("shared.md"), "USER-SHARED\n");
write(&proj.path().join("CLAUDE.md"), "PROJ\n@./conv.md\n");
write(&proj.path().join("conv.md"), "PROJ-CONV\n");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_layered_instructions(proj.path(), 65536, &mut warns).expect("loaded");
std::env::remove_var("APR_CONFIG");
assert!(out.contains("USER-SHARED"));
assert!(out.contains("PROJ-CONV"));
assert!(warns.is_empty(), "no warnings expected, got: {warns:?}");
}
}