Skip to main content

casc_lib/listfile/
parser.rs

1//! Listfile parser providing bidirectional FileDataID / path lookup.
2//!
3//! The community listfile format is one `FileDataID;FilePath` pair per line.
4//! This module parses that format into a `Listfile` struct that supports
5//! both forward (FDID -> path) and reverse (path -> FDID, case-insensitive)
6//! lookups.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use crate::error::Result;
12
13/// Parsed listfile mapping FileDataID <-> file path.
14///
15/// The community listfile format is one `FileDataID;FilePath` pair per line
16/// (semicolon-separated). This struct provides bidirectional lookup.
17pub struct Listfile {
18    /// FileDataID -> path (original case preserved)
19    by_id: HashMap<u32, String>,
20    /// Lowercase path -> FileDataID (case-insensitive lookup)
21    by_path: HashMap<String, u32>,
22}
23
24impl Listfile {
25    /// Parse listfile content (`FileDataID;Path` per line).
26    ///
27    /// Malformed lines (missing semicolon, non-numeric ID, empty ID) are
28    /// silently skipped.
29    pub fn parse(content: &str) -> Self {
30        let mut by_id = HashMap::new();
31        let mut by_path = HashMap::new();
32
33        for line in content.lines() {
34            let line = line.trim_end_matches('\r');
35            if line.is_empty() {
36                continue;
37            }
38
39            let Some((id_str, path)) = line.split_once(';') else {
40                continue;
41            };
42
43            let Ok(fdid) = id_str.parse::<u32>() else {
44                continue;
45            };
46
47            if path.is_empty() {
48                continue;
49            }
50
51            by_path.insert(path.to_lowercase(), fdid);
52            by_id.insert(fdid, path.to_string());
53        }
54
55        Self { by_id, by_path }
56    }
57
58    /// Load and parse a listfile from disk.
59    pub fn load(path: &Path) -> Result<Self> {
60        let content = std::fs::read_to_string(path)?;
61        Ok(Self::parse(&content))
62    }
63
64    /// Look up a file path by FileDataID.
65    pub fn path(&self, fdid: u32) -> Option<&str> {
66        self.by_id.get(&fdid).map(|s| s.as_str())
67    }
68
69    /// Look up a FileDataID by path (case-insensitive).
70    pub fn fdid(&self, path: &str) -> Option<u32> {
71        self.by_path.get(&path.to_lowercase()).copied()
72    }
73
74    /// Number of entries in the listfile.
75    pub fn len(&self) -> usize {
76        self.by_id.len()
77    }
78
79    /// Whether the listfile is empty.
80    pub fn is_empty(&self) -> bool {
81        self.by_id.is_empty()
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn parse_basic() {
91        let content = "53;Cameras/FlyBy.m2\n69;Creature/Bear/bear.m2\n";
92        let lf = Listfile::parse(content);
93        assert_eq!(lf.len(), 2);
94        assert_eq!(lf.path(53), Some("Cameras/FlyBy.m2"));
95        assert_eq!(lf.path(69), Some("Creature/Bear/bear.m2"));
96    }
97
98    #[test]
99    fn parse_reverse_lookup() {
100        let content = "53;Cameras/FlyBy.m2\n";
101        let lf = Listfile::parse(content);
102        assert_eq!(lf.fdid("cameras/flyby.m2"), Some(53)); // case-insensitive
103    }
104
105    #[test]
106    fn parse_skips_malformed() {
107        let content = "53;Valid/Path.m2\nnot_a_number;Bad\n;empty_id\n42\n\n";
108        let lf = Listfile::parse(content);
109        assert_eq!(lf.len(), 1);
110        assert_eq!(lf.path(53), Some("Valid/Path.m2"));
111    }
112
113    #[test]
114    fn parse_empty() {
115        let lf = Listfile::parse("");
116        assert!(lf.is_empty());
117    }
118
119    #[test]
120    fn lookup_miss() {
121        let lf = Listfile::parse("53;Test.m2\n");
122        assert!(lf.path(999).is_none());
123        assert!(lf.fdid("nonexistent").is_none());
124    }
125
126    #[test]
127    fn parse_handles_windows_line_endings() {
128        let content = "53;Path/A.m2\r\n69;Path/B.m2\r\n";
129        let lf = Listfile::parse(content);
130        assert_eq!(lf.len(), 2);
131        // Make sure no \r in paths
132        assert_eq!(lf.path(53), Some("Path/A.m2"));
133    }
134
135    #[test]
136    fn parse_preserves_original_case_in_path() {
137        let content = "53;World/Maps/Azeroth.wdt\n";
138        let lf = Listfile::parse(content);
139        // by_id stores original case
140        assert_eq!(lf.path(53), Some("World/Maps/Azeroth.wdt"));
141        // by_path uses lowercase for lookup
142        assert_eq!(lf.fdid("world/maps/azeroth.wdt"), Some(53));
143    }
144}