use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use argonauts_core::hash::sha256_hex;
use crate::error::Result;
use crate::skills::Drift;
use crate::store::{AGENTS_FILE, CLAUDE_FILE, Store};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncReport {
pub written: Vec<PathBuf>,
pub unchanged: Vec<PathBuf>,
pub warnings: Vec<String>,
pub edited: Vec<PathBuf>,
pub skill_drift: Vec<Drift>,
pub readers: Option<String>,
}
const AGENTS_READERS: &str = "Codex and agy";
pub const CODEX_CAP: usize = 32 * 1024;
pub const ANTIGRAVITY_CAP: usize = 24_000;
const NEAR_CAP_PERCENT: usize = 90;
const MARKER_START: &str = "<!-- argonauts sync: written from .argonauts/conventions.md. Edit that file, then run argonauts sync. sha256=";
const MARKER_END: &str = " -->";
static MARKER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^<!-- argonauts sync: [^\n]*sha256=([0-9a-f]{64}) -->[ \t]*\r?\n?")
.unwrap_or_else(|err| panic!("sync marker pattern: {err}"))
});
static HEADING: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(#{1,6})[ \t]+(.*?)[ \t#]*$")
.unwrap_or_else(|err| panic!("heading pattern: {err}"))
});
pub fn render(conventions: &str) -> (String, String) {
let (shared, claude_only) = split_claude_only(conventions);
let shared = tidy(&shared);
let claude_only = tidy(&claude_only);
let agents = with_marker("", &shared);
let mut claude = String::from("@AGENTS.md\n");
if !claude_only.is_empty() {
claude.push('\n');
claude.push_str(&claude_only);
}
let claude = with_marker("@AGENTS.md\n", &claude["@AGENTS.md\n".len()..]);
(agents, claude)
}
fn with_marker(head: &str, rest: &str) -> String {
let hash = sha256_hex(format!("{head}{rest}").as_bytes());
format!("{head}{MARKER_START}{hash}{MARKER_END}\n{rest}")
}
fn intact(text: &str) -> bool {
let Some(captures) = MARKER.captures(text) else {
return false;
};
let (Some(whole), Some(hash)) = (captures.get(0), captures.get(1)) else {
return false;
};
let rest = format!("{}{}", &text[..whole.start()], &text[whole.end()..]);
sha256_hex(rest.as_bytes()) == hash.as_str()
}
fn split_claude_only(conventions: &str) -> (String, String) {
let mut shared = String::new();
let mut claude = String::new();
let mut fence: Option<&str> = None;
let mut inside: Option<usize> = None;
for line in conventions.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\n', '\r']);
let opener = trimmed.trim_start();
if let Some(mark) = fence {
if opener.starts_with(mark) {
fence = None;
}
} else if opener.starts_with("```") || opener.starts_with("~~~") {
fence = Some(&opener[..3]);
} else if let Some(captures) = HEADING.captures(trimmed) {
let level = captures.get(1).map_or(0, |m| m.as_str().len());
let title = captures.get(2).map_or("", |m| m.as_str());
if inside.is_some_and(|open| level <= open) {
inside = None;
}
if inside.is_none() && title.to_lowercase().contains("claude code only") {
inside = Some(level);
}
}
if inside.is_some() {
claude.push_str(line);
} else {
shared.push_str(line);
}
}
(shared, claude)
}
fn tidy(text: &str) -> String {
let mut out = String::new();
let mut blank = 0;
for line in text.lines() {
if line.trim().is_empty() {
blank += 1;
if blank > 1 || out.is_empty() {
continue;
}
} else {
blank = 0;
}
out.push_str(line.trim_end());
out.push('\n');
}
while out.ends_with("\n\n") {
out.pop();
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncMode {
Write,
Check,
Force,
}
pub fn sync(store: &Store, mode: SyncMode) -> Result<SyncReport> {
let mut report = SyncReport::default();
match read_optional(&store.conventions())? {
Some(conventions) => {
let (agents, claude) = render(&conventions);
report.warnings.extend(size_warnings(&agents));
let claude_path = store.repo_root.join(CLAUDE_FILE);
for (name, wanted) in [(AGENTS_FILE, agents), (CLAUDE_FILE, claude.clone())] {
place(&store.repo_root.join(name), &wanted, mode, &mut report)?;
}
let claude = if report.edited.contains(&claude_path) {
read_optional(&claude_path)?.unwrap_or_default()
} else {
claude
};
if imports_agents(&claude) {
report.readers = Some(format!(
"Claude Code reads {CLAUDE_FILE}, which imports {AGENTS_FILE}. {AGENTS_READERS} \
read {AGENTS_FILE}."
));
} else {
report.warnings.push(format!(
"{CLAUDE_FILE} doesn't import {AGENTS_FILE}, so Claude Code won't read the \
conventions. Add @{AGENTS_FILE} as its first line."
));
report.readers = Some(format!("{AGENTS_READERS} read {AGENTS_FILE}."));
}
}
None => report.warnings.push(
"There's no .argonauts/conventions.md, so AGENTS.md and CLAUDE.md were left as they \
are."
.to_owned(),
),
}
report.skill_drift = crate::skills::drift(store)?;
for drift in &report.skill_drift {
report.warnings.push(format!(
"The {} skill was edited since it was exported, so it was left alone. Re-export its \
learned workflow, or keep your edits.",
drift.name
));
}
Ok(report)
}
fn place(path: &Path, wanted: &str, mode: SyncMode, report: &mut SyncReport) -> Result<()> {
let current = read_optional(path)?;
if current.as_deref() == Some(wanted) {
report.unchanged.push(path.to_path_buf());
return Ok(());
}
let hand_edited = current.as_deref().is_some_and(|text| !intact(text));
if hand_edited && mode != SyncMode::Force {
report.edited.push(path.to_path_buf());
report.warnings.push(format!(
"{} was edited by hand, so sync left it alone. Move the edits into \
.argonauts/conventions.md, or run sync with --force to replace them.",
file_name(path)
));
return Ok(());
}
if mode != SyncMode::Check {
write_atomically(path, wanted)?;
if hand_edited {
report
.warnings
.push(format!("Replaced the hand edits in {}.", file_name(path)));
}
}
report.written.push(path.to_path_buf());
Ok(())
}
fn imports_agents(claude: &str) -> bool {
claude.lines().any(|line| {
line.trim()
.strip_prefix('@')
.map(|target| target.trim_start_matches("./"))
== Some(AGENTS_FILE)
})
}
fn size_warnings(agents: &str) -> Vec<String> {
let bytes = agents.len();
let mut warnings = Vec::new();
for (cli, cap, cap_text) in [
("Codex", CODEX_CAP, "32 KiB"),
("agy", ANTIGRAVITY_CAP, "24,000 bytes"),
] {
if bytes > cap {
warnings.push(format!(
"AGENTS.md is {bytes} bytes, over {cli}'s {cap_text}, so {cli} drops the \
rest. Shorten the conventions."
));
} else if bytes * 100 >= cap * NEAR_CAP_PERCENT {
warnings.push(format!(
"AGENTS.md is {bytes} bytes, near {cli}'s {cap_text}, past which {cli} \
drops the rest."
));
}
}
warnings
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn read_optional(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
fn write_atomically(path: &Path, text: &str) -> Result<()> {
let temp = path.with_file_name(format!(
".{}.argonauts-{}",
file_name(path),
std::process::id()
));
fs::write(&temp, text)?;
if let Err(err) = fs::rename(&temp, path) {
let _ = fs::remove_file(&temp);
return Err(err.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markers_detect_edits() {
let (agents, claude) = render("# Rules\n\nUse tabs.\n");
assert!(intact(&agents));
assert!(intact(&claude));
assert!(!intact(&agents.replace("tabs", "spaces")));
assert!(!intact("# Rules\n"));
}
#[test]
fn fenced_headings_stay_put() {
let (shared, claude) = split_claude_only(
"# A\n```\n# Claude Code only\n```\n## Claude Code only\nx\n# B\ny\n",
);
assert_eq!(shared, "# A\n```\n# Claude Code only\n```\n# B\ny\n");
assert_eq!(claude, "## Claude Code only\nx\n");
}
}