use std::path::PathBuf;
use serde_json::{json, Value};
use super::jsonc::JsonDoc;
use super::{display_path, read_optional, upsert_file, Env, ItemResult, Outcome};
pub const BLOCK: &str = include_str!("templates/agents-block.md");
pub const BLOCK_VERSION: &str = "v1";
const BEGIN_PREFIX: &str = "<!-- BEGIN:arete";
const END_MARKER: &str = "<!-- END:arete -->";
pub const CLAUDE_IMPORT: &str = "@AGENTS.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockState {
Missing,
Stale(String),
Current,
Malformed(String),
}
pub const MALFORMED_FIX: &str =
"fix the <!-- BEGIN:arete v1 --> / <!-- END:arete --> markers in AGENTS.md by hand, then rerun";
enum Span {
Missing,
Found(usize, usize, String),
Malformed(String),
}
fn block_span(lines: &[&str]) -> Span {
let begin = lines
.iter()
.position(|line| line.trim_start().starts_with(BEGIN_PREFIX));
let end_from = |from: usize| {
lines[from..]
.iter()
.position(|line| line.trim() == END_MARKER)
.map(|offset| from + offset)
};
let Some(begin) = begin else {
return match end_from(0) {
Some(_) => Span::Malformed("end marker without a begin marker".to_string()),
None => Span::Missing,
};
};
let Some(end) = end_from(begin) else {
return Span::Malformed("begin marker without an end marker".to_string());
};
let token = lines[begin]
.trim()
.trim_start_matches(BEGIN_PREFIX)
.trim_end_matches("-->")
.trim()
.to_string();
Span::Found(begin, end, token)
}
pub fn block_state(content: &str) -> BlockState {
let lines: Vec<&str> = content.lines().collect();
match block_span(&lines) {
Span::Missing => BlockState::Missing,
Span::Malformed(reason) => BlockState::Malformed(reason),
Span::Found(begin, end, token) => {
let current: Vec<&str> = lines[begin..=end].to_vec();
if token == BLOCK_VERSION && current.join("\n") == BLOCK.trim_end_matches('\n') {
BlockState::Current
} else {
BlockState::Stale(token)
}
}
}
}
pub fn upsert_block(existing: Option<&str>) -> anyhow::Result<String> {
let block = BLOCK.trim_end_matches('\n');
let Some(existing) = existing else {
return Ok(format!("{block}\n"));
};
let lines: Vec<&str> = existing.lines().collect();
match block_span(&lines) {
Span::Found(begin, end, _) => {
let mut out: Vec<&str> = Vec::with_capacity(lines.len());
out.extend_from_slice(&lines[..begin]);
out.extend(block.lines());
out.extend_from_slice(&lines[end + 1..]);
let mut text = out.join("\n");
text.push('\n');
Ok(text)
}
Span::Missing => {
let body = existing.trim_end_matches(['\n', '\r']);
if body.trim().is_empty() {
Ok(format!("{block}\n"))
} else {
Ok(format!("{body}\n\n{block}\n"))
}
}
Span::Malformed(reason) => {
anyhow::bail!("AGENTS.md has a malformed Arete block ({reason}); {MALFORMED_FIX}")
}
}
}
pub fn claude_md_ok(content: &str) -> bool {
content.lines().any(|line| line.trim() == CLAUDE_IMPORT)
}
pub fn upsert_claude_md(existing: Option<&str>) -> String {
match existing {
None => format!("{CLAUDE_IMPORT}\n"),
Some(content) if claude_md_ok(content) => content.to_string(),
Some(content) => format!("{CLAUDE_IMPORT}\n{content}"),
}
}
const GEMINI_CONTEXT_FILES: [&str; 2] = ["AGENTS.md", "GEMINI.md"];
fn gemini_file_names(doc: &JsonDoc) -> Vec<String> {
match doc.get(&["context", "fileName"]) {
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect(),
Some(Value::String(single)) => vec![single],
_ => Vec::new(),
}
}
pub fn gemini_context_ok(content: &str) -> bool {
JsonDoc::parse(content)
.map(|doc| {
gemini_file_names(&doc)
.iter()
.any(|name| name == "AGENTS.md")
})
.unwrap_or(false)
}
pub fn upsert_gemini_context(existing: Option<&str>) -> anyhow::Result<Option<String>> {
let doc = JsonDoc::parse(existing.unwrap_or(""))?;
if !doc.root_is_object() {
anyhow::bail!("root value is not an object");
}
let mut names = gemini_file_names(&doc);
let before = names.len();
for wanted in GEMINI_CONTEXT_FILES {
if !names.iter().any(|name| name == wanted) {
names.push(wanted.to_string());
}
}
if names.len() == before && existing.is_some() {
return Ok(None);
}
doc.set(&["context", "fileName"], &json!(names));
Ok(Some(doc.render()))
}
pub fn agents_md_path(env: &Env) -> PathBuf {
env.root.join("AGENTS.md")
}
pub fn claude_md_path(env: &Env) -> PathBuf {
env.root.join("CLAUDE.md")
}
pub fn gemini_settings_path(env: &Env) -> PathBuf {
env.root.join(".gemini").join("settings.json")
}
fn write_with(
env: &Env,
item: &str,
path: PathBuf,
dry_run: bool,
desired: impl FnOnce(Option<&str>) -> anyhow::Result<Option<String>>,
) -> ItemResult {
let shown = Some(display_path(env, &path));
let existing = match read_optional(&path) {
Ok(existing) => existing,
Err(error) => {
return ItemResult::new(item, Outcome::error(format!("{error:#}"), None), shown)
}
};
let outcome = match desired(existing.as_deref()) {
Ok(Some(content)) => upsert_file(&path, &content, dry_run),
Ok(None) => Outcome::Unchanged,
Err(error) => Outcome::error(
format!("{}: {error:#}", path.display()),
Some("fix the file by hand, then re-run a4 init".to_string()),
),
};
ItemResult::new(item, outcome, shown)
}
pub fn write_agents_md(env: &Env, dry_run: bool) -> ItemResult {
write_with(env, "agents-md", agents_md_path(env), dry_run, |existing| {
upsert_block(existing).map(Some)
})
}
pub fn write_claude_md(env: &Env, dry_run: bool) -> ItemResult {
write_with(env, "claude-md", claude_md_path(env), dry_run, |existing| {
Ok(Some(upsert_claude_md(existing)))
})
}
pub fn write_gemini_context(env: &Env, dry_run: bool) -> ItemResult {
write_with(
env,
"gemini-context",
gemini_settings_path(env),
dry_run,
upsert_gemini_context,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_has_markers_and_version() {
assert!(BLOCK.starts_with("<!-- BEGIN:arete v1 -->\n"));
assert!(BLOCK.ends_with("<!-- END:arete -->\n"));
assert_eq!(block_state(BLOCK), BlockState::Current);
}
#[test]
fn missing_block_is_appended_after_a_blank_line() {
assert_eq!(upsert_block(None).unwrap(), BLOCK);
assert_eq!(upsert_block(Some("")).unwrap(), BLOCK);
let user = "# My project\n\nSome notes.\n";
let updated = upsert_block(Some(user)).unwrap();
assert_eq!(updated, format!("# My project\n\nSome notes.\n\n{BLOCK}"));
assert_eq!(block_state(&updated), BlockState::Current);
assert_eq!(upsert_block(Some(&updated)).unwrap(), updated);
assert_eq!(upsert_block(Some("x")).unwrap(), format!("x\n\n{BLOCK}"));
}
#[test]
fn stale_block_is_replaced_and_surroundings_kept() {
let stale = "above\n\n<!-- BEGIN:arete v0 -->\nold stuff\n<!-- END:arete -->\n\nbelow\n";
assert_eq!(block_state(stale), BlockState::Stale("v0".into()));
let updated = upsert_block(Some(stale)).unwrap();
assert_eq!(updated, format!("above\n\n{BLOCK}\nbelow\n"));
assert_eq!(block_state(&updated), BlockState::Current);
let edited = BLOCK.replace("## Arete", "## Arete (edited)");
assert!(matches!(block_state(&edited), BlockState::Stale(_)));
assert_eq!(upsert_block(Some(&edited)).unwrap(), BLOCK);
}
#[test]
fn claude_md_import_is_first_line() {
assert_eq!(upsert_claude_md(None), "@AGENTS.md\n");
assert_eq!(upsert_claude_md(Some("# Notes\n")), "@AGENTS.md\n# Notes\n");
assert_eq!(
upsert_claude_md(Some("x\n@AGENTS.md\ny\n")),
"x\n@AGENTS.md\ny\n"
);
assert!(claude_md_ok(" @AGENTS.md \n"));
assert!(!claude_md_ok("see @AGENTS.md"));
}
#[test]
fn gemini_context_is_a_union() {
let created = upsert_gemini_context(None).unwrap().unwrap();
assert!(gemini_context_ok(&created));
let parsed: Value = serde_json::from_str(&created).unwrap();
assert_eq!(
parsed["context"]["fileName"],
json!(["AGENTS.md", "GEMINI.md"])
);
let existing = r#"{"theme": "x", "context": {"fileName": ["GEMINI.md", "OTHER.md"]}}"#;
let updated = upsert_gemini_context(Some(existing)).unwrap().unwrap();
let parsed: Value = serde_json::from_str(&updated).unwrap();
assert_eq!(
parsed["context"]["fileName"],
json!(["GEMINI.md", "OTHER.md", "AGENTS.md"])
);
assert_eq!(parsed["theme"], "x");
assert_eq!(upsert_gemini_context(Some(&updated)).unwrap(), None);
let single = r#"{"context": {"fileName": "GEMINI.md"}}"#;
let updated = upsert_gemini_context(Some(single)).unwrap().unwrap();
assert!(gemini_context_ok(&updated));
}
#[test]
fn unterminated_block_is_reported_not_duplicated() {
let partial = "# Mine\n\n<!-- BEGIN:arete v1 -->\n## Arete\nhalf a block\n";
assert!(matches!(block_state(partial), BlockState::Malformed(_)));
let error = upsert_block(Some(partial)).unwrap_err().to_string();
assert!(error.contains("malformed"), "{error}");
assert!(error.contains("by hand"), "{error}");
let orphan_end = "text\n<!-- END:arete -->\n";
assert!(matches!(block_state(orphan_end), BlockState::Malformed(_)));
assert!(upsert_block(Some(orphan_end)).is_err());
}
}