use bread_sync::{ config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, delegates, machine, packages, SyncRepo, }; use std::fs; use tempfile::TempDir; fn make_bare_repo(path: &std::path::Path) -> git2::Repository { let mut opts = git2::RepositoryInitOptions::new(); opts.bare(true); opts.initial_head("main"); git2::Repository::init_opts(path, &opts).unwrap() } // Helper to create a git commit in a non-bare repo so we have initial state fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { let repo = SyncRepo::init(path).unwrap(); fs::write(path.join(".gitkeep"), "").unwrap(); repo.stage_all().unwrap(); repo.commit("initial commit").unwrap(); repo } #[test] fn sync_init_creates_toml_with_required_fields() { let tmp = TempDir::new().unwrap(); let config = SyncConfig { remote: RemoteConfig { url: "git@github.com:test/sync.git".to_string(), branch: "main".to_string(), }, machine: MachineConfig { name: "testbox".to_string(), tags: vec!["mobile".to_string()], }, packages: PackagesConfig::default(), delegates: DelegatesConfig::default(), }; config.save(tmp.path()).unwrap(); let loaded = SyncConfig::load(tmp.path()).unwrap(); assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); assert_eq!(loaded.remote.branch, "main"); assert_eq!(loaded.machine.name, "testbox"); assert_eq!(loaded.machine.tags, vec!["mobile"]); } #[test] fn sync_init_errors_if_already_initialized() { let tmp = TempDir::new().unwrap(); let config = SyncConfig { remote: RemoteConfig { url: "git@github.com:test/sync.git".to_string(), branch: "main".to_string(), }, machine: MachineConfig { name: "box".to_string(), tags: vec![], }, packages: PackagesConfig::default(), delegates: DelegatesConfig::default(), }; config.save(tmp.path()).unwrap(); // Second load should succeed (init itself must check for existence externally) // We test that load works let result = SyncConfig::load(tmp.path()); assert!(result.is_ok()); // sync.toml now exists — the CLI checks this before calling save assert!(tmp.path().join("sync.toml").exists()); } #[test] fn sync_push_creates_correct_directory_structure() { let repo_tmp = TempDir::new().unwrap(); let bare_tmp = TempDir::new().unwrap(); let bread_cfg_tmp = TempDir::new().unwrap(); // Create initial bare remote let _bare = make_bare_repo(bare_tmp.path()); // Create local bread config fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); // Init local sync repo let repo = SyncRepo::init(repo_tmp.path()).unwrap(); repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) .unwrap(); // Snapshot bread dir let bread_dest = repo_tmp.path().join("bread"); delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); // Write machine profile let machines_dir = repo_tmp.path().join("machines"); let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); profile.write(&machines_dir).unwrap(); // Commit and push repo.commit("sync: testbox").unwrap(); repo.push("origin", "main").unwrap(); // Verify structure in local repo assert!(repo_tmp.path().join("bread").exists()); assert!(repo_tmp.path().join("bread").join("init.lua").exists()); assert!(repo_tmp .path() .join("machines") .join("testbox.toml") .exists()); } #[test] fn sync_push_snapshots_bread_config() { let repo_tmp = TempDir::new().unwrap(); let bare_tmp = TempDir::new().unwrap(); let bread_cfg_tmp = TempDir::new().unwrap(); make_bare_repo(bare_tmp.path()); // Create a more complex bread config fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); fs::write( bread_cfg_tmp.path().join("modules/mymod/init.lua"), "-- mymod", ) .unwrap(); let repo = SyncRepo::init(repo_tmp.path()).unwrap(); repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) .unwrap(); let bread_dest = repo_tmp.path().join("bread"); delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); repo.commit("sync: testbox").unwrap(); repo.push("origin", "main").unwrap(); // Verify files were copied assert!(bread_dest.join("init.lua").exists()); assert!(bread_dest.join("modules/mymod/init.lua").exists()); let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); assert_eq!(content, "-- init"); } #[test] fn sync_pull_copies_files_from_repo() { let bare_tmp = TempDir::new().unwrap(); let local_tmp = TempDir::new().unwrap(); let apply_tmp = TempDir::new().unwrap(); make_bare_repo(bare_tmp.path()); // Create a local repo, add some files, push to bare let repo = SyncRepo::init(local_tmp.path()).unwrap(); repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) .unwrap(); let bread_dest = local_tmp.path().join("bread"); fs::create_dir_all(&bread_dest).unwrap(); fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); repo.commit("sync: first push").unwrap(); repo.push("origin", "main").unwrap(); // Now clone the bare repo and pull let clone_tmp = TempDir::new().unwrap(); let _cloned = SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); // Apply bread/ to apply_tmp let src = clone_tmp.path().join("bread"); if src.exists() { delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); } assert!(apply_tmp.path().join("init.lua").exists()); let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); assert_eq!(content, "-- from sync"); } #[test] fn package_manifest_pacman_parses_output_correctly() { let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; let pkgs = packages::parse_pacman(input); assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); } #[test] fn package_manifest_pip_parses_output_correctly() { let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; let pkgs = packages::parse_pip(input); assert_eq!(pkgs, vec!["requests", "numpy", "black"]); } #[test] fn delegates_exclude_globs_filter_correctly() { let src_tmp = TempDir::new().unwrap(); let dst_tmp = TempDir::new().unwrap(); // Create files that should and shouldn't be copied fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); assert!(dst_tmp.path().join("lua/init.lua").exists()); assert!(!dst_tmp.path().join(".git").exists()); assert!(!dst_tmp.path().join("log.cache").exists()); } #[test] fn machine_profile_written_with_correct_fields() { let machines_tmp = TempDir::new().unwrap(); let profile = machine::MachineProfile::new( "myhost".to_string(), vec!["mobile".to_string(), "battery".to_string()], ); profile.write(machines_tmp.path()).unwrap(); let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); assert_eq!(loaded.name, "myhost"); assert_eq!(loaded.tags, vec!["mobile", "battery"]); assert!(!loaded.hostname.is_empty()); // last_sync must be valid RFC 3339 let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); assert!( parsed.is_ok(), "last_sync '{}' is not valid RFC 3339", loaded.last_sync ); } #[test] fn status_shows_no_changes_when_clean() { let repo_tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(repo_tmp.path()); let changes = repo.local_changes().unwrap(); assert!( changes.is_empty(), "expected no local changes, got: {:?}", changes ); assert!(repo.is_clean().unwrap()); } #[test] fn push_with_no_changes_returns_none() { let repo_tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(repo_tmp.path()); // No new changes — commit should return None let result = repo.commit("second commit").unwrap(); assert!( result.is_none(), "expected None (nothing to commit), got: {:?}", result ); } // ─── git.rs additional coverage ──────────────────────────────────────────── #[test] fn init_creates_repo_with_main_branch() { let tmp = TempDir::new().unwrap(); let repo = SyncRepo::init(tmp.path()).unwrap(); fs::write(tmp.path().join("x"), "").unwrap(); repo.stage_all().unwrap(); let oid = repo.commit("initial").unwrap(); assert!(oid.is_some(), "first commit should succeed"); // Verify HEAD is on refs/heads/main. let head_ref = std::process::Command::new("git") .args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"]) .output() .unwrap(); let head_name = String::from_utf8_lossy(&head_ref.stdout); assert!( head_name.trim() == "refs/heads/main", "expected refs/heads/main, got {head_name}" ); } #[test] fn open_or_clone_opens_existing_repo() { let tmp = TempDir::new().unwrap(); SyncRepo::init(tmp.path()).unwrap(); // Calling open_or_clone on an existing path must not attempt to clone. let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path()); assert!(again.is_ok()); } #[test] fn open_or_clone_clones_into_missing_path() { let bare = TempDir::new().unwrap(); let bare_repo = make_bare_repo(bare.path()); // Seed the bare repo with at least one commit so a clone is meaningful. let local = TempDir::new().unwrap(); let repo = SyncRepo::init(local.path()).unwrap(); fs::write(local.path().join("seed"), "x").unwrap(); repo.commit("seed").unwrap(); repo.set_remote("origin", bare.path().to_str().unwrap()) .unwrap(); repo.push("origin", "main").unwrap(); drop(bare_repo); let dest_parent = TempDir::new().unwrap(); let dest = dest_parent.path().join("clone-target"); let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap(); assert_eq!(cloned.path, dest); assert!(dest.join("seed").exists()); } #[test] fn local_changes_reports_new_modified_and_deleted() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); fs::write(tmp.path().join("added.txt"), "new").unwrap(); fs::write(tmp.path().join(".gitkeep"), "modified").unwrap(); let changes = repo.local_changes().unwrap(); assert!(!changes.is_empty()); let kinds: Vec = changes.iter().map(|(c, _)| *c).collect(); assert!(kinds.contains(&'A')); assert!(kinds.contains(&'M')); } #[test] fn is_clean_after_commit() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); assert!(repo.is_clean().unwrap()); } #[test] fn working_diff_includes_modified_tracked_content() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); // Modify an already-tracked file so it appears in `git diff HEAD`. fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap(); let diff = repo.working_diff().unwrap(); assert!( diff.contains("tracked change"), "diff did not include tracked change, diff was: {diff:?}" ); } #[test] fn working_diff_empty_when_only_untracked_files() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap(); // working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED, // so untracked files don't appear — local_changes is the right tool for that. let diff = repo.working_diff().unwrap(); assert!( diff.is_empty() || !diff.contains("new-untracked"), "expected untracked file to be excluded, diff was: {diff:?}" ); } #[test] fn set_remote_overwrites_existing_remote() { let tmp = TempDir::new().unwrap(); let repo = SyncRepo::init(tmp.path()).unwrap(); repo.set_remote("origin", "https://example.com/a.git") .unwrap(); // A second call must not error out — it should replace the previous URL. repo.set_remote("origin", "https://example.com/b.git") .unwrap(); } #[test] fn last_commit_time_returns_none_for_empty_repo() { let tmp = TempDir::new().unwrap(); let repo = SyncRepo::init(tmp.path()).unwrap(); assert!(repo.last_commit_time().is_none()); } #[test] fn last_commit_time_present_after_commit() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); assert!(repo.last_commit_time().is_some()); } #[test] fn push_pull_round_trip_through_bare_remote() { let bare = TempDir::new().unwrap(); make_bare_repo(bare.path()); // Push from author repo. let author = TempDir::new().unwrap(); let r1 = SyncRepo::init(author.path()).unwrap(); r1.set_remote("origin", bare.path().to_str().unwrap()) .unwrap(); fs::write(author.path().join("note.txt"), "v1").unwrap(); r1.commit("v1").unwrap(); r1.push("origin", "main").unwrap(); // Clone into reader repo and confirm contents. let reader_tmp = TempDir::new().unwrap(); let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap(); assert_eq!( fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), "v1" ); // Author writes a second version and pushes. fs::write(author.path().join("note.txt"), "v2").unwrap(); r1.commit("v2").unwrap(); r1.push("origin", "main").unwrap(); // Reader pulls and sees the new content. r2.pull("origin", "main").unwrap(); assert_eq!( fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), "v2" ); } #[test] fn pull_with_no_remote_changes_is_noop() { let bare = TempDir::new().unwrap(); make_bare_repo(bare.path()); let local = TempDir::new().unwrap(); let repo = SyncRepo::init(local.path()).unwrap(); repo.set_remote("origin", bare.path().to_str().unwrap()) .unwrap(); fs::write(local.path().join("a"), "1").unwrap(); repo.commit("c1").unwrap(); repo.push("origin", "main").unwrap(); // Calling pull immediately after push must be up-to-date and succeed. repo.pull("origin", "main").unwrap(); assert!(repo.is_clean().unwrap()); } #[test] fn remote_changes_returns_empty_when_remote_unknown() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path()); let changes = repo.remote_changes("origin", "main").unwrap(); assert!(changes.is_empty()); } // ─── machine list ────────────────────────────────────────────────────────── #[test] fn machine_list_returns_all_profiles_sorted() { let machines_tmp = TempDir::new().unwrap(); for name in ["delta", "alpha", "charlie", "bravo"] { machine::MachineProfile::new(name.to_string(), vec![]) .write(machines_tmp.path()) .unwrap(); } let list = machine::MachineProfile::list(machines_tmp.path()).unwrap(); let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]); } // ─── packages snapshot ───────────────────────────────────────────────────── #[test] fn snapshot_writes_destination_when_manager_unknown_is_skipped() { let dest_tmp = TempDir::new().unwrap(); let dest = dest_tmp.path().join("nested/dir/file.txt"); let wrote = packages::snapshot("does-not-exist", &dest).unwrap(); assert!(!wrote); assert!(!dest.exists()); }