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/// Finds one file in `directory` with ASCII case-insensitive name matching.
12///
13/// When multiple entries differ only by case, this function returns a
14/// deterministic winner by sorting candidates on:
15/// 1. lowercase filename
16/// 2. original filename bytes
17pub fn find_case_insensitive_file(
18    directory: &Path,
19    expected_file_name: &str,
20) -> io::Result<Option<PathBuf>> {
21    let mut matches = Vec::new();
22    let entries = std::fs::read_dir(directory)?;
23    for entry in entries {
24        let entry = entry?;
25        let path = entry.path();
26        if !path.is_file() {
27            continue;
28        }
29        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
30            continue;
31        };
32        if name.eq_ignore_ascii_case(expected_file_name) {
33            matches.push(path);
34        }
35    }
36
37    if matches.is_empty() {
38        return Ok(None);
39    }
40
41    matches.sort_by(|a, b| {
42        let a_name = a
43            .file_name()
44            .and_then(|name| name.to_str())
45            .unwrap_or_default();
46        let b_name = b
47            .file_name()
48            .and_then(|name| name.to_str())
49            .unwrap_or_default();
50        cmp_ascii_case_insensitive(a_name, b_name).then(a_name.cmp(b_name))
51    });
52
53    Ok(matches.into_iter().next())
54}
55
56#[cfg(test)]
57mod tests {
58    use super::find_case_insensitive_file;
59
60    use std::fs;
61    use std::path::PathBuf;
62    use std::sync::atomic::{AtomicU64, Ordering};
63    use std::time::{SystemTime, UNIX_EPOCH};
64
65    fn unique_test_dir() -> PathBuf {
66        static COUNTER: AtomicU64 = AtomicU64::new(0);
67        let nanos = SystemTime::now()
68            .duration_since(UNIX_EPOCH)
69            .expect("system clock should be after unix epoch")
70            .as_nanos();
71        let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
72        std::env::temp_dir().join(format!(
73            "rakata_core_fs_test_{}_{}_{}",
74            std::process::id(),
75            nanos,
76            sequence
77        ))
78    }
79
80    #[test]
81    fn resolves_case_insensitive_match() {
82        let root = unique_test_dir();
83        fs::create_dir_all(&root).expect("create test dir");
84        let path = root.join("ChItIn.KeY");
85        fs::write(&path, b"key").expect("write fixture");
86
87        let found = find_case_insensitive_file(&root, "chitin.key")
88            .expect("lookup should succeed")
89            .expect("file should be found");
90        assert_eq!(found, path);
91
92        fs::remove_dir_all(root).expect("cleanup");
93    }
94
95    #[test]
96    fn returns_none_when_no_match() {
97        let root = unique_test_dir();
98        fs::create_dir_all(&root).expect("create test dir");
99
100        let found = find_case_insensitive_file(&root, "missing.txt").expect("lookup should run");
101        assert!(found.is_none());
102
103        fs::remove_dir_all(root).expect("cleanup");
104    }
105}