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