use std::fs;
use std::path::Path;
use anyhow::{Context, Result, bail};
use super::markdown::{END_MARKER, START_MARKER};
#[derive(Debug, PartialEq, Eq)]
pub enum SpliceOutcome {
UnchangedRoundTrip,
Spliced,
Appended,
}
pub fn splice_inline(path: &Path, body: &str) -> Result<()> {
let outcome = splice_inline_inner(path, body)?;
if outcome == SpliceOutcome::Appended {
eprintln!(
"alint: appended new alint-managed section to {} \
(no `{START_MARKER}` markers were found). \
Subsequent --inline runs will splice in place.",
path.display(),
);
}
Ok(())
}
pub fn splice_inline_inner(path: &Path, body: &str) -> Result<SpliceOutcome> {
let inner = body.trim_end_matches('\n');
debug_assert!(
inner.starts_with(START_MARKER) && inner.ends_with(END_MARKER),
"rendered body must include the alint markers",
);
let existing = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(anyhow::Error::from(e))
.with_context(|| format!("reading {}", path.display()));
}
};
let start_count = existing.matches(START_MARKER).count();
let end_count = existing.matches(END_MARKER).count();
if start_count > 1 {
bail!(
"{}: contains {} `{START_MARKER}` markers; refusing to splice ambiguously. \
Resolve to a single pair (or remove all of them and re-run).",
path.display(),
start_count,
);
}
if start_count == 1 && end_count == 0 {
bail!(
"{}: has `{START_MARKER}` but no matching `{END_MARKER}`. \
Resolve manually before re-running.",
path.display(),
);
}
if start_count == 0 && end_count >= 1 {
bail!(
"{}: has `{END_MARKER}` but no matching `{START_MARKER}`. \
Resolve manually before re-running.",
path.display(),
);
}
if start_count == 0 {
let mut new_contents = existing;
if !new_contents.is_empty() && !new_contents.ends_with('\n') {
new_contents.push('\n');
}
if !new_contents.is_empty() {
new_contents.push('\n');
}
new_contents.push_str(inner);
new_contents.push('\n');
fs::write(path, new_contents).with_context(|| format!("writing {}", path.display()))?;
return Ok(SpliceOutcome::Appended);
}
let start_idx = existing.find(START_MARKER).expect("checked count above");
let end_idx = existing.find(END_MARKER).expect("checked count above") + END_MARKER.len();
if end_idx <= start_idx {
bail!(
"{}: `{END_MARKER}` appears before `{START_MARKER}`; refusing to splice.",
path.display(),
);
}
let before = &existing[..start_idx];
let after = &existing[end_idx..];
let new_contents = format!("{before}{inner}{after}");
if new_contents == existing {
return Ok(SpliceOutcome::UnchangedRoundTrip);
}
fs::write(path, new_contents).with_context(|| format!("writing {}", path.display()))?;
Ok(SpliceOutcome::Spliced)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn td() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("alint-splice-")
.tempdir()
.unwrap()
}
fn body() -> String {
format!("{START_MARKER}\n\n## section\n\nbullet\n\n{END_MARKER}\n")
}
#[test]
fn appends_when_target_has_no_markers() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(&path, "# Existing\n\nSome prose.\n").unwrap();
let outcome = splice_inline_inner(&path, &body()).unwrap();
assert_eq!(outcome, SpliceOutcome::Appended);
let after = fs::read_to_string(&path).unwrap();
assert!(after.starts_with("# Existing"));
assert!(after.contains(START_MARKER));
assert!(after.contains(END_MARKER));
}
#[test]
fn appends_to_empty_or_missing_file() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
let outcome = splice_inline_inner(&path, &body()).unwrap();
assert_eq!(outcome, SpliceOutcome::Appended);
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains(START_MARKER));
assert!(after.contains(END_MARKER));
}
#[test]
fn splices_in_place_when_markers_present() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(
&path,
format!(
"# header\n\nprose before\n\n{START_MARKER}\n\nold body\n\n{END_MARKER}\n\nprose after\n"
),
)
.unwrap();
let outcome = splice_inline_inner(&path, &body()).unwrap();
assert_eq!(outcome, SpliceOutcome::Spliced);
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("prose before"));
assert!(after.contains("prose after"));
assert!(after.contains("## section"));
assert!(!after.contains("old body"));
}
#[test]
fn round_trip_identity_no_write() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(&path, body()).unwrap();
let outcome = splice_inline_inner(&path, &body()).unwrap();
assert_eq!(outcome, SpliceOutcome::UnchangedRoundTrip);
}
#[test]
fn rejects_multiple_start_markers() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(
&path,
format!(
"{START_MARKER}\nfirst\n{END_MARKER}\n\n{START_MARKER}\nsecond\n{END_MARKER}\n"
),
)
.unwrap();
let err = splice_inline_inner(&path, &body()).unwrap_err();
assert!(err.to_string().contains("ambiguously"), "unexpected: {err}");
}
#[test]
fn rejects_orphan_start() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(&path, format!("{START_MARKER}\nopen but no close\n")).unwrap();
let err = splice_inline_inner(&path, &body()).unwrap_err();
assert!(err.to_string().contains("no matching"), "unexpected: {err}");
}
#[test]
fn rejects_orphan_end() {
let tmp = td();
let path = tmp.path().join("AGENTS.md");
fs::write(&path, format!("only end {END_MARKER}\n")).unwrap();
let err = splice_inline_inner(&path, &body()).unwrap_err();
assert!(err.to_string().contains("no matching"), "unexpected: {err}");
}
}