Skip to main content

thrust/data/faa/
nasr.rs

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/// An AIRAC cycle representing a 28-day aeronautical information publication cycle.
22///
23/// AIRAC cycles are standardized worldwide and used to publish navigation data,
24/// airport information, and procedures.
25///
26/// # Fields
27/// * `code` - 4-character AIRAC code in format "YYCC" (e.g., "2508" for 2025 Cycle 08)
28/// * `effective_date` - The date when this cycle becomes effective
29#[derive(Debug, Clone)]
30pub struct AiracCycle {
31    pub code: String,
32    pub effective_date: chrono::NaiveDate,
33}
34
35/// Summary information about a single file in an NASR dataset.
36///
37/// NASR (National Airspace System Resource) data is distributed as CSV files
38/// within ZIP archives, one cycle per archive.
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct NasrFileSummary {
41    /// Filename within the archive
42    pub name: String,
43    /// Uncompressed size in bytes
44    pub size_bytes: u64,
45    /// Compressed size in bytes
46    pub compressed_size_bytes: u64,
47    /// Number of lines in the CSV file (if detected)
48    pub line_count: Option<u64>,
49    /// Number of header columns (if detected)
50    pub header_columns: Option<usize>,
51    /// CSV delimiter character (if detected)
52    pub delimiter: Option<String>,
53}
54
55/// Summary of all files in an NASR AIRAC cycle.
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct NasrCycleSummary {
58    /// AIRAC code (e.g., "2508")
59    pub airac_code: String,
60    /// Effective date in YYYY-MM-DD format
61    pub effective_date: String,
62    /// Local path to the downloaded NASR ZIP file
63    pub zip_path: String,
64    /// List of files contained in the archive
65    pub files: Vec<NasrFileSummary>,
66}
67
68/// A navigation point (waypoint, navaid, or fix) from FAA NASR data.
69///
70/// Points are referenced in routes, procedures, and airways.
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct NasrPoint {
73    /// Unique identifier (e.g., "ORF", "RDBOE")
74    pub identifier: String,
75    /// Type of point: "NAVAID", "FIX", "AIRPORT", etc.
76    pub kind: String,
77    /// Latitude in decimal degrees
78    pub latitude: f64,
79    /// Longitude in decimal degrees
80    pub longitude: f64,
81    /// Name or description of the point
82    pub name: Option<String>,
83    /// Additional descriptive text
84    pub description: Option<String>,
85    /// VHF frequency (for navaids) in MHz
86    pub frequency: Option<f64>,
87    /// Sub-type classification
88    pub point_type: Option<String>,
89    /// ICAO region code
90    pub region: Option<String>,
91}
92
93/// A segment of an ATS route (airway).
94///
95/// Airways consist of multiple segments, each defined by a "from" and "to" point.
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct NasrAirwaySegment {
98    /// Full airway name (e.g., "J500")
99    pub airway_name: String,
100    /// Airway identifier number
101    pub airway_id: String,
102    /// Airway designation (letter prefix, e.g., "J")
103    pub airway_designation: String,
104    /// Location code for this segment
105    pub airway_location: Option<String>,
106    /// Starting point identifier
107    pub from_point: String,
108    /// Ending point identifier
109    pub to_point: String,
110}
111
112/// An airspace boundary from FAA NASR data.
113///
114/// Airspaces are used for traffic management, approach control, and separation.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct NasrAirspace {
117    /// Designator (e.g., "ORF A", "CHO C")
118    pub designator: String,
119    /// Name of the airspace
120    pub name: Option<String>,
121    /// Type (e.g., "Class A", "Class C", "TRSA")
122    pub type_: Option<String>,
123    /// Minimum altitude in feet (mean sea level)
124    pub lower: Option<f64>,
125    /// Maximum altitude in feet (mean sea level)
126    pub upper: Option<f64>,
127    /// Boundary polygon as (longitude, latitude) pairs
128    pub coordinates: Vec<(f64, f64)>, // (lon, lat)
129}
130
131/// A leg (segment) of a SID or STAR procedure.
132///
133/// Procedures consist of multiple legs that guide aircraft along a defined path.
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct NasrProcedureLeg {
136    /// Type of procedure: "SID" or "STAR"
137    pub procedure_kind: String,
138    /// Procedure identifier (e.g., "RCKT2")
139    pub procedure_id: String,
140    /// Route portion classification
141    pub route_portion_type: String,
142    /// Name of the route (optional)
143    pub route_name: Option<String>,
144    /// Body sequence number for this leg
145    pub body_seq: Option<i32>,
146    /// Sequence within the procedure
147    pub point_seq: Option<i32>,
148    /// Waypoint identifier for this leg
149    pub point: String,
150    /// Next waypoint (for multi-point procedures)
151    pub next_point: Option<String>,
152}
153
154/// Complete Field15 navigation data for a single AIRAC cycle.
155///
156/// This is the result of parsing all navigation-related CSV files from NASR.
157/// It contains waypoints, airways, and procedures that can be used to resolve
158/// and enrich flight routes encoded in ICAO Field 15 format.
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct NasrField15Data {
161    /// All navigation points (waypoints, navaids, fixes)
162    pub points: Vec<NasrPoint>,
163    /// All ATS route segments (airways)
164    pub airways: Vec<NasrAirwaySegment>,
165    /// SID procedure identifiers
166    pub sid_designators: Vec<String>,
167    /// STAR procedure identifiers
168    pub star_designators: Vec<String>,
169    /// All legs for SID procedures
170    pub sid_legs: Vec<NasrProcedureLeg>,
171    /// All legs for STAR procedures
172    pub star_legs: Vec<NasrProcedureLeg>,
173}
174
175/// An index for quick lookup of Field15 elements by name.
176///
177/// This is used to speed up validation and enrichment of flight routes.
178#[derive(Debug, Clone, Default)]
179pub struct NasrField15Index {
180    /// Set of point identifiers and names (uppercase)
181    pub point_names: HashSet<String>,
182    /// Set of airway names (uppercase)
183    pub airway_names: HashSet<String>,
184    /// Set of SID identifiers (uppercase)
185    pub sid_names: HashSet<String>,
186    /// Set of STAR identifiers (uppercase)
187    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}