use crate::project::ProjectConfig;
use anyhow::Result;
use std::fs;
use std::path::Path;
const START_MARKER: &str = "<!-- aid:start -->";
const END_MARKER: &str = "<!-- aid:end -->";
pub fn sync_claude_md(git_root: &Path, config: &ProjectConfig) -> Result<()> {
let path = git_root.join("CLAUDE.md");
let section = generate_aid_section(config);
let next = match fs::read_to_string(&path) {
Ok(contents) => match marker_range(&contents) {
Some((start, end)) => {
let mut updated = String::new();
updated.push_str(&contents[..start]);
updated.push_str(§ion);
updated.push_str(&contents[end..]);
updated
}
None if contents.is_empty() => section,
None => format!("{contents}\n\n{section}"),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => section,
Err(err) => return Err(err.into()),
};
fs::write(path, next)?;
Ok(())
}
pub fn read_aid_section(git_root: &Path) -> Option<String> {
let path = git_root.join("CLAUDE.md");
let contents = fs::read_to_string(path).ok()?;
let (start, end) = marker_range(&contents)?;
Some(contents[start..end].to_string())
}
fn generate_aid_section(config: &ProjectConfig) -> String {
let mut lines = vec as the primary development method.".to_string(),
"Use `aid run` to dispatch coding tasks to AI agents instead of writing code directly.".to_string(),
String::new(),
];
push_value(&mut lines, "Project", Some(config.id.as_str()));
push_value(&mut lines, "Profile", config.profile.as_deref());
push_value(&mut lines, "Language", config.language.as_deref());
push_value(&mut lines, "Budget", config.budget.budget_shorthand().as_deref());
push_value(&mut lines, "Verify", config.verify.as_deref());
push_value(&mut lines, "Team", config.team.as_deref());
let rules: Vec<_> = config.rules.iter().map(String::as_str).filter(|v| !v.trim().is_empty()).collect();
if !rules.is_empty() {
lines.push(String::new());
lines.push("### Rules".to_string());
lines.extend(rules.into_iter().map(|rule| format!("- {rule}")));
}
lines.push(String::new());
lines.push("### Usage".to_string());
lines.push("- Dispatch work: `aid run <agent> \"<prompt>\" --dir .`".to_string());
lines.push("- Review output: `aid show <id> --diff`".to_string());
lines.push("- Batch dispatch: `aid batch <file> --parallel`".to_string());
lines.push("- Project config: `.aid/project.toml`".to_string());
lines.push(String::new());
lines.push(END_MARKER.to_string());
lines.join("\n") + "\n"
}
fn push_value(lines: &mut Vec<String>, label: &str, value: Option<&str>) {
if let Some(value) = value.filter(|v| !v.trim().is_empty()) {
lines.push(format!("- **{label}**: {value}"));
}
}
fn marker_range(contents: &str) -> Option<(usize, usize)> {
let start = contents.find(START_MARKER)?;
let end_marker = contents[start..].find(END_MARKER)? + start;
let end = end_marker + END_MARKER.len();
let end = if contents[end..].starts_with('\n') { end + 1 } else { end };
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn config() -> ProjectConfig {
ProjectConfig {
id: "alpha".to_string(),
profile: Some("standard".to_string()),
team: Some("dev".to_string()),
verify: Some("cargo check".to_string()),
language: Some("rust".to_string()),
rules: vec!["All changes must have tests".to_string()],
..Default::default()
}
}
#[test]
fn sync_creates_new_claude_md() {
let dir = TempDir::new().unwrap();
sync_claude_md(dir.path(), &config()).unwrap();
let body = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(body.contains(START_MARKER));
assert!(body.contains("- **Project**: alpha"));
}
#[test]
fn sync_updates_existing_section() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("CLAUDE.md"), "<!-- aid:start -->\nold\n<!-- aid:end -->\n").unwrap();
let mut cfg = config();
cfg.id = "beta".to_string();
sync_claude_md(dir.path(), &cfg).unwrap();
let body = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(!body.contains("\nold\n"));
assert!(body.contains("- **Project**: beta"));
}
#[test]
fn sync_appends_to_existing() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("CLAUDE.md"), "# Notes").unwrap();
sync_claude_md(dir.path(), &config()).unwrap();
let body = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(body.starts_with("# Notes\n\n"));
assert!(body.contains(START_MARKER));
}
#[test]
fn sync_preserves_surrounding_content() {
let dir = TempDir::new().unwrap();
let initial = "before\n<!-- aid:start -->\nold\n<!-- aid:end -->\nafter\n";
fs::write(dir.path().join("CLAUDE.md"), initial).unwrap();
sync_claude_md(dir.path(), &config()).unwrap();
let body = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(body.starts_with("before\n"));
assert!(body.ends_with("after\n"));
}
#[test]
fn read_returns_none_without_file() {
let dir = TempDir::new().unwrap();
assert_eq!(read_aid_section(dir.path()), None);
}
#[test]
fn read_returns_section() {
let dir = TempDir::new().unwrap();
let body = "x\n<!-- aid:start -->\nsection\n<!-- aid:end -->\ny\n";
fs::write(dir.path().join("CLAUDE.md"), body).unwrap();
assert_eq!(read_aid_section(dir.path()), Some("<!-- aid:start -->\nsection\n<!-- aid:end -->\n".to_string()));
}
#[test]
fn optional_fields_omitted() {
let dir = TempDir::new().unwrap();
let cfg = ProjectConfig { id: "alpha".to_string(), ..Default::default() };
sync_claude_md(dir.path(), &cfg).unwrap();
let body = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(!body.contains("### Rules"));
assert!(!body.contains("- **Team**:"));
}
}