use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::SystemTime;
use crate::update_log::format_rfc3339_utc;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MergeConflictReport {
pub loser: String,
#[serde(default)]
pub winner: Option<String>,
pub relative: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MergeConflictSnapshot {
pub timestamp: String,
pub reports: Vec<MergeConflictReport>,
}
pub fn load_snapshot(path: &Path) -> MergeConflictSnapshot {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return MergeConflictSnapshot::default();
}
Err(e) => {
eprintln!(
"\u{26a0} merge_conflicts: failed to read {}: {} (treating as empty)",
path.display(),
e
);
return MergeConflictSnapshot::default();
}
};
serde_json::from_str::<MergeConflictSnapshot>(&content).unwrap_or_else(|e| {
eprintln!(
"\u{26a0} merge_conflicts: failed to parse {}: {} (treating as empty)",
path.display(),
e
);
MergeConflictSnapshot::default()
})
}
pub fn save_snapshot(path: &Path, reports: Vec<MergeConflictReport>) -> Result<()> {
let snapshot = MergeConflictSnapshot {
timestamp: format_rfc3339_utc(SystemTime::now()),
reports,
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&snapshot).context("serialize merge_conflicts")?;
let parent = path.parent().unwrap_or(Path::new("."));
let tmp = tempfile::Builder::new()
.prefix(".rvpm-merge-conflicts-")
.suffix(".tmp")
.tempfile_in(parent)
.with_context(|| format!("create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), json.as_bytes())
.with_context(|| format!("write tempfile {}", tmp.path().display()))?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("rename tempfile to {}: {}", path.display(), e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_load_snapshot_missing_file_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
let snap = load_snapshot(&path);
assert!(snap.timestamp.is_empty());
assert!(snap.reports.is_empty());
}
#[test]
fn test_load_snapshot_malformed_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "{not valid json").unwrap();
let snap = load_snapshot(&path);
assert!(snap.reports.is_empty());
}
#[test]
fn test_save_then_load_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("merge_conflicts.json");
let reports = vec![
MergeConflictReport {
loser: "smart-splits.nvim".to_string(),
winner: Some("nvim-tree.lua".to_string()),
relative: "plugin/init.lua".to_string(),
},
MergeConflictReport {
loser: "other".to_string(),
winner: None,
relative: "lua/x/y.lua".to_string(),
},
];
save_snapshot(&path, reports.clone()).unwrap();
let snap = load_snapshot(&path);
assert_eq!(snap.reports, reports);
assert!(snap.timestamp.ends_with('Z'));
assert!(snap.timestamp.contains('T'));
}
#[test]
fn test_save_empty_reports_still_writes_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("merge_conflicts.json");
save_snapshot(&path, Vec::new()).unwrap();
assert!(path.exists());
let snap = load_snapshot(&path);
assert!(snap.reports.is_empty());
assert!(!snap.timestamp.is_empty());
}
#[test]
fn test_save_overwrites_previous_snapshot() {
let dir = tempdir().unwrap();
let path = dir.path().join("merge_conflicts.json");
let first = vec![MergeConflictReport {
loser: "a".to_string(),
winner: Some("b".to_string()),
relative: "x".to_string(),
}];
save_snapshot(&path, first).unwrap();
save_snapshot(&path, Vec::new()).unwrap();
let snap = load_snapshot(&path);
assert!(snap.reports.is_empty());
}
#[test]
fn test_report_missing_winner_defaults_to_none_on_parse() {
let dir = tempdir().unwrap();
let path = dir.path().join("old.json");
std::fs::write(
&path,
r#"{"timestamp":"2026-04-19T00:00:00Z","reports":[{"loser":"x","relative":"a/b"}]}"#,
)
.unwrap();
let snap = load_snapshot(&path);
assert_eq!(snap.reports.len(), 1);
assert!(snap.reports[0].winner.is_none());
}
}