const BEGIN: &str = "# >>> agentlink >>>";
const END: &str = "# <<< agentlink <<<";
const PREAMBLE: &str = "\
# Materialised by `agentlink apply` from the canonical layout (AGENTS.md,
# .agents/). Commit the canonical layout; these paths are per-developer.
# Managed automatically — edits inside this block are overwritten.";
pub fn update(existing: &str, entries: &[String]) -> String {
if entries.is_empty() && !is_managed(existing) {
return existing.to_string();
}
let newline = if existing.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let (before, after) = split(existing);
if entries.is_empty() {
return join(&before, "", &after, newline);
}
let mut block = String::with_capacity(256);
block.push_str(BEGIN);
block.push_str(newline);
for line in PREAMBLE.lines() {
block.push_str(line);
block.push_str(newline);
}
for entry in entries {
block.push('/');
block.push_str(entry.trim_start_matches('/'));
block.push_str(newline);
}
block.push_str(END);
join(&before, &block, &after, newline)
}
pub fn is_managed(existing: &str) -> bool {
existing.lines().any(|line| line.trim_end() == BEGIN)
}
fn split(existing: &str) -> (Vec<&str>, Vec<&str>) {
let lines: Vec<&str> = existing.lines().collect();
let begin = lines.iter().position(|line| line.trim_end() == BEGIN);
let Some(begin) = begin else {
return (lines, Vec::new());
};
let end = lines[begin..]
.iter()
.position(|line| line.trim_end() == END)
.map_or(lines.len(), |offset| begin + offset + 1);
(lines[..begin].to_vec(), lines[end..].to_vec())
}
fn join(before: &[&str], block: &str, after: &[&str], newline: &str) -> String {
let mut sections: Vec<String> = Vec::new();
let before = trim_trailing_blanks(before);
if !before.is_empty() {
sections.push(before.join(newline));
}
if !block.is_empty() {
sections.push(block.to_string());
}
let after = trim_leading_blanks(after);
if !after.is_empty() {
sections.push(after.join(newline));
}
if sections.is_empty() {
return String::new();
}
let separator = format!("{newline}{newline}");
format!("{}{newline}", sections.join(&separator))
}
fn trim_trailing_blanks<'a>(lines: &[&'a str]) -> Vec<&'a str> {
let end = lines
.iter()
.rposition(|line| !line.trim().is_empty())
.map_or(0, |i| i + 1);
lines[..end].to_vec()
}
fn trim_leading_blanks<'a>(lines: &[&'a str]) -> Vec<&'a str> {
let start = lines
.iter()
.position(|line| !line.trim().is_empty())
.unwrap_or(lines.len());
lines[start..].to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(paths: &[&str]) -> Vec<String> {
paths.iter().copied().map(String::from).collect()
}
#[test]
fn adds_a_block_to_an_empty_file() {
let result = update("", &entries(["CLAUDE.md", ".claude/skills"].as_slice()));
assert!(result.starts_with(BEGIN));
assert!(result.contains("\n/CLAUDE.md\n"));
assert!(result.contains("\n/.claude/skills\n"));
assert!(result.trim_end().ends_with(END));
}
#[test]
fn preserves_existing_content_around_the_block() {
let original = "node_modules/\ntarget/\n";
let result = update(original, &entries(["CLAUDE.md"].as_slice()));
assert!(result.starts_with("node_modules/\ntarget/\n"));
assert!(result.contains("/CLAUDE.md"));
}
#[test]
fn rewriting_is_idempotent() {
let paths = entries(["CLAUDE.md", ".claude/skills"].as_slice());
let once = update("node_modules/\n", &paths);
let twice = update(&once, &paths);
assert_eq!(once, twice);
}
#[test]
fn entries_that_stop_applying_are_removed() {
let first = update("", &entries(["CLAUDE.md", ".cursor/skills"].as_slice()));
let second = update(&first, &entries(["CLAUDE.md"].as_slice()));
assert!(second.contains("/CLAUDE.md"));
assert!(!second.contains(".cursor/skills"));
assert_eq!(second.matches(BEGIN).count(), 1);
}
#[test]
fn removing_the_block_restores_the_surrounding_file() {
let original = "node_modules/\ntarget/\n";
let with_block = update(original, &entries(["CLAUDE.md"].as_slice()));
assert_eq!(update(&with_block, &[]), original);
}
#[test]
fn user_content_after_the_block_survives_a_rewrite() {
let original = update("before/\n", &entries(["CLAUDE.md"].as_slice()));
let edited = format!("{original}\nafter/\n");
let result = update(&edited, &entries([".claude/skills"].as_slice()));
assert!(result.starts_with("before/"));
assert!(result.trim_end().ends_with("after/"));
assert!(result.contains("/.claude/skills"));
assert!(!result.contains("/CLAUDE.md"));
assert_eq!(result.matches(BEGIN).count(), 1);
}
#[test]
fn an_unterminated_block_is_repaired_rather_than_duplicated() {
let broken = format!("keep/\n{BEGIN}\n/stale\n");
let result = update(&broken, &entries(["CLAUDE.md"].as_slice()));
assert_eq!(result.matches(BEGIN).count(), 1);
assert_eq!(result.matches(END).count(), 1);
assert!(!result.contains("/stale"));
assert!(result.starts_with("keep/"));
}
#[test]
fn paths_are_anchored_to_the_repository_root() {
let result = update("", &entries(["CLAUDE.md"].as_slice()));
assert!(result.contains("\n/CLAUDE.md\n"));
}
#[test]
fn a_crlf_file_stays_crlf() {
let original = "node_modules/\r\ntarget/\r\n";
let result = update(original, &entries(["CLAUDE.md"].as_slice()));
assert!(result.contains("\r\n"), "line endings were converted to LF");
assert!(
!result.replace("\r\n", "").contains('\n'),
"output mixes CRLF and LF"
);
assert!(result.contains("/CLAUDE.md\r\n"));
}
#[test]
fn an_lf_file_stays_lf() {
let result = update("node_modules/\n", &entries(["CLAUDE.md"].as_slice()));
assert!(!result.contains('\r'));
}
#[test]
fn a_file_with_no_block_and_nothing_to_add_is_left_byte_identical() {
for original in ["node_modules/\r\ntarget/\r\n", "a/\n\n\n", ""] {
assert_eq!(update(original, &[]), original);
}
}
#[test]
fn detects_whether_a_file_is_already_managed() {
assert!(!is_managed("node_modules/\n"));
assert!(is_managed(&update("", &entries(["CLAUDE.md"].as_slice()))));
}
#[test]
fn output_always_ends_with_exactly_one_newline() {
for input in ["", "a/\n", "a/\n\n\n"] {
let result = update(input, &entries(["CLAUDE.md"].as_slice()));
assert!(result.ends_with('\n'));
assert!(!result.ends_with("\n\n"));
}
}
}