use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRef {
pub label: String,
pub path: String,
pub fragment: Option<String>,
pub description: Option<String>,
}
pub fn load_project_context(working_dir: &Path) -> Option<String> {
let agents_path = find_agents_md(working_dir)?;
let raw = fs::read_to_string(&agents_path).ok()?;
let base_dir = agents_path.parent()?;
let refs = parse_file_references(&raw);
if refs.is_empty() {
return Some(raw);
}
let resolved = resolve_references(base_dir, &refs);
let mut output = raw;
if !resolved.is_empty() {
output.push_str("\n---\n\n## Resolved File References\n\n");
for (file_ref, content) in &resolved {
let heading = file_ref
.description
.as_ref()
.map_or_else(|| format!("### {}\n", file_ref.label), |desc| format!("### {} — {}\n", file_ref.label, desc));
output.push_str(&heading);
output.push_str("\n```\n");
output.push_str(content);
if !content.ends_with('\n') {
output.push('\n');
}
output.push_str("```\n\n");
}
}
Some(output)
}
fn find_agents_md(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
let candidate = dir.join("AGENTS.md");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
pub fn parse_file_references(content: &str) -> Vec<FileRef> {
let mut refs = Vec::new();
let mut in_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") || trimmed.starts_with("# ") {
in_section = trimmed.to_lowercase().contains("file reference");
continue;
}
if !in_section {
continue;
}
if let Some(file_ref) = parse_link_line(trimmed) {
refs.push(file_ref);
}
}
refs
}
fn parse_link_line(line: &str) -> Option<FileRef> {
let line = line.strip_prefix("- ").or_else(|| line.strip_prefix("* "))?;
let open_bracket = line.find('[')?;
let close_bracket = line[open_bracket..].find(']')? + open_bracket;
let label = line[open_bracket + 1..close_bracket].to_string();
let rest = &line[close_bracket + 1..];
let open_paren = rest.find('(')?;
let close_paren = rest[open_paren..].find(')')? + open_paren;
let target = &rest[open_paren + 1..close_paren];
let (path, fragment) = target.find('#').map_or_else(
|| (target.to_string(), None),
|hash_pos| (target[..hash_pos].to_string(), Some(target[hash_pos + 1..].to_string())),
);
let after_link = &rest[close_paren + 1..];
let description = after_link
.strip_prefix(" — ")
.or_else(|| after_link.strip_prefix(" - "))
.or_else(|| after_link.strip_prefix(" -- "))
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty());
if path.is_empty() && fragment.is_none() {
return None;
}
Some(FileRef {
label,
path,
fragment,
description,
})
}
fn resolve_references(base_dir: &Path, refs: &[FileRef]) -> Vec<(FileRef, String)> {
let mut results = Vec::new();
for file_ref in refs {
let file_path = base_dir.join(&file_ref.path);
let Ok(content) = fs::read_to_string(&file_path) else {
continue; };
let resolved = if let Some(ref fragment) = file_ref.fragment {
extract_section(&content, fragment)
} else {
content
};
if !resolved.trim().is_empty() {
results.push((file_ref.clone(), resolved));
}
}
results
}
fn extract_section(content: &str, fragment: &str) -> String {
let target = normalize_fragment(fragment);
let lines: Vec<&str> = content.lines().collect();
let mut start = None;
let mut start_level = 0;
for (i, line) in lines.iter().enumerate() {
if let Some((level, text)) = parse_heading(line) {
let anchor = heading_to_anchor(text);
if anchor == target || anchor.contains(&target) || target.contains(&anchor) {
start = Some(i);
start_level = level;
continue;
}
if let Some(s) = start {
if level <= start_level {
return lines[s..i].join("\n");
}
}
}
}
if let Some(s) = start {
return lines[s..].join("\n");
}
String::new()
}
fn parse_heading(line: &str) -> Option<(usize, &str)> {
let trimmed = line.trim();
if !trimmed.starts_with('#') {
return None;
}
let level = trimmed.chars().take_while(|&c| c == '#').count();
let text = trimmed[level..].trim();
if text.is_empty() {
return None;
}
Some((level, text))
}
fn heading_to_anchor(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else if c == ' ' {
'-'
} else {
'\0'
}
})
.filter(|&c| c != '\0')
.collect::<String>()
.replace("--", "-")
}
fn normalize_fragment(fragment: &str) -> String {
heading_to_anchor(fragment)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_link() {
let r = parse_link_line("- [CLAUDE.md](CLAUDE.md) — Project overview").unwrap();
assert_eq!(r.label, "CLAUDE.md");
assert_eq!(r.path, "CLAUDE.md");
assert!(r.fragment.is_none());
assert_eq!(r.description.as_deref(), Some("Project overview"));
}
#[test]
fn parse_link_with_fragment() {
let r = parse_link_line("- [Pearl tracking](CLAUDE.md#6-pearl-tracking) — Pearl workflow").unwrap();
assert_eq!(r.label, "Pearl tracking");
assert_eq!(r.path, "CLAUDE.md");
assert_eq!(r.fragment.as_deref(), Some("6-pearl-tracking"));
assert_eq!(r.description.as_deref(), Some("Pearl workflow"));
}
#[test]
fn parse_link_no_description() {
let r = parse_link_line("- [README](README.md)").unwrap();
assert_eq!(r.label, "README");
assert_eq!(r.path, "README.md");
assert!(r.fragment.is_none());
assert!(r.description.is_none());
}
#[test]
fn parse_file_references_section() {
let content = "# Agent Instructions\n\nSome intro text.\n\n## File References\n\n\
- [CLAUDE.md](CLAUDE.md) — Full file\n\
- [Testing](CLAUDE.md#8-testing) — Testing reqs\n\n\
## Other Section\n\n\
- [not a ref](foo.md)\n";
let refs = parse_file_references(content);
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].path, "CLAUDE.md");
assert!(refs[0].fragment.is_none());
assert_eq!(refs[1].path, "CLAUDE.md");
assert_eq!(refs[1].fragment.as_deref(), Some("8-testing"));
}
#[test]
fn heading_to_anchor_basic() {
assert_eq!(heading_to_anchor("6. Pearl Tracking"), "6-pearl-tracking");
assert_eq!(heading_to_anchor("Testing - MANDATORY"), "testing--mandatory");
assert_eq!(heading_to_anchor("Simple Heading"), "simple-heading");
}
#[test]
fn extract_section_by_fragment() {
let content = "# Top\n\nIntro\n\n## Section A\n\nContent A\n\n## Section B\n\nContent B\n\n### Subsection\n\nSub content\n";
let section = extract_section(content, "section-a");
assert!(section.contains("## Section A"));
assert!(section.contains("Content A"));
assert!(!section.contains("Section B"));
}
#[test]
fn extract_section_to_eof() {
let content = "# Top\n\n## Last Section\n\nFinal content\n";
let section = extract_section(content, "last-section");
assert!(section.contains("## Last Section"));
assert!(section.contains("Final content"));
}
#[test]
fn extract_section_not_found() {
let content = "# Top\n\n## Existing\n\nContent\n";
let section = extract_section(content, "nonexistent");
assert!(section.is_empty());
}
#[test]
fn load_from_temp_dir() {
let tmp = tempfile::tempdir().expect("create temp dir");
let agents = tmp.path().join("AGENTS.md");
let claude = tmp.path().join("CLAUDE.md");
fs::write(
&claude,
"# Project\n\nOverview\n\n## Testing\n\nAll tests must pass.\n\n## Deploy\n\nNever deploy locally.\n",
)
.unwrap();
fs::write(
&agents,
"# Agent Instructions\n\n## File References\n\n- [Testing](CLAUDE.md#testing) — Test reqs\n\n## Rules\n\nBe helpful.\n",
)
.unwrap();
let ctx = load_project_context(tmp.path()).expect("load context");
assert!(ctx.contains("Agent Instructions"));
assert!(ctx.contains("Resolved File References"));
assert!(ctx.contains("All tests must pass"));
}
#[test]
fn load_returns_none_when_no_agents_md() {
let tmp = tempfile::tempdir().expect("create temp dir");
assert!(load_project_context(tmp.path()).is_none());
}
#[test]
fn load_without_file_references_returns_raw() {
let tmp = tempfile::tempdir().expect("create temp dir");
let agents = tmp.path().join("AGENTS.md");
fs::write(&agents, "# Agent Instructions\n\nJust some text.\n").unwrap();
let ctx = load_project_context(tmp.path()).expect("load context");
assert_eq!(ctx, "# Agent Instructions\n\nJust some text.\n");
}
}