1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8#[derive(Debug, Clone, Deserialize, PartialEq)]
10pub struct UnitStats {
11 pub hp: i32,
12 pub speed: f32,
13 #[serde(default)]
14 pub combat_rating: i32,
15 #[serde(default)]
16 pub build_time: f32,
17}
18
19#[derive(Debug, Clone, Deserialize)]
24pub struct UnitTemplate {
25 pub name: String,
26 pub stats: UnitStats,
27}
28
29#[derive(Debug)]
34pub struct DataRegistry {
35 units: HashMap<String, UnitTemplate>,
36}
37
38impl DataRegistry {
39 pub fn new() -> Self {
41 Self {
42 units: HashMap::new(),
43 }
44 }
45
46 pub fn load_units_from_dir(dir: &Path) -> Result<Self, Box<DataLoadError>> {
51 let mut units = HashMap::new();
52
53 let entries = std::fs::read_dir(dir).map_err(|e| {
54 Box::new(DataLoadError::Io {
55 path: dir.to_path_buf(),
56 source: e,
57 })
58 })?;
59
60 for entry in entries {
61 let entry = entry.map_err(|e| {
62 Box::new(DataLoadError::Io {
63 path: dir.to_path_buf(),
64 source: e,
65 })
66 })?;
67 let path = entry.path();
68
69 if path.extension().is_some_and(|ext| ext == "ron") {
70 let key = path
71 .file_stem()
72 .unwrap_or_default()
73 .to_string_lossy()
74 .to_string();
75
76 let contents = std::fs::read_to_string(&path).map_err(|e| {
77 Box::new(DataLoadError::Io {
78 path: path.clone(),
79 source: e,
80 })
81 })?;
82
83 let template: UnitTemplate = ron::from_str(&contents).map_err(|e| {
84 Box::new(DataLoadError::Ron {
85 path: path.clone(),
86 source: e,
87 })
88 })?;
89
90 units.insert(key, template);
91 }
92 }
93
94 Ok(Self { units })
95 }
96
97 pub fn load_unit_from_str(key: &str, ron_str: &str) -> Result<Self, Box<DataLoadError>> {
99 let template: UnitTemplate = ron::from_str(ron_str).map_err(|e| {
100 Box::new(DataLoadError::RonParse {
101 key: key.to_string(),
102 source: e,
103 })
104 })?;
105 let mut units = HashMap::new();
106 units.insert(key.to_string(), template);
107 Ok(Self { units })
108 }
109
110 pub fn unit(&self, name: &str) -> Option<&UnitTemplate> {
112 self.units.get(name)
113 }
114
115 pub fn unit_count(&self) -> usize {
117 self.units.len()
118 }
119
120 pub fn merge(&mut self, other: DataRegistry) {
122 self.units.extend(other.units);
123 }
124}
125
126impl Default for DataRegistry {
127 fn default() -> Self {
128 Self::new()
129 }
130}
131
132#[derive(Debug)]
134pub enum DataLoadError {
135 Io {
136 path: std::path::PathBuf,
137 source: std::io::Error,
138 },
139 Ron {
140 path: std::path::PathBuf,
141 source: ron::error::SpannedError,
142 },
143 RonParse {
144 key: String,
145 source: ron::error::SpannedError,
146 },
147}
148
149impl std::fmt::Display for DataLoadError {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 match self {
152 DataLoadError::Io { path, source } => {
153 write!(f, "IO error reading {}: {}", path.display(), source)
154 }
155 DataLoadError::Ron { path, source } => {
156 write!(f, "RON parse error in {}: {}", path.display(), source)
157 }
158 DataLoadError::RonParse { key, source } => {
159 write!(f, "RON parse error for '{}': {}", key, source)
160 }
161 }
162 }
163}
164
165impl std::error::Error for DataLoadError {}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::io::Write;
171
172 #[test]
173 fn deserialize_unit_template_from_ron() {
174 let ron_str = r#"
175 UnitTemplate(
176 name: "Sentinel",
177 stats: UnitStats(
178 hp: 120,
179 speed: 45.0,
180 combat_rating: 18,
181 build_time: 15.0,
182 ),
183 )
184 "#;
185
186 let template: UnitTemplate = ron::from_str(ron_str).unwrap();
187 assert_eq!(template.name, "Sentinel");
188 assert_eq!(template.stats.hp, 120);
189 assert!((template.stats.speed - 45.0).abs() < f32::EPSILON);
190 assert_eq!(template.stats.combat_rating, 18);
191 }
192
193 #[test]
194 fn load_unit_from_str() {
195 let ron_str = r#"UnitTemplate(name: "Scout", stats: UnitStats(hp: 50, speed: 80.0))"#;
196 let registry = DataRegistry::load_unit_from_str("scout", ron_str).unwrap();
197 let unit = registry.unit("scout").unwrap();
198 assert_eq!(unit.name, "Scout");
199 assert_eq!(unit.stats.hp, 50);
200 }
201
202 #[test]
203 fn load_units_from_directory() {
204 let dir = tempfile::tempdir().unwrap();
205
206 let sentinel_path = dir.path().join("sentinel.ron");
207 let mut f = std::fs::File::create(&sentinel_path).unwrap();
208 writeln!(
209 f,
210 r#"UnitTemplate(name: "Sentinel", stats: UnitStats(hp: 120, speed: 45.0))"#
211 )
212 .unwrap();
213
214 let scout_path = dir.path().join("scout.ron");
215 let mut f = std::fs::File::create(&scout_path).unwrap();
216 writeln!(
217 f,
218 r#"UnitTemplate(name: "Scout", stats: UnitStats(hp: 50, speed: 80.0))"#
219 )
220 .unwrap();
221
222 let txt_path = dir.path().join("notes.txt");
223 std::fs::write(&txt_path, "not a ron file").unwrap();
224
225 let registry = DataRegistry::load_units_from_dir(dir.path()).unwrap();
226 assert_eq!(registry.unit_count(), 2);
227 assert_eq!(registry.unit("sentinel").unwrap().stats.hp, 120);
228 assert_eq!(registry.unit("scout").unwrap().stats.hp, 50);
229 }
230
231 #[test]
232 fn missing_optional_fields_default() {
233 let ron_str = r#"UnitTemplate(name: "Minimal", stats: UnitStats(hp: 10, speed: 1.0))"#;
234 let template: UnitTemplate = ron::from_str(ron_str).unwrap();
235 assert_eq!(template.stats.combat_rating, 0);
236 assert!((template.stats.build_time - 0.0).abs() < f32::EPSILON);
237 }
238
239 #[test]
240 fn bad_ron_gives_descriptive_error() {
241 let bad_ron = r#"UnitTemplate(name: "Bad", stats: UnitStats(hp: "not a number"))"#;
242 let result = DataRegistry::load_unit_from_str("bad", bad_ron);
243 assert!(result.is_err());
244 let err = result.unwrap_err();
245 assert!(err.to_string().contains("bad"));
246 }
247}