Skip to main content

batuta/agent/
instructions.rs

1//! Project-instruction loading with `@import` syntax + user-level fallback
2//! (PMAT-CODE-MEMORY-PARITY-001).
3//!
4//! Mirrors Claude Code's CLAUDE.md memory mechanism. Two extensions over
5//! the legacy single-file project-only loader:
6//!
7//! 1. **`@<path>` inline imports** inside CLAUDE.md / APR.md content.
8//!    A line starting with `@` followed by a relative or absolute path
9//!    is replaced with that file's contents (transitively, with depth
10//!    limit). Mirrors Claude Code's `@./CONVENTIONS.md` syntax. Missing
11//!    or unreadable imports leave the line verbatim and emit a stderr
12//!    warning (Poka-Yoke — no silent partial expansion).
13//!
14//! 2. **User-level fallback**: when no project-level CLAUDE.md / APR.md
15//!    is found in `cwd`, load from user-level locations in this order:
16//!    `$APR_CONFIG/CLAUDE.md` → `~/.config/apr/CLAUDE.md` →
17//!    `~/.claude/CLAUDE.md` (Claude-Code cross-compat). Same fallback
18//!    applies to APR.md (apr-native filename takes precedence over
19//!    Claude-Code-flavored filename in either layer).
20//!
21//! Pure-function design: no terminal I/O, no caller-state mutation. Any
22//! warnings are returned via an `&mut Vec<String>` so the caller can
23//! decide where they go (REPL stderr vs CCPA trace).
24
25use std::path::{Path, PathBuf};
26
27/// Maximum recursive `@import` depth. A flat document with one level of
28/// `@CONVENTIONS.md` is the common case; 4 hops handles a chain like
29/// CLAUDE.md → conventions.md → security.md → boilerplate.md without
30/// pathological loops eating context budget.
31pub const MAX_IMPORT_DEPTH: usize = 4;
32
33/// Filenames considered as project-level instructions, in priority order.
34/// `APR.md` is the apr-native; `CLAUDE.md` is the cross-compat name.
35pub const PROJECT_FILENAMES: &[&str] = &["APR.md", "CLAUDE.md"];
36
37/// Find the first existing project-level instructions file under `cwd`.
38/// Honors [`PROJECT_FILENAMES`] priority order.
39pub fn find_project_instructions(cwd: &Path) -> Option<PathBuf> {
40    PROJECT_FILENAMES.iter().map(|f| cwd.join(f)).find(|p| p.is_file())
41}
42
43/// Find the first existing user-global instructions file. Search order:
44/// `$APR_CONFIG/<name>` → `~/.config/apr/<name>` → `~/.claude/<name>`,
45/// trying each `name` from [`PROJECT_FILENAMES`] within each layer
46/// before moving to the next layer.
47pub fn find_user_global_instructions() -> Option<PathBuf> {
48    for layer in user_global_search_dirs() {
49        for fname in PROJECT_FILENAMES {
50            let p = layer.join(fname);
51            if p.is_file() {
52                return Some(p);
53            }
54        }
55    }
56    None
57}
58
59/// User-global instruction search directories, in priority order.
60///
61/// * If `$APR_CONFIG` is set, that's the **only** location consulted —
62///   setting the env var is treated as an explicit opt-out of the
63///   default lookup chain (Poka-Yoke; tests + sandboxed runs need this
64///   so host-level CLAUDE.md can't leak in).
65/// * Otherwise, search XDG `~/.config/apr/` first, then `~/.claude/`
66///   (Claude-Code cross-compat).
67fn user_global_search_dirs() -> Vec<PathBuf> {
68    if let Ok(custom) = std::env::var("APR_CONFIG") {
69        if !custom.is_empty() {
70            return vec![PathBuf::from(custom)];
71        }
72    }
73    let mut out = Vec::new();
74    if let Some(cfg) = dirs::config_dir() {
75        out.push(cfg.join("apr"));
76    }
77    if let Some(home) = dirs::home_dir() {
78        out.push(home.join(".claude"));
79    }
80    out
81}
82
83/// Expand `@<path>` import lines in `content` recursively. Each
84/// imported file's contents replace the import line (relative paths
85/// resolved against `base_dir`).
86///
87/// Behavior:
88/// * Only lines whose **trimmed start** is `@` followed by a non-
89///   whitespace path token are imports. This means a line like
90///   `Talk to noah@paiml.com` is NOT an import — `@` must lead.
91/// * Imports are resolved relative to the importing file's directory,
92///   not `cwd`, matching Claude Code's `@./conventions.md` semantics.
93/// * Recursion depth is capped at [`MAX_IMPORT_DEPTH`]. Cycles or
94///   over-deep chains leave the import line verbatim and emit a
95///   warning. So does any I/O failure (Poka-Yoke).
96pub fn expand_imports(content: &str, base_dir: &Path, warnings: &mut Vec<String>) -> String {
97    expand_imports_inner(content, base_dir, 0, warnings)
98}
99
100fn expand_imports_inner(
101    content: &str,
102    base_dir: &Path,
103    depth: usize,
104    warnings: &mut Vec<String>,
105) -> String {
106    let mut out = String::with_capacity(content.len());
107    for line in content.lines() {
108        if let Some(import_path) = parse_import_line(line) {
109            if depth >= MAX_IMPORT_DEPTH {
110                warnings.push(format!(
111                    "@{import_path}: import depth limit ({MAX_IMPORT_DEPTH}) exceeded; line kept verbatim"
112                ));
113                out.push_str(line);
114                out.push('\n');
115                continue;
116            }
117            let resolved = resolve_import_path(import_path, base_dir);
118            match std::fs::read_to_string(&resolved) {
119                Ok(body) => {
120                    let next_base = resolved
121                        .parent()
122                        .map(Path::to_path_buf)
123                        .unwrap_or_else(|| base_dir.to_path_buf());
124                    let expanded = expand_imports_inner(&body, &next_base, depth + 1, warnings);
125                    out.push_str(&expanded);
126                    if !out.ends_with('\n') {
127                        out.push('\n');
128                    }
129                }
130                Err(e) => {
131                    warnings.push(format!("@{import_path}: {e}"));
132                    out.push_str(line);
133                    out.push('\n');
134                }
135            }
136        } else {
137            out.push_str(line);
138            out.push('\n');
139        }
140    }
141    out
142}
143
144/// If `line` is an `@<path>` import directive, return the path. The
145/// `@` must be at the start of the trimmed line (not mid-line) so an
146/// inline mention like `email noah@paiml.com` is preserved.
147///
148/// The path token runs until the first whitespace, so paths with
149/// spaces are NOT supported (matches Claude Code's grammar).
150fn parse_import_line(line: &str) -> Option<&str> {
151    let t = line.trim_start();
152    let after_at = t.strip_prefix('@')?;
153    let path = after_at.split_whitespace().next()?;
154    if path.is_empty() {
155        None
156    } else {
157        Some(path)
158    }
159}
160
161/// Resolve `import_path` relative to `base_dir`. Absolute paths and
162/// `~`-prefixed paths are expanded; otherwise the result is
163/// `base_dir.join(import_path)`.
164fn resolve_import_path(import_path: &str, base_dir: &Path) -> PathBuf {
165    if let Some(rest) = import_path.strip_prefix("~/") {
166        if let Some(home) = dirs::home_dir() {
167            return home.join(rest);
168        }
169    }
170    let p = Path::new(import_path);
171    if p.is_absolute() {
172        p.to_path_buf()
173    } else {
174        base_dir.join(p)
175    }
176}
177
178/// Truncate `content` to `max_bytes` on a UTF-8 char boundary,
179/// appending an `(truncated from N bytes)` annotation. `max_bytes==0`
180/// returns `None` (caller skips loading entirely).
181pub fn truncate_to_budget(content: String, max_bytes: usize) -> Option<String> {
182    if max_bytes == 0 {
183        return None;
184    }
185    if content.len() <= max_bytes {
186        return Some(content);
187    }
188    let end = content
189        .char_indices()
190        .take_while(|(i, _)| *i < max_bytes)
191        .last()
192        .map(|(i, c)| i + c.len_utf8())
193        .unwrap_or(max_bytes.min(content.len()));
194    Some(format!("{}...\n(truncated from {} bytes)", &content[..end], content.len()))
195}
196
197/// Load layered project + user-global instructions with `@import`
198/// expansion. Returns `None` if `max_bytes` is 0 or no file exists at
199/// any layer.
200///
201/// Layering: when both a user-global file AND a project file exist,
202/// the user-global content is concatenated FIRST (under a
203/// `## User-global instructions` heading) and the project content
204/// appears AFTER (matching Claude Code's "later layer wins context-
205/// wise but earlier layers still inform the model"). Either layer
206/// can be missing.
207pub fn load_layered_instructions(
208    cwd: &Path,
209    max_bytes: usize,
210    warnings: &mut Vec<String>,
211) -> Option<String> {
212    if max_bytes == 0 {
213        return None;
214    }
215    let mut accumulated = String::new();
216
217    if let Some(user_path) = find_user_global_instructions() {
218        if let Ok(body) = std::fs::read_to_string(&user_path) {
219            let user_dir = user_path.parent().unwrap_or(Path::new("."));
220            let expanded = expand_imports(&body, user_dir, warnings);
221            accumulated.push_str("## User-global instructions (");
222            accumulated.push_str(&user_path.display().to_string());
223            accumulated.push_str(")\n\n");
224            accumulated.push_str(&expanded);
225            if !accumulated.ends_with("\n\n") {
226                accumulated.push('\n');
227            }
228        }
229    }
230
231    if let Some(project_path) = find_project_instructions(cwd) {
232        if let Ok(body) = std::fs::read_to_string(&project_path) {
233            let project_dir = project_path.parent().unwrap_or(cwd);
234            let expanded = expand_imports(&body, project_dir, warnings);
235            if !accumulated.is_empty() {
236                accumulated.push_str("\n## Project instructions (");
237                accumulated.push_str(&project_path.display().to_string());
238                accumulated.push_str(")\n\n");
239            }
240            accumulated.push_str(&expanded);
241        }
242    }
243
244    if accumulated.is_empty() {
245        return None;
246    }
247    truncate_to_budget(accumulated, max_bytes)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    // PMAT-876: shared crate-wide env lock + save/restore guard, shared
254    // with auto_memory + settings tests (all touch the same global
255    // `APR_CONFIG`). `std::env` is process-global and tests run in
256    // parallel, so one lock serializes them ALL.
257    use crate::agent::env_test_support::{env_lock, ScopedEnv};
258    use std::fs;
259
260    fn write(path: &Path, body: &str) {
261        if let Some(p) = path.parent() {
262            fs::create_dir_all(p).expect("mkdir");
263        }
264        fs::write(path, body).expect("write");
265    }
266
267    // ── parse_import_line ──────────────────────────────────────────
268
269    #[test]
270    fn import_line_simple() {
271        assert_eq!(parse_import_line("@./CONVENTIONS.md"), Some("./CONVENTIONS.md"));
272    }
273
274    #[test]
275    fn import_line_strips_indent() {
276        assert_eq!(parse_import_line("  @abs/path.md"), Some("abs/path.md"));
277    }
278
279    #[test]
280    fn import_line_stops_at_whitespace() {
281        // anything after the path is ignored — Claude Code grammar
282        assert_eq!(parse_import_line("@./README.md trailing comment"), Some("./README.md"));
283    }
284
285    #[test]
286    fn import_line_email_not_an_import() {
287        assert_eq!(parse_import_line("Email noah@paiml.com"), None);
288    }
289
290    #[test]
291    fn import_line_bare_at_is_not() {
292        assert_eq!(parse_import_line("@"), None);
293        assert_eq!(parse_import_line("@   "), None);
294    }
295
296    #[test]
297    fn import_line_inline_at_ignored() {
298        // Claude Code only considers leading `@`. An inline `@` mid-line
299        // is just text.
300        assert_eq!(parse_import_line("see @./foo.md inline"), None);
301    }
302
303    // ── resolve_import_path ────────────────────────────────────────
304
305    #[test]
306    fn resolve_relative_against_base() {
307        let p = resolve_import_path("./conventions.md", Path::new("/tmp/proj"));
308        assert_eq!(p, Path::new("/tmp/proj/./conventions.md"));
309    }
310
311    #[test]
312    fn resolve_absolute_passes_through() {
313        let p = resolve_import_path("/abs/file.md", Path::new("/tmp/proj"));
314        assert_eq!(p, Path::new("/abs/file.md"));
315    }
316
317    #[test]
318    fn resolve_tilde_expands_home() {
319        let p = resolve_import_path("~/CONVENTIONS.md", Path::new("/tmp/proj"));
320        if let Some(home) = dirs::home_dir() {
321            assert_eq!(p, home.join("CONVENTIONS.md"));
322        }
323    }
324
325    // ── expand_imports ─────────────────────────────────────────────
326
327    #[test]
328    fn expand_no_imports_returns_unchanged_modulo_newlines() {
329        let mut warns = Vec::new();
330        let out = expand_imports("hello\nworld\n", Path::new("/tmp"), &mut warns);
331        assert_eq!(out, "hello\nworld\n");
332        assert!(warns.is_empty());
333    }
334
335    #[test]
336    fn expand_single_import() {
337        let dir = tempfile::tempdir().expect("tempdir");
338        let imp = dir.path().join("conv.md");
339        write(&imp, "## Conventions\n- camelCase\n");
340        let body = format!("Top-level\n@{}\nBottom\n", imp.display());
341        let mut warns = Vec::new();
342        let out = expand_imports(&body, dir.path(), &mut warns);
343        assert!(out.contains("Top-level"));
344        assert!(out.contains("## Conventions"));
345        assert!(out.contains("camelCase"));
346        assert!(out.contains("Bottom"));
347        assert!(warns.is_empty());
348    }
349
350    #[test]
351    fn expand_relative_import_against_base() {
352        let dir = tempfile::tempdir().expect("tempdir");
353        let imp = dir.path().join("conv.md");
354        write(&imp, "imported-body");
355        let body = "@./conv.md\n";
356        let mut warns = Vec::new();
357        let out = expand_imports(body, dir.path(), &mut warns);
358        assert!(out.contains("imported-body"));
359    }
360
361    #[test]
362    fn expand_missing_import_keeps_line_and_warns() {
363        let dir = tempfile::tempdir().expect("tempdir");
364        let body = "@./not-there.md\n";
365        let mut warns = Vec::new();
366        let out = expand_imports(body, dir.path(), &mut warns);
367        assert!(out.contains("@./not-there.md"));
368        assert_eq!(warns.len(), 1);
369        assert!(warns[0].contains("not-there.md"));
370    }
371
372    #[test]
373    fn expand_recursive_imports() {
374        // a.md @-imports b.md which @-imports c.md (3 levels).
375        let dir = tempfile::tempdir().expect("tempdir");
376        let a = dir.path().join("a.md");
377        let b = dir.path().join("b.md");
378        let c = dir.path().join("c.md");
379        write(&a, "AAA\n@./b.md\n");
380        write(&b, "BBB\n@./c.md\n");
381        write(&c, "CCC\n");
382        let mut warns = Vec::new();
383        let out = expand_imports(&fs::read_to_string(&a).unwrap(), dir.path(), &mut warns);
384        assert!(out.contains("AAA"));
385        assert!(out.contains("BBB"));
386        assert!(out.contains("CCC"));
387        assert!(warns.is_empty());
388    }
389
390    #[test]
391    fn expand_recursive_path_resolves_against_importing_file() {
392        // Sub-dir import: imported file lives elsewhere; its own
393        // imports must resolve against ITS directory, not the
394        // top-level one.
395        let dir = tempfile::tempdir().expect("tempdir");
396        let sub = dir.path().join("sub");
397        let outer = dir.path().join("outer.md");
398        let mid = sub.join("mid.md");
399        let leaf = sub.join("leaf.md");
400        write(&outer, "TOP\n@./sub/mid.md\n");
401        write(&mid, "MID\n@./leaf.md\n"); // relative to sub/, not to dir/
402        write(&leaf, "LEAF\n");
403        let mut warns = Vec::new();
404        let out = expand_imports(&fs::read_to_string(&outer).unwrap(), dir.path(), &mut warns);
405        assert!(out.contains("TOP"));
406        assert!(out.contains("MID"));
407        assert!(out.contains("LEAF"), "leaf.md should resolve relative to sub/, got: {out:?}");
408    }
409
410    #[test]
411    fn expand_depth_limit_prevents_cycle_blowup() {
412        // a.md @-imports b.md, b.md @-imports a.md → cycle. The depth
413        // limit caps the recursion; the line that would trigger the
414        // (depth+1)-th expansion is kept verbatim with a warning.
415        let dir = tempfile::tempdir().expect("tempdir");
416        let a = dir.path().join("a.md");
417        let b = dir.path().join("b.md");
418        write(&a, "@./b.md\n");
419        write(&b, "@./a.md\n");
420        let mut warns = Vec::new();
421        let out = expand_imports(&fs::read_to_string(&a).unwrap(), dir.path(), &mut warns);
422        // Stays bounded — output is non-empty but doesn't blow up.
423        assert!(out.len() < 100_000);
424        // The depth-limit warning fires.
425        assert!(warns.iter().any(|w| w.contains("depth limit")), "warns: {warns:?}");
426    }
427
428    // ── truncate_to_budget ─────────────────────────────────────────
429
430    #[test]
431    fn truncate_zero_budget_yields_none() {
432        assert!(truncate_to_budget("xxx".into(), 0).is_none());
433    }
434
435    #[test]
436    fn truncate_under_budget_passthrough() {
437        let s = truncate_to_budget("short".into(), 100).expect("kept");
438        assert_eq!(s, "short");
439    }
440
441    #[test]
442    fn truncate_over_budget_appends_annotation() {
443        let big = "x".repeat(500);
444        let s = truncate_to_budget(big, 100).expect("truncated");
445        assert!(s.starts_with("x"));
446        assert!(s.contains("truncated from 500 bytes"));
447    }
448
449    #[test]
450    fn truncate_respects_utf8_boundary() {
451        // 3-byte char at byte 99 must not be split.
452        let s = format!("{}é", "a".repeat(99));
453        let truncated = truncate_to_budget(s, 100).expect("truncated");
454        // No char-boundary panics; just verify it parses as valid UTF-8
455        // and contains the truncation annotation.
456        assert!(truncated.contains("truncated from"));
457    }
458
459    // ── find_user_global_instructions / load_layered_instructions ──
460    //
461    // PMAT-876: tests below mutate the process-wide `APR_CONFIG` env var.
462    // `std::env` is process-global and cargo test runs `#[test]` fns in
463    // parallel by default, so they all acquire the ONE crate-wide
464    // `env_test_support::env_lock` (shared with auto_memory + settings,
465    // which touch the same variable) and use ScopedEnv to restore the
466    // prior value on drop.
467
468    #[test]
469    fn user_global_honors_apr_config_env_first() {
470        let _guard = env_lock();
471        let dir = tempfile::tempdir().expect("tempdir");
472        write(&dir.path().join("CLAUDE.md"), "user-global-content");
473        let _env = ScopedEnv::set("APR_CONFIG", dir.path());
474        let p = find_user_global_instructions().expect("found");
475        assert_eq!(p, dir.path().join("CLAUDE.md"));
476    }
477
478    #[test]
479    fn user_global_prefers_apr_md_over_claude_md_within_layer() {
480        let _guard = env_lock();
481        let dir = tempfile::tempdir().expect("tempdir");
482        write(&dir.path().join("APR.md"), "apr-version");
483        write(&dir.path().join("CLAUDE.md"), "claude-version");
484        let _env = ScopedEnv::set("APR_CONFIG", dir.path());
485        let p = find_user_global_instructions().expect("found");
486        assert_eq!(p, dir.path().join("APR.md"), "APR.md wins over CLAUDE.md within a layer");
487    }
488
489    #[test]
490    fn load_layered_returns_none_when_nothing_to_load() {
491        let _guard = env_lock();
492        let cfg = tempfile::tempdir().expect("cfg");
493        let proj = tempfile::tempdir().expect("proj");
494        let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
495        let mut warns = Vec::new();
496        let out = load_layered_instructions(proj.path(), 4096, &mut warns);
497        assert!(out.is_none());
498    }
499
500    #[test]
501    fn load_layered_concatenates_user_global_then_project() {
502        let _guard = env_lock();
503        let cfg = tempfile::tempdir().expect("cfg");
504        let proj = tempfile::tempdir().expect("proj");
505        write(&cfg.path().join("CLAUDE.md"), "USER-GLOBAL-BODY\n");
506        write(&proj.path().join("CLAUDE.md"), "PROJECT-BODY\n");
507        let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
508        let mut warns = Vec::new();
509        let out = load_layered_instructions(proj.path(), 65536, &mut warns).expect("loaded");
510        let user_idx = out.find("USER-GLOBAL-BODY").expect("user-global present");
511        let proj_idx = out.find("PROJECT-BODY").expect("project present");
512        assert!(
513            user_idx < proj_idx,
514            "user-global must come before project so project wins context-wise"
515        );
516        assert!(out.contains("User-global instructions"));
517        assert!(out.contains("Project instructions"));
518    }
519
520    #[test]
521    fn load_layered_resolves_imports_in_each_layer() {
522        let _guard = env_lock();
523        let cfg = tempfile::tempdir().expect("cfg");
524        let proj = tempfile::tempdir().expect("proj");
525        // user-global: imports a sibling file
526        write(&cfg.path().join("CLAUDE.md"), "USER\n@./shared.md\n");
527        write(&cfg.path().join("shared.md"), "USER-SHARED\n");
528        // project: imports a sibling file
529        write(&proj.path().join("CLAUDE.md"), "PROJ\n@./conv.md\n");
530        write(&proj.path().join("conv.md"), "PROJ-CONV\n");
531        let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
532        let mut warns = Vec::new();
533        let out = load_layered_instructions(proj.path(), 65536, &mut warns).expect("loaded");
534        assert!(out.contains("USER-SHARED"));
535        assert!(out.contains("PROJ-CONV"));
536        assert!(warns.is_empty(), "no warnings expected, got: {warns:?}");
537    }
538}