use std::path::Path;
use crate::{DodotError, Result};
pub const MARKER_START: &str = ">>>>>> dodot-conflict (template)";
pub const MARKER_MID: &str = "====== dodot-conflict (deployed)";
pub const MARKER_END: &str = "<<<<<< dodot-conflict";
const MARKER_PREFIXES: &[&str] = &[
">>>>>> dodot-conflict",
"====== dodot-conflict",
"<<<<<< dodot-conflict",
];
pub fn find_unresolved_marker_lines(content: &str) -> Vec<(usize, String)> {
content
.lines()
.enumerate()
.filter_map(|(idx, line)| {
let trimmed = line.trim_end();
if MARKER_PREFIXES.iter().any(|p| trimmed.starts_with(p)) {
Some((idx + 1, trimmed.to_string()))
} else {
None
}
})
.collect()
}
pub fn contains_unresolved_markers(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_end();
MARKER_PREFIXES.iter().any(|p| trimmed.starts_with(p))
})
}
pub fn ensure_no_unresolved_markers(content: &str, source_file: &Path) -> Result<()> {
let lines = find_unresolved_marker_lines(content);
if lines.is_empty() {
return Ok(());
}
let line_numbers = lines.iter().map(|(n, _)| *n).collect();
Err(DodotError::UnresolvedConflictMarker {
source_file: source_file.to_path_buf(),
line_numbers,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_no_markers_in_clean_content() {
let content = "name = Alice\nport = 5432\nhost = localhost\n";
assert!(!contains_unresolved_markers(content));
assert!(find_unresolved_marker_lines(content).is_empty());
}
#[test]
fn detects_a_full_three_line_block() {
let content = format!(
"name = Alice\n{}\nhost = \"{{{{ env.DB_HOST }}}}\"\n{}\nhost = \"prod.db\"\n{}\nport = 5432\n",
MARKER_START, MARKER_MID, MARKER_END
);
assert!(contains_unresolved_markers(&content));
let lines = find_unresolved_marker_lines(&content);
assert_eq!(lines.len(), 3, "got: {lines:?}");
assert_eq!(lines[0].0, 2);
assert_eq!(lines[1].0, 4);
assert_eq!(lines[2].0, 6);
}
#[test]
fn detects_partial_block() {
let only_start = format!("name = Alice\n{}\nrest\n", MARKER_START);
assert!(contains_unresolved_markers(&only_start));
let only_end = format!("rest\n{}\nname = Alice\n", MARKER_END);
assert!(contains_unresolved_markers(&only_end));
}
#[test]
fn detects_marker_with_trailing_annotation() {
let content = ">>>>>> dodot-conflict (template, source line 12)\nstuff\n";
assert!(contains_unresolved_markers(content));
let lines = find_unresolved_marker_lines(content);
assert_eq!(lines.len(), 1);
assert!(lines[0].1.contains("(template, source line 12)"));
}
#[test]
fn detects_marker_with_crlf_line_ending() {
let content = format!("host = \"prod\"\r\n{}\r\nrest\r\n", MARKER_START);
assert!(contains_unresolved_markers(&content));
let lines = find_unresolved_marker_lines(&content);
assert_eq!(lines.len(), 1);
assert!(!lines[0].1.contains('\r'));
}
#[test]
fn skips_markers_in_the_middle_of_a_line() {
let content = "comment about >>>>>> dodot-conflict in docs\n";
assert!(!contains_unresolved_markers(content));
assert!(find_unresolved_marker_lines(content).is_empty());
}
#[test]
fn skips_markers_with_leading_whitespace() {
let content = format!(" {}\n", MARKER_START);
assert!(!contains_unresolved_markers(&content));
}
#[test]
fn ensure_no_unresolved_markers_passes_clean_content() {
let p = Path::new("/tmp/clean.tmpl");
ensure_no_unresolved_markers("name = Alice\n", p).expect("clean content must pass");
}
#[test]
fn ensure_no_unresolved_markers_returns_descriptive_error() {
let p = Path::new("/tmp/dirty.tmpl");
let content = format!("name = Alice\n{}\nbody\n{}\n", MARKER_START, MARKER_END);
let err = ensure_no_unresolved_markers(&content, p).unwrap_err();
match err {
DodotError::UnresolvedConflictMarker {
source_file,
line_numbers,
} => {
assert_eq!(source_file, p);
assert_eq!(line_numbers, vec![2, 4]);
}
other => panic!("wrong error variant: {other}"),
}
}
#[test]
fn ensure_no_unresolved_markers_error_renders_actionable_message() {
let p = Path::new("app/config.toml.tmpl");
let content = format!("first\n{}\nsecond\n", MARKER_START);
let err = ensure_no_unresolved_markers(&content, p).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("app/config.toml.tmpl"), "msg: {msg}");
assert!(msg.contains("line 2") || msg.contains("2"), "msg: {msg}");
assert!(
msg.contains("git diff -- 'app/config.toml.tmpl'"),
"msg: {msg}"
);
}
#[test]
fn error_message_quotes_paths_with_spaces() {
let p = Path::new("My Configs/app.tmpl");
let content = format!("{}\nbody\n", MARKER_START);
let err = ensure_no_unresolved_markers(&content, p).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("'My Configs/app.tmpl'"),
"expected single-quoted path in hint, got: {msg}"
);
}
#[test]
fn error_message_defangs_leading_dash_paths() {
let p = Path::new("-weird-name.tmpl");
let content = format!("{}\nbody\n", MARKER_START);
let err = ensure_no_unresolved_markers(&content, p).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("git diff -- "),
"expected `--` separator in hint, got: {msg}"
);
}
#[test]
fn marker_constants_use_distinct_directional_chars() {
assert!(MARKER_START.starts_with('>'));
assert!(MARKER_MID.starts_with('='));
assert!(MARKER_END.starts_with('<'));
assert!(MARKER_START.starts_with(">>>>>> "));
assert!(MARKER_MID.starts_with("====== "));
assert!(MARKER_END.starts_with("<<<<<< "));
assert!(!MARKER_START.starts_with(">>>>>>>"));
}
#[test]
fn empty_content_has_no_markers() {
assert!(!contains_unresolved_markers(""));
assert!(find_unresolved_marker_lines("").is_empty());
}
#[test]
fn finds_multiple_independent_blocks() {
let content = format!(
"header\n{}\nA\n{}\nmiddle\n{}\nB\n{}\nfooter\n",
MARKER_START, MARKER_END, MARKER_START, MARKER_END
);
let lines = find_unresolved_marker_lines(&content);
assert_eq!(lines.len(), 4);
assert_eq!(
lines.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
vec![2, 4, 6, 8]
);
}
}