use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::atomic::atomic_write;
#[derive(Debug, thiserror::Error)]
pub enum GitignoreError {
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("malformed managed block for pack {pack}: unclosed marker at line {line}")]
UnclosedBlock { pack: String, line: usize },
#[error("invalid pack name: {0}")]
InvalidPackName(String),
}
fn validate_pack_name(name: &str) -> Result<(), GitignoreError> {
if name.is_empty() {
return Err(GitignoreError::InvalidPackName(name.to_string()));
}
for ch in name.chars() {
if ch.is_ascii_control() || ch == '<' || ch == '>' || ch == '\n' || ch == '\r' {
return Err(GitignoreError::InvalidPackName(name.to_string()));
}
}
Ok(())
}
fn detect_line_ending(text: &str) -> &'static str {
if text.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn markers(pack: &str) -> (String, String) {
(format!("# >>> grex:{pack} >>>"), format!("# <<< grex:{pack} <<<"))
}
fn read_opt(target: &Path) -> Result<Option<String>, GitignoreError> {
match fs::read_to_string(target) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(GitignoreError::Io { path: target.to_path_buf(), source }),
}
}
#[allow(clippy::type_complexity)]
fn split_block<'a>(
text: &'a str,
pack: &str,
) -> Result<Option<(&'a str, Vec<&'a str>, &'a str)>, GitignoreError> {
let (open, close) = markers(pack);
let lines: Vec<&str> = text.split_inclusive(['\n']).collect();
let open_idx = lines.iter().position(|l| strip_eol(l) == open);
let Some(open_idx) = open_idx else {
return Ok(None);
};
let close_rel = lines[open_idx + 1..].iter().position(|l| strip_eol(l) == close);
let Some(close_rel) = close_rel else {
return Err(GitignoreError::UnclosedBlock {
pack: pack.to_string(),
line: open_idx + 1, });
};
let close_idx = open_idx + 1 + close_rel;
let before_end: usize = lines[..open_idx].iter().map(|l| l.len()).sum();
let after_start: usize = lines[..=close_idx].iter().map(|l| l.len()).sum();
let before = &text[..before_end];
let after = &text[after_start..];
let inner: Vec<&str> = lines[open_idx + 1..close_idx].iter().map(|l| strip_eol(l)).collect();
Ok(Some((before, inner, after)))
}
fn strip_eol(line: &str) -> &str {
line.strip_suffix("\r\n").or_else(|| line.strip_suffix('\n')).unwrap_or(line)
}
fn render_block(pack: &str, patterns: &[&str], eol: &str) -> String {
let (open, close) = markers(pack);
let mut out = String::new();
out.push_str(&open);
out.push_str(eol);
for p in patterns {
out.push_str(p);
out.push_str(eol);
}
out.push_str(&close);
out.push_str(eol);
out
}
pub fn upsert_managed_block(
target: &Path,
pack_name: &str,
patterns: &[&str],
) -> Result<(), GitignoreError> {
validate_pack_name(pack_name)?;
let existing = read_opt(target)?;
let (text, eol) = match &existing {
Some(t) => (t.as_str(), detect_line_ending(t)),
None => ("", "\n"),
};
let new_contents = match split_block(text, pack_name)? {
Some((before, _old, after)) => {
let mut out = String::with_capacity(text.len() + 64);
out.push_str(before);
out.push_str(&render_block(pack_name, patterns, eol));
out.push_str(after);
out
}
None => {
let mut out = String::with_capacity(text.len() + 64);
out.push_str(text);
if !text.is_empty() && !text.ends_with('\n') {
out.push_str(eol);
}
out.push_str(&render_block(pack_name, patterns, eol));
out
}
};
atomic_write(target, new_contents.as_bytes())
.map_err(|source| GitignoreError::Io { path: target.to_path_buf(), source })
}
pub fn remove_managed_block(target: &Path, pack_name: &str) -> Result<(), GitignoreError> {
validate_pack_name(pack_name)?;
let Some(text) = read_opt(target)? else {
return Ok(());
};
let Some((before, _inner, after)) = split_block(&text, pack_name)? else {
return Ok(());
};
let mut out = String::with_capacity(before.len() + after.len());
out.push_str(before);
out.push_str(after);
atomic_write(target, out.as_bytes())
.map_err(|source| GitignoreError::Io { path: target.to_path_buf(), source })
}
pub fn read_managed_block(
target: &Path,
pack_name: &str,
) -> Result<Option<Vec<String>>, GitignoreError> {
validate_pack_name(pack_name)?;
let Some(text) = read_opt(target)? else {
return Ok(None);
};
let Some((_before, inner, _after)) = split_block(&text, pack_name)? else {
return Ok(None);
};
Ok(Some(inner.into_iter().map(|s| s.to_string()).collect()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn read(p: &Path) -> String {
fs::read_to_string(p).unwrap()
}
#[test]
fn upsert_into_empty_file() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "").unwrap();
upsert_managed_block(&p, "foo", &["a", "b"]).unwrap();
let got = read(&p);
assert!(got.starts_with("# >>> grex:foo >>>\n"));
assert!(got.contains("\na\n"));
assert!(got.contains("\nb\n"));
assert!(got.ends_with("# <<< grex:foo <<<\n"));
}
#[test]
fn upsert_creates_missing_file() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
assert!(!p.exists());
upsert_managed_block(&p, "foo", &["target/"]).unwrap();
assert!(p.exists());
let got = read(&p);
assert!(got.contains("target/"));
}
#[test]
fn upsert_appends_after_user_content() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "# my rules\nnode_modules/\n").unwrap();
upsert_managed_block(&p, "foo", &["x"]).unwrap();
let got = read(&p);
assert!(got.starts_with("# my rules\nnode_modules/\n"));
assert!(got.contains("# >>> grex:foo >>>\nx\n# <<< grex:foo <<<\n"));
}
#[test]
fn upsert_two_packs_coexist() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
upsert_managed_block(&p, "a", &["x"]).unwrap();
upsert_managed_block(&p, "b", &["y"]).unwrap();
let got = read(&p);
assert!(got.contains("# >>> grex:a >>>\nx\n# <<< grex:a <<<\n"));
assert!(got.contains("# >>> grex:b >>>\ny\n# <<< grex:b <<<\n"));
let ia = got.find("grex:a").unwrap();
let ib = got.find("grex:b").unwrap();
assert!(ia < ib);
}
#[test]
fn update_existing_block_replaces_patterns() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
upsert_managed_block(&p, "foo", &["a", "b", "c"]).unwrap();
upsert_managed_block(&p, "foo", &["c", "a"]).unwrap();
let patterns = read_managed_block(&p, "foo").unwrap().unwrap();
assert_eq!(patterns, vec!["c".to_string(), "a".to_string()]);
assert_eq!(read(&p).matches("# >>> grex:foo >>>").count(), 1);
}
#[test]
fn remove_preserves_other_blocks() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
upsert_managed_block(&p, "a", &["x"]).unwrap();
upsert_managed_block(&p, "b", &["y"]).unwrap();
remove_managed_block(&p, "a").unwrap();
let got = read(&p);
assert!(!got.contains("grex:a"));
assert!(got.contains("# >>> grex:b >>>\ny\n# <<< grex:b <<<\n"));
}
#[test]
fn remove_preserves_user_content() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user1\n").unwrap();
upsert_managed_block(&p, "foo", &["x"]).unwrap();
let with_tail = format!("{}user2\n", read(&p));
fs::write(&p, with_tail).unwrap();
remove_managed_block(&p, "foo").unwrap();
let got = read(&p);
assert!(got.contains("user1\n"));
assert!(got.contains("user2\n"));
assert!(!got.contains("grex:foo"));
}
#[test]
fn remove_absent_block_noop() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user\n").unwrap();
remove_managed_block(&p, "foo").unwrap();
assert_eq!(read(&p), "user\n");
let p2 = dir.path().join("missing");
remove_managed_block(&p2, "foo").unwrap();
assert!(!p2.exists());
}
#[test]
fn read_existing_block_some() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
upsert_managed_block(&p, "foo", &["a", "b"]).unwrap();
let got = read_managed_block(&p, "foo").unwrap();
assert_eq!(got, Some(vec!["a".to_string(), "b".to_string()]));
}
#[test]
fn read_absent_block_none() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
assert_eq!(read_managed_block(&p, "foo").unwrap(), None);
fs::write(&p, "user\n").unwrap();
assert_eq!(read_managed_block(&p, "foo").unwrap(), None);
}
#[test]
fn unclosed_block_error() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "# >>> grex:foo >>>\na\nb\n").unwrap();
let err = read_managed_block(&p, "foo").unwrap_err();
match err {
GitignoreError::UnclosedBlock { pack, line } => {
assert_eq!(pack, "foo");
assert_eq!(line, 1);
}
other => panic!("expected UnclosedBlock, got {other:?}"),
}
}
#[test]
fn invalid_pack_name_angle() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
let err = upsert_managed_block(&p, "bad>name", &[]).unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
let err = remove_managed_block(&p, "bad<name").unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
let err = read_managed_block(&p, "bad>name").unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
}
#[test]
fn invalid_pack_name_newline() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
let err = upsert_managed_block(&p, "bad\nname", &[]).unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
let err = upsert_managed_block(&p, "", &[]).unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
let err = upsert_managed_block(&p, "bad\tname", &[]).unwrap_err();
assert!(matches!(err, GitignoreError::InvalidPackName(_)));
}
#[test]
fn lf_line_endings_preserved() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user1\nuser2\n").unwrap();
upsert_managed_block(&p, "foo", &["x", "y"]).unwrap();
let got = read(&p);
assert!(!got.contains("\r\n"), "must not introduce CRLF: {got:?}");
assert!(got.contains("\nx\n"));
}
#[test]
fn crlf_line_endings_preserved() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user1\r\nuser2\r\n").unwrap();
upsert_managed_block(&p, "foo", &["x", "y"]).unwrap();
let got = fs::read(&p).unwrap();
let s = String::from_utf8(got).unwrap();
assert!(s.contains("# >>> grex:foo >>>\r\n"));
assert!(s.contains("\r\nx\r\n"));
assert!(s.contains("\r\ny\r\n"));
assert!(s.contains("# <<< grex:foo <<<\r\n"));
}
#[test]
fn upsert_into_mixed_line_ending_file() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "lf-only\ncrlf-line\r\n").unwrap();
upsert_managed_block(&p, "foo", &["x"]).unwrap();
let s = fs::read_to_string(&p).unwrap();
assert!(s.contains("# >>> grex:foo >>>\r\n"), "block must use CRLF: {s:?}");
assert!(s.contains("\r\nx\r\n"), "pattern must use CRLF: {s:?}");
assert!(s.contains("# <<< grex:foo <<<\r\n"), "close must use CRLF: {s:?}");
}
#[test]
fn atomic_write_leaves_no_temp_files() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user\n").unwrap();
upsert_managed_block(&p, "foo", &["x"]).unwrap();
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec![".gitignore".to_string()]);
assert!(p.exists());
}
#[test]
fn upsert_is_idempotent() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
fs::write(&p, "user\n").unwrap();
upsert_managed_block(&p, "foo", &["x", "y"]).unwrap();
let first = read(&p);
upsert_managed_block(&p, "foo", &["x", "y"]).unwrap();
let second = read(&p);
assert_eq!(first, second);
}
#[test]
fn upsert_empty_patterns_keeps_block() {
let dir = tempdir().unwrap();
let p = dir.path().join(".gitignore");
upsert_managed_block(&p, "foo", &[]).unwrap();
let got = read(&p);
assert!(got.contains("# >>> grex:foo >>>\n# <<< grex:foo <<<\n"));
let patterns = read_managed_block(&p, "foo").unwrap().unwrap();
assert!(patterns.is_empty());
}
}