pub const CONFLICT_HASH_HEX_LEN: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileMeta {
pub name: String,
pub hash: Vec<u8>,
}
impl FileMeta {
pub fn new(name: impl Into<String>, hash: impl Into<Vec<u8>>) -> Self {
Self {
name: name.into(),
hash: hash.into(),
}
}
}
fn hex(bytes: &[u8]) -> String {
const HEXD: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(HEXD[(b >> 4) as usize] as char);
s.push(HEXD[(b & 0xf) as usize] as char);
}
s
}
pub fn conflict_name(name: &str, hash: &[u8]) -> String {
let full = hex(hash);
let short = if full.len() >= CONFLICT_HASH_HEX_LEN {
&full[..CONFLICT_HASH_HEX_LEN]
} else {
&full
};
format!("{name}.conflict-{short}")
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcilePlan {
pub want: Vec<String>,
pub rename_local: Vec<(String, String)>,
}
pub fn plan_pulls(local: &[FileMeta], remote: &[FileMeta]) -> ReconcilePlan {
let mut plan = ReconcilePlan::default();
let have_name = |n: &str| local.iter().any(|f| f.name == n);
for r in remote {
match local.iter().find(|l| l.name == r.name) {
None => {
plan.want.push(r.name.clone());
}
Some(l) if l.hash == r.hash => {
}
Some(l) => {
let peer_wins = r.hash > l.hash;
if peer_wins {
plan.want.push(r.name.clone());
let our_conflict = conflict_name(&l.name, &l.hash);
if !have_name(&our_conflict) {
plan.rename_local
.push((l.name.clone(), our_conflict));
}
} else {
let peer_conflict = conflict_name(&r.name, &r.hash);
if !have_name(&peer_conflict) {
plan.want.push(peer_conflict);
}
}
}
}
}
plan
}
pub fn merged_set(a: &[FileMeta], b: &[FileMeta]) -> Vec<(String, Vec<u8>)> {
use std::collections::BTreeMap;
let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut by_name: BTreeMap<String, Vec<Vec<u8>>> = BTreeMap::new();
for f in a.iter().chain(b.iter()) {
let slot = by_name.entry(f.name.clone()).or_default();
if !slot.contains(&f.hash) {
slot.push(f.hash.clone());
}
}
for (name, mut hashes) in by_name {
if hashes.len() == 1 {
out.insert(name, hashes.pop().unwrap());
continue;
}
hashes.sort();
let winner = hashes.pop().unwrap(); for loser in &hashes {
out.insert(conflict_name(&name, loser), loser.clone());
}
out.insert(name, winner);
}
out.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn f(name: &str, hash: &[u8]) -> FileMeta {
FileMeta::new(name, hash.to_vec())
}
#[test]
fn same_name_different_content_deterministic_winner() {
let a = vec![f("notes.txt", b"\x01")];
let b = vec![f("notes.txt", b"\x02")]; let m_ab = merged_set(&a, &b);
let m_ba = merged_set(&b, &a);
assert_eq!(m_ab, m_ba, "winner must not depend on order");
assert_eq!(
m_ab.iter().find(|(n, _)| n == "notes.txt").unwrap().1,
b"\x02".to_vec()
);
let conflict = conflict_name("notes.txt", b"\x01");
assert_eq!(
m_ab.iter().find(|(n, _)| n == &conflict).unwrap().1,
b"\x01".to_vec()
);
}
#[test]
fn merge_is_symmetric_convergent() {
let a = vec![
f("only_a.txt", b"AA"),
f("shared_same.txt", b"SAME"),
f("conflict.txt", b"\x10\x00"),
];
let b = vec![
f("only_b.txt", b"BB"),
f("shared_same.txt", b"SAME"),
f("conflict.txt", b"\x20\x00"), ];
let m_ab = merged_set(&a, &b);
let m_ba = merged_set(&b, &a);
assert_eq!(m_ab, m_ba, "reconcile must be symmetric (convergence)");
let get = |m: &Vec<(String, Vec<u8>)>, n: &str| {
m.iter().find(|(name, _)| name == n).map(|(_, h)| h.clone())
};
assert_eq!(get(&m_ab, "only_a.txt"), Some(b"AA".to_vec()));
assert_eq!(get(&m_ab, "only_b.txt"), Some(b"BB".to_vec()));
assert_eq!(get(&m_ab, "shared_same.txt"), Some(b"SAME".to_vec()));
assert_eq!(get(&m_ab, "conflict.txt"), Some(b"\x20\x00".to_vec()));
let loser_copy = conflict_name("conflict.txt", b"\x10\x00");
assert_eq!(get(&m_ab, &loser_copy), Some(b"\x10\x00".to_vec()));
}
#[test]
fn distinct_names_union() {
let a = vec![f("a.txt", b"a"), f("b.txt", b"b")];
let b = vec![f("c.txt", b"c")];
let m = merged_set(&a, &b);
let names: Vec<&str> = m.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
assert_eq!(merged_set(&a, &b), merged_set(&b, &a));
}
#[test]
fn identical_files_are_noop() {
let a = vec![f("x.txt", b"X"), f("y.txt", b"Y")];
let b = a.clone();
let m = merged_set(&a, &b);
assert_eq!(m.len(), 2, "no conflict copies for identical content");
assert_eq!(m, merged_set(&b, &a));
assert_eq!(plan_pulls(&a, &b), ReconcilePlan::default());
assert_eq!(plan_pulls(&b, &a), ReconcilePlan::default());
}
#[test]
fn conflict_copy_preserves_both_contents() {
let a = vec![f("doc", b"\xaa")];
let b = vec![f("doc", b"\xbb")]; let m = merged_set(&a, &b);
let contents: std::collections::BTreeSet<Vec<u8>> =
m.iter().map(|(_, h)| h.clone()).collect();
assert!(contents.contains(b"\xaa".as_slice()), "loser preserved");
assert!(contents.contains(b"\xbb".as_slice()), "winner preserved");
assert_eq!(m.len(), 2);
}
#[test]
fn empty_side() {
let a = vec![f("a.txt", b"a"), f("b.txt", b"b")];
let empty: Vec<FileMeta> = Vec::new();
let m = merged_set(&a, &empty);
assert_eq!(m.len(), 2);
assert_eq!(merged_set(&a, &empty), merged_set(&empty, &a));
assert_eq!(merged_set(&empty, &empty), Vec::new());
}
#[test]
fn plans_converge_to_merged_set() {
let a = vec![
f("only_a", b"A"),
f("shared", b"\x05"),
f("dup", b"DUP"),
];
let b = vec![
f("only_b", b"B"),
f("shared", b"\x09"), f("dup", b"DUP"),
];
let final_a = apply_plan(&a, &b);
let final_b = apply_plan(&b, &a);
assert_eq!(final_a, final_b, "both devices reach the same folder");
assert_eq!(final_a, merged_set(&a, &b), "matches the direct fixed point");
}
fn apply_plan(local: &[FileMeta], remote: &[FileMeta]) -> Vec<(String, Vec<u8>)> {
use std::collections::BTreeMap;
let plan = plan_pulls(local, remote);
let mut folder: BTreeMap<String, Vec<u8>> =
local.iter().map(|f| (f.name.clone(), f.hash.clone())).collect();
for (from, to) in &plan.rename_local {
if let Some(h) = folder.get(from).cloned() {
folder.insert(to.clone(), h);
}
}
for want in &plan.want {
if let Some(rf) = remote.iter().find(|f| &f.name == want) {
folder.insert(want.clone(), rf.hash.clone());
} else if let Some(rf) = remote
.iter()
.find(|f| &conflict_name(&f.name, &f.hash) == want)
{
folder.insert(want.clone(), rf.hash.clone());
}
}
folder.into_iter().collect()
}
}