casc_lib/listfile/
parser.rs1use std::collections::HashMap;
9use std::path::Path;
10
11use crate::error::Result;
12
13pub struct Listfile {
18 by_id: HashMap<u32, String>,
20 by_path: HashMap<String, u32>,
22}
23
24impl Listfile {
25 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 pub fn load(path: &Path) -> Result<Self> {
60 let content = std::fs::read_to_string(path)?;
61 Ok(Self::parse(&content))
62 }
63
64 pub fn path(&self, fdid: u32) -> Option<&str> {
66 self.by_id.get(&fdid).map(|s| s.as_str())
67 }
68
69 pub fn fdid(&self, path: &str) -> Option<u32> {
71 self.by_path.get(&path.to_lowercase()).copied()
72 }
73
74 pub fn len(&self) -> usize {
76 self.by_id.len()
77 }
78
79 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)); }
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 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 assert_eq!(lf.path(53), Some("World/Maps/Azeroth.wdt"));
141 assert_eq!(lf.fdid("world/maps/azeroth.wdt"), Some(53));
143 }
144}