Skip to main content

modde_core/
link.rs

1use std::path::Path;
2
3use tracing::{debug, warn};
4
5use crate::error::{CoreError, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LinkKind {
9    Hardlink,
10    Reflink,
11    Copy,
12}
13
14/// Link or copy one file, preferring zero-copy filesystem mechanisms.
15///
16/// The destination is removed first if it exists. Symlink handling remains the
17/// caller's responsibility; this helper operates on regular files only.
18pub async fn link_or_copy(src: &Path, dst: &Path) -> Result<LinkKind> {
19    if let Some(parent) = dst.parent() {
20        tokio::fs::create_dir_all(parent).await?;
21    }
22
23    if dst.exists() || dst.symlink_metadata().is_ok() {
24        tokio::fs::remove_file(dst).await.ok();
25    }
26
27    match tokio::fs::hard_link(src, dst).await {
28        Ok(()) => {
29            debug!(src = %src.display(), dst = %dst.display(), "created hardlink");
30            return Ok(LinkKind::Hardlink);
31        }
32        Err(e) if crate::fs::is_cross_device_error(&e) => {
33            debug!(src = %src.display(), dst = %dst.display(), "hardlink crossed filesystem");
34        }
35        Err(e) => {
36            debug!(src = %src.display(), dst = %dst.display(), "hardlink failed: {e}");
37        }
38    }
39
40    let src = src.to_path_buf();
41    let dst = dst.to_path_buf();
42    match tokio::task::spawn_blocking({
43        let src = src.clone();
44        let dst = dst.clone();
45        move || reflink_copy::reflink(&src, &dst)
46    })
47    .await
48    .map_err(|e| CoreError::Other(format!("reflink task panicked: {e}").into()))?
49    {
50        Ok(()) => {
51            debug!(src = %src.display(), dst = %dst.display(), "created reflink");
52            return Ok(LinkKind::Reflink);
53        }
54        Err(e) => {
55            warn!(
56                src = %src.display(),
57                dst = %dst.display(),
58                "reflink failed; falling back to copy: {e}"
59            );
60            if dst.exists() || dst.symlink_metadata().is_ok() {
61                tokio::fs::remove_file(&dst).await.ok();
62            }
63        }
64    }
65
66    tokio::fs::copy(&src, &dst).await.map_err(|e| {
67        CoreError::Other(
68            format!(
69                "copy fallback failed: {} -> {}: {e}",
70                src.display(),
71                dst.display()
72            )
73            .into(),
74        )
75    })?;
76    debug!(src = %src.display(), dst = %dst.display(), "copied file");
77    Ok(LinkKind::Copy)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[tokio::test]
85    async fn link_or_copy_replaces_existing_destination() {
86        let temp = tempfile::tempdir().unwrap();
87        let src = temp.path().join("src.bin");
88        let dst = temp.path().join("nested").join("dst.bin");
89        tokio::fs::create_dir_all(dst.parent().unwrap())
90            .await
91            .unwrap();
92        tokio::fs::write(&src, b"source").await.unwrap();
93        tokio::fs::write(&dst, b"old").await.unwrap();
94
95        let kind = link_or_copy(&src, &dst).await.unwrap();
96
97        assert!(matches!(
98            kind,
99            LinkKind::Hardlink | LinkKind::Reflink | LinkKind::Copy
100        ));
101        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"source");
102    }
103}