Skip to main content

nms_graph/
import.rs

1//! Community data import from CSV files.
2//!
3//! Imports coordinate data (e.g., from NMSCE, wiki, or community spreadsheets)
4//! into the galaxy model. Each row must include at minimum a system name and
5//! portal glyphs; galaxy, biome, and platform columns are optional.
6
7use 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/// Statistics from a CSV import operation.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ImportStats {
22    /// Number of new systems added to the model.
23    pub added: usize,
24    /// Number of rows that matched an existing system (skipped).
25    pub duplicates: usize,
26    /// Number of rows that could not be parsed (skipped).
27    pub skipped: usize,
28}
29
30/// Errors that can occur during community data import.
31#[derive(Debug, thiserror::Error)]
32pub enum ImportError {
33    /// CSV reading/parsing error.
34    #[error("CSV error: {0}")]
35    Csv(#[from] csv::Error),
36    /// I/O error reading the file.
37    #[error("I/O error: {0}")]
38    Io(#[from] std::io::Error),
39}
40
41/// A single row from a community coordinate CSV.
42#[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
70/// Resolve a galaxy name to its index (0-255).
71///
72/// Performs case-insensitive matching against all 256 galaxy names.
73/// Falls back to 0 (Euclid) if the name is not recognized.
74fn resolve_galaxy_index(name: &str) -> u8 {
75    // Try parsing as a numeric index first
76    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
92/// Import community coordinate data from a CSV file into the galaxy model.
93///
94/// Expected CSV columns (flexible naming via aliases):
95/// - `System Name` (required)
96/// - `Portal Glyphs` (required) -- 12 hex digits
97/// - `Galaxy` (optional, defaults to "Euclid")
98/// - `Biome` (optional)
99/// - `Platform` (optional, currently ignored)
100///
101/// Returns [`ImportStats`] summarizing what was imported.
102pub 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        // Parse portal glyphs (12 hex digits) into a packed u64
121        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        // Check for duplicate
155        if model.systems.contains_key(&sys_id) {
156            stats.duplicates += 1;
157            continue;
158        }
159
160        // Parse optional biome
161        let biome = record
162            .biome
163            .as_deref()
164            .and_then(|b| b.parse::<Biome>().ok());
165
166        // Build planet list (a single planet if biome is provided)
167        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    /// Create a temporary CSV file with the given content.
189    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        // Pre-populate with a system
315        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"; // headers only
334        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        // Should have separate spatial indexes per galaxy
357        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}