Skip to main content

yui/
link.rs

1//! Cross-platform link operations.
2//!
3//! Mode resolution:
4//!   - file `auto` → Unix=symlink, Windows=hardlink
5//!   - dir  `auto` → Unix=symlink, Windows=junction
6//!
7//! On Windows, file symlinks need Developer Mode or admin; the default
8//! `auto` (hardlink + junction) avoids that requirement entirely.
9
10use camino::Utf8Path;
11
12use crate::config::{DirLinkMode, FileLinkMode};
13use crate::{Error, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EffectiveFileMode {
17    Symlink,
18    Hardlink,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EffectiveDirMode {
23    Symlink,
24    Junction,
25}
26
27pub fn resolve_file_mode(mode: FileLinkMode) -> EffectiveFileMode {
28    match mode {
29        FileLinkMode::Symlink => EffectiveFileMode::Symlink,
30        FileLinkMode::Hardlink => EffectiveFileMode::Hardlink,
31        FileLinkMode::Auto => {
32            if cfg!(windows) {
33                EffectiveFileMode::Hardlink
34            } else {
35                EffectiveFileMode::Symlink
36            }
37        }
38    }
39}
40
41pub fn resolve_dir_mode(mode: DirLinkMode) -> EffectiveDirMode {
42    match mode {
43        DirLinkMode::Symlink => EffectiveDirMode::Symlink,
44        DirLinkMode::Junction => EffectiveDirMode::Junction,
45        DirLinkMode::Auto => {
46            if cfg!(windows) {
47                EffectiveDirMode::Junction
48            } else {
49                EffectiveDirMode::Symlink
50            }
51        }
52    }
53}
54
55pub fn link_file(src: &Utf8Path, dst: &Utf8Path, mode: EffectiveFileMode) -> Result<()> {
56    if let Some(parent) = dst.parent() {
57        std::fs::create_dir_all(parent)?;
58    }
59    match mode {
60        EffectiveFileMode::Hardlink => std::fs::hard_link(src, dst)?,
61        EffectiveFileMode::Symlink => create_file_symlink(src, dst)?,
62    }
63    Ok(())
64}
65
66pub fn link_dir(src: &Utf8Path, dst: &Utf8Path, mode: EffectiveDirMode) -> Result<()> {
67    if let Some(parent) = dst.parent() {
68        std::fs::create_dir_all(parent)?;
69    }
70    match mode {
71        EffectiveDirMode::Junction => create_junction(src, dst)?,
72        EffectiveDirMode::Symlink => create_dir_symlink(src, dst)?,
73    }
74    Ok(())
75}
76
77/// Remove a yui-managed link. No-op if the path doesn't exist. Refuses to
78/// recursively delete a regular directory with contents.
79pub fn unlink(dst: &Utf8Path) -> Result<()> {
80    let meta = match std::fs::symlink_metadata(dst) {
81        Ok(m) => m,
82        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
83        Err(e) => return Err(Error::Io(e)),
84    };
85    let ft = meta.file_type();
86
87    if ft.is_symlink() {
88        #[cfg(windows)]
89        {
90            // Windows quirk: junctions report as is_symlink()=true,
91            // is_dir()=false, is_file()=false. Try junction::delete first
92            // (handles real junctions, may leave the empty dir entry behind
93            // depending on Windows version — clean it up after); fall back
94            // to remove_file / remove_dir for genuine file/dir symlinks.
95            if junction::delete(dst.as_std_path()).is_ok() {
96                let _ = std::fs::remove_dir(dst);
97                return Ok(());
98            }
99            if std::fs::remove_file(dst).is_ok() {
100                return Ok(());
101            }
102            std::fs::remove_dir(dst)?;
103            return Ok(());
104        }
105        #[cfg(unix)]
106        {
107            std::fs::remove_file(dst)?;
108            return Ok(());
109        }
110    }
111
112    if ft.is_dir() {
113        #[cfg(windows)]
114        {
115            return remove_link_dir_windows(dst);
116        }
117        #[cfg(unix)]
118        return std::fs::remove_dir(dst).map_err(|e| {
119            Error::Other(anyhow::anyhow!(
120                "unlink: {dst} not removed as a directory link (regular dir with content?): {e}"
121            ))
122        });
123    }
124
125    // regular file (or a hardlink — indistinguishable, removing only drops
126    // this name from the inode's link count).
127    std::fs::remove_file(dst)?;
128    Ok(())
129}
130
131#[cfg(unix)]
132fn create_file_symlink(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
133    std::os::unix::fs::symlink(src, dst)?;
134    Ok(())
135}
136
137#[cfg(unix)]
138fn create_dir_symlink(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
139    std::os::unix::fs::symlink(src, dst)?;
140    Ok(())
141}
142
143#[cfg(unix)]
144fn create_junction(_src: &Utf8Path, _dst: &Utf8Path) -> Result<()> {
145    Err(Error::Other(anyhow::anyhow!(
146        "junctions are Windows-only; use symlink mode on Unix"
147    )))
148}
149
150#[cfg(windows)]
151fn create_file_symlink(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
152    std::os::windows::fs::symlink_file(src, dst)?;
153    Ok(())
154}
155
156#[cfg(windows)]
157fn create_dir_symlink(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
158    std::os::windows::fs::symlink_dir(src, dst)?;
159    Ok(())
160}
161
162#[cfg(windows)]
163fn create_junction(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
164    junction::create(src.as_std_path(), dst.as_std_path())?;
165    Ok(())
166}
167
168/// Windows-only directory-link remover. Tries `junction::delete` first
169/// (handles real junctions, may leave the empty entry which we then clean
170/// up); falls back to `remove_dir` for directory symlinks and empty regular
171/// dirs.
172#[cfg(windows)]
173fn remove_link_dir_windows(dst: &Utf8Path) -> Result<()> {
174    if junction::delete(dst.as_std_path()).is_ok() {
175        let _ = std::fs::remove_dir(dst);
176        return Ok(());
177    }
178    std::fs::remove_dir(dst).map_err(|e| {
179        Error::Other(anyhow::anyhow!(
180            "unlink: {dst} not removed as a directory link: {e}"
181        ))
182    })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use camino::Utf8PathBuf;
189    use tempfile::TempDir;
190
191    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
192        Utf8PathBuf::from_path_buf(p).unwrap()
193    }
194
195    #[test]
196    fn auto_resolves_per_platform() {
197        let f = resolve_file_mode(FileLinkMode::Auto);
198        let d = resolve_dir_mode(DirLinkMode::Auto);
199        if cfg!(windows) {
200            assert_eq!(f, EffectiveFileMode::Hardlink);
201            assert_eq!(d, EffectiveDirMode::Junction);
202        } else {
203            assert_eq!(f, EffectiveFileMode::Symlink);
204            assert_eq!(d, EffectiveDirMode::Symlink);
205        }
206    }
207
208    #[test]
209    fn explicit_overrides_auto() {
210        assert_eq!(
211            resolve_file_mode(FileLinkMode::Symlink),
212            EffectiveFileMode::Symlink
213        );
214        assert_eq!(
215            resolve_dir_mode(DirLinkMode::Junction),
216            EffectiveDirMode::Junction
217        );
218    }
219
220    #[test]
221    fn hardlink_file_and_unlink() {
222        let tmp = TempDir::new().unwrap();
223        let src = utf8(tmp.path().join("src.txt"));
224        std::fs::write(&src, "hello").unwrap();
225
226        let dst = utf8(tmp.path().join("nested/dst.txt"));
227        link_file(&src, &dst, EffectiveFileMode::Hardlink).unwrap();
228
229        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "hello");
230
231        // Editing through dst affects src (hardlink → same inode).
232        std::fs::write(&dst, "updated").unwrap();
233        assert_eq!(std::fs::read_to_string(&src).unwrap(), "updated");
234
235        unlink(&dst).unwrap();
236        assert!(!dst.exists());
237        assert!(src.exists());
238    }
239
240    #[test]
241    fn unlink_missing_is_noop() {
242        let tmp = TempDir::new().unwrap();
243        let dst = utf8(tmp.path().join("nonexistent"));
244        unlink(&dst).unwrap();
245    }
246
247    #[cfg(windows)]
248    #[test]
249    fn junction_dir_and_unlink() {
250        let tmp = TempDir::new().unwrap();
251        std::fs::create_dir_all(tmp.path().join("src_dir/sub")).unwrap();
252        std::fs::write(tmp.path().join("src_dir/a.txt"), "A").unwrap();
253        let src = utf8(std::fs::canonicalize(tmp.path().join("src_dir")).unwrap());
254
255        let dst = utf8(tmp.path().join("nested/dst_dir"));
256        link_dir(&src, &dst, EffectiveDirMode::Junction).unwrap();
257
258        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "A");
259        assert!(dst.join("sub").is_dir());
260
261        unlink(&dst).unwrap();
262        assert!(!dst.exists());
263        assert!(src.join("a.txt").exists());
264    }
265
266    #[cfg(unix)]
267    #[test]
268    fn symlink_file_and_unlink() {
269        let tmp = TempDir::new().unwrap();
270        let src = utf8(tmp.path().join("src.txt"));
271        std::fs::write(&src, "hello").unwrap();
272
273        let dst = utf8(tmp.path().join("nested/dst.txt"));
274        link_file(&src, &dst, EffectiveFileMode::Symlink).unwrap();
275
276        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "hello");
277
278        unlink(&dst).unwrap();
279        assert!(!dst.exists());
280        assert!(src.exists());
281    }
282
283    #[cfg(unix)]
284    #[test]
285    fn symlink_dir_and_unlink() {
286        let tmp = TempDir::new().unwrap();
287        std::fs::create_dir_all(tmp.path().join("src_dir")).unwrap();
288        std::fs::write(tmp.path().join("src_dir/a.txt"), "A").unwrap();
289        let src = utf8(tmp.path().join("src_dir"));
290
291        let dst = utf8(tmp.path().join("nested/dst_dir"));
292        link_dir(&src, &dst, EffectiveDirMode::Symlink).unwrap();
293
294        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "A");
295
296        unlink(&dst).unwrap();
297        assert!(!dst.exists());
298        assert!(src.join("a.txt").exists());
299    }
300}