1use crate::error::ThrustError;
2use quick_xml::events::Event;
3use quick_xml::Reader;
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6#[cfg(not(target_arch = "wasm32"))]
7#[cfg(feature = "net")]
8use std::fs;
9use std::fs::File;
10#[cfg(not(target_arch = "wasm32"))]
11#[cfg(feature = "net")]
12use std::io::Write;
13use std::io::{BufRead, BufReader, Cursor};
14use std::path::{Path, PathBuf};
15use zip::read::ZipArchive;
16
17pub use crate::data::airac::{airac_code_from_date, effective_date_from_airac_code};
18
19const NASR_BASE_URL: &str = "https://nfdc.faa.gov/webContent/28DaySub";
20
21#[derive(Debug, Clone)]
30pub struct AiracCycle {
31 pub code: String,
32 pub effective_date: chrono::NaiveDate,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct NasrFileSummary {
41 pub name: String,
43 pub size_bytes: u64,
45 pub compressed_size_bytes: u64,
47 pub line_count: Option<u64>,
49 pub header_columns: Option<usize>,
51 pub delimiter: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct NasrCycleSummary {
58 pub airac_code: String,
60 pub effective_date: String,
62 pub zip_path: String,
64 pub files: Vec<NasrFileSummary>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct NasrPoint {
73 pub identifier: String,
75 pub kind: String,
77 pub latitude: f64,
79 pub longitude: f64,
81 pub name: Option<String>,
83 pub description: Option<String>,
85 pub frequency: Option<f64>,
87 pub point_type: Option<String>,
89 pub region: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct NasrAirwaySegment {
98 pub airway_name: String,
100 pub airway_id: String,
102 pub airway_designation: String,
104 pub airway_location: Option<String>,
106 pub from_point: String,
108 pub to_point: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct NasrAirspace {
117 pub designator: String,
119 pub name: Option<String>,
121 pub type_: Option<String>,
123 pub lower: Option<f64>,
125 pub upper: Option<f64>,
127 pub coordinates: Vec<(f64, f64)>, }
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct NasrProcedureLeg {
136 pub procedure_kind: String,
138 pub procedure_id: String,
140 pub route_portion_type: String,
142 pub route_name: Option<String>,
144 pub body_seq: Option<i32>,
146 pub point_seq: Option<i32>,
148 pub point: String,
150 pub next_point: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct NasrField15Data {
161 pub points: Vec<NasrPoint>,
163 pub airways: Vec<NasrAirwaySegment>,
165 pub sid_designators: Vec<String>,
167 pub star_designators: Vec<String>,
169 pub sid_legs: Vec<NasrProcedureLeg>,
171 pub star_legs: Vec<NasrProcedureLeg>,
173}
174
175#[derive(Debug, Clone, Default)]
179pub struct NasrField15Index {
180 pub point_names: HashSet<String>,
182 pub airway_names: HashSet<String>,
184 pub sid_names: HashSet<String>,
186 pub star_names: HashSet<String>,
188}
189
190impl NasrField15Index {
191 pub fn from_data(data: &NasrField15Data) -> Self {
192 let mut idx = Self::default();
193
194 for point in &data.points {
195 if !point.identifier.is_empty() {
196 idx.point_names.insert(point.identifier.to_uppercase());
197 }
198 if let Some(name) = &point.name {
199 if !name.is_empty() {
200 idx.point_names.insert(name.to_uppercase());
201 }
202 }
203 }
204
205 for airway in &data.airways {
206 if !airway.airway_name.is_empty() {
207 idx.airway_names.insert(airway.airway_name.to_uppercase());
208 }
209 if !airway.airway_id.is_empty() {
210 idx.airway_names.insert(airway.airway_id.to_uppercase());
211 }
212 }
213
214 for sid in &data.sid_designators {
215 idx.sid_names.insert(sid.to_uppercase());
216 }
217 for star in &data.star_designators {
218 idx.star_names.insert(star.to_uppercase());
219 }
220
221 idx
222 }
223}
224
225pub fn cycle_from_airac_code(airac_code: &str) -> Result<AiracCycle, ThrustError> {
226 let effective_date = effective_date_from_airac_code(airac_code)?;
227 Ok(AiracCycle {
228 code: airac_code.to_string(),
229 effective_date,
230 })
231}
232
233pub fn nasr_zip_url_from_airac_code(airac_code: &str) -> Result<String, ThrustError> {
234 let cycle = cycle_from_airac_code(airac_code)?;
235 Ok(format!(
236 "{NASR_BASE_URL}/28DaySubscription_Effective_{}.zip",
237 cycle.effective_date.format("%Y-%m-%d")
238 ))
239}
240
241pub fn download_nasr_zip_for_airac<P: AsRef<Path>>(airac_code: &str, output_dir: P) -> Result<PathBuf, ThrustError> {
242 #[cfg(target_arch = "wasm32")]
243 {
244 let _ = (airac_code, output_dir);
245 return Err("download_nasr_zip_for_airac is not available on wasm; fetch in JS and pass bytes".into());
246 }
247
248 #[cfg(not(target_arch = "wasm32"))]
249 {
250 #[cfg(not(feature = "net"))]
251 {
252 let _ = (airac_code, output_dir);
253 Err("NASR download is disabled; enable feature 'net'".into())
254 }
255
256 #[cfg(feature = "net")]
257 {
258 let cycle = cycle_from_airac_code(airac_code)?;
259 fs::create_dir_all(&output_dir)?;
260
261 let filename = format!("NASR_{}_{}.zip", airac_code, cycle.effective_date.format("%Y-%m-%d"));
262 let output_path = output_dir.as_ref().join(filename);
263
264 if output_path.exists() {
265 return Ok(output_path);
266 }
267
268 let url = nasr_zip_url_from_airac_code(airac_code)?;
269 let bytes = reqwest::blocking::get(url)?.error_for_status()?.bytes()?;
270
271 let mut file = File::create(&output_path)?;
272 file.write_all(&bytes)?;
273
274 Ok(output_path)
275 }
276 }
277}
278
279pub fn parse_nasr_zip_file<P: AsRef<Path>>(path: P) -> Result<Vec<NasrFileSummary>, ThrustError> {
280 let file = File::open(path)?;
281 let mut archive = ZipArchive::new(file)?;
282 let mut summaries = Vec::new();
283
284 for i in 0..archive.len() {
285 let file = archive.by_index(i)?;
286 if file.is_dir() {
287 continue;
288 }
289
290 let name = file.name().to_string();
291 let size_bytes = file.size();
292 let compressed_size_bytes = file.compressed_size();
293
294 let mut summary = NasrFileSummary {
295 name: name.clone(),
296 size_bytes,
297 compressed_size_bytes,
298 line_count: None,
299 header_columns: None,
300 delimiter: None,
301 };
302
303 if is_text_like(&name) {
304 let (line_count, header_columns, delimiter) = inspect_delimited_content(file)?;
305 summary.line_count = Some(line_count);
306 summary.header_columns = header_columns;
307 summary.delimiter = delimiter.map(|c| c.to_string());
308 }
309
310 summaries.push(summary);
311 }
312
313 summaries.sort_by(|a, b| a.name.cmp(&b.name));
314 Ok(summaries)
315}
316
317pub fn load_nasr_cycle_summary<P: AsRef<Path>>(
318 airac_code: &str,
319 output_dir: P,
320) -> Result<NasrCycleSummary, ThrustError> {
321 let cycle = cycle_from_airac_code(airac_code)?;
322 let zip_path = download_nasr_zip_for_airac(airac_code, output_dir)?;
323 let files = parse_nasr_zip_file(&zip_path)?;
324
325 Ok(NasrCycleSummary {
326 airac_code: cycle.code,
327 effective_date: cycle.effective_date.to_string(),
328 zip_path: zip_path.display().to_string(),
329 files,
330 })
331}
332
333pub fn parse_field15_data_from_nasr_zip<P: AsRef<Path>>(path: P) -> Result<NasrField15Data, ThrustError> {
334 let mut csv_zip = open_csv_bundle(path)?;
335 parse_field15_data_from_csv_bundle(&mut csv_zip)
336}
337
338pub fn parse_field15_data_from_nasr_bytes(bytes: &[u8]) -> Result<NasrField15Data, ThrustError> {
339 let mut csv_zip = open_csv_bundle_from_bytes(bytes)?;
340 parse_field15_data_from_csv_bundle(&mut csv_zip)
341}
342
343pub fn parse_airspaces_from_nasr_bytes(bytes: &[u8]) -> Result<Vec<NasrAirspace>, ThrustError> {
344 let mut outer = ZipArchive::new(Cursor::new(bytes.to_vec()))?;
345 let mut saa_bytes = Vec::new();
346
347 {
348 let mut saa_zip = outer.by_name("Additional_Data/AIXM/SAA-AIXM_5_Schema/SaaSubscriberFile.zip")?;
349 std::io::copy(&mut saa_zip, &mut saa_bytes)?;
350 }
351
352 let mut level1 = ZipArchive::new(Cursor::new(saa_bytes))?;
353 let mut sub_bytes = Vec::new();
354 {
355 let mut sub = level1.by_name("Saa_Sub_File.zip")?;
356 std::io::copy(&mut sub, &mut sub_bytes)?;
357 }
358
359 let mut level2 = ZipArchive::new(Cursor::new(sub_bytes))?;
360 let mut all = Vec::new();
361 for i in 0..level2.len() {
362 let mut entry = level2.by_index(i)?;
363 if !entry.name().to_lowercase().ends_with(".xml") {
364 continue;
365 }
366 let mut xml = Vec::new();
367 std::io::copy(&mut entry, &mut xml)?;
368 all.extend(parse_saa_xml_airspaces(&xml));
369 }
370
371 Ok(all)
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
375pub struct NasrAirportRecord {
376 pub code: String,
377 pub iata: Option<String>,
378 pub icao: Option<String>,
379 pub name: Option<String>,
380 pub latitude: f64,
381 pub longitude: f64,
382 pub region: Option<String>,
383 pub source: String,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, Default)]
387pub struct NasrNavpointRecord {
388 pub code: String,
389 pub identifier: String,
390 pub kind: String,
391 pub name: Option<String>,
392 pub latitude: f64,
393 pub longitude: f64,
394 pub description: Option<String>,
395 pub frequency: Option<f64>,
396 pub point_type: Option<String>,
397 pub region: Option<String>,
398 pub source: String,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, Default)]
402pub struct NasrAirwayPointRecord {
403 pub code: String,
404 pub raw_code: String,
405 pub kind: String,
406 pub latitude: f64,
407 pub longitude: f64,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, Default)]
411pub struct NasrAirwayRecord {
412 pub name: String,
413 pub source: String,
414 pub route_class: Option<String>,
415 pub points: Vec<NasrAirwayPointRecord>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct NasrProcedureRecord {
420 pub name: String,
421 pub source: String,
422 pub procedure_kind: String,
423 pub route_class: Option<String>,
424 pub airport: Option<String>,
425 pub points: Vec<NasrAirwayPointRecord>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, Default)]
429pub struct NasrResolverData {
430 pub airports: Vec<NasrAirportRecord>,
431 pub navaids: Vec<NasrNavpointRecord>,
432 pub airways: Vec<NasrAirwayRecord>,
433 pub procedures: Vec<NasrProcedureRecord>,
434 pub airspaces: Vec<NasrAirspace>,
435}
436
437pub fn parse_resolver_data_from_nasr_bytes(bytes: &[u8]) -> Result<NasrResolverData, ThrustError> {
438 let data = parse_field15_data_from_nasr_bytes(bytes)?;
439 let nasr_airspaces = parse_airspaces_from_nasr_bytes(bytes)?;
440
441 let NasrField15Data {
442 points,
443 airways: airway_segments,
444 sid_designators,
445 star_designators,
446 sid_legs,
447 star_legs,
448 } = data;
449
450 let airports: Vec<NasrAirportRecord> = points
451 .iter()
452 .filter(|p| p.kind == "AIRPORT")
453 .map(|p| {
454 let code = p.identifier.to_uppercase();
455 let iata = if code.len() == 3 { Some(code.clone()) } else { None };
456 let icao = if code.len() == 4 { Some(code.clone()) } else { None };
457
458 NasrAirportRecord {
459 code,
460 iata,
461 icao,
462 name: p.name.clone(),
463 latitude: p.latitude,
464 longitude: p.longitude,
465 region: p.region.clone(),
466 source: "faa_nasr".to_string(),
467 }
468 })
469 .collect();
470
471 let fixes: Vec<NasrNavpointRecord> = points
472 .iter()
473 .filter(|p| p.kind == "FIX")
474 .map(|p| NasrNavpointRecord {
475 code: normalize_point_code(&p.identifier),
476 identifier: p.identifier.to_uppercase(),
477 kind: "fix".to_string(),
478 name: p.name.clone(),
479 latitude: p.latitude,
480 longitude: p.longitude,
481 description: p.description.clone(),
482 frequency: p.frequency,
483 point_type: p.point_type.clone(),
484 region: p.region.clone(),
485 source: "faa_nasr".to_string(),
486 })
487 .collect();
488
489 let mut navaids: Vec<NasrNavpointRecord> = points
490 .iter()
491 .filter(|p| p.kind == "NAVAID")
492 .map(|p| NasrNavpointRecord {
493 code: normalize_point_code(&p.identifier),
494 identifier: p.identifier.to_uppercase(),
495 kind: "navaid".to_string(),
496 name: p.name.clone(),
497 latitude: p.latitude,
498 longitude: p.longitude,
499 description: p.description.clone(),
500 frequency: p.frequency,
501 point_type: p.point_type.clone(),
502 region: p.region.clone(),
503 source: "faa_nasr".to_string(),
504 })
505 .collect();
506
507 navaids.extend(fixes.iter().cloned());
508 navaids.sort_by(|a, b| a.code.cmp(&b.code).then(a.point_type.cmp(&b.point_type)));
509 navaids.dedup_by(|a, b| {
510 a.code == b.code && a.point_type == b.point_type && a.latitude == b.latitude && a.longitude == b.longitude
511 });
512
513 let mut point_index: HashMap<String, NasrAirwayPointRecord> = HashMap::new();
514 for p in &points {
515 let normalized = normalize_point_code(&p.identifier);
516 let record = NasrAirwayPointRecord {
517 code: normalized.clone(),
518 raw_code: p.identifier.to_uppercase(),
519 kind: point_kind(&p.kind),
520 latitude: p.latitude,
521 longitude: p.longitude,
522 };
523 point_index.entry(p.identifier.to_uppercase()).or_insert(record.clone());
524 point_index.entry(normalized).or_insert(record);
525 }
526
527 let mut grouped: HashMap<String, Vec<NasrAirwayPointRecord>> = HashMap::new();
528 for seg in airway_segments {
529 let route_name = if seg.airway_id.trim().is_empty() {
530 seg.airway_name.clone()
531 } else {
532 seg.airway_id.clone()
533 };
534 let entry = grouped.entry(route_name).or_default();
535
536 let from_key = seg.from_point.to_uppercase();
537 let to_key = seg.to_point.to_uppercase();
538 let from = point_index.get(&from_key).cloned().unwrap_or(NasrAirwayPointRecord {
539 code: normalize_point_code(&from_key),
540 raw_code: from_key.clone(),
541 kind: "point".to_string(),
542 latitude: 0.0,
543 longitude: 0.0,
544 });
545 let to = point_index.get(&to_key).cloned().unwrap_or(NasrAirwayPointRecord {
546 code: normalize_point_code(&to_key),
547 raw_code: to_key.clone(),
548 kind: "point".to_string(),
549 latitude: 0.0,
550 longitude: 0.0,
551 });
552
553 if entry.last().map(|x| &x.code) != Some(&from.code) {
554 entry.push(from);
555 }
556 if entry.last().map(|x| &x.code) != Some(&to.code) {
557 entry.push(to);
558 }
559 }
560
561 let airways: Vec<NasrAirwayRecord> = grouped
562 .into_iter()
563 .map(|(name, points)| NasrAirwayRecord {
564 name,
565 source: "faa_nasr".to_string(),
566 route_class: None,
567 points,
568 })
569 .collect();
570
571 let procedures = build_procedure_records(&point_index, sid_designators, star_designators, sid_legs, star_legs);
572
573 Ok(NasrResolverData {
574 airports,
575 navaids,
576 airways,
577 procedures,
578 airspaces: nasr_airspaces,
579 })
580}
581
582fn build_procedure_records(
583 point_index: &HashMap<String, NasrAirwayPointRecord>,
584 sid_designators: Vec<String>,
585 star_designators: Vec<String>,
586 sid_legs: Vec<NasrProcedureLeg>,
587 star_legs: Vec<NasrProcedureLeg>,
588) -> Vec<NasrProcedureRecord> {
589 fn route_class_for(kind: &str) -> Option<String> {
590 match kind {
591 "SID" => Some("DP".to_string()),
592 "STAR" => Some("AP".to_string()),
593 _ => None,
594 }
595 }
596
597 fn build_one(
598 name: &str,
599 kind: &str,
600 legs: &[NasrProcedureLeg],
601 point_index: &HashMap<String, NasrAirwayPointRecord>,
602 ) -> NasrProcedureRecord {
603 let mut sorted_legs = legs.to_vec();
604 sorted_legs.sort_by_key(|leg| (leg.body_seq.unwrap_or(i32::MAX), leg.point_seq.unwrap_or(i32::MAX)));
605
606 let mut ids: Vec<String> = Vec::new();
607 for leg in &sorted_legs {
608 let point = leg.point.trim().to_uppercase();
609 if !point.is_empty() && ids.last() != Some(&point) {
610 ids.push(point);
611 }
612 if let Some(next) = &leg.next_point {
613 let next_id = next.trim().to_uppercase();
614 if !next_id.is_empty() && ids.last() != Some(&next_id) {
615 ids.push(next_id);
616 }
617 }
618 }
619
620 let points = ids
621 .into_iter()
622 .filter_map(|id| {
623 point_index.get(&id).cloned().or_else(|| {
624 let normalized = normalize_point_code(&id);
625 point_index.get(&normalized).cloned()
626 })
627 })
628 .collect::<Vec<_>>();
629
630 NasrProcedureRecord {
631 name: name.to_uppercase(),
632 source: "faa_nasr".to_string(),
633 procedure_kind: kind.to_string(),
634 route_class: route_class_for(kind),
635 airport: None,
636 points,
637 }
638 }
639
640 let mut legs_by_id_kind: HashMap<(String, String), Vec<NasrProcedureLeg>> = HashMap::new();
641 for leg in sid_legs.into_iter().chain(star_legs.into_iter()) {
642 let id = leg.procedure_id.trim().to_uppercase();
643 let kind = leg.procedure_kind.trim().to_uppercase();
644 if id.is_empty() || kind.is_empty() {
645 continue;
646 }
647 legs_by_id_kind.entry((id, kind)).or_default().push(leg);
648 }
649
650 let mut all_names: Vec<(String, String)> = sid_designators
651 .into_iter()
652 .map(|name| (name.trim().to_uppercase(), "SID".to_string()))
653 .chain(
654 star_designators
655 .into_iter()
656 .map(|name| (name.trim().to_uppercase(), "STAR".to_string())),
657 )
658 .collect();
659
660 for (id, kind) in legs_by_id_kind.keys() {
661 all_names.push((id.clone(), kind.clone()));
662 }
663
664 all_names.sort();
665 all_names.dedup();
666
667 all_names
668 .into_iter()
669 .map(|(name, kind)| {
670 let legs = legs_by_id_kind
671 .get(&(name.clone(), kind.clone()))
672 .map(|v| v.as_slice())
673 .unwrap_or(&[]);
674 build_one(&name, &kind, legs, point_index)
675 })
676 .collect()
677}
678
679fn normalize_point_code(value: &str) -> String {
680 value.split(':').next().unwrap_or(value).to_uppercase()
681}
682
683fn point_kind(kind: &str) -> String {
684 match kind {
685 "FIX" => "fix".to_string(),
686 "NAVAID" => "navaid".to_string(),
687 "AIRPORT" => "airport".to_string(),
688 _ => "point".to_string(),
689 }
690}
691
692fn parse_field15_data_from_csv_bundle(
693 csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>,
694) -> Result<NasrField15Data, ThrustError> {
695 let points = parse_points(csv_zip)?;
696 let airways = parse_airways(csv_zip)?;
697 let sid_designators = parse_designators(csv_zip, "DP_BASE.csv", &["DP_NAME", "DP_COMPUTER_CODE"])?;
698 let star_designators = parse_designators(csv_zip, "STAR_BASE.csv", &["ARRIVAL_NAME", "STAR_COMPUTER_CODE"])?;
699 let sid_legs = parse_procedure_legs(csv_zip, "DP_RTE.csv", "SID")?;
700 let star_legs = parse_procedure_legs(csv_zip, "STAR_RTE.csv", "STAR")?;
701
702 Ok(NasrField15Data {
703 points,
704 airways,
705 sid_designators,
706 star_designators,
707 sid_legs,
708 star_legs,
709 })
710}
711
712fn open_csv_bundle_from_bytes(bytes: &[u8]) -> Result<ZipArchive<Cursor<Vec<u8>>>, ThrustError> {
713 let mut outer = ZipArchive::new(Cursor::new(bytes.to_vec()))?;
714
715 for i in 0..outer.len() {
716 let mut entry = outer.by_index(i)?;
717 let name = entry.name().to_string();
718 if name.starts_with("CSV_Data/") && name.ends_with("_CSV.zip") {
719 let mut inner_bytes = Vec::new();
720 std::io::copy(&mut entry, &mut inner_bytes)?;
721 return Ok(ZipArchive::new(Cursor::new(inner_bytes))?);
722 }
723 }
724
725 Err("CSV bundle not found in NASR zip".into())
726}
727
728fn open_csv_bundle<P: AsRef<Path>>(path: P) -> Result<ZipArchive<Cursor<Vec<u8>>>, ThrustError> {
729 let file = File::open(path)?;
730 let mut outer = ZipArchive::new(file)?;
731
732 for i in 0..outer.len() {
733 let mut entry = outer.by_index(i)?;
734 let name = entry.name().to_string();
735 if name.starts_with("CSV_Data/") && name.ends_with("_CSV.zip") {
736 let mut bytes = Vec::new();
737 std::io::copy(&mut entry, &mut bytes)?;
738 return Ok(ZipArchive::new(Cursor::new(bytes))?);
739 }
740 }
741
742 Err("CSV bundle not found in NASR zip".into())
743}
744
745fn parse_saa_xml_airspaces(xml: &[u8]) -> Vec<NasrAirspace> {
746 fn tag_is(tag: &[u8], suffix: &[u8]) -> bool {
747 tag.ends_with(suffix)
748 }
749
750 fn read_text(reader: &mut Reader<Cursor<&[u8]>>, end: Vec<u8>) -> Option<String> {
751 let mut out = String::new();
752 let mut buf = Vec::new();
753 loop {
754 match reader.read_event_into(&mut buf) {
755 Ok(Event::Text(t)) => {
756 out.push_str(&String::from_utf8_lossy(t.as_ref()));
757 }
758 Ok(Event::CData(t)) => {
759 out.push_str(&String::from_utf8_lossy(t.as_ref()));
760 }
761 Ok(Event::End(e)) if e.name().as_ref() == end.as_slice() => break,
762 Ok(Event::Eof) | Err(_) => break,
763 _ => {}
764 }
765 buf.clear();
766 }
767 let txt = out.trim().to_string();
768 if txt.is_empty() {
769 None
770 } else {
771 Some(txt)
772 }
773 }
774
775 let mut reader = Reader::from_reader(Cursor::new(xml));
776 reader.config_mut().trim_text(true);
777
778 let mut buf = Vec::new();
779 let mut in_airspace = false;
780 let mut designator: Option<String> = None;
781 let mut name: Option<String> = None;
782 let mut type_: Option<String> = None;
783 let mut lower: Option<f64> = None;
784 let mut upper: Option<f64> = None;
785 let mut coords: Vec<(f64, f64)> = Vec::new();
786 let mut out = Vec::new();
787
788 loop {
789 match reader.read_event_into(&mut buf) {
790 Ok(Event::Start(e)) => {
791 let tag = e.name().as_ref().to_vec();
792 if tag_is(tag.as_slice(), b"Airspace") {
793 in_airspace = true;
794 designator = None;
795 name = None;
796 type_ = None;
797 lower = None;
798 upper = None;
799 coords.clear();
800 } else if in_airspace && tag_is(tag.as_slice(), b"designator") {
801 designator = read_text(&mut reader, tag);
802 } else if in_airspace && tag_is(tag.as_slice(), b"name") {
803 name = read_text(&mut reader, tag);
804 } else if in_airspace && tag_is(tag.as_slice(), b"type") {
805 type_ = read_text(&mut reader, tag);
806 } else if in_airspace && tag_is(tag.as_slice(), b"lowerLimit") {
807 lower = read_text(&mut reader, tag).and_then(|v| v.parse::<f64>().ok());
808 } else if in_airspace && tag_is(tag.as_slice(), b"upperLimit") {
809 upper = read_text(&mut reader, tag).and_then(|v| v.parse::<f64>().ok());
810 } else if in_airspace && tag_is(tag.as_slice(), b"pos") {
811 if let Some(text) = read_text(&mut reader, tag) {
812 let mut it = text.split_whitespace().filter_map(|x| x.parse::<f64>().ok());
813 if let (Some(lat), Some(lon)) = (it.next(), it.next()) {
814 coords.push((lon, lat));
815 }
816 }
817 }
818 }
819 Ok(Event::End(e)) => {
820 if tag_is(e.name().as_ref(), b"Airspace") && in_airspace {
821 let key = designator.clone().or_else(|| name.clone());
822 if let Some(des) = key {
823 if coords.len() >= 3 {
824 out.push(NasrAirspace {
825 designator: des,
826 name: name.clone(),
827 type_: type_.clone(),
828 lower,
829 upper,
830 coordinates: coords.clone(),
831 });
832 }
833 }
834 in_airspace = false;
835 }
836 }
837 Ok(Event::Eof) | Err(_) => break,
838 _ => {}
839 }
840 buf.clear();
841 }
842
843 out
844}
845
846fn parse_points(csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>) -> Result<Vec<NasrPoint>, ThrustError> {
847 let mut points = Vec::new();
848
849 for row in read_csv_rows(csv_zip, "FIX_BASE.csv")? {
850 if let (Some(id), Some(lat), Some(lon)) = (
851 row.get("FIX_ID").map(|x| x.trim()).filter(|x| !x.is_empty()),
852 parse_f64(row.get("LAT_DECIMAL")),
853 parse_f64(row.get("LONG_DECIMAL")),
854 ) {
855 points.push(NasrPoint {
856 identifier: id.to_string(),
857 kind: "FIX".to_string(),
858 latitude: lat,
859 longitude: lon,
860 name: Some(id.to_string()),
861 description: None,
862 frequency: None,
863 point_type: row
864 .get("FIX_USE_CODE")
865 .map(|x| x.trim().to_string())
866 .filter(|x| !x.is_empty()),
867 region: row
868 .get("ICAO_REGION_CODE")
869 .map(|x| x.trim().to_string())
870 .filter(|x| !x.is_empty()),
871 });
872 }
873 }
874
875 for row in read_csv_rows(csv_zip, "NAV_BASE.csv")? {
876 if let (Some(id), Some(lat), Some(lon)) = (
877 row.get("NAV_ID").map(|x| x.trim()).filter(|x| !x.is_empty()),
878 parse_f64(row.get("LAT_DECIMAL")),
879 parse_f64(row.get("LONG_DECIMAL")),
880 ) {
881 let nav_type = row.get("NAV_TYPE").map(|x| x.trim()).unwrap_or("");
882 let base_name = row.get("NAME").map(|x| x.trim()).filter(|x| !x.is_empty());
883 let city = row.get("CITY").map(|x| x.trim()).filter(|x| !x.is_empty());
884 let description = match (base_name, city) {
885 (Some(name), Some(city_name)) => {
886 Some(format!("{} {} {}", name, city_name, nav_type).trim().to_string())
887 }
888 (Some(name), None) => Some(format!("{} {}", name, nav_type).trim().to_string()),
889 _ => None,
890 };
891 points.push(NasrPoint {
892 identifier: format!("{}:{}", id, nav_type),
893 kind: "NAVAID".to_string(),
894 latitude: lat,
895 longitude: lon,
896 name: row.get("NAME").map(|x| x.trim().to_string()).filter(|x| !x.is_empty()),
897 description,
898 frequency: parse_f64(row.get("FREQ")),
899 point_type: Some(nav_type.to_string()),
900 region: row
901 .get("REGION_CODE")
902 .map(|x| x.trim().to_string())
903 .filter(|x| !x.is_empty()),
904 });
905 }
906 }
907
908 for row in read_csv_rows(csv_zip, "APT_BASE.csv")? {
909 let lat = parse_f64(row.get("LAT_DECIMAL"));
910 let lon = parse_f64(row.get("LONG_DECIMAL"));
911 if let (Some(lat), Some(lon)) = (lat, lon) {
912 for id in [row.get("ARPT_ID"), row.get("ICAO_ID")]
913 .into_iter()
914 .flatten()
915 .map(|x| x.trim())
916 .filter(|x| !x.is_empty())
917 {
918 points.push(NasrPoint {
919 identifier: id.to_string(),
920 kind: "AIRPORT".to_string(),
921 latitude: lat,
922 longitude: lon,
923 name: row
924 .get("ARPT_NAME")
925 .map(|x| x.trim().to_string())
926 .filter(|x| !x.is_empty()),
927 description: None,
928 frequency: None,
929 point_type: None,
930 region: row
931 .get("REGION_CODE")
932 .map(|x| x.trim().to_string())
933 .filter(|x| !x.is_empty()),
934 });
935 }
936 }
937 }
938
939 Ok(points)
940}
941
942fn parse_airways(csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>) -> Result<Vec<NasrAirwaySegment>, ThrustError> {
943 let mut segments = Vec::new();
944
945 for row in read_csv_rows(csv_zip, "AWY_BASE.csv")? {
946 let airway_id = row.get("AWY_ID").map(|x| x.trim()).unwrap_or("");
947 let airway_designation = row.get("AWY_DESIGNATION").map(|x| x.trim()).unwrap_or("");
948 let airway_string = row.get("AIRWAY_STRING").map(|x| x.trim()).unwrap_or("");
949 if airway_id.is_empty() || airway_string.is_empty() {
950 continue;
951 }
952
953 let points = airway_string
954 .split_whitespace()
955 .map(|x| x.trim())
956 .filter(|x| !x.is_empty())
957 .collect::<Vec<_>>();
958
959 for window in points.windows(2) {
960 if let [from, to] = window {
961 segments.push(NasrAirwaySegment {
962 airway_name: build_airway_name(airway_designation, airway_id),
963 airway_id: airway_id.to_string(),
964 airway_designation: airway_designation.to_string(),
965 airway_location: row
966 .get("AWY_LOCATION")
967 .map(|x| x.trim().to_string())
968 .filter(|x| !x.is_empty()),
969 from_point: (*from).to_string(),
970 to_point: (*to).to_string(),
971 });
972 }
973 }
974 }
975
976 Ok(segments)
977}
978
979fn parse_designators(
980 csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>,
981 filename: &str,
982 fields: &[&str],
983) -> Result<Vec<String>, ThrustError> {
984 let mut set = HashSet::new();
985 for row in read_csv_rows(csv_zip, filename)? {
986 for field in fields {
987 if let Some(value) = row.get(*field).map(|x| x.trim()).filter(|x| !x.is_empty()) {
988 for token in normalize_designator_candidates(value) {
989 set.insert(token);
990 }
991 }
992 }
993 }
994 let mut out = set.into_iter().collect::<Vec<_>>();
995 out.sort();
996 Ok(out)
997}
998
999fn normalize_designator_candidates(value: &str) -> Vec<String> {
1000 let mut out = Vec::new();
1001 let trimmed = value.trim();
1002 if trimmed.is_empty() {
1003 return out;
1004 }
1005
1006 out.push(trimmed.to_string());
1007
1008 let before_dot = trimmed.split('.').next().unwrap_or(trimmed).trim();
1009 if !before_dot.is_empty() {
1010 out.push(before_dot.to_string());
1011 }
1012
1013 let compact = before_dot
1014 .chars()
1015 .filter(|c| c.is_ascii_alphanumeric())
1016 .collect::<String>();
1017 if !compact.is_empty() {
1018 out.push(compact);
1019 }
1020
1021 out.sort();
1022 out.dedup();
1023 out
1024}
1025
1026fn build_airway_name(designation: &str, airway_id: &str) -> String {
1027 if designation.is_empty() {
1028 return airway_id.to_string();
1029 }
1030 if airway_id.starts_with(designation) {
1031 airway_id.to_string()
1032 } else {
1033 format!("{}{}", designation, airway_id)
1034 }
1035}
1036
1037fn parse_procedure_legs(
1038 csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>,
1039 filename: &str,
1040 kind: &str,
1041) -> Result<Vec<NasrProcedureLeg>, ThrustError> {
1042 let rows = read_csv_rows(csv_zip, filename)?;
1043 let mut legs = rows
1044 .into_iter()
1045 .filter_map(|row| {
1046 let point = row.get("POINT").map(|x| x.trim().to_string()).unwrap_or_default();
1047 if point.is_empty() {
1048 return None;
1049 }
1050
1051 let procedure_id = row
1052 .get("DP_COMPUTER_CODE")
1053 .or_else(|| row.get("STAR_COMPUTER_CODE"))
1054 .map(|x| x.trim().to_string())
1055 .unwrap_or_default();
1056
1057 if procedure_id.is_empty() {
1058 return None;
1059 }
1060
1061 Some(NasrProcedureLeg {
1062 procedure_kind: kind.to_string(),
1063 procedure_id,
1064 route_portion_type: row
1065 .get("ROUTE_PORTION_TYPE")
1066 .map(|x| x.trim().to_string())
1067 .unwrap_or_default(),
1068 route_name: row
1069 .get("ROUTE_NAME")
1070 .map(|x| x.trim().to_string())
1071 .filter(|x| !x.is_empty()),
1072 body_seq: parse_i32(row.get("BODY_SEQ")),
1073 point_seq: parse_i32(row.get("POINT_SEQ")),
1074 point,
1075 next_point: row
1076 .get("NEXT_POINT")
1077 .map(|x| x.trim().to_string())
1078 .filter(|x| !x.is_empty()),
1079 })
1080 })
1081 .collect::<Vec<_>>();
1082
1083 legs.sort_by_key(|leg| {
1084 (
1085 leg.procedure_id.clone(),
1086 leg.body_seq.unwrap_or(0),
1087 leg.point_seq.unwrap_or(0),
1088 )
1089 });
1090 Ok(legs)
1091}
1092
1093fn read_csv_rows(
1094 csv_zip: &mut ZipArchive<Cursor<Vec<u8>>>,
1095 filename: &str,
1096) -> Result<Vec<HashMap<String, String>>, ThrustError> {
1097 let file = csv_zip.by_name(filename)?;
1098 let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_reader(file);
1099 let mut rows = Vec::new();
1100
1101 let headers = rdr
1102 .byte_headers()?
1103 .iter()
1104 .map(|h| String::from_utf8_lossy(h).trim().to_string())
1105 .collect::<Vec<_>>();
1106
1107 for record in rdr.byte_records() {
1108 let record = record?;
1109 let mut row = HashMap::new();
1110
1111 for (idx, key) in headers.iter().enumerate() {
1112 let value = record
1113 .get(idx)
1114 .map(|v| String::from_utf8_lossy(v).trim().to_string())
1115 .unwrap_or_default();
1116 row.insert(key.clone(), value);
1117 }
1118
1119 rows.push(row);
1120 }
1121
1122 Ok(rows)
1123}
1124
1125fn parse_f64(value: Option<&String>) -> Option<f64> {
1126 value.and_then(|x| x.trim().parse::<f64>().ok())
1127}
1128
1129fn parse_i32(value: Option<&String>) -> Option<i32> {
1130 value.and_then(|x| x.trim().parse::<i32>().ok())
1131}
1132
1133fn is_text_like(name: &str) -> bool {
1134 let lower = name.to_ascii_lowercase();
1135 lower.ends_with(".csv")
1136 || lower.ends_with(".txt")
1137 || lower.ends_with(".dat")
1138 || lower.ends_with(".xml")
1139 || lower.ends_with(".json")
1140}
1141
1142fn detect_delimiter(header: &str) -> Option<char> {
1143 let candidates = [',', '|', '\t', ';'];
1144 candidates
1145 .into_iter()
1146 .max_by_key(|c| header.matches(*c).count())
1147 .filter(|c| header.matches(*c).count() > 0)
1148}
1149
1150type DelimitedContentInfo = (u64, Option<usize>, Option<char>);
1151
1152fn inspect_delimited_content<R: std::io::Read>(file: R) -> Result<DelimitedContentInfo, ThrustError> {
1153 let mut reader = BufReader::new(file);
1154 let mut first_line_bytes = Vec::new();
1155 let mut line_count = 0u64;
1156
1157 if reader.read_until(b'\n', &mut first_line_bytes)? > 0 {
1158 line_count = 1;
1159 }
1160
1161 let mut buffer = Vec::new();
1162 loop {
1163 buffer.clear();
1164 let bytes = reader.read_until(b'\n', &mut buffer)?;
1165 if bytes == 0 {
1166 break;
1167 }
1168 line_count += 1;
1169 }
1170
1171 let first_line = String::from_utf8_lossy(&first_line_bytes).trim_end().to_string();
1172 let delimiter = detect_delimiter(&first_line);
1173 let header_columns = delimiter.map(|d| first_line.split(d).count());
1174 Ok((line_count, header_columns, delimiter))
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::{build_procedure_records, read_csv_rows, NasrAirwayPointRecord, NasrProcedureLeg};
1180 use crate::data::field15::{Connector, Field15Element, Field15Parser};
1181 use std::collections::HashMap;
1182 use std::io::{Cursor, Write};
1183 use zip::write::SimpleFileOptions;
1184 use zip::ZipWriter;
1185
1186 #[test]
1187 fn read_csv_rows_tolerates_invalid_utf8() {
1188 let mut inner_buf = Cursor::new(Vec::new());
1189 {
1190 let mut writer = ZipWriter::new(&mut inner_buf);
1191 writer
1192 .start_file("APT_BASE.csv", SimpleFileOptions::default())
1193 .expect("cannot start csv entry");
1194 writer
1195 .write_all(b"ICAO_ID,ARPT_ID,LAT_DECIMAL,LONG_DECIMAL,ARPT_NAME,REGION_CODE\n")
1196 .expect("cannot write header");
1197 writer
1198 .write_all(b"KLAX,LAX,33.94,-118.40,LOS\xFFANGELES,US\n")
1199 .expect("cannot write row");
1200 writer.finish().expect("cannot finish zip");
1201 }
1202
1203 let mut archive =
1204 zip::read::ZipArchive::new(Cursor::new(inner_buf.into_inner())).expect("cannot open in-memory zip");
1205 let rows = read_csv_rows(&mut archive, "APT_BASE.csv").expect("csv parse failed");
1206
1207 assert_eq!(rows.len(), 1);
1208 let row = &rows[0];
1209 assert_eq!(row.get("ICAO_ID").map(String::as_str), Some("KLAX"));
1210 assert_eq!(row.get("ARPT_ID").map(String::as_str), Some("LAX"));
1211 let name = row.get("ARPT_NAME").expect("missing airport name");
1212 assert!(name.starts_with("LOS"));
1213 assert!(name.contains('�'));
1214 }
1215
1216 #[test]
1217 fn build_procedure_records_includes_sid_and_star_examples_from_notebook() {
1218 let mut point_index = HashMap::new();
1219 point_index.insert(
1220 "FISTO".to_string(),
1221 NasrAirwayPointRecord {
1222 code: "FISTO".to_string(),
1223 raw_code: "FISTO".to_string(),
1224 kind: "fix".to_string(),
1225 latitude: 43.60,
1226 longitude: 1.20,
1227 },
1228 );
1229 point_index.insert(
1230 "KEPER".to_string(),
1231 NasrAirwayPointRecord {
1232 code: "KEPER".to_string(),
1233 raw_code: "KEPER".to_string(),
1234 kind: "fix".to_string(),
1235 latitude: 49.50,
1236 longitude: 2.30,
1237 },
1238 );
1239
1240 let sid_legs = vec![NasrProcedureLeg {
1241 procedure_kind: "SID".to_string(),
1242 procedure_id: "FISTO5A".to_string(),
1243 route_portion_type: "COMMON".to_string(),
1244 route_name: None,
1245 body_seq: Some(1),
1246 point_seq: Some(1),
1247 point: "FISTO".to_string(),
1248 next_point: None,
1249 }];
1250
1251 let star_legs = vec![NasrProcedureLeg {
1252 procedure_kind: "STAR".to_string(),
1253 procedure_id: "KEPER9E".to_string(),
1254 route_portion_type: "COMMON".to_string(),
1255 route_name: None,
1256 body_seq: Some(1),
1257 point_seq: Some(1),
1258 point: "KEPER".to_string(),
1259 next_point: None,
1260 }];
1261
1262 let procedures = build_procedure_records(
1263 &point_index,
1264 vec!["FISTO5A".to_string()],
1265 vec!["KEPER9E".to_string()],
1266 sid_legs,
1267 star_legs,
1268 );
1269
1270 let sid = procedures
1271 .iter()
1272 .find(|p| p.name == "FISTO5A")
1273 .expect("missing SID FISTO5A");
1274 assert_eq!(sid.procedure_kind, "SID");
1275 assert_eq!(sid.route_class.as_deref(), Some("DP"));
1276 assert_eq!(sid.points.first().map(|p| p.code.as_str()), Some("FISTO"));
1277
1278 let star = procedures
1279 .iter()
1280 .find(|p| p.name == "KEPER9E")
1281 .expect("missing STAR KEPER9E");
1282 assert_eq!(star.procedure_kind, "STAR");
1283 assert_eq!(star.route_class.as_deref(), Some("AP"));
1284 assert_eq!(star.points.first().map(|p| p.code.as_str()), Some("KEPER"));
1285 }
1286
1287 #[test]
1288 fn field15_parser_detects_sid_and_star_for_notebook_route() {
1289 let field15 = "N0430F300 FISTO6B FISTO DCT POI DCT PEPAX UT182 NIMER/N0401F240 UT182 KEPER KEPER9E";
1290 let elements = Field15Parser::parse(field15);
1291
1292 let has_sid = elements
1293 .iter()
1294 .any(|e| matches!(e, Field15Element::Connector(Connector::Sid(name)) if name == "FISTO6B"));
1295 let has_star = elements
1296 .iter()
1297 .any(|e| matches!(e, Field15Element::Connector(Connector::Star(name)) if name == "KEPER9E"));
1298
1299 assert!(has_sid, "expected SID FISTO6B in parsed elements");
1300 assert!(has_star, "expected STAR KEPER9E in parsed elements");
1301 }
1302}