1use std::path::Path;
8
9use serde::Deserialize;
10
11use nms_core::address::GalacticAddress;
12use nms_core::biome::Biome;
13use nms_core::galaxy::Galaxy;
14use nms_core::system::{Planet, System};
15
16use crate::model::GalaxyModel;
17use crate::spatial::SystemId;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ImportStats {
22 pub added: usize,
24 pub duplicates: usize,
26 pub skipped: usize,
28}
29
30#[derive(Debug, thiserror::Error)]
32pub enum ImportError {
33 #[error("CSV error: {0}")]
35 Csv(#[from] csv::Error),
36 #[error("I/O error: {0}")]
38 Io(#[from] std::io::Error),
39}
40
41#[derive(Debug, Deserialize)]
43struct CommunityRecord {
44 #[serde(alias = "System Name", alias = "system_name", alias = "Name")]
45 system_name: String,
46
47 #[serde(alias = "Galaxy", alias = "galaxy", default = "default_galaxy")]
48 galaxy: String,
49
50 #[serde(
51 alias = "Portal Glyphs",
52 alias = "portal_glyphs",
53 alias = "Glyphs",
54 alias = "glyphs"
55 )]
56 portal_glyphs: String,
57
58 #[serde(alias = "Biome", alias = "biome", default)]
59 biome: Option<String>,
60
61 #[allow(dead_code)]
62 #[serde(alias = "Platform", alias = "platform", default)]
63 platform: Option<String>,
64}
65
66fn default_galaxy() -> String {
67 "Euclid".to_string()
68}
69
70fn resolve_galaxy_index(name: &str) -> u8 {
75 if let Ok(idx) = name.parse::<u8>() {
77 return idx;
78 }
79
80 let lower = name.to_lowercase();
81 for i in 0..=255u8 {
82 let galaxy = Galaxy::by_index(i);
83 if galaxy.name.to_lowercase() == lower {
84 return i;
85 }
86 }
87
88 log::warn!("Unknown galaxy name '{}', defaulting to Euclid (0)", name);
89 0
90}
91
92pub fn import_csv(
103 model: &mut GalaxyModel,
104 path: &Path,
105 _source_name: &str,
106) -> Result<ImportStats, ImportError> {
107 let mut stats = ImportStats::default();
108 let mut reader = csv::Reader::from_path(path)?;
109
110 for result in reader.deserialize() {
111 let record: CommunityRecord = match result {
112 Ok(r) => r,
113 Err(e) => {
114 log::warn!("Skipping malformed CSV row: {}", e);
115 stats.skipped += 1;
116 continue;
117 }
118 };
119
120 let glyphs = record.portal_glyphs.trim();
122 let hex_str = glyphs
123 .strip_prefix("0x")
124 .or_else(|| glyphs.strip_prefix("0X"))
125 .unwrap_or(glyphs);
126
127 if hex_str.len() != 12 {
128 log::warn!(
129 "Skipping '{}': portal glyphs '{}' must be exactly 12 hex digits",
130 record.system_name,
131 record.portal_glyphs
132 );
133 stats.skipped += 1;
134 continue;
135 }
136
137 let packed = match u64::from_str_radix(hex_str, 16) {
138 Ok(v) => v,
139 Err(_) => {
140 log::warn!(
141 "Skipping '{}': invalid hex in portal glyphs '{}'",
142 record.system_name,
143 record.portal_glyphs
144 );
145 stats.skipped += 1;
146 continue;
147 }
148 };
149
150 let galaxy_index = resolve_galaxy_index(&record.galaxy);
151 let addr = GalacticAddress::from_packed(packed, galaxy_index);
152 let sys_id = SystemId::from_address(&addr);
153
154 if model.systems.contains_key(&sys_id) {
156 stats.duplicates += 1;
157 continue;
158 }
159
160 let biome = record
162 .biome
163 .as_deref()
164 .and_then(|b| b.parse::<Biome>().ok());
165
166 let planets = if let Some(b) = biome {
168 vec![Planet::new(0, Some(b), None, false, None, None)]
169 } else {
170 vec![]
171 };
172
173 let system = System::new(addr, Some(record.system_name), None, None, planets);
174
175 model.insert_system(system);
176 stats.added += 1;
177 }
178
179 Ok(stats)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::io::Write;
186 use tempfile::NamedTempFile;
187
188 fn csv_file(content: &str) -> NamedTempFile {
190 let mut file = NamedTempFile::new().unwrap();
191 file.write_all(content.as_bytes()).unwrap();
192 file.flush().unwrap();
193 file
194 }
195
196 #[test]
197 fn test_import_csv_basic() {
198 let csv = "\
199System Name,Galaxy,Portal Glyphs,Biome
200Alpha System,Euclid,01717D8A4EA2,Lush
201Beta System,Euclid,0A0002001234,Toxic
202";
203 let file = csv_file(csv);
204 let mut model = GalaxyModel::new();
205 let stats = import_csv(&mut model, file.path(), "test").unwrap();
206
207 assert_eq!(stats.added, 2);
208 assert_eq!(stats.duplicates, 0);
209 assert_eq!(stats.skipped, 0);
210 assert_eq!(model.system_count(), 2);
211 }
212
213 #[test]
214 fn test_import_csv_duplicate_detection() {
215 let csv = "\
216System Name,Galaxy,Portal Glyphs,Biome
217Alpha System,Euclid,01717D8A4EA2,Lush
218Alpha Duplicate,Euclid,01717D8A4EA2,Toxic
219";
220 let file = csv_file(csv);
221 let mut model = GalaxyModel::new();
222 let stats = import_csv(&mut model, file.path(), "test").unwrap();
223
224 assert_eq!(stats.added, 1);
225 assert_eq!(stats.duplicates, 1);
226 assert_eq!(model.system_count(), 1);
227 }
228
229 #[test]
230 fn test_import_csv_bad_glyphs_skipped() {
231 let csv = "\
232System Name,Galaxy,Portal Glyphs,Biome
233Good System,Euclid,01717D8A4EA2,Lush
234Bad Glyphs,Euclid,ZZZZZZZZZZZZ,Lush
235Short Glyphs,Euclid,0171,Lush
236";
237 let file = csv_file(csv);
238 let mut model = GalaxyModel::new();
239 let stats = import_csv(&mut model, file.path(), "test").unwrap();
240
241 assert_eq!(stats.added, 1);
242 assert_eq!(stats.skipped, 2);
243 assert_eq!(model.system_count(), 1);
244 }
245
246 #[test]
247 fn test_import_csv_no_biome() {
248 let csv = "\
249System Name,Galaxy,Portal Glyphs
250No Biome System,Euclid,01717D8A4EA2
251";
252 let file = csv_file(csv);
253 let mut model = GalaxyModel::new();
254 let stats = import_csv(&mut model, file.path(), "test").unwrap();
255
256 assert_eq!(stats.added, 1);
257 let sys_id = *model.systems.keys().next().unwrap();
258 let system = model.system(&sys_id).unwrap();
259 assert!(system.planets.is_empty());
260 }
261
262 #[test]
263 fn test_import_csv_default_galaxy() {
264 let csv = "\
265System Name,Portal Glyphs
266Default Galaxy,01717D8A4EA2
267";
268 let file = csv_file(csv);
269 let mut model = GalaxyModel::new();
270 let stats = import_csv(&mut model, file.path(), "test").unwrap();
271
272 assert_eq!(stats.added, 1);
273 let sys_id = *model.systems.keys().next().unwrap();
274 let system = model.system(&sys_id).unwrap();
275 assert_eq!(system.address.reality_index, 0);
276 }
277
278 #[test]
279 fn test_import_csv_eissentam_galaxy() {
280 let csv = "\
281System Name,Galaxy,Portal Glyphs
282Eissentam System,Eissentam,01717D8A4EA2
283";
284 let file = csv_file(csv);
285 let mut model = GalaxyModel::new();
286 let stats = import_csv(&mut model, file.path(), "test").unwrap();
287
288 assert_eq!(stats.added, 1);
289 let sys_id = *model.systems.keys().next().unwrap();
290 let system = model.system(&sys_id).unwrap();
291 assert_eq!(system.address.reality_index, 9);
292 }
293
294 #[test]
295 fn test_import_csv_galaxy_numeric() {
296 let csv = "\
297System Name,Galaxy,Portal Glyphs
298Numeric Galaxy,9,01717D8A4EA2
299";
300 let file = csv_file(csv);
301 let mut model = GalaxyModel::new();
302 let stats = import_csv(&mut model, file.path(), "test").unwrap();
303
304 assert_eq!(stats.added, 1);
305 let sys_id = *model.systems.keys().next().unwrap();
306 let system = model.system(&sys_id).unwrap();
307 assert_eq!(system.address.reality_index, 9);
308 }
309
310 #[test]
311 fn test_import_csv_into_populated_model() {
312 let mut model = GalaxyModel::new();
313
314 let addr = GalacticAddress::new(100, 0, 0, 0x100, 0, 0);
316 let existing = System::new(addr, Some("Existing".into()), None, None, vec![]);
317 model.insert_system(existing);
318 assert_eq!(model.system_count(), 1);
319
320 let csv = "\
321System Name,Galaxy,Portal Glyphs
322New System,Euclid,01717D8A4EA2
323";
324 let file = csv_file(csv);
325 let stats = import_csv(&mut model, file.path(), "test").unwrap();
326
327 assert_eq!(stats.added, 1);
328 assert_eq!(model.system_count(), 2);
329 }
330
331 #[test]
332 fn test_import_csv_empty_file() {
333 let csv = "System Name,Galaxy,Portal Glyphs\n"; let file = csv_file(csv);
335 let mut model = GalaxyModel::new();
336 let stats = import_csv(&mut model, file.path(), "test").unwrap();
337
338 assert_eq!(stats.added, 0);
339 assert_eq!(stats.duplicates, 0);
340 assert_eq!(stats.skipped, 0);
341 }
342
343 #[test]
344 fn test_import_csv_multi_galaxy() {
345 let csv = "\
346System Name,Galaxy,Portal Glyphs
347Euclid Sys,Euclid,01717D8A4EA2
348Hilbert Sys,Hilbert Dimension,0A0002001234
349";
350 let file = csv_file(csv);
351 let mut model = GalaxyModel::new();
352 let stats = import_csv(&mut model, file.path(), "test").unwrap();
353
354 assert_eq!(stats.added, 2);
355
356 let galaxies = model.discovered_galaxies();
358 assert_eq!(galaxies.len(), 2);
359 assert!(galaxies.contains(&0));
360 assert!(galaxies.contains(&1));
361 }
362
363 #[test]
364 fn test_import_csv_with_biome_creates_planet() {
365 let csv = "\
366System Name,Galaxy,Portal Glyphs,Biome
367Lush World,Euclid,01717D8A4EA2,Lush
368";
369 let file = csv_file(csv);
370 let mut model = GalaxyModel::new();
371 import_csv(&mut model, file.path(), "test").unwrap();
372
373 let sys_id = *model.systems.keys().next().unwrap();
374 let system = model.system(&sys_id).unwrap();
375 assert_eq!(system.planets.len(), 1);
376 assert_eq!(system.planets[0].biome, Some(Biome::Lush));
377 }
378
379 #[test]
380 fn test_import_csv_file_not_found() {
381 let mut model = GalaxyModel::new();
382 let result = import_csv(&mut model, Path::new("/no/such/file.csv"), "test");
383 assert!(result.is_err());
384 }
385
386 #[test]
387 fn test_resolve_galaxy_index_euclid() {
388 assert_eq!(resolve_galaxy_index("Euclid"), 0);
389 }
390
391 #[test]
392 fn test_resolve_galaxy_index_case_insensitive() {
393 assert_eq!(resolve_galaxy_index("euclid"), 0);
394 assert_eq!(resolve_galaxy_index("EUCLID"), 0);
395 assert_eq!(resolve_galaxy_index("eissentam"), 9);
396 }
397
398 #[test]
399 fn test_resolve_galaxy_index_numeric() {
400 assert_eq!(resolve_galaxy_index("0"), 0);
401 assert_eq!(resolve_galaxy_index("9"), 9);
402 assert_eq!(resolve_galaxy_index("255"), 255);
403 }
404
405 #[test]
406 fn test_resolve_galaxy_index_unknown_defaults_to_zero() {
407 assert_eq!(resolve_galaxy_index("NotAGalaxy"), 0);
408 }
409
410 #[test]
411 fn test_import_csv_glyphs_with_0x_prefix() {
412 let csv = "\
413System Name,Galaxy,Portal Glyphs
414Prefixed,Euclid,0x01717D8A4EA2
415";
416 let file = csv_file(csv);
417 let mut model = GalaxyModel::new();
418 let stats = import_csv(&mut model, file.path(), "test").unwrap();
419
420 assert_eq!(stats.added, 1);
421 }
422
423 #[test]
424 fn test_import_stats_default() {
425 let stats = ImportStats::default();
426 assert_eq!(stats.added, 0);
427 assert_eq!(stats.duplicates, 0);
428 assert_eq!(stats.skipped, 0);
429 }
430}