Skip to main content

modde_core/
fs.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5/// Check if an I/O error is a cross-device link error (EXDEV on Unix,
6/// `ERROR_NOT_SAME_DEVICE` on Windows). Used to fall back from `rename`
7/// to copy+delete when source and destination are on different filesystems.
8#[must_use]
9pub fn is_cross_device_error(e: &std::io::Error) -> bool {
10    #[cfg(unix)]
11    {
12        e.raw_os_error() == Some(libc::EXDEV)
13    }
14    #[cfg(windows)]
15    {
16        e.raw_os_error() == Some(17) // ERROR_NOT_SAME_DEVICE
17    }
18}
19
20/// Create a symlink at `link` pointing to `original`, using the correct
21/// platform API.  On Windows the call inspects `original` to decide between
22/// `symlink_file` and `symlink_dir`.
23pub fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
24    #[cfg(unix)]
25    {
26        std::os::unix::fs::symlink(original, link)
27    }
28    #[cfg(windows)]
29    {
30        if original.is_dir() {
31            std::os::windows::fs::symlink_dir(original, link)
32        } else {
33            std::os::windows::fs::symlink_file(original, link)
34        }
35    }
36}
37
38/// Recursively visit every file under `dir`, calling `visitor(absolute_path)` for each.
39///
40/// This is the single recursive walker that all public helpers delegate to.
41fn walk_dir(dir: &Path, visitor: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
42    if !dir.exists() {
43        return Ok(());
44    }
45    for entry in std::fs::read_dir(dir)
46        .with_context(|| format!("failed to read directory: {}", dir.display()))?
47    {
48        let entry = entry?;
49        let path = entry.path();
50        if path.is_dir() {
51            walk_dir(&path, visitor)?;
52        } else {
53            visitor(&path)?;
54        }
55    }
56    Ok(())
57}
58
59/// Recursively walk a directory, collecting `(relative_path, absolute_path)` pairs for all files.
60pub fn walk_files_relative(base: &Path) -> Result<Vec<(String, PathBuf)>> {
61    let mut files = Vec::new();
62    walk_dir(base, &mut |path| {
63        let rel = path
64            .strip_prefix(base)
65            .with_context(|| "failed to compute relative path")?;
66        files.push((rel.to_string_lossy().to_string(), path.to_path_buf()));
67        Ok(())
68    })?;
69    Ok(files)
70}
71
72/// Recursively walk a directory, collecting all absolute file paths.
73pub fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
74    let mut files = Vec::new();
75    walk_dir(dir, &mut |path| {
76        files.push(path.to_path_buf());
77        Ok(())
78    })?;
79    Ok(files)
80}
81
82/// Count files recursively in a directory.
83pub fn count_files(dir: &Path) -> Result<u64> {
84    let mut count = 0u64;
85    walk_dir(dir, &mut |_| {
86        count += 1;
87        Ok(())
88    })?;
89    Ok(count)
90}
91
92/// Create a symlink asynchronously, using the correct platform API.
93/// On Windows, inspects `original` to pick `symlink_file` vs `symlink_dir`.
94pub async fn symlink_async(original: &Path, link: &Path) -> std::io::Result<()> {
95    #[cfg(unix)]
96    {
97        tokio::fs::symlink(original, link).await
98    }
99    #[cfg(windows)]
100    {
101        if original.is_dir() {
102            tokio::fs::symlink_dir(original, link).await
103        } else {
104            tokio::fs::symlink_file(original, link).await
105        }
106    }
107}
108
109/// Deploy symlinks from `src` into `dst` recursively (creating directories as needed).
110pub fn deploy_symlinks(src: &Path, dst: &Path) -> Result<()> {
111    if !dst.exists() {
112        std::fs::create_dir_all(dst)?;
113    }
114    for entry in std::fs::read_dir(src)
115        .with_context(|| format!("failed to read staging dir: {}", src.display()))?
116    {
117        let entry = entry?;
118        let src_path = entry.path();
119        let dst_path = dst.join(entry.file_name());
120
121        if src_path.is_dir() {
122            std::fs::create_dir_all(&dst_path)?;
123            deploy_symlinks(&src_path, &dst_path)?;
124        } else {
125            if dst_path.exists() || dst_path.symlink_metadata().is_ok() {
126                std::fs::remove_file(&dst_path)?;
127            }
128            symlink(&src_path, &dst_path)?;
129        }
130    }
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use tempfile::TempDir;
138
139    #[test]
140    fn walk_files_relative_empty() {
141        let tmp = TempDir::new().unwrap();
142        let files = walk_files_relative(tmp.path()).unwrap();
143        assert!(files.is_empty());
144    }
145
146    #[test]
147    fn walk_files_relative_flat() {
148        let tmp = TempDir::new().unwrap();
149        std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
150        std::fs::write(tmp.path().join("b.esp"), "b").unwrap();
151        let mut files = walk_files_relative(tmp.path()).unwrap();
152        files.sort_by(|a, b| a.0.cmp(&b.0));
153        assert_eq!(files.len(), 2);
154        assert_eq!(files[0].0, "a.txt");
155        assert_eq!(files[1].0, "b.esp");
156    }
157
158    #[test]
159    fn walk_files_relative_nested() {
160        let tmp = TempDir::new().unwrap();
161        std::fs::create_dir_all(tmp.path().join("sub/deep")).unwrap();
162        std::fs::write(tmp.path().join("sub/deep/file.txt"), "x").unwrap();
163        std::fs::write(tmp.path().join("top.txt"), "y").unwrap();
164        let files = walk_files_relative(tmp.path()).unwrap();
165        assert_eq!(files.len(), 2);
166        let rels: Vec<&str> = files.iter().map(|(r, _)| r.as_str()).collect();
167        assert!(rels.contains(&"top.txt"));
168        assert!(rels.contains(&"sub/deep/file.txt"));
169    }
170
171    #[test]
172    fn walk_files_relative_nonexistent() {
173        let tmp = TempDir::new().unwrap();
174        let files = walk_files_relative(&tmp.path().join("nope")).unwrap();
175        assert!(files.is_empty());
176    }
177
178    #[test]
179    fn walk_files_flat_test() {
180        let tmp = TempDir::new().unwrap();
181        std::fs::write(tmp.path().join("a"), "a").unwrap();
182        std::fs::write(tmp.path().join("b"), "b").unwrap();
183        let files = walk_files(tmp.path()).unwrap();
184        assert_eq!(files.len(), 2);
185        assert!(files.iter().all(|p| p.is_absolute()));
186    }
187
188    #[test]
189    fn count_files_test() {
190        let tmp = TempDir::new().unwrap();
191        std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
192        std::fs::write(tmp.path().join("a"), "a").unwrap();
193        std::fs::write(tmp.path().join("sub/b"), "b").unwrap();
194        assert_eq!(count_files(tmp.path()).unwrap(), 2);
195    }
196
197    #[test]
198    fn count_files_nonexistent() {
199        let tmp = TempDir::new().unwrap();
200        assert_eq!(count_files(&tmp.path().join("nope")).unwrap(), 0);
201    }
202
203    #[test]
204    fn deploy_symlinks_test() {
205        let tmp = TempDir::new().unwrap();
206        let src = tmp.path().join("src");
207        let dst = tmp.path().join("dst");
208        std::fs::create_dir_all(src.join("sub")).unwrap();
209        std::fs::write(src.join("a.txt"), "a").unwrap();
210        std::fs::write(src.join("sub/b.txt"), "b").unwrap();
211
212        deploy_symlinks(&src, &dst).unwrap();
213
214        assert!(
215            dst.join("a.txt")
216                .symlink_metadata()
217                .unwrap()
218                .file_type()
219                .is_symlink()
220        );
221        assert!(
222            dst.join("sub/b.txt")
223                .symlink_metadata()
224                .unwrap()
225                .file_type()
226                .is_symlink()
227        );
228        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "a");
229        assert_eq!(std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(), "b");
230    }
231}