use crate::error::{RailResult, ResultExt};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum ConflictStrategy {
Ours,
Theirs,
#[default]
Manual,
Union,
}
impl ConflictStrategy {
pub(crate) fn authority_name(self) -> &'static str {
match self {
Self::Ours => "ours",
Self::Theirs => "theirs",
Self::Manual => "manual",
Self::Union => "union",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictClass {
Content,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictInfo {
pub file_path: PathBuf,
pub class: ConflictClass,
}
#[derive(Debug)]
pub enum MergeResult {
Success,
Conflicts(Vec<PathBuf>),
Failed(String),
}
#[derive(Debug)]
pub struct ConflictResolver {
strategy: ConflictStrategy,
work_dir: PathBuf,
}
impl ConflictResolver {
pub fn new(strategy: ConflictStrategy, work_dir: PathBuf) -> Self {
Self { strategy, work_dir }
}
pub fn strategy(&self) -> ConflictStrategy {
self.strategy
}
pub fn resolve_file(
&self,
current_path: &Path,
base_content: &[u8],
incoming_content: &[u8],
) -> RailResult<MergeResult> {
let (result, merged_content) = self.resolve_file_content(current_path, base_content, incoming_content)?;
crate::utils::write_file_atomic(current_path, &merged_content)?;
Ok(result)
}
pub(crate) fn resolve_file_content(
&self,
current_path: &Path,
base_content: &[u8],
incoming_content: &[u8],
) -> RailResult<(MergeResult, Vec<u8>)> {
let current_metadata = std::fs::symlink_metadata(current_path)?;
if current_metadata.file_type().is_symlink() || !current_metadata.is_file() {
return Err(crate::error::RailError::message(format!(
"sync conflict path '{}' must be a regular file",
current_path.display()
)));
}
let current_content = std::fs::read(current_path)?;
self.resolve_content(current_path, ¤t_content, base_content, incoming_content)
}
pub(crate) fn resolve_content(
&self,
path: &Path,
current_content: &[u8],
base_content: &[u8],
incoming_content: &[u8],
) -> RailResult<(MergeResult, Vec<u8>)> {
std::fs::create_dir_all(&self.work_dir).context("Failed to create conflict working directory")?;
let temp_base = self.work_dir.join("merge-base");
let temp_current = self.work_dir.join("merge-current");
let temp_incoming = self.work_dir.join("merge-incoming");
std::fs::write(&temp_base, base_content).context("Failed to write base file for merge")?;
std::fs::write(&temp_current, current_content).context("Failed to write current file for merge")?;
std::fs::write(&temp_incoming, incoming_content).context("Failed to write incoming file for merge")?;
let mut cmd = crate::git::git_command();
cmd.arg("merge-file");
match self.strategy {
ConflictStrategy::Ours => {
cmd.arg("--ours");
}
ConflictStrategy::Theirs => {
cmd.arg("--theirs");
}
ConflictStrategy::Manual => {
}
ConflictStrategy::Union => {
cmd.arg("--union");
}
}
cmd.arg("-L")
.arg(path)
.args(["-L", "cargo-rail merge base", "-L", "incoming split commit"]);
cmd.arg(&temp_current);
cmd.arg(&temp_base);
cmd.arg(&temp_incoming);
let output = cmd.output().context("Failed to run git merge-file")?;
let merged_content = std::fs::read(&temp_current)?;
let result = match output.status.code() {
Some(code @ (0 | 1)) => {
if code == 0 {
MergeResult::Success
} else {
MergeResult::Conflicts(vec![path.to_path_buf()])
}
}
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
MergeResult::Failed(format!("git merge-file failed with code {}: {}", code, stderr))
}
None => MergeResult::Failed("git merge-file was terminated by signal".to_string()),
};
std::fs::remove_file(&temp_base)?;
std::fs::remove_file(&temp_current)?;
std::fs::remove_file(&temp_incoming)?;
Ok((result, merged_content))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_strategy_value_enum() {
use clap::ValueEnum;
assert_eq!(
ConflictStrategy::from_str("ours", false).unwrap(),
ConflictStrategy::Ours
);
assert_eq!(
ConflictStrategy::from_str("theirs", false).unwrap(),
ConflictStrategy::Theirs
);
assert_eq!(
ConflictStrategy::from_str("manual", false).unwrap(),
ConflictStrategy::Manual
);
assert_eq!(
ConflictStrategy::from_str("union", false).unwrap(),
ConflictStrategy::Union
);
ConflictStrategy::from_str("invalid", false).unwrap_err();
}
#[test]
fn test_clean_merge() {
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Manual, temp.path().to_path_buf());
let current_file = temp.path().join("test.txt");
std::fs::write(¤t_file, "line 1\nline 2\nline 3\n").unwrap();
let base = b"line 1\nline 2\nline 3\n";
let incoming = b"line 1\nline 2 modified\nline 3\n";
let result = resolver.resolve_file(¤t_file, base, incoming).unwrap();
match result {
MergeResult::Success => {
let content = std::fs::read_to_string(¤t_file).unwrap();
assert!(content.contains("line 2 modified"));
}
_ => panic!("Expected clean merge"),
}
}
#[test]
fn test_conflict_detection() {
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Manual, temp.path().to_path_buf());
let current_file = temp.path().join("test.txt");
std::fs::write(¤t_file, "line 1\nline 2 current\nline 3\n").unwrap();
let base = b"line 1\nline 2\nline 3\n";
let incoming = b"line 1\nline 2 incoming\nline 3\n";
let result = resolver.resolve_file(¤t_file, base, incoming).unwrap();
match result {
MergeResult::Conflicts(paths) => {
assert_eq!(paths.len(), 1);
let content = std::fs::read_to_string(¤t_file).unwrap();
assert!(content.contains("<<<<<<<"));
}
_ => panic!("Expected conflicts"),
}
}
#[test]
fn test_ours_strategy() {
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Ours, temp.path().to_path_buf());
let current_file = temp.path().join("test.txt");
std::fs::write(¤t_file, "line 1\nline 2 current\nline 3\n").unwrap();
let base = b"line 1\nline 2\nline 3\n";
let incoming = b"line 1\nline 2 incoming\nline 3\n";
let result = resolver.resolve_file(¤t_file, base, incoming).unwrap();
match result {
MergeResult::Success => {
let content = std::fs::read_to_string(¤t_file).unwrap();
assert!(content.contains("line 2 current"));
assert!(!content.contains("line 2 incoming"));
}
_ => panic!("Expected clean merge with --ours"),
}
}
#[test]
fn test_theirs_strategy() {
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Theirs, temp.path().to_path_buf());
let current_file = temp.path().join("test.txt");
std::fs::write(¤t_file, "line 1\nline 2 current\nline 3\n").unwrap();
let base = b"line 1\nline 2\nline 3\n";
let incoming = b"line 1\nline 2 incoming\nline 3\n";
let result = resolver.resolve_file(¤t_file, base, incoming).unwrap();
match result {
MergeResult::Success => {
let content = std::fs::read_to_string(¤t_file).unwrap();
assert!(!content.contains("line 2 current"));
assert!(content.contains("line 2 incoming"));
}
_ => panic!("Expected clean merge with --theirs"),
}
}
#[test]
fn test_union_strategy() {
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Union, temp.path().to_path_buf());
let current_file = temp.path().join("test.txt");
std::fs::write(¤t_file, "line 1\nline 2 current\nline 3\n").unwrap();
let base = b"line 1\nline 2\nline 3\n";
let incoming = b"line 1\nline 2 incoming\nline 3\n";
let result = resolver.resolve_file(¤t_file, base, incoming).unwrap();
match result {
MergeResult::Success => {
let content = std::fs::read_to_string(¤t_file).unwrap();
assert!(content.contains("line 2 current"));
assert!(content.contains("line 2 incoming"));
}
_ => panic!("Expected clean merge with --union"),
}
}
#[cfg(unix)]
#[test]
fn conflict_resolution_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let resolver = ConflictResolver::new(ConflictStrategy::Manual, temp.path().to_path_buf());
let outside = TempDir::new().unwrap();
let victim = outside.path().join("victim");
std::fs::write(&victim, "outside\n").unwrap();
let current = temp.path().join("current");
symlink(&victim, ¤t).unwrap();
let error = resolver.resolve_file(¤t, b"base\n", b"incoming\n").unwrap_err();
assert!(error.to_string().contains("must be a regular file"));
assert_eq!(std::fs::read_to_string(victim).unwrap(), "outside\n");
}
}