Skip to main content

xil_core/
fsutil.rs

1//! `os.path` / `glob` behaviours the commands lean on.
2
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6/// `os.path.abspath`: absolute and lexically normalised (`.` and `..`
7/// collapsed, trailing slash dropped). Symlinks are not resolved.
8pub fn abspath(p: &Path) -> PathBuf {
9    let abs = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
10    let mut out = PathBuf::new();
11    for c in abs.components() {
12        match c {
13            Component::CurDir => {}
14            Component::ParentDir => {
15                if !out.pop() {
16                    out.push("..");
17                }
18            }
19            other => out.push(other.as_os_str()),
20        }
21    }
22    out
23}
24
25/// `os.path.relpath(path, start)` for two absolute paths: `.` when equal,
26/// `..` segments when `path` is not under `start`.
27pub fn relpath(path: &Path, start: &Path) -> PathBuf {
28    let a: Vec<_> = abspath(path)
29        .components()
30        .map(|c| c.as_os_str().to_owned())
31        .collect();
32    let b: Vec<_> = abspath(start)
33        .components()
34        .map(|c| c.as_os_str().to_owned())
35        .collect();
36    let common = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
37    let mut out = PathBuf::new();
38    for _ in common..b.len() {
39        out.push("..");
40    }
41    for seg in &a[common..] {
42        out.push(seg);
43    }
44    if out.as_os_str().is_empty() {
45        PathBuf::from(".")
46    } else {
47        out
48    }
49}
50
51/// `sorted(glob.glob(os.path.join(dir, f"{prefix}*{suffix}")))` — direct
52/// children only, sorted by full path, dotfiles excluded like glob does.
53pub fn glob_children(dir: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {
54    let mut out: Vec<PathBuf> = fs::read_dir(dir)
55        .map(|rd| {
56            rd.filter_map(Result::ok)
57                .map(|e| e.path())
58                .filter(|p| name_matches(p, prefix, suffix))
59                .collect()
60        })
61        .unwrap_or_default();
62    out.sort();
63    out
64}
65
66/// `sorted(glob.glob(str(root / "**" / f"{prefix}*{suffix}"), recursive=True))`
67/// — every depth, sorted by full path, hidden directories and files skipped.
68pub fn glob_recursive(root: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {
69    let mut out = Vec::new();
70    walk(root, prefix, suffix, &mut out);
71    out.sort();
72    out
73}
74
75fn walk(dir: &Path, prefix: &str, suffix: &str, out: &mut Vec<PathBuf>) {
76    let Ok(rd) = fs::read_dir(dir) else { return };
77    for e in rd.filter_map(Result::ok) {
78        let p = e.path();
79        let hidden = p
80            .file_name()
81            .map(|n| n.to_string_lossy().starts_with('.'))
82            .unwrap_or(false);
83        if hidden {
84            continue;
85        }
86        if p.is_dir() {
87            walk(&p, prefix, suffix, out);
88        } else if name_matches(&p, prefix, suffix) {
89            out.push(p);
90        }
91    }
92}
93
94fn name_matches(p: &Path, prefix: &str, suffix: &str) -> bool {
95    let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
96        return false;
97    };
98    !name.starts_with('.')
99        && name.starts_with(prefix)
100        && name.ends_with(suffix)
101        && name.len() >= prefix.len() + suffix.len()
102}
103
104/// Directory entries in raw `readdir` order — what `os.listdir`, `os.scandir`
105/// and `glob.glob` return. Some Python code never sorts, so neither can the port.
106pub fn list_dir_raw(dir: &Path) -> Vec<PathBuf> {
107    fs::read_dir(dir)
108        .map(|rd| rd.filter_map(Result::ok).map(|e| e.path()).collect())
109        .unwrap_or_default()
110}
111
112/// Sort paths the way Python sorts `Path` objects: by their string form.
113/// `PathBuf`'s own `Ord` compares component-wise, which can differ when a
114/// name contains a byte below `/`.
115pub fn sort_py(paths: &mut [PathBuf]) {
116    paths.sort_by(|a, b| {
117        a.as_os_str()
118            .as_encoded_bytes()
119            .cmp(b.as_os_str().as_encoded_bytes())
120    });
121}
122
123/// `sorted(Path(dir).glob(f"{prefix}*{suffix}"))` — pathlib's glob, which
124/// unlike the `glob` module does match dotfiles.
125pub fn pathlib_glob(dir: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {
126    let mut out: Vec<PathBuf> = list_dir_raw(dir)
127        .into_iter()
128        .filter(|p| {
129            p.file_name()
130                .and_then(|n| n.to_str())
131                .map(|n| {
132                    n.starts_with(prefix)
133                        && n.ends_with(suffix)
134                        && n.len() >= prefix.len() + suffix.len()
135                })
136                .unwrap_or(false)
137        })
138        .collect();
139    sort_py(&mut out);
140    out
141}
142
143/// `sorted(Path(dir).rglob(f"*{infix}*{suffix}"))` — every depth, dotfiles
144/// and hidden directories included, filename must contain `infix` and end
145/// with `suffix`.
146pub fn pathlib_rglob_contains(dir: &Path, infix: &str, suffix: &str) -> Vec<PathBuf> {
147    let mut out = Vec::new();
148    let mut stack = vec![dir.to_path_buf()];
149    while let Some(d) = stack.pop() {
150        for p in list_dir_raw(&d) {
151            if p.is_dir() {
152                stack.push(p);
153            } else if let Some(n) = p.file_name().and_then(|n| n.to_str()) {
154                if let Some(stem) = n.strip_suffix(suffix) {
155                    if stem.contains(infix) {
156                        out.push(p);
157                    }
158                }
159            }
160        }
161    }
162    sort_py(&mut out);
163    out
164}
165
166/// `os.path.basename`
167pub fn basename(p: &Path) -> String {
168    p.file_name()
169        .map(|n| n.to_string_lossy().into_owned())
170        .unwrap_or_default()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    // Unix path semantics: on Windows "/a/b" is drive-relative, so
178    // absolute() prepends a drive letter and the comparison is meaningless.
179    // The pipeline only ever runs on Linux/WSL/macOS.
180    #[test]
181    #[cfg(unix)]
182    fn abspath_normalises() {
183        assert_eq!(abspath(Path::new("/a/b/../c/./d/")), Path::new("/a/c/d"));
184        assert_eq!(abspath(Path::new("/a/b/")), Path::new("/a/b"));
185    }
186
187    #[test]
188    fn relpath_cases() {
189        assert_eq!(
190            relpath(Path::new("/a/b/c.mp3"), Path::new("/a/b")),
191            Path::new("c.mp3")
192        );
193        assert_eq!(
194            relpath(Path::new("/a/b"), Path::new("/a/b")),
195            Path::new(".")
196        );
197        assert_eq!(
198            relpath(Path::new("/a/x/y"), Path::new("/a/b/c")),
199            Path::new("../../x/y")
200        );
201    }
202
203    #[test]
204    fn globs_sort_and_filter() {
205        let tmp = tempfile::tempdir().unwrap();
206        let r = tmp.path();
207        fs::create_dir_all(r.join("sub/deeper")).unwrap();
208        fs::create_dir_all(r.join(".hidden")).unwrap();
209        for f in [
210            "parsed_b.json",
211            "parsed_a.json",
212            "orig_parsed_a.json",
213            "sub/parsed_c.json",
214            "sub/deeper/parsed_d.json",
215            ".hidden/parsed_z.json",
216            "parsed_.json",
217        ] {
218            fs::write(r.join(f), "{}").unwrap();
219        }
220        let names = |v: Vec<PathBuf>| {
221            v.iter()
222                .map(|p| relpath(p, r).display().to_string().replace('\\', "/"))
223                .collect::<Vec<_>>()
224        };
225        assert_eq!(
226            names(glob_children(r, "parsed_", ".json")),
227            vec!["parsed_.json", "parsed_a.json", "parsed_b.json"]
228        );
229        assert_eq!(
230            names(glob_recursive(r, "parsed_", ".json")),
231            vec![
232                "parsed_.json",
233                "parsed_a.json",
234                "parsed_b.json",
235                "sub/deeper/parsed_d.json",
236                "sub/parsed_c.json"
237            ]
238        );
239    }
240}