pub const CONFLICT_HASH_HEX_LEN: usize = 8;
pub const CONFLICT_SEP: &str = ".conflict-";
pub const CONFLICT_SUFFIX_MAX_LEN: usize = CONFLICT_SEP.len() + CONFLICT_HASH_HEX_LEN;
pub fn is_conflict_name(name: &str) -> bool {
let Some(idx) = name.rfind(CONFLICT_SEP) else {
return false;
};
if idx == 0 {
return false; }
let short = &name[idx + CONFLICT_SEP.len()..];
!short.is_empty()
&& short.len() <= CONFLICT_HASH_HEX_LEN
&& short.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
#[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");
}
#[test]
fn conflict_suffix_len_matches_what_conflict_name_appends() {
let max = conflict_name("x", &[0xab, 0xcd, 0xef, 0x01, 0x23]);
assert_eq!(max.len() - "x".len(), CONFLICT_SUFFIX_MAX_LEN);
for base in ["a", "notes.txt", &"z".repeat(64)] {
for hash in [&b""[..], &b"\x00"[..], &b"\xff\xff\xff\xff\xff\xff"[..]] {
let cn = conflict_name(base, hash);
assert!(
cn.len() <= base.len() + CONFLICT_SUFFIX_MAX_LEN,
"conflict suffix for {base:?}/{hash:?} overran the budget"
);
}
}
}
#[test]
fn capped_base_yields_in_bounds_conflict_name() {
const NAME_CAP: usize = 128;
let base = "n".repeat(NAME_CAP - CONFLICT_SUFFIX_MAX_LEN); assert!(base.len() <= NAME_CAP - CONFLICT_SUFFIX_MAX_LEN);
let cn = conflict_name(&base, &[0xde, 0xad, 0xbe, 0xef, 0x99]);
assert!(
cn.len() <= NAME_CAP,
"conflict name {} > cap {NAME_CAP}",
cn.len()
);
assert!(is_conflict_name(&cn), "must be recognised as a conflict name");
}
#[test]
fn is_conflict_name_round_trips() {
assert!(is_conflict_name(&conflict_name("notes.txt", b"\x01")));
assert!(is_conflict_name(&conflict_name("a", &[0xab, 0xcd, 0xef, 0x12])));
assert!(is_conflict_name(&conflict_name(
"doc",
&[0xff, 0xff, 0xff, 0xff, 0xff]
)));
assert!(!is_conflict_name("notes.txt"));
assert!(!is_conflict_name("a.b.c"));
assert!(!is_conflict_name(".conflict-ab"));
assert!(!is_conflict_name("x.conflict-"));
assert!(!is_conflict_name("x.conflict-abcdef012")); assert!(!is_conflict_name("x.conflict-zz")); assert!(!is_conflict_name("x.conflict-AB")); }
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()
}
}