use std::path::PathBuf;
use crate::crypto::PublicKey;
use super::super::path_ops::OpType;
use super::types::{Conflict, Resolution};
use super::ConflictResolver;
#[derive(Debug, Clone, Default)]
pub struct ConflictFile {
pub hash_length: usize,
}
impl ConflictFile {
pub fn new() -> Self {
Self { hash_length: 8 }
}
pub fn with_hash_length(hash_length: usize) -> Self {
Self { hash_length }
}
pub fn conflict_path(path: &std::path::Path, version: &str) -> PathBuf {
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("file");
let conflict_name = format!("{}@{}", file_name, version);
match path.parent() {
Some(parent) if parent != std::path::Path::new("") => parent.join(conflict_name),
_ => PathBuf::from(conflict_name),
}
}
}
impl ConflictResolver for ConflictFile {
fn resolve(&self, conflict: &Conflict, _local_peer: &PublicKey) -> Resolution {
match (&conflict.base.op_type, &conflict.incoming.op_type) {
(OpType::Add, OpType::Add) => {
let version = match &conflict.incoming.content_link {
Some(link) => {
let hash_str = link.hash().to_string();
hash_str.chars().take(self.hash_length).collect::<String>()
}
None => conflict.incoming.id.timestamp.to_string(),
};
let new_path = Self::conflict_path(&conflict.incoming.path, &version);
Resolution::RenameIncoming { new_path }
}
_ => {
if conflict.incoming.id > conflict.base.id {
Resolution::UseIncoming
} else {
Resolution::UseBase
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::SecretKey;
use crate::linked_data::Link;
use crate::mount::path_ops::{OpId, OpType, PathOperation};
fn make_peer_id(seed: u8) -> PublicKey {
let mut seed_bytes = [0u8; 32];
seed_bytes[0] = seed;
let secret = SecretKey::from(seed_bytes);
secret.public()
}
fn make_op(peer_id: PublicKey, timestamp: u64, op_type: OpType, path: &str) -> PathOperation {
PathOperation {
id: OpId { timestamp, peer_id },
op_type,
path: PathBuf::from(path),
content_link: None,
is_dir: false,
}
}
fn make_op_with_link(
peer_id: PublicKey,
timestamp: u64,
op_type: OpType,
path: &str,
hash_seed: u8,
) -> PathOperation {
let mut hash_bytes = [0u8; 32];
hash_bytes[0] = hash_seed;
let hash = iroh_blobs::Hash::from_bytes(hash_bytes);
let link = Link::new(crate::linked_data::LD_RAW_CODEC, hash);
PathOperation {
id: OpId { timestamp, peer_id },
op_type,
path: PathBuf::from(path),
content_link: Some(link),
is_dir: false,
}
}
#[test]
fn test_conflict_file_path_with_extension() {
let path = PathBuf::from("document.txt");
let result = ConflictFile::conflict_path(&path, "abc12345");
assert_eq!(result, PathBuf::from("document.txt@abc12345"));
}
#[test]
fn test_conflict_file_path_without_extension() {
let path = PathBuf::from("README");
let result = ConflictFile::conflict_path(&path, "abc12345");
assert_eq!(result, PathBuf::from("README@abc12345"));
}
#[test]
fn test_conflict_file_path_nested() {
let path = PathBuf::from("docs/notes/file.md");
let result = ConflictFile::conflict_path(&path, "v42");
assert_eq!(result, PathBuf::from("docs/notes/file.md@v42"));
}
#[test]
fn test_conflict_file_resolver_add_vs_add() {
let peer1 = make_peer_id(1);
let peer2 = make_peer_id(2);
let base = make_op_with_link(peer1, 1, OpType::Add, "file.txt", 0xAA);
let incoming = make_op_with_link(peer2, 100, OpType::Add, "file.txt", 0xBB);
let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming.clone());
let resolver = ConflictFile::new();
let resolution = resolver.resolve(&conflict, &peer1);
match resolution {
Resolution::RenameIncoming { new_path } => {
let expected_version: String = incoming
.content_link
.unwrap()
.hash()
.to_string()
.chars()
.take(8)
.collect();
assert_eq!(
new_path,
PathBuf::from(format!("file.txt@{}", expected_version))
);
}
_ => panic!("Expected RenameIncoming, got {:?}", resolution),
}
}
#[test]
fn test_conflict_file_resolver_add_vs_remove() {
let peer1 = make_peer_id(1);
let peer2 = make_peer_id(2);
let base = make_op(peer1, 1, OpType::Add, "file.txt");
let incoming = make_op(peer2, 100, OpType::Remove, "file.txt");
let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
let resolver = ConflictFile::new();
assert_eq!(resolver.resolve(&conflict, &peer1), Resolution::UseIncoming);
}
#[test]
fn test_conflict_file_resolver_remove_vs_add() {
let peer1 = make_peer_id(1);
let peer2 = make_peer_id(2);
let base = make_op(peer1, 100, OpType::Remove, "file.txt");
let incoming = make_op(peer2, 1, OpType::Add, "file.txt");
let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
let resolver = ConflictFile::new();
assert_eq!(resolver.resolve(&conflict, &peer1), Resolution::UseBase);
}
#[test]
fn scenario_two_peers_create_same_file_offline() {
let alice = make_peer_id(1);
let bob = make_peer_id(2);
let alice_creates_notes = make_op_with_link(
alice,
1000, OpType::Add,
"notes.txt",
0x11, );
let bob_creates_notes = make_op_with_link(
bob,
1001, OpType::Add,
"notes.txt",
0x22, );
let conflict = Conflict::new(
PathBuf::from("notes.txt"),
alice_creates_notes, bob_creates_notes.clone(), );
let resolver = ConflictFile::new();
let resolution = resolver.resolve(&conflict, &alice);
match resolution {
Resolution::RenameIncoming { new_path } => {
let path_str = new_path.to_string_lossy();
assert!(
path_str.starts_with("notes.txt@"),
"Should be notes.txt@<hash>"
);
assert!(path_str.contains("22"), "Should contain Bob's hash prefix");
}
other => panic!("Expected RenameIncoming, got {:?}", other),
}
}
#[test]
fn scenario_conflict_file_naming_examples() {
let path1 = ConflictFile::conflict_path(&PathBuf::from("report.txt"), "abc123de");
assert_eq!(path1.to_string_lossy(), "report.txt@abc123de");
let path2 = ConflictFile::conflict_path(&PathBuf::from("Makefile"), "deadbeef");
assert_eq!(path2.to_string_lossy(), "Makefile@deadbeef");
let path3 = ConflictFile::conflict_path(&PathBuf::from("src/lib/utils.rs"), "cafebabe");
assert_eq!(path3.to_string_lossy(), "src/lib/utils.rs@cafebabe");
}
#[test]
fn scenario_different_conflict_types() {
let alice = make_peer_id(1);
let bob = make_peer_id(2);
let resolver = ConflictFile::new();
let alice_adds = make_op_with_link(alice, 1, OpType::Add, "file.txt", 0xAA);
let bob_adds = make_op_with_link(bob, 2, OpType::Add, "file.txt", 0xBB);
let add_conflict = Conflict::new(PathBuf::from("file.txt"), alice_adds, bob_adds);
assert!(matches!(
resolver.resolve(&add_conflict, &alice),
Resolution::RenameIncoming { .. }
));
let alice_adds = make_op(alice, 1, OpType::Add, "file.txt");
let bob_removes = make_op(bob, 2, OpType::Remove, "file.txt");
let mixed_conflict = Conflict::new(PathBuf::from("file.txt"), alice_adds, bob_removes);
assert_eq!(
resolver.resolve(&mixed_conflict, &alice),
Resolution::UseIncoming
);
let alice_removes = make_op(alice, 1, OpType::Remove, "file.txt");
let bob_removes = make_op(bob, 2, OpType::Remove, "file.txt");
let both_remove = Conflict::new(PathBuf::from("file.txt"), alice_removes, bob_removes);
assert_eq!(
resolver.resolve(&both_remove, &alice),
Resolution::UseIncoming
);
}
#[test]
fn scenario_custom_hash_length() {
let alice = make_peer_id(1);
let bob = make_peer_id(2);
let alice_adds = make_op_with_link(alice, 1, OpType::Add, "doc.md", 0xAA);
let bob_adds = make_op_with_link(bob, 2, OpType::Add, "doc.md", 0xBB);
let conflict = Conflict::new(PathBuf::from("doc.md"), alice_adds, bob_adds);
let resolver_8 = ConflictFile::new();
if let Resolution::RenameIncoming { new_path } = resolver_8.resolve(&conflict, &alice) {
let name = new_path.file_name().unwrap().to_string_lossy();
let hash_part = name.split('@').nth(1).unwrap();
assert_eq!(hash_part.len(), 8, "Default should use 8 hash chars");
}
let resolver_4 = ConflictFile::with_hash_length(4);
if let Resolution::RenameIncoming { new_path } = resolver_4.resolve(&conflict, &alice) {
let name = new_path.file_name().unwrap().to_string_lossy();
let hash_part = name.split('@').nth(1).unwrap();
assert_eq!(hash_part.len(), 4, "Custom should use 4 hash chars");
}
let resolver_16 = ConflictFile::with_hash_length(16);
if let Resolution::RenameIncoming { new_path } = resolver_16.resolve(&conflict, &alice) {
let name = new_path.file_name().unwrap().to_string_lossy();
let hash_part = name.split('@').nth(1).unwrap();
assert_eq!(hash_part.len(), 16, "Custom should use 16 hash chars");
}
}
}