use std::fs;
use syncor_core::config::SyncorPaths;
use syncor_core::link::{LinkId, LinkInfo, LinkMode};
use syncor_core::sync::engine::SyncEngine;
use syncor_core::sync::state::StateDb;
use syncor_core::transport::git::GitTransport;
use syncor_core::transport::SyncTransport;
use tempfile::TempDir;
struct Machine {
workspace: TempDir,
data: TempDir,
link: LinkInfo,
paths: SyncorPaths,
}
impl Machine {
fn engine(&self) -> SyncEngine {
let transport = GitTransport::new(self.paths.clone());
SyncEngine::new(self.paths.clone(), Box::new(transport))
}
fn state_db(&self) -> StateDb {
StateDb::open(self.paths.link_state_db()).unwrap()
}
fn write(&self, path: &str, content: &str) {
let full = self.workspace.path().join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full, content).unwrap();
}
fn read(&self, path: &str) -> String {
fs::read_to_string(self.workspace.path().join(path)).unwrap()
}
fn exists(&self, path: &str) -> bool {
self.workspace.path().join(path).exists()
}
fn delete(&self, path: &str) {
fs::remove_file(self.workspace.path().join(path)).unwrap();
}
}
fn two_machines(link_name: &str) -> (TempDir, Machine, Machine) {
let remote = TempDir::new().unwrap();
git2::Repository::init_bare(remote.path()).unwrap();
let url = remote.path().to_str().unwrap().to_string();
let mk = |mode: LinkMode| -> Machine {
let workspace = TempDir::new().unwrap();
let data = TempDir::new().unwrap();
let link = LinkInfo {
id: LinkId::from_parts(&url, link_name),
name: link_name.to_string(),
repo: url.clone(),
local_dir: workspace.path().to_path_buf(),
mode,
poll_interval_secs: None,
};
let paths = SyncorPaths::with_home(data.path());
Machine {
workspace,
data,
link,
paths,
}
};
let a = mk(LinkMode::Push);
let b = mk(LinkMode::Pull);
(remote, a, b)
}
fn bootstrap(a: &Machine, b: &Machine) {
let ea = a.engine();
ea.init_link(&a.link).unwrap();
ea.push(&a.link).unwrap();
let eb = b.engine();
eb.init_link(&b.link).unwrap();
eb.restore_latest(&b.link).unwrap();
}
#[test]
fn conflict_both_modify_same_file() {
let (_remote, a, b) = two_machines("conflict-both-mod");
a.write("config.yaml", "key: original");
bootstrap(&a, &b);
assert_eq!(b.read("config.yaml"), "key: original");
a.write("config.yaml", "key: from-machine-a");
a.engine().push(&a.link).unwrap();
b.write("config.yaml", "key: from-machine-b");
let result = b.engine().pull(&b.link);
assert!(result.is_err(), "pull should return conflict error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("conflict"),
"error should mention conflict: {}",
err
);
let db = b.state_db();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].file_path, "config.yaml");
assert!(conflicts[0].local_hash.is_some());
assert!(conflicts[0].remote_hash.is_some());
assert_eq!(b.read("config.yaml"), "key: from-machine-b");
}
#[test]
fn merge_non_overlapping_changes() {
let (_remote, a, b) = two_machines("merge-no-overlap");
a.write("file1.txt", "original1");
a.write("file2.txt", "original2");
bootstrap(&a, &b);
a.write("file1.txt", "modified-by-a");
a.engine().push(&a.link).unwrap();
let result = b.engine().pull(&b.link).unwrap();
assert!(result.restored);
assert_eq!(b.read("file1.txt"), "modified-by-a");
assert_eq!(b.read("file2.txt"), "original2");
}
#[test]
fn auto_apply_remote_change() {
let (_remote, a, b) = two_machines("auto-apply");
a.write("data.txt", "v1");
bootstrap(&a, &b);
a.write("data.txt", "v2");
a.engine().push(&a.link).unwrap();
let result = b.engine().pull(&b.link).unwrap();
assert!(result.restored);
assert_eq!(b.read("data.txt"), "v2");
}
#[test]
fn keep_local_change_when_remote_unchanged() {
let (_remote, a, b) = two_machines("keep-local");
a.write("data.txt", "original");
bootstrap(&a, &b);
a.engine().push(&a.link).unwrap();
b.write("data.txt", "local-edit");
let result = b.engine().pull(&b.link).unwrap();
assert_eq!(b.read("data.txt"), "local-edit");
}
#[test]
fn conflict_both_add_same_new_file() {
let (_remote, a, b) = two_machines("conflict-both-add");
a.write("base.txt", "base");
bootstrap(&a, &b);
a.write("new.txt", "from-a");
a.engine().push(&a.link).unwrap();
b.write("new.txt", "from-b");
let result = b.engine().pull(&b.link);
assert!(result.is_err());
let db = b.state_db();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert!(
conflicts.iter().any(|c| c.file_path == "new.txt"),
"new.txt should be in conflicts"
);
assert_eq!(b.read("new.txt"), "from-b");
}
#[test]
fn conflict_remote_delete_local_modify() {
let (_remote, a, b) = two_machines("conflict-del-mod");
a.write("important.txt", "original");
bootstrap(&a, &b);
a.delete("important.txt");
a.engine().push(&a.link).unwrap();
b.write("important.txt", "b-edited");
let result = b.engine().pull(&b.link);
assert!(result.is_err());
let db = b.state_db();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert!(
conflicts.iter().any(|c| c.file_path == "important.txt"),
"should conflict on deleted-vs-modified"
);
assert_eq!(b.read("important.txt"), "b-edited");
}
#[test]
fn conflict_remote_modify_local_delete() {
let (_remote, a, b) = two_machines("conflict-mod-del");
a.write("shared.txt", "original");
bootstrap(&a, &b);
a.write("shared.txt", "a-modified");
a.engine().push(&a.link).unwrap();
b.delete("shared.txt");
let result = b.engine().pull(&b.link);
assert!(result.is_err());
let db = b.state_db();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert!(
conflicts.iter().any(|c| c.file_path == "shared.txt"),
"should conflict on modified-vs-deleted"
);
}
#[test]
fn no_conflict_when_both_change_to_same() {
let (_remote, a, b) = two_machines("same-change");
a.write("data.txt", "original");
bootstrap(&a, &b);
a.write("data.txt", "converged");
a.engine().push(&a.link).unwrap();
b.write("data.txt", "converged");
let result = b.engine().pull(&b.link);
assert!(result.is_ok(), "identical changes should not conflict");
assert_eq!(b.read("data.txt"), "converged");
}
#[test]
fn auto_apply_remote_add() {
let (_remote, a, b) = two_machines("auto-add");
a.write("base.txt", "base");
bootstrap(&a, &b);
a.write("extra.txt", "new content");
a.engine().push(&a.link).unwrap();
let result = b.engine().pull(&b.link).unwrap();
assert!(result.restored);
assert!(b.exists("extra.txt"));
assert_eq!(b.read("extra.txt"), "new content");
}
#[test]
fn auto_apply_remote_delete() {
let (_remote, a, b) = two_machines("auto-delete");
a.write("file1.txt", "keep");
a.write("file2.txt", "will-delete");
bootstrap(&a, &b);
assert!(b.exists("file2.txt"));
a.delete("file2.txt");
a.engine().push(&a.link).unwrap();
let result = b.engine().pull(&b.link).unwrap();
assert!(result.restored);
assert!(b.exists("file1.txt"));
assert!(!b.exists("file2.txt"), "deleted file should be removed");
}
#[test]
fn no_conflict_both_delete() {
let (_remote, a, b) = two_machines("both-delete");
a.write("temp.txt", "temporary");
bootstrap(&a, &b);
a.delete("temp.txt");
a.engine().push(&a.link).unwrap();
b.delete("temp.txt");
let result = b.engine().pull(&b.link);
assert!(result.is_ok(), "both deleting should not conflict");
assert!(!b.exists("temp.txt"));
}
#[test]
fn conflict_resolution_unblocks_sync() {
let (_remote, a, b) = two_machines("resolve-unblock");
a.write("config.txt", "original");
bootstrap(&a, &b);
a.write("config.txt", "from-a");
a.engine().push(&a.link).unwrap();
b.write("config.txt", "from-b");
let _ = b.engine().pull(&b.link);
let db = b.state_db();
assert!(db.has_conflicts(b.link.id.as_str()).unwrap());
b.write("config.txt", "from-a");
db.clear_conflicts(b.link.id.as_str()).unwrap();
assert!(!db.has_conflicts(b.link.id.as_str()).unwrap());
a.write("config.txt", "from-a-v2");
a.engine().push(&a.link).unwrap();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert!(conflicts.is_empty(), "conflicts should be cleared");
}
#[test]
fn complex_non_conflicting_merge() {
let (_remote, a, b) = two_machines("complex-merge");
a.write("f1.txt", "original1");
a.write("f2.txt", "original2");
a.write("f4.txt", "will-delete");
bootstrap(&a, &b);
a.write("f1.txt", "a-modified-f1");
a.write("f3.txt", "new-file-from-a");
a.delete("f4.txt");
a.engine().push(&a.link).unwrap();
b.write("f2.txt", "b-modified-f2");
let result = b.engine().pull(&b.link);
assert!(
result.is_ok(),
"non-overlapping changes should merge cleanly"
);
let result = result.unwrap();
assert!(result.restored);
assert_eq!(b.read("f1.txt"), "a-modified-f1"); assert_eq!(b.read("f2.txt"), "b-modified-f2"); assert!(b.exists("f3.txt")); assert_eq!(b.read("f3.txt"), "new-file-from-a");
assert!(!b.exists("f4.txt")); }
#[test]
fn multiple_conflicts_at_once() {
let (_remote, a, b) = two_machines("multi-conflict");
a.write("a.txt", "orig-a");
a.write("b.txt", "orig-b");
a.write("c.txt", "orig-c");
bootstrap(&a, &b);
a.write("a.txt", "a-v2");
a.write("b.txt", "b-v2");
a.write("c.txt", "c-v2");
a.engine().push(&a.link).unwrap();
b.write("a.txt", "a-local");
b.write("b.txt", "b-local");
b.write("c.txt", "c-local");
let result = b.engine().pull(&b.link);
assert!(result.is_err());
let db = b.state_db();
let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
assert_eq!(conflicts.len(), 3, "should have 3 conflicts");
let paths: Vec<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
assert!(paths.contains(&"a.txt"));
assert!(paths.contains(&"b.txt"));
assert!(paths.contains(&"c.txt"));
}
#[test]
fn sequential_sync_rounds() {
let (_remote, a, b) = two_machines("seq-rounds");
a.write("file.txt", "round1");
bootstrap(&a, &b);
assert_eq!(b.read("file.txt"), "round1");
a.write("file.txt", "round2");
a.engine().push(&a.link).unwrap();
b.engine().pull(&b.link).unwrap();
assert_eq!(b.read("file.txt"), "round2");
a.write("new.txt", "round3");
a.engine().push(&a.link).unwrap();
b.engine().pull(&b.link).unwrap();
assert_eq!(b.read("new.txt"), "round3");
assert_eq!(b.read("file.txt"), "round2");
a.delete("file.txt");
a.engine().push(&a.link).unwrap();
b.engine().pull(&b.link).unwrap();
assert!(!b.exists("file.txt"));
assert_eq!(b.read("new.txt"), "round3"); }
#[test]
fn bidirectional_sync_rounds() {
let (_remote, a, b) = two_machines("bidir-sync");
a.write("file1.txt", "from-a-v1");
bootstrap(&a, &b);
assert_eq!(b.read("file1.txt"), "from-a-v1");
b.write("file1.txt", "from-b-v1");
b.write("file2.txt", "new-from-b");
b.engine().push(&b.link).unwrap();
a.engine().pull(&a.link).unwrap();
assert_eq!(a.read("file1.txt"), "from-b-v1");
assert_eq!(a.read("file2.txt"), "new-from-b");
a.write("file2.txt", "modified-by-a");
a.engine().push(&a.link).unwrap();
b.engine().pull(&b.link).unwrap();
assert_eq!(b.read("file2.txt"), "modified-by-a");
assert_eq!(b.read("file1.txt"), "from-b-v1"); }
#[test]
fn concurrent_push_detects_conflict() {
let (_remote, a, b) = two_machines("concurrent-push");
a.write("shared.txt", "initial");
bootstrap(&a, &b);
a.write("shared.txt", "from-a");
a.engine().push(&a.link).unwrap();
b.write("shared.txt", "from-b");
let result = b.engine().push(&b.link);
assert!(
result.is_err(),
"B's push without pull should fail with conflict"
);
let err = result.unwrap_err().to_string();
assert!(
err.to_lowercase().contains("conflict")
|| err.to_lowercase().contains("non-fast-forward")
|| err.to_lowercase().contains("rejected"),
"error should indicate conflict: {}",
err
);
}
#[test]
fn has_remote_changes_detects_new_push() {
let (_remote, a, b) = two_machines("has-remote-changes");
a.write("data.txt", "v1");
bootstrap(&a, &b);
let transport_b = GitTransport::new(b.paths.clone());
assert!(
!transport_b.has_remote_changes(&b.link).unwrap(),
"should be up-to-date right after bootstrap"
);
a.write("data.txt", "v2");
a.engine().push(&a.link).unwrap();
assert!(
transport_b.has_remote_changes(&b.link).unwrap(),
"should detect A's push as remote change"
);
b.engine().pull(&b.link).unwrap();
assert_eq!(b.read("data.txt"), "v2");
assert!(
!transport_b.has_remote_changes(&b.link).unwrap(),
"should be up-to-date after pull"
);
}
#[test]
fn list_remote_links_discovers_links() {
let (remote, a, _b) = two_machines("list-links");
a.write("file.txt", "content");
a.engine().init_link(&a.link).unwrap();
a.engine().push(&a.link).unwrap();
let transport = GitTransport::new(a.paths.clone());
let repo_url = remote.path().to_str().unwrap();
let links = transport.list_remote_links(repo_url).unwrap();
assert!(
!links.is_empty(),
"should discover at least one link in the remote"
);
assert!(
links.iter().any(|l| l.name == "list-links"),
"should find the 'list-links' link name, got: {:?}",
links.iter().map(|l| &l.name).collect::<Vec<_>>()
);
}
#[test]
fn pull_when_up_to_date_returns_no_change() {
let (_remote, a, b) = two_machines("up-to-date");
a.write("file.txt", "content");
bootstrap(&a, &b);
assert_eq!(b.read("file.txt"), "content");
let result = b.engine().pull(&b.link).unwrap();
assert!(
!result.restored,
"second pull with no remote changes should return restored: false"
);
}