Skip to main content

rakata_core/
fs.rs

1//! Filesystem helpers shared across KotOR crates.
2//!
3//! This module provides deterministic case-insensitive file-name lookup
4//! utilities for cross-platform behavior.
5
6use std::io;
7use std::path::{Path, PathBuf};
8
9use crate::ascii::cmp_ascii_case_insensitive;
10
11/// Suffix marking a file something in this workspace is part-way through
12/// writing.
13///
14/// A write goes to `<name>.rakata-new` beside its target, is flushed, and is
15/// then renamed over it, so a failure leaves the target untouched. One spelling
16/// across the workspace is what lets a leftover be recognised as inert by
17/// whatever finds it next rather than reported as an unknown file.
18pub const TEMP_SUFFIX: &str = ".rakata-new";
19
20/// Whether two files hold the same bytes.
21///
22/// Streamed in blocks rather than read whole, since a caller comparing game
23/// content is comparing textures and movies that run to hundreds of megabytes.
24///
25/// Sizes are not consulted. A caller that can cheaply rule a pair out on length
26/// should do so before calling this; what this answers is the exact question.
27///
28/// # Errors
29///
30/// Whatever stopped either file being opened or read.
31pub fn files_have_same_bytes(one: &Path, other: &Path) -> io::Result<bool> {
32    let open = |path: &Path| std::fs::File::open(path).map(io::BufReader::new);
33    // Filled to the end of the buffer rather than taking one `read`, which may
34    // stop short without being at the end and would leave the two readers at
35    // different offsets, comparing the wrong bytes against each other.
36    fn fill(reader: &mut impl io::Read, buffer: &mut [u8]) -> io::Result<usize> {
37        let mut filled = 0;
38        while filled < buffer.len() {
39            match reader.read(&mut buffer[filled..])? {
40                0 => break,
41                took => filled += took,
42            }
43        }
44        Ok(filled)
45    }
46
47    let mut ours = open(one)?;
48    let mut theirs = open(other)?;
49    // 64 KiB, which is arbitrary: large enough that the syscall is not the
50    // cost, small enough to hold two of.
51    let mut here = vec![0_u8; 64 * 1024];
52    let mut there = vec![0_u8; 64 * 1024];
53    loop {
54        let took = fill(&mut ours, &mut here)?;
55        let got = fill(&mut theirs, &mut there)?;
56        if took != got || here[..took] != there[..got] {
57            return Ok(false);
58        }
59        if took == 0 {
60            return Ok(true);
61        }
62    }
63}
64
65/// Every file beneath `directory`, at any depth, sorted by path.
66///
67/// Directories are descended into and are not themselves returned, so what
68/// comes back is the leaves. Symlinks are followed only where the platform's
69/// metadata already resolved them, and a cycle is not guarded against: callers
70/// point this at a mod payload or an install subtree rather than at an
71/// arbitrary root.
72///
73/// The counterpart to the single-level listings above, for a caller that has to
74/// see a whole tree rather than answer a name in one directory. A mod payload
75/// is the case: most ship subdirectories, and what the engine loads is the
76/// flattened set.
77///
78/// # Errors
79///
80/// Propagates the [`io::Error`] from reading any directory in the tree, naming
81/// nothing further: a caller wanting to know which one wraps this per subtree.
82pub fn files_beneath(directory: &Path) -> io::Result<Vec<PathBuf>> {
83    let mut found = Vec::new();
84    let mut pending = vec![directory.to_path_buf()];
85    while let Some(next) = pending.pop() {
86        for entry in std::fs::read_dir(&next)? {
87            let path = entry?.path();
88            if path.is_dir() {
89                pending.push(path);
90            } else {
91                found.push(path);
92            }
93        }
94    }
95    found.sort();
96    Ok(found)
97}
98
99/// Every file in `directory` whose name matches `expected_file_name`
100/// ASCII case-insensitively.
101///
102/// Sorted on lowercase name then original bytes, so the order is the same
103/// twice.
104///
105/// A caller that has to produce an answer wants
106/// [`find_case_insensitive_file`], which takes the first. A caller for which
107/// several matches is itself the finding wants this: on a case-sensitive
108/// filesystem two entries differing only by case are two real files, and which
109/// one the engine reads depends on the order a directory walk hands them back.
110///
111/// # Errors
112///
113/// Propagates the [`io::Error`] from reading `directory` or one of its entries.
114pub fn find_case_insensitive_files(
115    directory: &Path,
116    expected_file_name: &str,
117) -> io::Result<Vec<PathBuf>> {
118    matching(directory, expected_file_name, Kind::File)
119}
120
121/// Every subdirectory of `directory` matching `expected_name` ASCII
122/// case-insensitively.
123///
124/// The directory half of [`find_case_insensitive_files`], with the same reason
125/// to prefer it over [`find_case_insensitive_directory`].
126///
127/// # Errors
128///
129/// Propagates the [`io::Error`] from reading `directory` or one of its entries.
130pub fn find_case_insensitive_directories(
131    directory: &Path,
132    expected_name: &str,
133) -> io::Result<Vec<PathBuf>> {
134    matching(directory, expected_name, Kind::Directory)
135}
136
137/// Which of the two a walk is looking for.
138#[derive(Clone, Copy)]
139enum Kind {
140    File,
141    Directory,
142}
143
144impl Kind {
145    /// Whether a path is the kind being looked for.
146    fn matches(self, path: &Path) -> bool {
147        match self {
148            Self::File => path.is_file(),
149            Self::Directory => path.is_dir(),
150        }
151    }
152}
153
154/// Every entry of one kind whose name matches, in a stable order.
155fn matching(directory: &Path, expected: &str, kind: Kind) -> io::Result<Vec<PathBuf>> {
156    let mut matches = Vec::new();
157    for entry in std::fs::read_dir(directory)? {
158        let path = entry?.path();
159        if !kind.matches(&path) {
160            continue;
161        }
162        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
163            continue;
164        };
165        if name.eq_ignore_ascii_case(expected) {
166            matches.push(path);
167        }
168    }
169    matches.sort_by(|a, b| {
170        let name_of = |path: &Path| {
171            path.file_name()
172                .and_then(|name| name.to_str())
173                .unwrap_or_default()
174                .to_owned()
175        };
176        let (a, b) = (name_of(a), name_of(b));
177        cmp_ascii_case_insensitive(&a, &b).then(a.cmp(&b))
178    });
179    Ok(matches)
180}
181
182/// Finds one file in `directory` with ASCII case-insensitive name matching.
183///
184/// Where several entries differ only by case this takes a deterministic winner:
185/// the first of [`find_case_insensitive_files`]. A caller that should report
186/// rather than resolve wants that one instead.
187///
188/// # Errors
189///
190/// Propagates the [`io::Error`] from reading `directory` or one of its
191/// entries. A directory that exists and holds no match is `Ok(None)`.
192pub fn find_case_insensitive_file(
193    directory: &Path,
194    expected_file_name: &str,
195) -> io::Result<Option<PathBuf>> {
196    Ok(find_case_insensitive_files(directory, expected_file_name)?
197        .into_iter()
198        .next())
199}
200
201/// Finds one subdirectory in `directory` with ASCII case-insensitive name
202/// matching.
203///
204/// The same deterministic tie-break as [`find_case_insensitive_file`], and the
205/// same caveat: [`find_case_insensitive_directories`] is what to reach for when
206/// several matches is the answer rather than a tie to break.
207///
208/// Used for resolving canonical install paths like `Override/`, `modules/` and
209/// `lips/` that mods or platform copies frequently case-fold differently.
210///
211/// # Errors
212///
213/// Propagates the [`io::Error`] from reading `directory` or one of its
214/// entries. A directory that exists and holds no match is `Ok(None)`.
215pub fn find_case_insensitive_directory(
216    directory: &Path,
217    expected_name: &str,
218) -> io::Result<Option<PathBuf>> {
219    Ok(find_case_insensitive_directories(directory, expected_name)?
220        .into_iter()
221        .next())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::{
227        files_beneath, files_have_same_bytes, find_case_insensitive_directories,
228        find_case_insensitive_directory, find_case_insensitive_file,
229    };
230
231    use std::fs;
232
233    use tempfile::TempDir;
234
235    #[test]
236    fn resolves_case_insensitive_match() {
237        let temp = TempDir::new().expect("create tempdir");
238        let root = temp.path();
239        let path = root.join("ChItIn.KeY");
240        fs::write(&path, b"key").expect("write fixture");
241
242        let found = find_case_insensitive_file(root, "chitin.key")
243            .expect("lookup should succeed")
244            .expect("file should be found");
245        assert_eq!(found, path);
246    }
247
248    #[test]
249    fn returns_none_when_no_match() {
250        let temp = TempDir::new().expect("create tempdir");
251
252        let found =
253            find_case_insensitive_file(temp.path(), "missing.txt").expect("lookup should run");
254        assert!(found.is_none());
255    }
256
257    #[test]
258    fn resolves_case_insensitive_directory_match() {
259        let temp = TempDir::new().expect("create tempdir");
260        let root = temp.path();
261        let path = root.join("OvErRiDe");
262        fs::create_dir(&path).expect("create fixture dir");
263
264        let found = find_case_insensitive_directory(root, "override")
265            .expect("lookup should succeed")
266            .expect("dir should be found");
267        assert_eq!(found, path);
268    }
269
270    #[test]
271    fn directory_lookup_ignores_files_with_matching_name() {
272        // A file named "override" must not satisfy a directory lookup.
273        let temp = TempDir::new().expect("create tempdir");
274        let root = temp.path();
275        fs::write(root.join("override"), b"not a directory").expect("write fixture");
276
277        let found = find_case_insensitive_directory(root, "override").expect("lookup should run");
278        assert!(found.is_none());
279    }
280
281    #[test]
282    fn directory_lookup_returns_none_when_no_match() {
283        let temp = TempDir::new().expect("create tempdir");
284
285        let found =
286            find_case_insensitive_directory(temp.path(), "missing").expect("lookup should run");
287        assert!(found.is_none());
288    }
289
290    /// The tree walk reaches every depth and returns leaves only.
291    #[test]
292    fn files_beneath_reaches_every_depth() {
293        let temp = TempDir::new().expect("tempdir");
294        let deep = temp.path().join("Override").join("nested");
295        fs::create_dir_all(&deep).expect("create");
296        fs::write(temp.path().join("readme.txt"), b"a").expect("write");
297        fs::write(temp.path().join("Override").join("heads.2da"), b"b").expect("write");
298        fs::write(deep.join("appearance.2da"), b"c").expect("write");
299
300        let found = files_beneath(temp.path()).expect("the tree reads");
301        let names: Vec<&str> = found
302            .iter()
303            .filter_map(|path| path.file_name()?.to_str())
304            .collect();
305
306        assert_eq!(names, vec!["heads.2da", "appearance.2da", "readme.txt"]);
307    }
308
309    /// An empty tree is empty rather than an error.
310    #[test]
311    fn files_beneath_an_empty_directory_is_empty() {
312        let temp = TempDir::new().expect("tempdir");
313        fs::create_dir(temp.path().join("empty")).expect("create");
314        assert!(files_beneath(temp.path()).expect("reads").is_empty());
315    }
316
317    /// Every match comes back, not just the first, which is what separates the
318    /// plural finders from the singular ones.
319    #[test]
320    fn the_plural_finder_returns_both_spellings() {
321        let temp = TempDir::new().expect("tempdir");
322        fs::create_dir(temp.path().join("Override")).expect("create");
323        fs::create_dir(temp.path().join("override")).expect("create");
324
325        let all = find_case_insensitive_directories(temp.path(), "override").expect("reads");
326        assert_eq!(all.len(), 2, "{all:?}");
327        assert_eq!(
328            find_case_insensitive_directory(temp.path(), "override")
329                .expect("reads")
330                .as_ref(),
331            all.first(),
332            "the singular finder is not the first of the plural"
333        );
334    }
335
336    /// A difference past the first block is found.
337    ///
338    /// The comparison reads in blocks, so a pair agreeing for the first of them
339    /// is what would pass one that stopped early.
340    #[test]
341    fn a_difference_past_the_first_block_is_found() {
342        let temp = TempDir::new().expect("tempdir");
343        let ours = vec![b'a'; 200 * 1024];
344        let mut theirs = ours.clone();
345        let last = theirs.len() - 1;
346        theirs[last] = b'b';
347        let one = temp.path().join("one");
348        let other = temp.path().join("other");
349        fs::write(&one, &ours).expect("write");
350        fs::write(&other, &theirs).expect("write");
351
352        assert!(!files_have_same_bytes(&one, &other).expect("both read"));
353        assert!(files_have_same_bytes(&one, &one).expect("reads"));
354    }
355}