use anyhow::{Context, Result};
use std::io::ErrorKind;
use std::path::Path;
fn read_existing(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn ensure(project_root: &Path, comment: &str, entry: &str) -> Result<()> {
let path = project_root.join(".gitignore");
let existing = read_existing(&path)?.unwrap_or_default();
if existing.lines().any(|l| l.trim() == entry) {
return Ok(());
}
let mut out = existing;
if !out.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n'); }
out.push_str(comment);
out.push('\n');
out.push_str(entry);
out.push('\n');
std::fs::write(&path, out).with_context(|| format!("updating {}", path.display()))
}
pub fn remove(project_root: &Path, comment: &str, entry: &str) -> Result<()> {
let path = project_root.join(".gitignore");
let Some(existing) = read_existing(&path)? else {
return Ok(());
};
let mut kept: Vec<&str> = Vec::new();
let mut prev_was_spm_comment = false;
for line in existing.lines() {
let t = line.trim();
if t == comment {
prev_was_spm_comment = true;
continue;
}
if t == entry && prev_was_spm_comment {
prev_was_spm_comment = false;
continue;
}
prev_was_spm_comment = false;
kept.push(line);
}
while kept.last().is_some_and(|l| l.trim().is_empty()) {
kept.pop();
}
let out = if kept.is_empty() {
String::new()
} else {
format!("{}\n", kept.join("\n"))
};
std::fs::write(&path, out).with_context(|| format!("updating {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
const COMMENT: &str = "# spm-managed test entry";
const ENTRY: &str = ".spm-test/";
#[test]
fn ensure_and_remove_roundtrip() {
let tmp = std::env::temp_dir().join(format!("spm-gi-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let gi = tmp.join(".gitignore");
std::fs::write(&gi, "target/\n").unwrap();
ensure(&tmp, COMMENT, ENTRY).unwrap();
let after = std::fs::read_to_string(&gi).unwrap();
assert!(after.contains(ENTRY), "{after}");
assert!(after.contains(COMMENT), "{after}");
assert!(
after.starts_with("target/\n"),
"preserves prior content: {after}"
);
ensure(&tmp, COMMENT, ENTRY).unwrap();
let twice = std::fs::read_to_string(&gi).unwrap();
assert_eq!(twice.matches(ENTRY).count(), 1, "{twice}");
remove(&tmp, COMMENT, ENTRY).unwrap();
let cleaned = std::fs::read_to_string(&gi).unwrap();
assert!(!cleaned.contains(ENTRY), "{cleaned}");
assert!(!cleaned.contains(COMMENT), "{cleaned}");
assert_eq!(cleaned, "target/\n");
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn remove_preserves_user_authored_entry_without_spm_comment() {
let tmp = std::env::temp_dir().join(format!("spm-gi-user-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let gi = tmp.join(".gitignore");
std::fs::write(&gi, format!("target/\n{ENTRY}\n")).unwrap();
remove(&tmp, COMMENT, ENTRY).unwrap();
let after = std::fs::read_to_string(&gi).unwrap();
assert!(
after.contains(ENTRY),
"user-authored entry must be preserved: {after}"
);
assert_eq!(after, format!("target/\n{ENTRY}\n"));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn remove_deletes_only_spm_block_keeping_duplicate_user_entry() {
let tmp = std::env::temp_dir().join(format!("spm-gi-dup-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let gi = tmp.join(".gitignore");
let content = format!("{ENTRY}\n\n{COMMENT}\n{ENTRY}\n");
std::fs::write(&gi, &content).unwrap();
remove(&tmp, COMMENT, ENTRY).unwrap();
let after = std::fs::read_to_string(&gi).unwrap();
assert!(!after.contains(COMMENT), "spm comment gone: {after}");
assert_eq!(
after.matches(ENTRY).count(),
1,
"only the user-authored entry remains: {after}"
);
assert_eq!(after, format!("{ENTRY}\n"));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn ensure_surfaces_read_error_instead_of_clobbering() {
let tmp = std::env::temp_dir().join(format!("spm-gi-badutf8-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let gi = tmp.join(".gitignore");
std::fs::write(&gi, [0xff, 0xfe, 0x00, 0x9f]).unwrap();
let err = ensure(&tmp, COMMENT, ENTRY);
assert!(err.is_err(), "expected a surfaced read error");
assert_eq!(std::fs::read(&gi).unwrap(), vec![0xff, 0xfe, 0x00, 0x9f]);
assert!(
remove(&tmp, COMMENT, ENTRY).is_err(),
"remove must also error"
);
std::fs::remove_dir_all(&tmp).unwrap();
}
}