use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use super::types::{
BranchError, ChangeType, Commit, ConflictType, FileChange, FileVersion, MergeConflict,
MergeResult, MergeStrategy,
};
pub trait FileStateProvider {
fn list_files(&self, txg: u64) -> Result<Vec<FileInfo>, String>;
fn get_file(&self, path: &str, txg: u64) -> Result<Option<FileInfo>, String>;
fn read_file(&self, path: &str, txg: u64) -> Result<Vec<u8>, String>;
fn files_equal(&self, path1: &str, txg1: u64, path2: &str, txg2: u64) -> Result<bool, String>;
}
#[derive(Debug, Clone)]
pub struct FileInfo {
pub path: String,
pub size: u64,
pub checksum: [u64; 4],
pub mtime: u64,
pub is_dir: bool,
}
impl FileInfo {
pub fn to_version(&self, txg: u64) -> FileVersion {
FileVersion::new(txg, self.size, self.checksum, self.mtime)
}
}
#[derive(Debug, Clone)]
pub enum DiffEntry {
Added(FileInfo),
Deleted(FileInfo),
Modified {
old: FileInfo,
new: FileInfo,
},
}
impl DiffEntry {
pub fn path(&self) -> &str {
match self {
DiffEntry::Added(f) => &f.path,
DiffEntry::Deleted(f) => &f.path,
DiffEntry::Modified { new, .. } => &new.path,
}
}
}
pub struct ThreeWayMerge<'a, P: FileStateProvider> {
provider: &'a P,
strategy: MergeStrategy,
}
impl<'a, P: FileStateProvider> ThreeWayMerge<'a, P> {
pub fn new(provider: &'a P, strategy: MergeStrategy) -> Self {
Self { provider, strategy }
}
pub fn diff(&self, base_txg: u64, target_txg: u64) -> Result<Vec<DiffEntry>, BranchError> {
let base_files = self
.provider
.list_files(base_txg)
.map_err(BranchError::IoError)?;
let target_files = self
.provider
.list_files(target_txg)
.map_err(BranchError::IoError)?;
let base_map: BTreeMap<_, _> = base_files
.into_iter()
.map(|f| (f.path.clone(), f))
.collect();
let target_map: BTreeMap<_, _> = target_files
.into_iter()
.map(|f| (f.path.clone(), f))
.collect();
let mut diffs = Vec::new();
for (path, target_file) in &target_map {
match base_map.get(path) {
Some(base_file) => {
if base_file.checksum != target_file.checksum {
diffs.push(DiffEntry::Modified {
old: base_file.clone(),
new: target_file.clone(),
});
}
}
None => {
diffs.push(DiffEntry::Added(target_file.clone()));
}
}
}
for (path, base_file) in &base_map {
if !target_map.contains_key(path) {
diffs.push(DiffEntry::Deleted(base_file.clone()));
}
}
Ok(diffs)
}
pub fn merge(
&self,
base_txg: u64,
ours_txg: u64,
theirs_txg: u64,
) -> Result<MergeAnalysis, BranchError> {
let ours_diff = self.diff(base_txg, ours_txg)?;
let theirs_diff = self.diff(base_txg, theirs_txg)?;
let ours_changes: BTreeMap<_, _> = ours_diff
.into_iter()
.map(|d| (d.path().to_string(), d))
.collect();
let theirs_changes: BTreeMap<_, _> = theirs_diff
.into_iter()
.map(|d| (d.path().to_string(), d))
.collect();
let mut all_paths: Vec<_> = ours_changes
.keys()
.chain(theirs_changes.keys())
.cloned()
.collect();
all_paths.sort();
all_paths.dedup();
let mut merged_changes = Vec::new();
let mut conflicts = Vec::new();
for path in all_paths {
let ours = ours_changes.get(&path);
let theirs = theirs_changes.get(&path);
match (ours, theirs) {
(Some(our_change), None) => {
merged_changes.push(self.diff_to_change(our_change)?);
}
(None, Some(their_change)) => {
merged_changes.push(self.diff_to_change(their_change)?);
}
(Some(our_change), Some(their_change)) => {
match self.resolve_conflict(
&path,
our_change,
their_change,
base_txg,
ours_txg,
theirs_txg,
)? {
ConflictResolution::NoConflict(change) => {
merged_changes.push(change);
}
ConflictResolution::Conflict(conflict) => {
match self.strategy {
MergeStrategy::Normal => {
conflicts.push(conflict);
}
MergeStrategy::Ours => {
merged_changes.push(self.diff_to_change(our_change)?);
}
MergeStrategy::Theirs => {
merged_changes.push(self.diff_to_change(their_change)?);
}
MergeStrategy::ConflictMarkers => {
conflicts.push(conflict);
}
}
}
ConflictResolution::BothSame => {
merged_changes.push(self.diff_to_change(our_change)?);
}
}
}
(None, None) => {
}
}
}
Ok(MergeAnalysis {
base_txg,
ours_txg,
theirs_txg,
changes: merged_changes,
conflicts,
strategy: self.strategy,
})
}
fn diff_to_change(&self, diff: &DiffEntry) -> Result<FileChange, BranchError> {
match diff {
DiffEntry::Added(file) => Ok(FileChange::created(
file.path.clone(),
file.checksum,
file.size,
)),
DiffEntry::Deleted(file) => Ok(FileChange::deleted(
file.path.clone(),
file.checksum,
file.size,
)),
DiffEntry::Modified { old, new } => Ok(FileChange::modified(
new.path.clone(),
old.checksum,
new.checksum,
old.size,
new.size,
)),
}
}
fn resolve_conflict(
&self,
path: &str,
ours: &DiffEntry,
theirs: &DiffEntry,
base_txg: u64,
ours_txg: u64,
theirs_txg: u64,
) -> Result<ConflictResolution, BranchError> {
match (ours, theirs) {
(DiffEntry::Added(our_file), DiffEntry::Added(their_file)) => {
if our_file.checksum == their_file.checksum {
Ok(ConflictResolution::BothSame)
} else {
Ok(ConflictResolution::Conflict(MergeConflict::both_created(
path.to_string(),
our_file.to_version(ours_txg),
their_file.to_version(theirs_txg),
)))
}
}
(DiffEntry::Deleted(_), DiffEntry::Deleted(_)) => {
Ok(ConflictResolution::BothSame)
}
(
DiffEntry::Modified {
old: our_old,
new: our_new,
},
DiffEntry::Modified {
old: _their_old,
new: their_new,
},
) => {
if our_new.checksum == their_new.checksum {
Ok(ConflictResolution::BothSame)
} else {
Ok(ConflictResolution::Conflict(MergeConflict::both_modified(
path.to_string(),
our_old.to_version(base_txg),
our_new.to_version(ours_txg),
their_new.to_version(theirs_txg),
)))
}
}
(DiffEntry::Modified { old, new }, DiffEntry::Deleted(_)) => {
Ok(ConflictResolution::Conflict(MergeConflict::modify_delete(
path.to_string(),
old.to_version(base_txg),
new.to_version(ours_txg),
)))
}
(DiffEntry::Deleted(deleted), DiffEntry::Modified { new, .. }) => {
Ok(ConflictResolution::Conflict(MergeConflict::delete_modify(
path.to_string(),
deleted.to_version(base_txg),
new.to_version(theirs_txg),
)))
}
_ => {
let base_version = self
.provider
.get_file(path, base_txg)
.map_err(BranchError::IoError)?
.map(|f| f.to_version(base_txg));
let ours_version = self
.provider
.get_file(path, ours_txg)
.map_err(BranchError::IoError)?
.map(|f| f.to_version(ours_txg));
let theirs_version = self
.provider
.get_file(path, theirs_txg)
.map_err(BranchError::IoError)?
.map(|f| f.to_version(theirs_txg));
Ok(ConflictResolution::Conflict(MergeConflict {
path: path.to_string(),
conflict_type: ConflictType::BothModified,
base: base_version,
ours: ours_version,
theirs: theirs_version,
}))
}
}
}
}
enum ConflictResolution {
NoConflict(FileChange),
Conflict(MergeConflict),
BothSame,
}
#[derive(Debug, Clone)]
pub struct MergeAnalysis {
pub base_txg: u64,
pub ours_txg: u64,
pub theirs_txg: u64,
pub changes: Vec<FileChange>,
pub conflicts: Vec<MergeConflict>,
pub strategy: MergeStrategy,
}
impl MergeAnalysis {
pub fn is_clean(&self) -> bool {
self.conflicts.is_empty()
}
pub fn conflict_count(&self) -> usize {
self.conflicts.len()
}
pub fn change_count(&self) -> usize {
self.changes.len()
}
pub fn to_result(&self, result_txg: Option<u64>, merge_commit: Option<Commit>) -> MergeResult {
MergeResult {
merged_files: self.changes.len(),
conflicts: self.conflicts.clone(),
result_txg,
merge_commit,
}
}
pub fn conflicting_paths(&self) -> Vec<&str> {
self.conflicts.iter().map(|c| c.path.as_str()).collect()
}
}
pub trait MergeExecutor {
fn apply_merge(&mut self, analysis: &MergeAnalysis) -> Result<u64, String>;
fn create_conflict_markers(
&mut self,
path: &str,
base_content: Option<&[u8]>,
ours_content: &[u8],
theirs_content: &[u8],
) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
struct MockFileProvider {
states: BTreeMap<u64, BTreeMap<String, FileInfo>>,
}
impl MockFileProvider {
fn new() -> Self {
Self {
states: BTreeMap::new(),
}
}
fn add_file(&mut self, txg: u64, path: &str, checksum: [u64; 4], size: u64) {
let state = self.states.entry(txg).or_default();
state.insert(
path.to_string(),
FileInfo {
path: path.to_string(),
size,
checksum,
mtime: txg * 1000,
is_dir: false,
},
);
}
}
impl FileStateProvider for MockFileProvider {
fn list_files(&self, txg: u64) -> Result<Vec<FileInfo>, String> {
Ok(self
.states
.get(&txg)
.map(|s| s.values().cloned().collect())
.unwrap_or_default())
}
fn get_file(&self, path: &str, txg: u64) -> Result<Option<FileInfo>, String> {
Ok(self.states.get(&txg).and_then(|s| s.get(path).cloned()))
}
fn read_file(&self, _path: &str, _txg: u64) -> Result<Vec<u8>, String> {
Ok(vec![])
}
fn files_equal(
&self,
path1: &str,
txg1: u64,
path2: &str,
txg2: u64,
) -> Result<bool, String> {
let f1 = self.get_file(path1, txg1)?;
let f2 = self.get_file(path2, txg2)?;
match (f1, f2) {
(Some(a), Some(b)) => Ok(a.checksum == b.checksum),
(None, None) => Ok(true),
_ => Ok(false),
}
}
}
#[test]
fn test_diff_added() {
let mut provider = MockFileProvider::new();
provider.states.insert(0, BTreeMap::new());
provider.add_file(1, "/new.txt", [1; 4], 100);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let diff = merger.diff(0, 1).unwrap();
assert_eq!(diff.len(), 1);
assert!(matches!(diff[0], DiffEntry::Added(_)));
}
#[test]
fn test_diff_deleted() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/old.txt", [1; 4], 100);
provider.states.insert(1, BTreeMap::new());
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let diff = merger.diff(0, 1).unwrap();
assert_eq!(diff.len(), 1);
assert!(matches!(diff[0], DiffEntry::Deleted(_)));
}
#[test]
fn test_diff_modified() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 200);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let diff = merger.diff(0, 1).unwrap();
assert_eq!(diff.len(), 1);
assert!(matches!(diff[0], DiffEntry::Modified { .. }));
}
#[test]
fn test_merge_no_conflict() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file_a.txt", [1; 4], 100);
provider.add_file(1, "/file_a.txt", [2; 4], 100);
provider.add_file(0, "/file_a.txt", [1; 4], 100); provider.add_file(2, "/file_a.txt", [1; 4], 100);
provider.add_file(2, "/file_b.txt", [3; 4], 200);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(analysis.is_clean());
assert_eq!(analysis.change_count(), 2); }
#[test]
fn test_merge_conflict_both_modified() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 100);
provider.add_file(2, "/file.txt", [3; 4], 100);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(!analysis.is_clean());
assert_eq!(analysis.conflict_count(), 1);
assert!(matches!(
analysis.conflicts[0].conflict_type,
ConflictType::BothModified
));
}
#[test]
fn test_merge_conflict_modify_delete() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 100);
provider.states.insert(2, BTreeMap::new());
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(!analysis.is_clean());
assert_eq!(analysis.conflict_count(), 1);
assert!(matches!(
analysis.conflicts[0].conflict_type,
ConflictType::ModifyDelete
));
}
#[test]
fn test_merge_both_same_change() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 100);
provider.add_file(2, "/file.txt", [2; 4], 100);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Normal);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(analysis.is_clean());
assert_eq!(analysis.change_count(), 1);
}
#[test]
fn test_merge_strategy_ours() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 100);
provider.add_file(2, "/file.txt", [3; 4], 100);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Ours);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(analysis.is_clean());
assert_eq!(analysis.change_count(), 1);
let change = &analysis.changes[0];
assert_eq!(change.new_checksum, Some([2; 4]));
}
#[test]
fn test_merge_strategy_theirs() {
let mut provider = MockFileProvider::new();
provider.add_file(0, "/file.txt", [1; 4], 100);
provider.add_file(1, "/file.txt", [2; 4], 100);
provider.add_file(2, "/file.txt", [3; 4], 100);
let merger = ThreeWayMerge::new(&provider, MergeStrategy::Theirs);
let analysis = merger.merge(0, 1, 2).unwrap();
assert!(analysis.is_clean());
assert_eq!(analysis.change_count(), 1);
let change = &analysis.changes[0];
assert_eq!(change.new_checksum, Some([3; 4]));
}
#[test]
fn test_merge_analysis_to_result() {
let analysis = MergeAnalysis {
base_txg: 0,
ours_txg: 1,
theirs_txg: 2,
changes: vec![FileChange::created("/file.txt".into(), [1; 4], 100)],
conflicts: vec![],
strategy: MergeStrategy::Normal,
};
let result = analysis.to_result(Some(3), None);
assert!(result.is_success());
assert_eq!(result.merged_files, 1);
assert_eq!(result.result_txg, Some(3));
}
#[test]
fn test_conflicting_paths() {
let analysis = MergeAnalysis {
base_txg: 0,
ours_txg: 1,
theirs_txg: 2,
changes: vec![],
conflicts: vec![
MergeConflict {
path: "/a.txt".into(),
conflict_type: ConflictType::BothModified,
base: None,
ours: None,
theirs: None,
},
MergeConflict {
path: "/b.txt".into(),
conflict_type: ConflictType::ModifyDelete,
base: None,
ours: None,
theirs: None,
},
],
strategy: MergeStrategy::Normal,
};
let paths = analysis.conflicting_paths();
assert_eq!(paths, vec!["/a.txt", "/b.txt"]);
}
}