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 entry = entry?;
159        let name = entry.file_name();
160        if !name
161            .to_str()
162            .is_some_and(|name| name.eq_ignore_ascii_case(expected))
163        {
164            continue;
165        }
166        // The kind check stats the entry, so it waits for a name match.
167        let path = entry.path();
168        if kind.matches(&path) {
169            matches.push(path);
170        }
171    }
172    matches.sort_by(|a, b| {
173        let name_of = |path: &Path| {
174            path.file_name()
175                .and_then(|name| name.to_str())
176                .unwrap_or_default()
177                .to_owned()
178        };
179        let (a, b) = (name_of(a), name_of(b));
180        cmp_ascii_case_insensitive(&a, &b).then(a.cmp(&b))
181    });
182    Ok(matches)
183}
184
185/// Finds one file in `directory` with ASCII case-insensitive name matching.
186///
187/// Where several entries differ only by case this takes a deterministic winner:
188/// the first of [`find_case_insensitive_files`]. A caller that should report
189/// rather than resolve wants that one instead.
190///
191/// # Errors
192///
193/// Propagates the [`io::Error`] from reading `directory` or one of its
194/// entries. A directory that exists and holds no match is `Ok(None)`.
195pub fn find_case_insensitive_file(
196    directory: &Path,
197    expected_file_name: &str,
198) -> io::Result<Option<PathBuf>> {
199    Ok(find_case_insensitive_files(directory, expected_file_name)?
200        .into_iter()
201        .next())
202}
203
204/// Finds one subdirectory in `directory` with ASCII case-insensitive name
205/// matching.
206///
207/// The same deterministic tie-break as [`find_case_insensitive_file`], and the
208/// same caveat: [`find_case_insensitive_directories`] is what to reach for when
209/// several matches is the answer rather than a tie to break.
210///
211/// Used for resolving canonical install paths like `Override/`, `modules/` and
212/// `lips/` that mods or platform copies frequently case-fold differently.
213///
214/// # Errors
215///
216/// Propagates the [`io::Error`] from reading `directory` or one of its
217/// entries. A directory that exists and holds no match is `Ok(None)`.
218pub fn find_case_insensitive_directory(
219    directory: &Path,
220    expected_name: &str,
221) -> io::Result<Option<PathBuf>> {
222    Ok(find_case_insensitive_directories(directory, expected_name)?
223        .into_iter()
224        .next())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{
230        files_beneath, files_have_same_bytes, find_case_insensitive_directories,
231        find_case_insensitive_directory, find_case_insensitive_file,
232    };
233
234    use std::fs;
235
236    use tempfile::TempDir;
237
238    #[test]
239    fn resolves_case_insensitive_match() {
240        let temp = TempDir::new().expect("create tempdir");
241        let root = temp.path();
242        let path = root.join("ChItIn.KeY");
243        fs::write(&path, b"key").expect("write fixture");
244
245        let found = find_case_insensitive_file(root, "chitin.key")
246            .expect("lookup should succeed")
247            .expect("file should be found");
248        assert_eq!(found, path);
249    }
250
251    #[test]
252    fn returns_none_when_no_match() {
253        let temp = TempDir::new().expect("create tempdir");
254
255        let found =
256            find_case_insensitive_file(temp.path(), "missing.txt").expect("lookup should run");
257        assert!(found.is_none());
258    }
259
260    #[test]
261    fn resolves_case_insensitive_directory_match() {
262        let temp = TempDir::new().expect("create tempdir");
263        let root = temp.path();
264        let path = root.join("OvErRiDe");
265        fs::create_dir(&path).expect("create fixture dir");
266
267        let found = find_case_insensitive_directory(root, "override")
268            .expect("lookup should succeed")
269            .expect("dir should be found");
270        assert_eq!(found, path);
271    }
272
273    #[test]
274    fn directory_lookup_ignores_files_with_matching_name() {
275        // A file named "override" must not satisfy a directory lookup.
276        let temp = TempDir::new().expect("create tempdir");
277        let root = temp.path();
278        fs::write(root.join("override"), b"not a directory").expect("write fixture");
279
280        let found = find_case_insensitive_directory(root, "override").expect("lookup should run");
281        assert!(found.is_none());
282    }
283
284    #[test]
285    fn directory_lookup_returns_none_when_no_match() {
286        let temp = TempDir::new().expect("create tempdir");
287
288        let found =
289            find_case_insensitive_directory(temp.path(), "missing").expect("lookup should run");
290        assert!(found.is_none());
291    }
292
293    /// The tree walk reaches every depth and returns leaves only.
294    #[test]
295    fn files_beneath_reaches_every_depth() {
296        let temp = TempDir::new().expect("tempdir");
297        let deep = temp.path().join("Override").join("nested");
298        fs::create_dir_all(&deep).expect("create");
299        fs::write(temp.path().join("readme.txt"), b"a").expect("write");
300        fs::write(temp.path().join("Override").join("heads.2da"), b"b").expect("write");
301        fs::write(deep.join("appearance.2da"), b"c").expect("write");
302
303        let found = files_beneath(temp.path()).expect("the tree reads");
304        let names: Vec<&str> = found
305            .iter()
306            .filter_map(|path| path.file_name()?.to_str())
307            .collect();
308
309        assert_eq!(names, vec!["heads.2da", "appearance.2da", "readme.txt"]);
310    }
311
312    /// An empty tree is empty rather than an error.
313    #[test]
314    fn files_beneath_an_empty_directory_is_empty() {
315        let temp = TempDir::new().expect("tempdir");
316        fs::create_dir(temp.path().join("empty")).expect("create");
317        assert!(files_beneath(temp.path()).expect("reads").is_empty());
318    }
319
320    /// Every match comes back, not just the first, which is what separates the
321    /// plural finders from the singular ones.
322    #[test]
323    #[cfg_attr(
324        any(windows, target_os = "macos"),
325        ignore = "needs a filesystem that tells names apart by case"
326    )]
327    fn the_plural_finder_returns_both_spellings() {
328        let temp = TempDir::new().expect("tempdir");
329        fs::create_dir(temp.path().join("Override")).expect("create");
330        fs::create_dir(temp.path().join("override")).expect("create");
331
332        let all = find_case_insensitive_directories(temp.path(), "override").expect("reads");
333        assert_eq!(all.len(), 2, "{all:?}");
334        assert_eq!(
335            find_case_insensitive_directory(temp.path(), "override")
336                .expect("reads")
337                .as_ref(),
338            all.first(),
339            "the singular finder is not the first of the plural"
340        );
341    }
342
343    /// A difference past the first block is found.
344    ///
345    /// The comparison reads in blocks, so a pair agreeing for the first of them
346    /// is what would pass one that stopped early.
347    #[test]
348    fn a_difference_past_the_first_block_is_found() {
349        let temp = TempDir::new().expect("tempdir");
350        let ours = vec![b'a'; 200 * 1024];
351        let mut theirs = ours.clone();
352        let last = theirs.len() - 1;
353        theirs[last] = b'b';
354        let one = temp.path().join("one");
355        let other = temp.path().join("other");
356        fs::write(&one, &ours).expect("write");
357        fs::write(&other, &theirs).expect("write");
358
359        assert!(!files_have_same_bytes(&one, &other).expect("both read"));
360        assert!(files_have_same_bytes(&one, &one).expect("reads"));
361    }
362}