Skip to main content

kando_core/board/
sync.rs

1use std::env;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug)]
6pub struct SyncState {
7    /// Path to the shadow clone in XDG cache.
8    pub shadow_path: PathBuf,
9    /// Branch to sync on.
10    pub branch: String,
11    /// Whether we're currently online (last git op succeeded).
12    pub online: bool,
13    /// Last error message from a git operation, shown in the status bar.
14    pub last_error: Option<String>,
15}
16
17#[derive(Debug, PartialEq, Eq)]
18pub enum SyncStatus {
19    Updated,
20    AlreadyUpToDate,
21    Offline,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum SyncError {
26    #[error("io error: {0}")]
27    Io(#[from] std::io::Error),
28    #[error("not a git repository")]
29    NotGitRepo,
30    #[error("no remote configured")]
31    NoRemote,
32    #[error("git command failed: {0}")]
33    GitFailed(String),
34}
35
36/// Check if a path is inside a git repository.
37pub fn find_git_root(start: &Path) -> Option<PathBuf> {
38    let mut dir = start.to_path_buf();
39    loop {
40        if dir.join(".git").exists() {
41            return Some(dir);
42        }
43        if !dir.pop() {
44            return None;
45        }
46    }
47}
48
49/// Check if the remote uses SSH and warn if no ssh-agent is running.
50/// Returns true if the user should be warned.
51pub fn check_ssh_agent(remote_url: &str) -> bool {
52    let is_ssh = remote_url.starts_with("git@")
53        || remote_url.starts_with("ssh://")
54        || remote_url.contains("@") && !remote_url.starts_with("http");
55
56    if !is_ssh {
57        return false;
58    }
59
60    // Check if ssh-agent is available
61    match env::var("SSH_AUTH_SOCK") {
62        Ok(sock) if !sock.is_empty() => {
63            // Agent socket exists, check if it has keys loaded
64            let output = Command::new("ssh-add").arg("-l").output();
65            match output {
66                Ok(out) if out.status.success() => false, // keys loaded, all good
67                _ => true,                                // no keys loaded
68            }
69        }
70        _ => true, // no agent running
71    }
72}
73
74/// Build a git Command with SSH connection multiplexing enabled.
75/// This reuses a single SSH connection for multiple git operations,
76/// avoiding repeated passphrase prompts within the same session.
77fn git_cmd(shadow_path: &Path) -> Command {
78    let mut cmd = Command::new("git");
79    cmd.current_dir(shadow_path);
80
81    // Set up SSH ControlMaster for connection reuse.
82    // Socket path must be short (< 104 bytes on macOS), so use /tmp.
83    let hash = djb2(&shadow_path.to_string_lossy());
84    let ssh_cmd = format!(
85        "ssh -o ControlMaster=auto -o ControlPath=/tmp/kando-ssh-{hash:016x} -o ControlPersist=600",
86    );
87    cmd.env("GIT_SSH_COMMAND", ssh_cmd);
88
89    cmd
90}
91
92/// Get the remote URL of the git repo.
93pub fn get_remote_url(repo_root: &Path) -> Result<String, SyncError> {
94    let output = Command::new("git")
95        .args(["remote", "get-url", "origin"])
96        .current_dir(repo_root)
97        .output()?;
98
99    if !output.status.success() {
100        return Err(SyncError::NoRemote);
101    }
102
103    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
104}
105
106/// DJB2 hash — stable across Rust versions, unlike `DefaultHasher`.
107fn djb2(s: &str) -> u64 {
108    let mut hash: u64 = 5381;
109    for b in s.bytes() {
110        hash = hash.wrapping_mul(33).wrapping_add(u64::from(b));
111    }
112    hash
113}
114
115/// Compute a stable shadow path for a given repo based on its remote URL.
116pub fn shadow_dir_for(remote_url: &str) -> PathBuf {
117    let cache_dir = dirs::cache_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
118    let hash = djb2(remote_url);
119    cache_dir.join(".kando").join(format!("{hash:016x}"))
120}
121
122/// Initialize or validate the shadow clone.
123pub fn init_shadow(kando_dir: &Path, branch: &str) -> Result<SyncState, SyncError> {
124    // kando_dir is .kando/, repo_root is its parent
125    let repo_root = kando_dir
126        .parent()
127        .ok_or_else(|| SyncError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "no parent")))?;
128
129    let git_root = find_git_root(repo_root).ok_or(SyncError::NotGitRepo)?;
130    let remote_url = get_remote_url(&git_root)?;
131    let shadow_path = shadow_dir_for(&remote_url);
132
133    let ssh_warning = if check_ssh_agent(&remote_url) {
134        Some("No ssh-agent keys found. Run `ssh-add` to avoid passphrase prompts.".to_string())
135    } else {
136        None
137    };
138
139    if shadow_path.join(".git").exists() {
140        // Validate remote matches
141        let shadow_remote = get_remote_url(&shadow_path).unwrap_or_default();
142        if shadow_remote != remote_url {
143            // Remote changed, re-clone
144            std::fs::remove_dir_all(&shadow_path)?;
145            clone_shadow(&remote_url, &shadow_path, branch)?;
146        } else {
147            // Ensure we're on the right branch and tracking remote
148            let _ = git_cmd(&shadow_path)
149                .args(["checkout", branch])
150                .output();
151            let _ = git_cmd(&shadow_path)
152                .args(["branch", "--set-upstream-to", &format!("origin/{branch}"), branch])
153                .output();
154        }
155    } else {
156        clone_shadow(&remote_url, &shadow_path, branch)?;
157    }
158
159    Ok(SyncState {
160        shadow_path,
161        branch: branch.to_string(),
162        online: true,
163        last_error: ssh_warning,
164    })
165}
166
167fn clone_shadow(remote_url: &str, shadow_path: &Path, branch: &str) -> Result<(), SyncError> {
168    std::fs::create_dir_all(shadow_path)?;
169
170    // Try to clone with the specific branch
171    let output = Command::new("git")
172        .args([
173            "clone",
174            "--branch",
175            branch,
176            "--single-branch",
177            remote_url,
178            &shadow_path.to_string_lossy(),
179        ])
180        .output()?;
181
182    if !output.status.success() {
183        // Branch might not exist yet; clone default and create branch
184        let _ = std::fs::remove_dir_all(shadow_path);
185        std::fs::create_dir_all(shadow_path)?;
186
187        let output = Command::new("git")
188            .args(["clone", remote_url, &shadow_path.to_string_lossy()])
189            .output()?;
190
191        if !output.status.success() {
192            return Err(SyncError::GitFailed(
193                String::from_utf8_lossy(&output.stderr).to_string(),
194            ));
195        }
196
197        // Create and checkout the branch
198        let _ = Command::new("git")
199            .args(["checkout", "-b", branch])
200            .current_dir(shadow_path)
201            .output();
202
203        // Set push upstream so first push creates the remote branch
204        let _ = Command::new("git")
205            .args(["config", "push.default", "current"])
206            .current_dir(shadow_path)
207            .output();
208    }
209
210    Ok(())
211}
212
213/// Pull latest changes from remote into shadow, then copy .kando/ to working dir.
214pub fn pull(state: &mut SyncState, kando_dir: &Path) -> SyncStatus {
215    // Pull in shadow
216    let output = git_cmd(&state.shadow_path)
217        .args(["pull", "--rebase", "--autostash"])
218        .output();
219
220    match output {
221        Ok(out) if out.status.success() => {
222            state.online = true;
223            state.last_error = None;
224
225            let stderr = String::from_utf8_lossy(&out.stderr);
226            let stdout = String::from_utf8_lossy(&out.stdout);
227
228            // Copy .kando/ from shadow to working dir
229            let shadow_kando = state.shadow_path.join(".kando");
230            if shadow_kando.exists() {
231                if let Err(e) = copy_dir_contents(&shadow_kando, kando_dir) {
232                    state.last_error = Some(format!("sync copy failed: {e}"));
233                }
234            }
235
236            if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
237                SyncStatus::AlreadyUpToDate
238            } else {
239                SyncStatus::Updated
240            }
241        }
242        Ok(out) => {
243            let stderr = String::from_utf8_lossy(&out.stderr);
244            // No tracking info means branch is new and hasn't been pushed yet — not an error
245            if stderr.contains("no tracking information") {
246                state.online = true;
247                state.last_error = None;
248                SyncStatus::AlreadyUpToDate
249            } else {
250                state.online = false;
251                state.last_error = Some(stderr.trim().to_string());
252                SyncStatus::Offline
253            }
254        }
255        Err(e) => {
256            state.online = false;
257            state.last_error = Some(format!("git pull failed: {e}"));
258            SyncStatus::Offline
259        }
260    }
261}
262
263/// Copy .kando/ from working dir to shadow, commit, and push.
264pub fn commit_and_push(state: &mut SyncState, kando_dir: &Path, message: &str) {
265    // Replace shadow .kando/ with working dir version to capture deletions
266    let shadow_kando = state.shadow_path.join(".kando");
267    let _ = std::fs::remove_dir_all(&shadow_kando);
268    if let Err(e) = copy_dir_contents(kando_dir, &shadow_kando) {
269        state.last_error = Some(format!("sync copy failed: {e}"));
270        return;
271    }
272
273    // Stage all changes including deletions
274    let _ = git_cmd(&state.shadow_path)
275        .args(["add", "-A", ".kando/"])
276        .output();
277
278    // Check if there are changes to commit
279    let diff_output = git_cmd(&state.shadow_path)
280        .args(["diff", "--cached", "--quiet"])
281        .output();
282
283    match diff_output {
284        Ok(out) if out.status.success() => {
285            // No changes staged, nothing to commit
286            return;
287        }
288        _ => {}
289    }
290
291    // Commit
292    let commit_result = git_cmd(&state.shadow_path)
293        .args(["commit", "-m", message])
294        .output();
295
296    match commit_result {
297        Ok(out) if !out.status.success() => {
298            state.last_error = Some(
299                String::from_utf8_lossy(&out.stderr).trim().to_string()
300            );
301            return;
302        }
303        Err(e) => {
304            state.last_error = Some(format!("git commit failed: {e}"));
305            return;
306        }
307        _ => {}
308    }
309
310    // Push
311    let push_result = git_cmd(&state.shadow_path)
312        .args(["push", "origin", &state.branch])
313        .output();
314
315    match push_result {
316        Ok(out) if out.status.success() => {
317            state.online = true;
318            state.last_error = None;
319        }
320        Ok(out) => {
321            state.online = false;
322            state.last_error = Some(
323                String::from_utf8_lossy(&out.stderr).trim().to_string()
324            );
325        }
326        Err(e) => {
327            state.online = false;
328            state.last_error = Some(format!("git push failed: {e}"));
329        }
330    }
331}
332
333/// Initialize a shadow clone for a git-synced board (no local `.kando/` dir needed).
334///
335/// Unlike `init_shadow`, this derives the repo info from the project root
336/// rather than from a `.kando/` directory.
337pub fn init_shadow_for_gitsync(project_root: &Path, branch: &str) -> Result<SyncState, SyncError> {
338    let git_root = find_git_root(project_root).ok_or(SyncError::NotGitRepo)?;
339    let remote_url = get_remote_url(&git_root)?;
340    let shadow_path = shadow_dir_for(&remote_url);
341
342    let ssh_warning = if check_ssh_agent(&remote_url) {
343        Some("No ssh-agent keys found. Run `ssh-add` to avoid passphrase prompts.".to_string())
344    } else {
345        None
346    };
347
348    if shadow_path.join(".git").exists() {
349        let shadow_remote = get_remote_url(&shadow_path).unwrap_or_default();
350        if shadow_remote != remote_url {
351            std::fs::remove_dir_all(&shadow_path)?;
352            clone_shadow(&remote_url, &shadow_path, branch)?;
353        } else {
354            let _ = git_cmd(&shadow_path)
355                .args(["checkout", branch])
356                .output();
357            let _ = git_cmd(&shadow_path)
358                .args(["branch", "--set-upstream-to", &format!("origin/{branch}"), branch])
359                .output();
360        }
361    } else {
362        clone_shadow(&remote_url, &shadow_path, branch)?;
363    }
364
365    Ok(SyncState {
366        shadow_path,
367        branch: branch.to_string(),
368        online: true,
369        last_error: ssh_warning,
370    })
371}
372
373/// Pull latest changes in shadow (no copy to working tree — shadow IS the data).
374pub fn pull_shadow(state: &mut SyncState) -> SyncStatus {
375    let output = git_cmd(&state.shadow_path)
376        .args(["pull", "--rebase", "--autostash"])
377        .output();
378
379    match output {
380        Ok(out) if out.status.success() => {
381            state.online = true;
382            state.last_error = None;
383
384            let stderr = String::from_utf8_lossy(&out.stderr);
385            let stdout = String::from_utf8_lossy(&out.stdout);
386
387            if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
388                SyncStatus::AlreadyUpToDate
389            } else {
390                SyncStatus::Updated
391            }
392        }
393        Ok(out) => {
394            let stderr = String::from_utf8_lossy(&out.stderr);
395            if stderr.contains("no tracking information") {
396                state.online = true;
397                state.last_error = None;
398                SyncStatus::AlreadyUpToDate
399            } else {
400                state.online = false;
401                state.last_error = Some(stderr.trim().to_string());
402                SyncStatus::Offline
403            }
404        }
405        Err(e) => {
406            state.online = false;
407            state.last_error = Some(format!("git pull failed: {e}"));
408            SyncStatus::Offline
409        }
410    }
411}
412
413/// Commit and push changes in the shadow (shadow IS the source, no copy needed).
414pub fn commit_and_push_shadow(state: &mut SyncState, message: &str) {
415    // Stage all changes
416    let _ = git_cmd(&state.shadow_path)
417        .args(["add", "-A", ".kando/"])
418        .output();
419
420    // Check if there are changes to commit
421    let diff_output = git_cmd(&state.shadow_path)
422        .args(["diff", "--cached", "--quiet"])
423        .output();
424
425    match diff_output {
426        Ok(out) if out.status.success() => {
427            // No changes staged
428            return;
429        }
430        _ => {}
431    }
432
433    // Commit
434    let commit_result = git_cmd(&state.shadow_path)
435        .args(["commit", "-m", message])
436        .output();
437
438    match commit_result {
439        Ok(out) if !out.status.success() => {
440            state.last_error = Some(
441                String::from_utf8_lossy(&out.stderr).trim().to_string()
442            );
443            return;
444        }
445        Err(e) => {
446            state.last_error = Some(format!("git commit failed: {e}"));
447            return;
448        }
449        _ => {}
450    }
451
452    // Push
453    let push_result = git_cmd(&state.shadow_path)
454        .args(["push", "origin", &state.branch])
455        .output();
456
457    match push_result {
458        Ok(out) if out.status.success() => {
459            state.online = true;
460            state.last_error = None;
461        }
462        Ok(out) => {
463            state.online = false;
464            state.last_error = Some(
465                String::from_utf8_lossy(&out.stderr).trim().to_string()
466            );
467        }
468        Err(e) => {
469            state.online = false;
470            state.last_error = Some(format!("git push failed: {e}"));
471        }
472    }
473}
474
475/// Public wrapper for `copy_dir_contents` for use by the migrate command.
476pub fn copy_dir_contents_pub(src: &Path, dst: &Path) -> std::io::Result<()> {
477    copy_dir_contents(src, dst)
478}
479
480/// Recursively copy directory contents from src to dst.
481///
482/// Skips symlinks and removes files/dirs in dst that don't exist in src,
483/// so that deletions are propagated correctly.
484fn copy_dir_contents(src: &Path, dst: &Path) -> std::io::Result<()> {
485    if !dst.exists() {
486        std::fs::create_dir_all(dst)?;
487    }
488
489    // Collect source entry names for stale-file cleanup
490    let mut src_names = std::collections::HashSet::new();
491
492    for entry in std::fs::read_dir(src)? {
493        let entry = entry?;
494        let file_type = entry.file_type()?;
495
496        // Skip symlinks
497        if file_type.is_symlink() {
498            continue;
499        }
500
501        let name = entry.file_name();
502        src_names.insert(name.clone());
503        let src_path = entry.path();
504        let dst_path = dst.join(&name);
505
506        if file_type.is_dir() {
507            copy_dir_contents(&src_path, &dst_path)?;
508        } else {
509            std::fs::copy(&src_path, &dst_path)?;
510        }
511    }
512
513    // Remove stale entries in dst that no longer exist in src
514    for entry in std::fs::read_dir(dst)? {
515        let entry = entry?;
516        if !src_names.contains(&entry.file_name()) {
517            let path = entry.path();
518            if path.is_dir() {
519                std::fs::remove_dir_all(&path)?;
520            } else {
521                std::fs::remove_file(&path)?;
522            }
523        }
524    }
525
526    Ok(())
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use std::fs;
533
534    // -----------------------------------------------------------------------
535    // find_git_root tests
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_find_git_root_at_root() {
540        let dir = tempfile::tempdir().unwrap();
541        fs::create_dir(dir.path().join(".git")).unwrap();
542        let result = find_git_root(dir.path());
543        assert_eq!(result, Some(dir.path().to_path_buf()));
544    }
545
546    #[test]
547    fn test_find_git_root_nested() {
548        let dir = tempfile::tempdir().unwrap();
549        fs::create_dir(dir.path().join(".git")).unwrap();
550        let nested = dir.path().join("src/deep/nested");
551        fs::create_dir_all(&nested).unwrap();
552        let result = find_git_root(&nested);
553        assert_eq!(result, Some(dir.path().to_path_buf()));
554    }
555
556    #[test]
557    fn test_find_git_root_not_found() {
558        let dir = tempfile::tempdir().unwrap();
559        // No .git directory created
560        let result = find_git_root(dir.path());
561        assert!(result.is_none());
562    }
563
564    // -----------------------------------------------------------------------
565    // djb2 tests
566    // -----------------------------------------------------------------------
567
568    #[test]
569    fn test_djb2_deterministic() {
570        let url = "git@github.com:user/repo.git";
571        assert_eq!(djb2(url), djb2(url));
572    }
573
574    #[test]
575    fn test_djb2_different_inputs() {
576        assert_ne!(djb2("repo-a"), djb2("repo-b"));
577    }
578
579    #[test]
580    fn test_djb2_empty_string() {
581        // Should not panic, returns the initial value
582        assert_eq!(djb2(""), 5381);
583    }
584
585    #[test]
586    fn test_djb2_known_value() {
587        // Pin a known value to catch accidental algorithm changes
588        assert_eq!(djb2("git@github.com:user/repo.git"), 5107748758901446025);
589    }
590
591    // -----------------------------------------------------------------------
592    // shadow_dir_for tests
593    // -----------------------------------------------------------------------
594
595    #[test]
596    fn test_shadow_dir_deterministic() {
597        let url = "git@github.com:user/repo.git";
598        assert_eq!(shadow_dir_for(url), shadow_dir_for(url));
599    }
600
601    #[test]
602    fn test_shadow_dir_different_urls() {
603        let a = shadow_dir_for("git@github.com:user/repo-a.git");
604        let b = shadow_dir_for("git@github.com:user/repo-b.git");
605        assert_ne!(a, b);
606    }
607
608    #[test]
609    fn test_shadow_dir_uses_kando_subdir() {
610        let path = shadow_dir_for("git@github.com:user/repo.git");
611        // Should be under <cache_dir>/.kando/<hash>
612        let parent = path.parent().unwrap();
613        assert_eq!(parent.file_name().unwrap(), ".kando");
614    }
615
616    // -----------------------------------------------------------------------
617    // check_ssh_agent tests (URL classification only)
618    // -----------------------------------------------------------------------
619
620    #[test]
621    fn test_check_ssh_agent_https_url() {
622        // HTTPS URLs should never warn
623        assert!(!check_ssh_agent("https://github.com/user/repo.git"));
624    }
625
626    #[test]
627    fn test_check_ssh_agent_http_url() {
628        assert!(!check_ssh_agent("http://github.com/user/repo.git"));
629    }
630
631    // Note: SSH URL tests depend on the environment (SSH_AUTH_SOCK),
632    // so we only test the URL classification above.
633
634    // -----------------------------------------------------------------------
635    // copy_dir_contents tests
636    // -----------------------------------------------------------------------
637
638    #[test]
639    fn test_copy_basic_files() {
640        let dir = tempfile::tempdir().unwrap();
641        let src = dir.path().join("src");
642        let dst = dir.path().join("dst");
643        fs::create_dir(&src).unwrap();
644
645        fs::write(src.join("a.txt"), "hello").unwrap();
646        fs::write(src.join("b.txt"), "world").unwrap();
647
648        copy_dir_contents(&src, &dst).unwrap();
649
650        assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
651        assert_eq!(fs::read_to_string(dst.join("b.txt")).unwrap(), "world");
652    }
653
654    #[test]
655    fn test_copy_nested_dirs() {
656        let dir = tempfile::tempdir().unwrap();
657        let src = dir.path().join("src");
658        let dst = dir.path().join("dst");
659        let sub = src.join("sub");
660        fs::create_dir_all(&sub).unwrap();
661
662        fs::write(src.join("top.txt"), "top").unwrap();
663        fs::write(sub.join("deep.txt"), "deep").unwrap();
664
665        copy_dir_contents(&src, &dst).unwrap();
666
667        assert_eq!(fs::read_to_string(dst.join("top.txt")).unwrap(), "top");
668        assert_eq!(fs::read_to_string(dst.join("sub/deep.txt")).unwrap(), "deep");
669    }
670
671    #[test]
672    fn test_copy_creates_dst_if_missing() {
673        let dir = tempfile::tempdir().unwrap();
674        let src = dir.path().join("src");
675        let dst = dir.path().join("nonexistent/deep/dst");
676        fs::create_dir(&src).unwrap();
677        fs::write(src.join("f.txt"), "data").unwrap();
678
679        copy_dir_contents(&src, &dst).unwrap();
680
681        assert!(dst.exists());
682        assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "data");
683    }
684
685    #[test]
686    fn test_copy_removes_stale_files() {
687        let dir = tempfile::tempdir().unwrap();
688        let src = dir.path().join("src");
689        let dst = dir.path().join("dst");
690        fs::create_dir_all(&src).unwrap();
691        fs::create_dir_all(&dst).unwrap();
692
693        // Put a file in src and dst
694        fs::write(src.join("keep.txt"), "keep").unwrap();
695        // Put a stale file only in dst
696        fs::write(dst.join("stale.txt"), "old").unwrap();
697
698        copy_dir_contents(&src, &dst).unwrap();
699
700        assert!(dst.join("keep.txt").exists());
701        assert!(!dst.join("stale.txt").exists(), "stale file should be removed");
702    }
703
704    #[test]
705    fn test_copy_removes_stale_dirs() {
706        let dir = tempfile::tempdir().unwrap();
707        let src = dir.path().join("src");
708        let dst = dir.path().join("dst");
709        fs::create_dir_all(&src).unwrap();
710        fs::create_dir_all(dst.join("stale_dir")).unwrap();
711        fs::write(dst.join("stale_dir/f.txt"), "old").unwrap();
712
713        fs::write(src.join("keep.txt"), "keep").unwrap();
714
715        copy_dir_contents(&src, &dst).unwrap();
716
717        assert!(dst.join("keep.txt").exists());
718        assert!(!dst.join("stale_dir").exists(), "stale dir should be removed");
719    }
720
721    #[test]
722    fn test_copy_overwrites_existing_files() {
723        let dir = tempfile::tempdir().unwrap();
724        let src = dir.path().join("src");
725        let dst = dir.path().join("dst");
726        fs::create_dir_all(&src).unwrap();
727        fs::create_dir_all(&dst).unwrap();
728
729        fs::write(src.join("f.txt"), "new content").unwrap();
730        fs::write(dst.join("f.txt"), "old content").unwrap();
731
732        copy_dir_contents(&src, &dst).unwrap();
733
734        assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "new content");
735    }
736
737    #[cfg(unix)]
738    #[test]
739    fn test_copy_skips_symlinks() {
740        use std::os::unix::fs as unix_fs;
741
742        let dir = tempfile::tempdir().unwrap();
743        let src = dir.path().join("src");
744        let dst = dir.path().join("dst");
745        fs::create_dir(&src).unwrap();
746
747        fs::write(src.join("real.txt"), "real").unwrap();
748        unix_fs::symlink(src.join("real.txt"), src.join("link.txt")).unwrap();
749
750        copy_dir_contents(&src, &dst).unwrap();
751
752        assert!(dst.join("real.txt").exists());
753        assert!(!dst.join("link.txt").exists(), "symlink should be skipped");
754    }
755
756    #[test]
757    fn test_copy_empty_src() {
758        let dir = tempfile::tempdir().unwrap();
759        let src = dir.path().join("src");
760        let dst = dir.path().join("dst");
761        fs::create_dir(&src).unwrap();
762
763        copy_dir_contents(&src, &dst).unwrap();
764
765        assert!(dst.exists());
766        // dst should be empty
767        let entries: Vec<_> = fs::read_dir(&dst).unwrap().collect();
768        assert!(entries.is_empty());
769    }
770
771    #[test]
772    fn test_copy_cleans_dst_fully_when_src_empty() {
773        let dir = tempfile::tempdir().unwrap();
774        let src = dir.path().join("src");
775        let dst = dir.path().join("dst");
776        fs::create_dir(&src).unwrap();
777        fs::create_dir_all(&dst).unwrap();
778        fs::write(dst.join("leftover.txt"), "old").unwrap();
779
780        copy_dir_contents(&src, &dst).unwrap();
781
782        assert!(!dst.join("leftover.txt").exists(), "leftover should be cleaned");
783    }
784}