Skip to main content

thrust/data/
field15.rs

1//! ## ICAO Field 15 Parser
2//!
3//! This module parses ICAO Field 15 entries from flight plans.
4//! Field 15 contains the route description including speed, altitude, waypoints,
5//! airways, and various modifiers.
6//!
7//! Based on ICAO DOC 4444 specifications and implements the three basic token types:
8//! - Points: Published Route Points (PRP), lat/lon coordinates, Point/Bearing/Distance, Aerodrome
9//! - Connectors: ATS routes, SID, STAR, DCT, VFR/IFR, OAT/GAT
10//! - Modifiers: Speed and Level changes
11//!
12//! The parser tokenizes the input string, identifies each token type,
13//! and constructs a structured representation using Rust enums and structs.
14//!
15//! # Acknowledgement
16//!
17//! This parser is inspired by and adapted from the Python implementation
18//! available at <https://github.com/pventon/ICAO-F15-Parser/>
19//!
20//! ## Example
21//!
22//! The following field 15 entry:
23//!
24//! `N0490F360 ELCOB6B ELCOB UT300 SENLO UN502 JSY DCT LIZAD DCT MOPAT DCT LUNIG DCT MOMIN DCT PIKIL/M084F380 NATD HOIST/N0490F380 N756C ANATI/N0441F340 DCT MIVAX DCT OBTEK DCT XORLO ROCKT2`
25//!
26//! becomes a structured sequence which serializes to JSON as follows:
27//!
28//!   ```json
29//!   [
30//!     {"speed": {"kts": 490}, "altitude": {"FL": 360}},
31//!     {"SID": "ELCOB6B"},
32//!     {"waypoint": "ELCOB"},
33//!     {"airway": "UT300"},
34//!     {"waypoint": "SENLO"},
35//!     {"airway": "UN502"},
36//!     {"waypoint": "JSY"},
37//!     "DCT",
38//!     {"waypoint": "LIZAD"},
39//!     // (truncated)
40//!     {"waypoint": "PIKIL"},
41//!     {"speed": {"Mach": 0.84}, "altitude": {"FL": 380}},
42//!     {"NAT": "NATD"},
43//!     {"waypoint": "HOIST"},
44//!     // (truncated)
45//!     {"waypoint": "XORLO"},
46//!     {"STAR": "ROCKT2"}
47//!   ]
48//!   ```
49//!
50//! ## Usage
51//!
52//! ```rust
53//! use thrust::data::field15::{Field15Element, Field15Parser};
54//!
55//! let elements: Vec<Field15Element> = Field15Parser::parse(&line);
56//! match serde_json::to_string(&elements) {
57//!     Ok(json) => println!("{}", json),
58//!     Err(e) => eprintln!("JSON serialization error: {}", e),
59//! }
60//! ```
61//!
62
63use serde::{Deserialize, Serialize};
64use std::fmt;
65
66/// A single element in a Field 15 ICAO route
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(untagged)]
69pub enum Field15Element {
70    /// A point in the route (waypoint, coordinate, or navaid)
71    Point(Point),
72    /// A connector between points (airway or direct)
73    Connector(Connector),
74    /// A modifier that changes speed, altitude, or other parameters
75    Modifier(Modifier),
76}
77
78/// A point in the route (waypoint, coordinate, or navaid)
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub enum Point {
81    /// Named waypoint or navaid (Published Route Point)  
82    /// - Basic point: `[A-Z]{1,3}`  
83    /// - Point with 5 characters: `[A-Z]{5}`
84    #[serde(rename = "waypoint")]
85    Waypoint(String),
86    /// latitude/longitude coordinates in degrees, e.g., (52.5, 13.4)
87    /// - lat/lon in degrees: `[0-9]{2}[NS][0-9]{3}[EW]`
88    /// - lat/lon in DM: `[0-9]{4}[NS][0-9]{5}[EW]`
89    #[serde(rename = "coords")]
90    Coordinates((f64, f64)),
91    /// Point/Bearing/Distance format, e.g., "POINT180060" or "5430N01020E180060"
92    /// - From PRP: `[A-Z]{1,5}[0-9]{6}`
93    /// - From lat/lon in degrees: `[0-9]{2}[NS][0-9]{3}[EW][0-9]{6}`
94    /// - From lat/lon in DM: `[0-9]{4}[NS][0-9]{5}[EW][0-9]{6}`
95    #[serde(rename = "point_bearing_distance")]
96    BearingDistance {
97        /// Base point (waypoint or coordinate)
98        point: Box<Point>,
99        /// Bearing in degrees (000-360)
100        bearing: u16,
101        /// Distance in nautical miles (001-999)
102        distance: u16,
103    },
104    /// Aerodrome (ICAO location indicator) - 4 letter code
105    #[serde(rename = "aerodrome")]
106    Aerodrome(String),
107}
108
109/// A connector between points (airway or direct)
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub enum Connector {
112    /// ATS routes (e.g., "UM184", "L738", "A308")
113    ///
114    /// They include A, B, G, H, J, L, M, N, P, Q, R, T, U routes
115    /// and upper airways like UN, UM, UL, UY, etc.
116    /// - `[A-Z]{1,2}[0-9]`
117    /// - `[A-Z][0-9]{1,3}[A-Z]`
118    /// - `[A-Z][0-9]{2,3}`
119    /// - `[A-Z]{3}[0-9]{3}` (Turkish type)
120    /// - `[A-Z]{3}[0-9]{1,2}`
121    /// - `[A-Z]{2}[0-9][A-Z]`
122    /// - `[A-Z]{2}[0-9]{2,3}`
123    /// - `[A-Z]{4}[0-9]{1,2}` (Russian air corridors)
124    /// - `[A-Z]{2}[0-9]{2,3}[A-Z]`
125    /// - `[A-Z][0-9]{3}[A-Z]`
126    #[serde(rename = "airway")]
127    Airway(String),
128    /// Direct routing (DCT)
129    #[serde(rename = "DCT")]
130    Direct,
131    /// SID (Standard Instrument Departure), can be literal "SID" or a named SID
132    /// - `[A-Z]{3}[0-9]{1,2}[A-Z]`
133    /// - `[A-Z]{5}[0-9]{1,2}`
134    /// - `[A-Z]{4,6}[0-9][A-Z]`
135    /// - `[A-Z]{5}[0-9]{2}[A-Z]`
136    #[serde(rename = "SID")]
137    Sid(String),
138    /// STAR (Standard Arrival Route), can be literal "STAR" or a named STAR
139    /// - `[A-Z]{3}[0-9]{1,2}[A-Z]`
140    /// - `[A-Z]{5}[0-9]{1,2}`
141    /// - `[A-Z]{4,6}[0-9][A-Z]`
142    /// - `[A-Z]{5}[0-9]{2}[A-Z]`
143    #[serde(rename = "STAR")]
144    Star(String),
145    /// VFR indicator: change to Visual Flight Rules
146    #[serde(rename = "VFR")]
147    Vfr,
148    /// IFR indicator: change to Instrument Flight Rules
149    #[serde(rename = "IFR")]
150    Ifr,
151    /// OAT indicator: change to Operational Air Traffic (military)
152    #[serde(rename = "OAT")]
153    Oat,
154    /// GAT indicator: change to General Air Traffic
155    #[serde(rename = "GAT")]
156    Gat,
157    /// IFPSTOP: CFMU IFPS special, stop IFR handling
158    #[serde(rename = "IFPSTOP")]
159    IfpStop,
160    /// IFPSTART: CFMU IFPS special, start IFR handling
161    #[serde(rename = "IFPSTART")]
162    IfpStart,
163    /// Stay at current position with optional time
164    #[serde(rename = "STAY")]
165    StayTime { minutes: Option<u16> },
166    /// North Atlantic Track (NAT), e.g. NATA-NATZ, NAT1-NAT9, NATX, etc.
167    /// - `NAT[A-Z]`
168    /// - `NAT[A-Z][0-9]`
169    #[serde(rename = "NAT")]
170    Nat(String),
171    /// Polar Track Structure (PTS) track, e.g. PTS0-PTS9, PTSA-PTSZ
172    /// - `PTS[0-9]` or `PTS[A-Z]`
173    #[serde(rename = "PTS")]
174    Pts(String),
175}
176
177///  A modifier that changes flight parameters
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct Modifier {
180    /// Speed (e.g., "N0456" for 456 knots, "M079" for Mach 0.79, "K0893" for 893 km/h)
181    pub speed: Option<Speed>,
182    /// Flight level or altitude (e.g., "F340" for FL340, "S1130" for 11,300 meters)
183    pub altitude: Option<Altitude>,
184    /// Cruise climb end altitude (for cruise climb)
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub altitude_cruise_to: Option<Altitude>,
187    /// Cruise climb indicator (e.g., "PLUS" after speed/altitude)
188    #[serde(skip_serializing_if = "std::ops::Not::not")]
189    pub cruise_climb: bool,
190}
191
192/// Speed representation
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub enum Speed {
195    /// Knots (N followed by 4 digits)
196    #[serde(rename = "kts")]
197    Knots(u16),
198    /// Mach number as a float (e.g., 0.79)
199    #[serde(rename = "Mach")]
200    Mach(f32),
201    /// Kilometers per hour (K followed by 4 digits)
202    #[serde(rename = "km/h")]
203    KilometersPerHour(u16),
204}
205
206/// Altitude representation
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub enum Altitude {
209    /// Flight level (F followed by 3 digits)
210    #[serde(rename = "FL")]
211    FlightLevel(u16),
212    /// Standard metric level (S followed by 4 digits, in tens of meters)
213    #[serde(rename = "S")]
214    MetricLevel(u16),
215    /// Altitude in feet (A followed by 4 digits, in hundreds of feet)
216    #[serde(rename = "ft")]
217    Altitude(u16),
218    /// Metric altitude (M followed by 4 digits, in tens of meters)
219    #[serde(rename = "m")]
220    MetricAltitude(u16),
221    /// VFR altitude
222    #[serde(rename = "VFR")]
223    Vfr,
224}
225
226impl fmt::Display for Field15Element {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Field15Element::Point(p) => write!(f, "Point({})", p),
230            Field15Element::Connector(c) => write!(f, "Connector({})", c),
231            Field15Element::Modifier(m) => write!(f, "Modifier({})", m),
232        }
233    }
234}
235
236impl fmt::Display for Point {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            Point::Waypoint(s) => write!(f, "Waypoint({})", s),
240            Point::Coordinates((lat, lon)) => write!(f, "Coordinate({:.5},{:.5})", lat, lon),
241            Point::BearingDistance {
242                point,
243                bearing,
244                distance,
245            } => {
246                write!(f, "BearingDistance({}/{:03}/{:03})", point, bearing, distance)
247            }
248            Point::Aerodrome(s) => write!(f, "Aerodrome({})", s),
249        }
250    }
251}
252
253impl fmt::Display for Connector {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        match self {
256            Connector::Airway(s) => write!(f, "Airway({})", s),
257            Connector::Direct => write!(f, "DCT"),
258            Connector::Vfr => write!(f, "VFR"),
259            Connector::Ifr => write!(f, "IFR"),
260            Connector::Oat => write!(f, "OAT"),
261            Connector::Gat => write!(f, "GAT"),
262            Connector::IfpStop => write!(f, "IFPSTOP"),
263            Connector::IfpStart => write!(f, "IFPSTART"),
264            Connector::StayTime { minutes } => {
265                if let Some(m) = minutes {
266                    write!(f, "STAY({})", m)
267                } else {
268                    write!(f, "STAY")
269                }
270            }
271            Connector::Sid(s) => write!(f, "SID({})", s),
272            Connector::Star(s) => write!(f, "STAR({})", s),
273            Connector::Nat(s) => write!(f, "NAT({})", s),
274            Connector::Pts(s) => write!(f, "PTS({})", s),
275        }
276    }
277}
278
279impl fmt::Display for Modifier {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        match (&self.speed, &self.altitude, self.cruise_climb) {
282            (Some(s), Some(a), true) => write!(f, "{}{}PLUS", s, a),
283            (Some(s), Some(a), false) => write!(f, "{}{}", s, a),
284            (Some(s), None, true) => write!(f, "{}PLUS", s),
285            (Some(s), None, false) => write!(f, "{}", s),
286            (None, Some(a), true) => write!(f, "{}PLUS", a),
287            (None, Some(a), false) => write!(f, "{}", a),
288            (None, None, _) => write!(f, ""),
289        }
290    }
291}
292
293impl fmt::Display for Speed {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self {
296            Speed::Knots(n) => write!(f, "N{:04}", n),
297            Speed::Mach(m) => write!(f, "M{:0>5.2}", m),
298            Speed::KilometersPerHour(k) => write!(f, "K{:04}", k),
299        }
300    }
301}
302
303impl fmt::Display for Altitude {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        match self {
306            Altitude::FlightLevel(fl) => write!(f, "F{:03}", fl),
307            Altitude::MetricLevel(s) => write!(f, "S{:04}", s),
308            Altitude::Altitude(a) => write!(f, "A{:04}", a),
309            Altitude::MetricAltitude(m) => write!(f, "M{:04}", m),
310            Altitude::Vfr => write!(f, "VFR"),
311        }
312    }
313}
314
315/// A parser for ICAO Field 15 route strings
316pub struct Field15Parser;
317
318impl Field15Parser {
319    /// Parse a Field 15 route string into a list of elements
320    ///
321    /// The parser treats forward slash (/) as both whitespace and a token separator,
322    /// similar to the reference Python implementation's tokenization approach.
323    pub fn parse(route: &str) -> Vec<Field15Element> {
324        let mut elements = Vec::new();
325        let tokens = Self::tokenize(route);
326        let mut i = 0;
327        let mut first_point_parsed = false;
328
329        while i < tokens.len() {
330            let token = tokens[i];
331
332            // Handle truncate indicator 'T' - must be last token
333            if token == "T" {
334                // Truncate indicator - no more tokens should follow
335                if i + 1 < tokens.len() {
336                    // Error: tokens after truncate, but continue parsing
337                }
338                break;
339            }
340
341            // Handle forward slash - it signals a speed/altitude change is coming
342            if token == "/" {
343                i += 1;
344                continue;
345            }
346
347            // Check for STAY element (STAY followed by digit)
348            if Self::is_stay(token) {
349                // Look for optional /HHMM
350                let stay_minutes = if i + 2 < tokens.len() && tokens[i + 1] == "/" {
351                    if let Some(mins) = Self::parse_stay_time(tokens[i + 2]) {
352                        i += 2; // Skip slash and time
353                        Some(mins)
354                    } else {
355                        None
356                    }
357                } else {
358                    None
359                };
360                elements.push(Field15Element::Connector(Connector::StayTime { minutes: stay_minutes }));
361                i += 1;
362                continue;
363            }
364
365            // Check for "C" cruise climb indicator
366            if token == "C" && i + 1 < tokens.len() && tokens[i + 1] == "/" {
367                // This is a cruise climb indicator, skip it (handled by modifier)
368                i += 1;
369                continue;
370            }
371
372            // Check for modifiers first (this handles post-slash modifiers too)
373            if let Some(modifier) = Self::parse_modifier(token) {
374                elements.push(Field15Element::Modifier(modifier));
375            }
376            // Check for keywords
377            else if token == "DCT" {
378                elements.push(Field15Element::Connector(Connector::Direct));
379            } else if token == "VFR" {
380                elements.push(Field15Element::Connector(Connector::Vfr));
381            } else if token == "IFR" {
382                elements.push(Field15Element::Connector(Connector::Ifr));
383            } else if token == "OAT" {
384                elements.push(Field15Element::Connector(Connector::Oat));
385            } else if token == "GAT" {
386                elements.push(Field15Element::Connector(Connector::Gat));
387            } else if token == "IFPSTOP" {
388                elements.push(Field15Element::Connector(Connector::IfpStop));
389            } else if token == "IFPSTART" {
390                elements.push(Field15Element::Connector(Connector::IfpStart));
391            } else if token == "SID" {
392                // Literal "SID" keyword
393                elements.push(Field15Element::Connector(Connector::Sid("SID".to_string())));
394                first_point_parsed = true;
395            } else if token == "STAR" {
396                // Literal "STAR" keyword
397                elements.push(Field15Element::Connector(Connector::Star("STAR".to_string())));
398                first_point_parsed = true;
399            }
400            // Check for SID/STAR procedures BEFORE checking airways and waypoints
401            else if !first_point_parsed && Self::is_procedure(token) {
402                // First procedure is a SID
403                elements.push(Field15Element::Connector(Connector::Sid(token.to_string())));
404                first_point_parsed = true;
405            } else if Self::is_procedure(token) && i == tokens.len() - 1 {
406                // Last procedure-like item is a STAR (only if it's the last token)
407                elements.push(Field15Element::Connector(Connector::Star(token.to_string())));
408                first_point_parsed = true;
409            }
410            // After DCT, check if last element was DCT
411            else if !elements.is_empty()
412                && matches!(elements.last(), Some(Field15Element::Connector(Connector::Direct)))
413            {
414                // Force parsing as a point after DCT - skip airway check
415                if let Some(point) = Self::parse_point(token) {
416                    elements.push(Field15Element::Point(point));
417                    first_point_parsed = true;
418                }
419            }
420            // NAT/PTS connectors
421            else if Self::is_nat(token) {
422                elements.push(Field15Element::Connector(Connector::Nat(token.to_string())));
423            } else if Self::is_pts(token) {
424                elements.push(Field15Element::Connector(Connector::Pts(token.to_string())));
425            }
426            // Check for airways (only if not after DCT)
427            else if Self::is_airway(token) {
428                // If this is the last token and matches SID/STAR, treat as STAR not airway
429                if i == tokens.len() - 1 && Self::is_procedure(token) {
430                    elements.push(Field15Element::Connector(Connector::Star(token.to_string())));
431                    first_point_parsed = true;
432                } else {
433                    elements.push(Field15Element::Connector(Connector::Airway(token.to_string())));
434                }
435            }
436            // Finally, check for points (this includes waypoints as fallback)
437            else if let Some(point) = Self::parse_point(token) {
438                elements.push(Field15Element::Point(point));
439                first_point_parsed = true;
440            }
441
442            i += 1;
443        }
444
445        elements
446    }
447
448    /// Tokenize the route string
449    ///
450    /// Treats whitespace (space, newline, tab, carriage return) and forward slash
451    /// as delimiters. The forward slash is also returned as a separate token.
452    fn tokenize(route: &str) -> Vec<&str> {
453        let mut tokens = Vec::new();
454        let mut current_token_start = 0;
455        let mut in_token = false;
456
457        for (i, ch) in route.char_indices() {
458            let is_whitespace = ch == ' ' || ch == '\n' || ch == '\t' || ch == '\r';
459            let is_slash = ch == '/';
460
461            if is_whitespace || is_slash {
462                // End current token if we're in one
463                if in_token {
464                    tokens.push(&route[current_token_start..i]);
465                    in_token = false;
466                }
467
468                // Add slash as a separate token
469                if is_slash {
470                    tokens.push("/");
471                }
472            } else if !in_token {
473                // Start a new token
474                current_token_start = i;
475                in_token = true;
476            }
477        }
478
479        // Add final token if we ended while in a token
480        if in_token {
481            tokens.push(&route[current_token_start..]);
482        }
483
484        tokens
485    }
486
487    /// Try to parse a token as a speed/altitude modifier
488    fn parse_modifier(token: &str) -> Option<Modifier> {
489        if token.len() < 4 {
490            return None;
491        }
492
493        // Check for PLUS suffix (cruise climb)
494        let (base_token, cruise_climb) = if let Some(stripped) = token.strip_suffix("PLUS") {
495            (stripped, true)
496        } else {
497            (token, false)
498        };
499
500        if base_token.len() < 4 {
501            return None;
502        }
503
504        // Try speed+altitude first with flexible speed lengths for N/K (3 or 4 digits)
505        let speed_lengths: &[usize] = if base_token.starts_with('M') { &[4] } else { &[5, 4] };
506
507        for speed_len in speed_lengths {
508            if base_token.len() < *speed_len {
509                continue;
510            }
511            let speed = Self::parse_speed(&base_token[..*speed_len]);
512            if speed.is_none() || base_token.len() <= *speed_len {
513                continue;
514            }
515            let remaining = &base_token[*speed_len..];
516            // Check for two altitudes (cruise climb: speed/alt1/alt2)
517            let (altitude, altitude_cruise_to) = if remaining.len() >= 7 {
518                let first_alt_len = if remaining.starts_with('F') || remaining.starts_with('A') {
519                    4
520                } else {
521                    5
522                };
523                if remaining.len() >= first_alt_len + 3 {
524                    let alt1 = Self::parse_altitude(&remaining[..first_alt_len]);
525                    let alt2 = Self::parse_altitude(&remaining[first_alt_len..]);
526                    if alt1.is_some() && alt2.is_some() {
527                        (alt1, alt2)
528                    } else {
529                        (Self::parse_altitude(remaining), None)
530                    }
531                } else {
532                    (Self::parse_altitude(remaining), None)
533                }
534            } else {
535                (Self::parse_altitude(remaining), None)
536            };
537
538            if altitude.is_some() {
539                return Some(Modifier {
540                    speed,
541                    altitude,
542                    altitude_cruise_to,
543                    cruise_climb,
544                });
545            }
546        }
547
548        // Fall back to altitude-only modifier
549        let (altitude, altitude_cruise_to) = if base_token.len() >= 3 {
550            (Self::parse_altitude(base_token), None)
551        } else {
552            (None, None)
553        };
554
555        // Only treat as modifier if altitude is present
556        if altitude.is_some() {
557            Some(Modifier {
558                speed: None,
559                altitude,
560                altitude_cruise_to,
561                cruise_climb,
562            })
563        } else {
564            None
565        }
566    }
567
568    /// Parse speed component
569    fn parse_speed(s: &str) -> Option<Speed> {
570        if s.len() < 4 {
571            return None;
572        }
573
574        let speed_type = s.chars().next()?;
575        let value_str = &s[1..];
576
577        match speed_type {
578            'N' if value_str.len() == 4 || value_str.len() == 3 => value_str.parse::<u16>().ok().map(Speed::Knots),
579            'M' if value_str.len() == 3 => {
580                // Mach is given as 3 digits, e.g., "079" => 0.79
581                value_str.parse::<u16>().ok().map(|v| Speed::Mach((v as f32) / 100.0))
582            }
583            'K' if value_str.len() == 4 || value_str.len() == 3 => {
584                value_str.parse::<u16>().ok().map(Speed::KilometersPerHour)
585            }
586            _ => None,
587        }
588    }
589
590    /// Parse altitude component
591    fn parse_altitude(s: &str) -> Option<Altitude> {
592        if s == "VFR" {
593            return Some(Altitude::Vfr);
594        }
595
596        if s.len() < 4 {
597            return None;
598        }
599
600        let alt_type = s.chars().next()?;
601        let value_str = &s[1..];
602
603        match alt_type {
604            'F' if value_str.len() == 3 => value_str.parse::<u16>().ok().map(Altitude::FlightLevel),
605            'S' if value_str.len() == 4 => value_str.parse::<u16>().ok().map(Altitude::MetricLevel),
606            'A' if value_str.len() == 4 => value_str.parse::<u16>().ok().map(Altitude::Altitude),
607            'M' if value_str.len() == 4 => value_str.parse::<u16>().ok().map(Altitude::MetricAltitude),
608            _ => None,
609        }
610    }
611
612    /// Check if a token is a NAT track (NATA-NATZ, NAT1-NAT9, NATX, etc.)
613    fn is_nat(token: &str) -> bool {
614        // NAT[A-Z] or NAT[A-Z][0-9]
615        if token.len() == 4 && token.starts_with("NAT") {
616            let c = token.chars().nth(3).unwrap();
617            c.is_ascii_uppercase()
618        } else if token.len() == 5 && token.starts_with("NAT") {
619            let c = token.chars().nth(3).unwrap();
620            let d = token.chars().nth(4).unwrap();
621            c.is_ascii_uppercase() && d.is_ascii_digit()
622        } else {
623            false
624        }
625    }
626
627    /// Check if a token is a PTS track (PTS0-PTS9, PTSA-PTSZ)
628    fn is_pts(token: &str) -> bool {
629        // PTS[0-9] or PTS[A-Z]
630        if token.len() == 4 && token.starts_with("PTS") {
631            let c = token.chars().nth(3).unwrap();
632            c.is_ascii_digit() || c.is_ascii_uppercase()
633        } else {
634            false
635        }
636    }
637
638    /// Check if a token is an airway designation (excluding NAT/PTS)
639    fn is_airway(token: &str) -> bool {
640        if token.len() < 2 || token.len() > 7 {
641            return false;
642        }
643
644        // Exclude NAT/PTS tracks
645        if Self::is_nat(token) || Self::is_pts(token) {
646            return false;
647        }
648
649        let first_char = token.chars().next().unwrap();
650        if !first_char.is_alphabetic() {
651            return false;
652        }
653
654        // Airways must contain at least one digit
655        let has_digit = token.chars().any(|c| c.is_ascii_digit());
656        if !has_digit {
657            return false;
658        }
659
660        let valid_prefixes = [
661            "UN", "UM", "UL", "UT", "UZ", "UY", "UP", "UA", "UB", "UG", "UH", "UJ", "UQ", "UR", "UV", "UW", "L", "A",
662            "B", "G", "H", "J", "Q", "R", "T", "V", "W", "Y", "Z", "M", "N", "P",
663        ];
664
665        valid_prefixes.iter().any(|&p| token.starts_with(p))
666    }
667
668    /// Parse a point (waypoint, coordinate, bearing/distance, or aerodrome)
669    fn parse_point(token: &str) -> Option<Point> {
670        if token.is_empty() {
671            return None;
672        }
673
674        // Check if it's a coordinate - must be checked before bearing/distance
675        if Self::is_coordinate(token) {
676            if let Some(coord) = Self::parse_coordinate(token) {
677                return Some(Point::Coordinates(coord));
678            } else {
679                return None;
680            }
681        }
682
683        // Check for bearing/distance format (e.g., POINT180060 or 02S001W180060)
684        // Format: point name followed by exactly 6 digits (bearing 3 digits, distance 3 digits)
685        if token.len() > 6 {
686            let potential_digits = &token[token.len() - 6..];
687            if potential_digits.chars().all(|c| c.is_ascii_digit()) {
688                let point_name = &token[..token.len() - 6];
689
690                // Try as coordinate first
691                if Self::is_coordinate(point_name) {
692                    if let Some(coord) = Self::parse_coordinate(point_name) {
693                        if let (Ok(bearing), Ok(distance)) = (
694                            potential_digits[..3].parse::<u16>(),
695                            potential_digits[3..].parse::<u16>(),
696                        ) {
697                            if bearing <= 360 && distance <= 999 {
698                                return Some(Point::BearingDistance {
699                                    point: Box::new(Point::Coordinates(coord)),
700                                    bearing,
701                                    distance,
702                                });
703                            }
704                        }
705                    }
706                } else if !point_name.is_empty() && point_name.chars().all(|c| c.is_ascii_alphabetic()) {
707                    // Only allow Waypoint for non-coordinate
708                    if let (Ok(bearing), Ok(distance)) = (
709                        potential_digits[..3].parse::<u16>(),
710                        potential_digits[3..].parse::<u16>(),
711                    ) {
712                        if bearing <= 360 && distance <= 999 {
713                            return Some(Point::BearingDistance {
714                                point: Box::new(Point::Waypoint(point_name.to_string())),
715                                bearing,
716                                distance,
717                            });
718                        }
719                    }
720                }
721            }
722        }
723
724        // Check if it's a 4-letter aerodrome code (all uppercase letters, no digits)
725        if token.len() == 4
726            && token.chars().all(|c| c.is_ascii_uppercase())
727            && !token.chars().any(|c| c.is_ascii_digit())
728        {
729            return Some(Point::Aerodrome(token.to_string()));
730        }
731
732        // Otherwise, it's a waypoint (PRP)
733        Some(Point::Waypoint(token.to_string()))
734    }
735
736    /// Parse ICAO coordinate string into (lat, lon) in degrees.
737    /// Supports formats like 5430N01020E, 54N010E, 5430N, 01020E, etc.
738    fn parse_coordinate(token: &str) -> Option<(f64, f64)> {
739        // Find N/S and E/W
740        let n_idx = token.find('N');
741        let s_idx = token.find('S');
742        let e_idx = token.find('E');
743        let w_idx = token.find('W');
744
745        // Latitude
746        let (lat_val, lat_sign, lat_end) = match (n_idx, s_idx) {
747            (Some(idx), _) => (&token[..idx], 1.0, idx + 1),
748            (_, Some(idx)) => (&token[..idx], -1.0, idx + 1),
749            _ => return None,
750        };
751        let lat = match lat_val.len() {
752            2 => lat_val.parse::<f64>().ok()? * lat_sign,
753            4 => {
754                let deg = lat_val[..2].parse::<f64>().ok()?;
755                let min = lat_val[2..4].parse::<f64>().ok()?;
756                (deg + min / 60.0) * lat_sign
757            }
758            _ => return None,
759        };
760
761        // Longitude
762        let (lon_val, lon_sign) = match (e_idx, w_idx) {
763            (Some(idx), _) => (&token[lat_end..idx], 1.0),
764            (_, Some(idx)) => (&token[lat_end..idx], -1.0),
765            _ => return None,
766        };
767        let lon = match lon_val.len() {
768            3 => lon_val.parse::<f64>().ok()? * lon_sign,
769            5 => {
770                let deg = lon_val[..3].parse::<f64>().ok()?;
771                let min = lon_val[3..5].parse::<f64>().ok()?;
772                (deg + min / 60.0) * lon_sign
773            }
774            _ => return None,
775        };
776
777        Some((lat, lon))
778    }
779
780    /// Check if a token is a coordinate
781    ///
782    /// Coordinates can be in various formats:
783    /// - 5020N (degrees/minutes latitude)
784    /// - 5020N00130W (degrees/minutes lat/lon)
785    /// - 50N005W (degrees only)
786    /// - 5020N00130W (full format)
787    fn is_coordinate(token: &str) -> bool {
788        if token.len() < 4 {
789            return false;
790        }
791
792        // Must contain N/S for latitude or E/W for longitude
793        let has_lat = token.contains('N') || token.contains('S');
794        let has_lon = token.contains('E') || token.contains('W');
795
796        if !has_lat && !has_lon {
797            return false;
798        }
799
800        // Find positions of direction indicators
801        let lat_pos = token.find('N').or_else(|| token.find('S'));
802        let lon_pos = token.find('E').or_else(|| token.find('W'));
803
804        // Check format validity
805        match (lat_pos, lon_pos) {
806            (Some(lat_idx), Some(lon_idx)) => {
807                // Both lat and lon present - lat must come first
808                if lat_idx >= lon_idx {
809                    return false;
810                }
811
812                // Characters before lat indicator must be digits
813                let lat_part = &token[..lat_idx];
814                if lat_part.is_empty() || !lat_part.chars().all(|c| c.is_ascii_digit()) {
815                    return false;
816                }
817
818                // Characters between lat and lon indicators must be digits
819                let lon_part = &token[lat_idx + 1..lon_idx];
820                if lon_part.is_empty() || !lon_part.chars().all(|c| c.is_ascii_digit()) {
821                    return false;
822                }
823
824                // Nothing should follow the longitude indicator
825                lon_idx == token.len() - 1
826            }
827            (Some(lat_idx), None) => {
828                // Only latitude present
829                let lat_part = &token[..lat_idx];
830                !lat_part.is_empty() && lat_part.chars().all(|c| c.is_ascii_digit()) && lat_idx == token.len() - 1
831            }
832            (None, Some(lon_idx)) => {
833                // Only longitude present (unusual but valid)
834                let lon_part = &token[..lon_idx];
835                !lon_part.is_empty() && lon_part.chars().all(|c| c.is_ascii_digit()) && lon_idx == token.len() - 1
836            }
837            (None, None) => false,
838        }
839    }
840
841    /// Check if a token is a procedure (SID/STAR)
842    fn is_procedure(token: &str) -> bool {
843        // [A-Z]{3}[0-9]{1,2}[A-Z]
844        if token.len() >= 5 && token.len() <= 7 {
845            let bytes = token.as_bytes();
846            if bytes.len() >= 5 && bytes[0..3].iter().all(|b| b.is_ascii_uppercase()) && (bytes[3].is_ascii_digit()) {
847                // [A-Z]{3}[0-9]{1,2}[A-Z]
848                if bytes.len() == 5 && bytes[4].is_ascii_uppercase() {
849                    return true;
850                }
851                if bytes.len() == 6 && bytes[4].is_ascii_digit() && bytes[5].is_ascii_uppercase() {
852                    return true;
853                }
854            }
855        }
856        // [A-Z]{5}[0-9]{1,2}
857        if token.len() == 6 || token.len() == 7 {
858            let bytes = token.as_bytes();
859            if bytes[0..5].iter().all(|b| b.is_ascii_uppercase())
860                && bytes[5].is_ascii_digit()
861                && (token.len() == 6 || (token.len() == 7 && bytes[6].is_ascii_digit()))
862            {
863                return true;
864            }
865        }
866        // [A-Z]{4,6}[0-9][A-Z]
867        if token.len() >= 6 && token.len() <= 8 {
868            let bytes = token.as_bytes();
869            let prefix_len = token.len() - 2;
870            if (4..=6).contains(&prefix_len)
871                && bytes[0..prefix_len].iter().all(|b| b.is_ascii_uppercase())
872                && bytes[prefix_len].is_ascii_digit()
873                && bytes[prefix_len + 1].is_ascii_uppercase()
874            {
875                return true;
876            }
877        }
878        // [A-Z]{5}[0-9]{2}[A-Z]
879        if token.len() == 8 {
880            let bytes = token.as_bytes();
881            if bytes[0..5].iter().all(|b| b.is_ascii_uppercase())
882                && bytes[5].is_ascii_digit()
883                && bytes[6].is_ascii_digit()
884                && bytes[7].is_ascii_uppercase()
885            {
886                return true;
887            }
888        }
889        false
890    }
891
892    /// Check if a token is a STAY element (STAY followed by digit)
893    fn is_stay(token: &str) -> bool {
894        token.len() == 5 && token.starts_with("STAY") && token.chars().nth(4).unwrap().is_ascii_digit()
895    }
896
897    /// Parse STAY time in HHMM format
898    fn parse_stay_time(time_str: &str) -> Option<u16> {
899        if time_str.len() != 4 {
900            return None;
901        }
902        // Validate HHMM format: HH must be 00-23, MM must be 00-59
903        let hh = time_str[..2].parse::<u16>().ok()?;
904        let mm = time_str[2..].parse::<u16>().ok()?;
905        if hh <= 23 && mm <= 59 {
906            Some(hh * 60 + mm)
907        } else {
908            None
909        }
910    }
911}
912
913#[cfg(test)]
914mod tests {
915
916    use super::*;
917
918    #[test]
919    fn test_speed_parsing() {
920        assert_eq!(Field15Parser::parse_speed("N0456"), Some(Speed::Knots(456)));
921        assert_eq!(Field15Parser::parse_speed("M079"), Some(Speed::Mach(0.79)));
922        assert_eq!(Field15Parser::parse_speed("K0893"), Some(Speed::KilometersPerHour(893)));
923    }
924
925    #[test]
926    fn test_altitude_parsing() {
927        assert_eq!(Field15Parser::parse_altitude("F340"), Some(Altitude::FlightLevel(340)));
928        assert_eq!(
929            Field15Parser::parse_altitude("S1130"),
930            Some(Altitude::MetricLevel(1130))
931        );
932    }
933
934    #[test]
935    fn test_coordinate_detection() {
936        assert!(Field15Parser::is_coordinate("62N010W"));
937        assert!(Field15Parser::is_coordinate("5430N"));
938        assert!(Field15Parser::is_coordinate("53N100W"));
939        assert!(!Field15Parser::is_coordinate("LACOU"));
940    }
941
942    #[test]
943    fn test_procedure_detection() {
944        assert!(Field15Parser::is_procedure("LACOU5A"));
945        assert!(Field15Parser::is_procedure("ROXOG1H"));
946        assert!(Field15Parser::is_procedure("RANUX3D"));
947        assert!(!Field15Parser::is_procedure("LACOU"));
948        assert!(!Field15Parser::is_procedure("CNA"));
949    }
950
951    #[test]
952    fn test_airway_detection() {
953        assert!(Field15Parser::is_airway("UM184"));
954        assert!(Field15Parser::is_airway("UN863"));
955        assert!(Field15Parser::is_airway("L738"));
956        assert!(Field15Parser::is_airway("A308"));
957        assert!(!Field15Parser::is_airway("DCT"));
958        assert!(!Field15Parser::is_airway("LACOU"));
959    }
960
961    #[test]
962    fn test_tokenization() {
963        let tokens = Field15Parser::tokenize("N0450F100 POINT/M079F200 DCT");
964        assert_eq!(tokens, vec!["N0450F100", "POINT", "/", "M079F200", "DCT"]);
965    }
966
967    #[test]
968    fn test_tokenization_multiple_whitespace() {
969        let tokens = Field15Parser::tokenize("A  B\tC\nD\rE");
970        assert_eq!(tokens, vec!["A", "B", "C", "D", "E"]);
971    }
972
973    #[test]
974    fn test_slash_handling() {
975        let route = "N0450F100 POINT/M079F200";
976        let elements = Field15Parser::parse(route);
977
978        // Should have initial modifier, point, and then modified speed/altitude
979        assert!(elements.len() >= 3);
980
981        let modifiers: Vec<_> = elements
982            .iter()
983            .filter(|e| matches!(e, Field15Element::Modifier(_)))
984            .collect();
985        assert_eq!(modifiers.len(), 2);
986    }
987
988    #[test]
989    fn test_coordinate_validation() {
990        assert!(Field15Parser::is_coordinate("5020N"));
991        assert!(Field15Parser::is_coordinate("5020N00130W"));
992        assert!(Field15Parser::is_coordinate("50N005W"));
993        assert!(Field15Parser::is_coordinate("00N000E"));
994
995        assert!(!Field15Parser::is_coordinate("N5020")); // Wrong order
996        assert!(!Field15Parser::is_coordinate("5020W00130N")); // Lon before lat
997        assert!(!Field15Parser::is_coordinate("ABC")); // No direction
998        assert!(!Field15Parser::is_coordinate("50N")); // Too short
999    }
1000
1001    #[test]
1002    fn test_bearing_distance_with_coordinate() {
1003        let route = "N0450F100 02S001W180060";
1004        let elements = Field15Parser::parse(route);
1005
1006        let bearing_dist = elements
1007            .iter()
1008            .find(|e| matches!(e, Field15Element::Point(Point::BearingDistance { .. })));
1009
1010        assert!(bearing_dist.is_some());
1011        if let Some(Field15Element::Point(Point::BearingDistance {
1012            point,
1013            bearing,
1014            distance,
1015        })) = bearing_dist
1016        {
1017            assert_eq!(**point, Point::Coordinates((-2.0, -1.0)));
1018            assert_eq!(*bearing, 180);
1019            assert_eq!(*distance, 60);
1020        }
1021    }
1022
1023    #[test]
1024    fn test_simple_route() {
1025        let route = "N0456F340 LACOU5A LACOU UM184 CNA UN863 MANAK UY110 REVTU UP87 ROXOG ROXOG1H";
1026        let elements = Field15Parser::parse(route);
1027
1028        assert_eq!(elements.len(), 12);
1029        assert_eq!(
1030            elements,
1031            vec![
1032                Field15Element::Modifier(Modifier {
1033                    speed: Some(Speed::Knots(456)),
1034                    altitude: Some(Altitude::FlightLevel(340)),
1035                    cruise_climb: false,
1036                    altitude_cruise_to: None
1037                }),
1038                Field15Element::Connector(Connector::Sid("LACOU5A".to_string())),
1039                Field15Element::Point(Point::Waypoint("LACOU".to_string())),
1040                Field15Element::Connector(Connector::Airway("UM184".to_string())),
1041                Field15Element::Point(Point::Waypoint("CNA".to_string())),
1042                Field15Element::Connector(Connector::Airway("UN863".to_string())),
1043                Field15Element::Point(Point::Waypoint("MANAK".to_string())),
1044                Field15Element::Connector(Connector::Airway("UY110".to_string())),
1045                Field15Element::Point(Point::Waypoint("REVTU".to_string())),
1046                Field15Element::Connector(Connector::Airway("UP87".to_string())),
1047                Field15Element::Point(Point::Waypoint("ROXOG".to_string())),
1048                Field15Element::Connector(Connector::Star("ROXOG1H".to_string())),
1049            ]
1050        );
1051    }
1052
1053    #[test]
1054    fn test_readme_example() {
1055        // Example from the reference README
1056
1057        let route = "N0450M0825 00N000E B9 00N001E VFR IFR 00N001W/N0350F100 01N001W 01S001W 02S001W180060";
1058        let elements = Field15Parser::parse(route);
1059
1060        assert_eq!(
1061            elements,
1062            vec![
1063                Field15Element::Modifier(Modifier {
1064                    speed: Some(Speed::Knots(450)),
1065                    altitude: Some(Altitude::MetricAltitude(825)),
1066                    cruise_climb: false,
1067                    altitude_cruise_to: None
1068                }),
1069                Field15Element::Point(Point::Coordinates((0., 0.))),
1070                Field15Element::Connector(Connector::Airway("B9".to_string())),
1071                Field15Element::Point(Point::Coordinates((0., 1.))),
1072                Field15Element::Connector(Connector::Vfr),
1073                Field15Element::Connector(Connector::Ifr),
1074                Field15Element::Point(Point::Coordinates((0., -1.))),
1075                Field15Element::Modifier(Modifier {
1076                    speed: Some(Speed::Knots(350)),
1077                    altitude: Some(Altitude::FlightLevel(100)),
1078                    cruise_climb: false,
1079                    altitude_cruise_to: None
1080                }),
1081                Field15Element::Point(Point::Coordinates((1., -1.))),
1082                Field15Element::Point(Point::Coordinates((-1., -1.))),
1083                Field15Element::Point(Point::BearingDistance {
1084                    point: Box::new(Point::Coordinates((-2., -1.))),
1085                    bearing: 180,
1086                    distance: 60,
1087                }),
1088            ]
1089        );
1090    }
1091
1092    #[test]
1093    fn test_oat_gat_connectors() {
1094        let route = "N0450F100 POINT OAT POINT GAT POINT";
1095        let elements = Field15Parser::parse(route);
1096
1097        assert_eq!(elements.len(), 6);
1098        assert_eq!(
1099            elements,
1100            vec![
1101                Field15Element::Modifier(Modifier {
1102                    speed: Some(Speed::Knots(450)),
1103                    altitude: Some(Altitude::FlightLevel(100)),
1104                    cruise_climb: false,
1105                    altitude_cruise_to: None
1106                }),
1107                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1108                Field15Element::Connector(Connector::Oat),
1109                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1110                Field15Element::Connector(Connector::Gat),
1111                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1112            ]
1113        );
1114    }
1115
1116    #[test]
1117    fn test_ifp_stop_start() {
1118        let route = "N0450F100 POINT IFPSTOP POINT IFPSTART POINT";
1119        let elements = Field15Parser::parse(route);
1120        assert_eq!(elements.len(), 6);
1121        assert_eq!(
1122            elements,
1123            vec![
1124                Field15Element::Modifier(Modifier {
1125                    speed: Some(Speed::Knots(450)),
1126                    altitude: Some(Altitude::FlightLevel(100)),
1127                    cruise_climb: false,
1128                    altitude_cruise_to: None
1129                }),
1130                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1131                Field15Element::Connector(Connector::IfpStop),
1132                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1133                Field15Element::Connector(Connector::IfpStart),
1134                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1135            ]
1136        );
1137    }
1138
1139    #[test]
1140    fn test_bearing_distance_format() {
1141        let route = "N0450F100 POINT WAYPOINT180060";
1142        let elements = Field15Parser::parse(route);
1143
1144        assert_eq!(elements.len(), 3);
1145        assert_eq!(
1146            elements,
1147            vec![
1148                Field15Element::Modifier(Modifier {
1149                    speed: Some(Speed::Knots(450)),
1150                    altitude: Some(Altitude::FlightLevel(100)),
1151                    cruise_climb: false,
1152                    altitude_cruise_to: None
1153                }),
1154                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1155                Field15Element::Point(Point::BearingDistance {
1156                    point: Box::new(Point::Waypoint("WAYPOINT".to_string())),
1157                    bearing: 180,
1158                    distance: 60,
1159                }),
1160            ]
1161        );
1162    }
1163
1164    #[test]
1165    fn test_aerodrome_detection() {
1166        let route = "N0450F100 LFPG DCT EGLL";
1167        let elements = Field15Parser::parse(route);
1168        assert_eq!(elements.len(), 4);
1169        assert_eq!(
1170            elements,
1171            vec![
1172                Field15Element::Modifier(Modifier {
1173                    speed: Some(Speed::Knots(450)),
1174                    altitude: Some(Altitude::FlightLevel(100)),
1175                    cruise_climb: false,
1176                    altitude_cruise_to: None
1177                }),
1178                Field15Element::Point(Point::Aerodrome("LFPG".to_string())),
1179                Field15Element::Connector(Connector::Direct),
1180                Field15Element::Point(Point::Aerodrome("EGLL".to_string())),
1181            ]
1182        );
1183    }
1184
1185    #[test]
1186    fn test_truncate_indicator() {
1187        let route = "N0450F100 POINT DCT POINT2 T";
1188        let elements = Field15Parser::parse(route);
1189        assert_eq!(
1190            elements,
1191            vec![
1192                Field15Element::Modifier(Modifier {
1193                    speed: Some(Speed::Knots(450)),
1194                    altitude: Some(Altitude::FlightLevel(100)),
1195                    cruise_climb: false,
1196                    altitude_cruise_to: None
1197                }),
1198                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1199                Field15Element::Connector(Connector::Direct),
1200                Field15Element::Point(Point::Waypoint("POINT2".to_string())),
1201            ]
1202        );
1203    }
1204
1205    #[test]
1206    fn test_literal_sid_star() {
1207        let route = "N0450F100 SID POINT DCT POINT2 STAR";
1208        let elements = Field15Parser::parse(route);
1209
1210        assert_eq!(
1211            elements,
1212            vec![
1213                Field15Element::Modifier(Modifier {
1214                    speed: Some(Speed::Knots(450)),
1215                    altitude: Some(Altitude::FlightLevel(100)),
1216                    cruise_climb: false,
1217                    altitude_cruise_to: None
1218                }),
1219                Field15Element::Connector(Connector::Sid("SID".to_string())),
1220                Field15Element::Point(Point::Waypoint("POINT".to_string())),
1221                Field15Element::Connector(Connector::Direct),
1222                Field15Element::Point(Point::Waypoint("POINT2".to_string())),
1223                Field15Element::Connector(Connector::Star("STAR".to_string())),
1224            ]
1225        );
1226    }
1227
1228    #[test]
1229    fn test_aerodrome_no_digits() {
1230        let route = "N0450F100 LFPG DCT EGLL";
1231        let elements = Field15Parser::parse(route);
1232
1233        assert_eq!(
1234            elements,
1235            vec![
1236                Field15Element::Modifier(Modifier {
1237                    speed: Some(Speed::Knots(450)),
1238                    altitude: Some(Altitude::FlightLevel(100)),
1239                    cruise_climb: false,
1240                    altitude_cruise_to: None
1241                }),
1242                Field15Element::Point(Point::Aerodrome("LFPG".to_string())),
1243                Field15Element::Connector(Connector::Direct),
1244                Field15Element::Point(Point::Aerodrome("EGLL".to_string())),
1245            ]
1246        );
1247    }
1248
1249    #[test]
1250    fn test_star_must_be_last() {
1251        // STAR-like pattern in middle should be waypoint or airway
1252        let route = "N0450F100 POINT1A POINT DCT POINT";
1253        let elements = Field15Parser::parse(route);
1254
1255        // POINT1A should not be STAR since it's not last
1256        assert!(!elements
1257            .iter()
1258            .any(|e| { matches!(e, Field15Element::Connector(Connector::Star(s)) if s == "POINT1A") }));
1259    }
1260
1261    #[test]
1262    fn test_single_char_point() {
1263        // Single character "C" should be a waypoint, not cruise climb indicator
1264        let route = "N0450F100 POINT DCT C DCT POINT";
1265        let elements = Field15Parser::parse(route);
1266
1267        // C should appear as a waypoint
1268        assert!(elements
1269            .iter()
1270            .any(|e| { matches!(e, Field15Element::Point(Point::Waypoint(s)) if s == "C") }));
1271    }
1272
1273    #[test]
1274    fn test_readme_tokenization() {
1275        // From README: whitespace is space, newline, tab, carriage return, and forward slash
1276        let route = "N0450M0825\n00N000E\tB9 00N001E/VFR";
1277        let elements = Field15Parser::parse(route);
1278
1279        assert!(elements.len() >= 5);
1280        assert!(elements
1281            .iter()
1282            .any(|e| matches!(e, Field15Element::Connector(Connector::Vfr))));
1283    }
1284
1285    #[test]
1286    fn test_bearing_distance_validation() {
1287        // Test bearing > 360 (should still parse but with validation in real impl)
1288        let route = "N0450F100 POINT999999";
1289        let elements = Field15Parser::parse(route);
1290
1291        // Should not parse as bearing/distance if bearing > 360
1292        assert!(!elements
1293            .iter()
1294            .any(|e| { matches!(e, Field15Element::Point(Point::BearingDistance { bearing, .. }) if *bearing > 360) }));
1295    }
1296
1297    #[test]
1298    fn test_route_with_speed_changes() {
1299        let route = "N0495F320 RANUX3D RANUX UN858 VALEK/N0491F330 UM163 DIK UN853 ARCKY DCT NVO DCT BERIM DCT BIKRU/N0482F350 DCT VEDEN";
1300        let elements = Field15Parser::parse(route);
1301
1302        assert_eq!(elements.len(), 19);
1303        assert_eq!(
1304            elements,
1305            vec![
1306                Field15Element::Modifier(Modifier {
1307                    speed: Some(Speed::Knots(495)),
1308                    altitude: Some(Altitude::FlightLevel(320)),
1309                    cruise_climb: false,
1310                    altitude_cruise_to: None
1311                }),
1312                Field15Element::Connector(Connector::Sid("RANUX3D".to_string())),
1313                Field15Element::Point(Point::Waypoint("RANUX".to_string())),
1314                Field15Element::Connector(Connector::Airway("UN858".to_string())),
1315                Field15Element::Point(Point::Waypoint("VALEK".to_string())),
1316                Field15Element::Modifier(Modifier {
1317                    speed: Some(Speed::Knots(491)),
1318                    altitude: Some(Altitude::FlightLevel(330)),
1319                    cruise_climb: false,
1320                    altitude_cruise_to: None
1321                }),
1322                Field15Element::Connector(Connector::Airway("UM163".to_string())),
1323                Field15Element::Point(Point::Waypoint("DIK".to_string())),
1324                Field15Element::Connector(Connector::Airway("UN853".to_string())),
1325                Field15Element::Point(Point::Waypoint("ARCKY".to_string())),
1326                Field15Element::Connector(Connector::Direct),
1327                Field15Element::Point(Point::Waypoint("NVO".to_string())),
1328                Field15Element::Connector(Connector::Direct),
1329                Field15Element::Point(Point::Waypoint("BERIM".to_string())),
1330                Field15Element::Connector(Connector::Direct),
1331                Field15Element::Point(Point::Waypoint("BIKRU".to_string())),
1332                Field15Element::Modifier(Modifier {
1333                    speed: Some(Speed::Knots(482)),
1334                    altitude: Some(Altitude::FlightLevel(350)),
1335                    cruise_climb: false,
1336                    altitude_cruise_to: None
1337                }),
1338                Field15Element::Connector(Connector::Direct),
1339                Field15Element::Point(Point::Waypoint("VEDEN".to_string())),
1340            ]
1341        );
1342    }
1343
1344    #[test]
1345    fn test_route_with_coordinates() {
1346        let route = "N0458F320 BERGI UL602 SUPUR UP1 GODOS P1 ROLUM P13 ASKAM L7 SUM DCT PEMOS/M079F320 DCT 62N010W 63N020W 63N030W 64N040W 64N050W";
1347        let elements = Field15Parser::parse(route);
1348        assert_eq!(
1349            elements,
1350            vec![
1351                Field15Element::Modifier(Modifier {
1352                    speed: Some(Speed::Knots(458)),
1353                    altitude: Some(Altitude::FlightLevel(320)),
1354                    cruise_climb: false,
1355                    altitude_cruise_to: None
1356                }),
1357                Field15Element::Point(Point::Waypoint("BERGI".to_string())),
1358                Field15Element::Connector(Connector::Airway("UL602".to_string())),
1359                Field15Element::Point(Point::Waypoint("SUPUR".to_string())),
1360                Field15Element::Connector(Connector::Airway("UP1".to_string())),
1361                Field15Element::Point(Point::Waypoint("GODOS".to_string())),
1362                Field15Element::Connector(Connector::Airway("P1".to_string())),
1363                Field15Element::Point(Point::Waypoint("ROLUM".to_string())),
1364                Field15Element::Connector(Connector::Airway("P13".to_string())),
1365                Field15Element::Point(Point::Waypoint("ASKAM".to_string())),
1366                Field15Element::Connector(Connector::Airway("L7".to_string())),
1367                Field15Element::Point(Point::Waypoint("SUM".to_string())),
1368                Field15Element::Connector(Connector::Direct),
1369                Field15Element::Point(Point::Waypoint("PEMOS".to_string())),
1370                Field15Element::Modifier(Modifier {
1371                    speed: Some(Speed::Mach(0.79)),
1372                    altitude: Some(Altitude::FlightLevel(320)),
1373                    cruise_climb: false,
1374                    altitude_cruise_to: None
1375                }),
1376                Field15Element::Connector(Connector::Direct),
1377                Field15Element::Point(Point::Coordinates((62., -10.))),
1378                Field15Element::Point(Point::Coordinates((63., -20.))),
1379                Field15Element::Point(Point::Coordinates((63., -30.))),
1380                Field15Element::Point(Point::Coordinates((64., -40.))),
1381                Field15Element::Point(Point::Coordinates((64., -50.))),
1382            ]
1383        );
1384    }
1385
1386    #[test]
1387    fn test_route_with_modifiers() {
1388        let route = "N0427F230 DET1J DET L6 DVR L9 KONAN/N0470F350 UL607 MATUG";
1389        let elements = Field15Parser::parse(route);
1390
1391        assert_eq!(
1392            elements,
1393            vec![
1394                Field15Element::Modifier(Modifier {
1395                    speed: Some(Speed::Knots(427)),
1396                    altitude: Some(Altitude::FlightLevel(230)),
1397                    cruise_climb: false,
1398                    altitude_cruise_to: None
1399                }),
1400                Field15Element::Connector(Connector::Sid("DET1J".to_string())),
1401                Field15Element::Point(Point::Waypoint("DET".to_string())),
1402                Field15Element::Connector(Connector::Airway("L6".to_string())),
1403                Field15Element::Point(Point::Waypoint("DVR".to_string())),
1404                Field15Element::Connector(Connector::Airway("L9".to_string())),
1405                Field15Element::Point(Point::Waypoint("KONAN".to_string())),
1406                Field15Element::Modifier(Modifier {
1407                    speed: Some(Speed::Knots(470)),
1408                    altitude: Some(Altitude::FlightLevel(350)),
1409                    cruise_climb: false,
1410                    altitude_cruise_to: None
1411                }),
1412                Field15Element::Connector(Connector::Airway("UL607".to_string())),
1413                Field15Element::Point(Point::Waypoint("MATUG".to_string())),
1414            ]
1415        );
1416    }
1417
1418    #[test]
1419    fn test_multiple_airways() {
1420        let route =
1421            "N0463F350 ERIXU3B ERIXU UN860 ETAMO UZ271 ADEKA UT18 AMLIR/N0461F370 UT18 BADAM UZ151 FJR UM731 DIVKO";
1422        let elements = Field15Parser::parse(route);
1423
1424        assert_eq!(
1425            elements,
1426            vec![
1427                Field15Element::Modifier(Modifier {
1428                    speed: Some(Speed::Knots(463)),
1429                    altitude: Some(Altitude::FlightLevel(350)),
1430                    cruise_climb: false,
1431                    altitude_cruise_to: None
1432                }),
1433                Field15Element::Connector(Connector::Sid("ERIXU3B".to_string())),
1434                Field15Element::Point(Point::Waypoint("ERIXU".to_string())),
1435                Field15Element::Connector(Connector::Airway("UN860".to_string())),
1436                Field15Element::Point(Point::Waypoint("ETAMO".to_string())),
1437                Field15Element::Connector(Connector::Airway("UZ271".to_string())),
1438                Field15Element::Point(Point::Waypoint("ADEKA".to_string())),
1439                Field15Element::Connector(Connector::Airway("UT18".to_string())),
1440                Field15Element::Point(Point::Waypoint("AMLIR".to_string())),
1441                Field15Element::Modifier(Modifier {
1442                    speed: Some(Speed::Knots(461)),
1443                    altitude: Some(Altitude::FlightLevel(370)),
1444                    cruise_climb: false,
1445                    altitude_cruise_to: None
1446                }),
1447                Field15Element::Connector(Connector::Airway("UT18".to_string())),
1448                Field15Element::Point(Point::Waypoint("BADAM".to_string())),
1449                Field15Element::Connector(Connector::Airway("UZ151".to_string())),
1450                Field15Element::Point(Point::Waypoint("FJR".to_string())),
1451                Field15Element::Connector(Connector::Airway("UM731".to_string())),
1452                Field15Element::Point(Point::Waypoint("DIVKO".to_string())),
1453            ]
1454        );
1455    }
1456
1457    #[test]
1458    fn test_long_complex_route() {
1459        let route = "N0459F320 OBOKA UZ29 TORNU DCT RAVLO Y70 OTBED L60 PENIL M144 BAGSO DCT RINUS DCT GISTI/M079F330 DCT MALOT/M079F340 DCT 54N020W 55N030W 54N040W 51N050W DCT ALLRY/N0463F360 DCT YQX";
1460        let elements = Field15Parser::parse(route);
1461        assert_eq!(
1462            elements,
1463            vec![
1464                Field15Element::Modifier(Modifier {
1465                    speed: Some(Speed::Knots(459)),
1466                    altitude: Some(Altitude::FlightLevel(320)),
1467                    cruise_climb: false,
1468                    altitude_cruise_to: None
1469                }),
1470                Field15Element::Point(Point::Waypoint("OBOKA".to_string())),
1471                Field15Element::Connector(Connector::Airway("UZ29".to_string())),
1472                Field15Element::Point(Point::Waypoint("TORNU".to_string())),
1473                Field15Element::Connector(Connector::Direct),
1474                Field15Element::Point(Point::Waypoint("RAVLO".to_string())),
1475                Field15Element::Connector(Connector::Airway("Y70".to_string())),
1476                Field15Element::Point(Point::Waypoint("OTBED".to_string())),
1477                Field15Element::Connector(Connector::Airway("L60".to_string())),
1478                Field15Element::Point(Point::Waypoint("PENIL".to_string())),
1479                Field15Element::Connector(Connector::Airway("M144".to_string())),
1480                Field15Element::Point(Point::Waypoint("BAGSO".to_string())),
1481                Field15Element::Connector(Connector::Direct),
1482                Field15Element::Point(Point::Waypoint("RINUS".to_string())),
1483                Field15Element::Connector(Connector::Direct),
1484                Field15Element::Point(Point::Waypoint("GISTI".to_string())),
1485                Field15Element::Modifier(Modifier {
1486                    speed: Some(Speed::Mach(0.79)),
1487                    altitude: Some(Altitude::FlightLevel(330)),
1488                    cruise_climb: false,
1489                    altitude_cruise_to: None
1490                }),
1491                Field15Element::Connector(Connector::Direct),
1492                Field15Element::Point(Point::Waypoint("MALOT".to_string())),
1493                Field15Element::Modifier(Modifier {
1494                    speed: Some(Speed::Mach(0.79)),
1495                    altitude: Some(Altitude::FlightLevel(340)),
1496                    cruise_climb: false,
1497                    altitude_cruise_to: None
1498                }),
1499                Field15Element::Connector(Connector::Direct),
1500                // Fix for the $PLACEHOLDER$ in test_long_complex_route
1501                Field15Element::Point(Point::Coordinates((54., -20.))),
1502                Field15Element::Point(Point::Coordinates((55., -30.))),
1503                Field15Element::Point(Point::Coordinates((54., -40.))),
1504                Field15Element::Point(Point::Coordinates((51., -50.))),
1505                Field15Element::Connector(Connector::Direct),
1506                Field15Element::Point(Point::Waypoint("ALLRY".to_string())),
1507                Field15Element::Modifier(Modifier {
1508                    speed: Some(Speed::Knots(463)),
1509                    altitude: Some(Altitude::FlightLevel(360)),
1510                    cruise_climb: false,
1511                    altitude_cruise_to: None
1512                }),
1513                Field15Element::Connector(Connector::Direct),
1514                Field15Element::Point(Point::Waypoint("YQX".to_string())),
1515            ]
1516        );
1517    }
1518
1519    #[test]
1520    fn test_complex_route_for_tokenization() {
1521        // Full example from README with proper tokenization
1522        let route = "N0450M0825 00N000E B9 00N001E VFR IFR 00N001W/N0350F100 01N001W 01S001W 02S001W180060";
1523        let elements = Field15Parser::parse(route);
1524        assert_eq!(
1525            elements,
1526            vec![
1527                Field15Element::Modifier(Modifier {
1528                    speed: Some(Speed::Knots(450)),
1529                    altitude: Some(Altitude::MetricAltitude(825)),
1530                    cruise_climb: false,
1531                    altitude_cruise_to: None
1532                }),
1533                Field15Element::Point(Point::Coordinates((0., 0.))),
1534                Field15Element::Connector(Connector::Airway("B9".to_string())),
1535                Field15Element::Point(Point::Coordinates((0., 1.))),
1536                Field15Element::Connector(Connector::Vfr),
1537                Field15Element::Connector(Connector::Ifr),
1538                Field15Element::Point(Point::Coordinates((0., -1.))),
1539                Field15Element::Modifier(Modifier {
1540                    speed: Some(Speed::Knots(350)),
1541                    altitude: Some(Altitude::FlightLevel(100)),
1542                    cruise_climb: false,
1543                    altitude_cruise_to: None
1544                }),
1545                Field15Element::Point(Point::Coordinates((1., -1.))),
1546                Field15Element::Point(Point::Coordinates((-1., -1.))),
1547                Field15Element::Point(Point::BearingDistance {
1548                    point: Box::new(Point::Coordinates((-2., -1.))),
1549                    bearing: 180,
1550                    distance: 60,
1551                }),
1552            ]
1553        );
1554    }
1555
1556    #[test]
1557    fn test_nat_track_is_nat() {
1558        // NAT tracks should be parsed as airways, not aerodromes
1559        assert!(Field15Parser::is_nat("NATD"));
1560        assert!(Field15Parser::is_nat("NATA"));
1561        assert!(Field15Parser::is_nat("NATZ"));
1562        assert!(Field15Parser::is_nat("NATZ1"));
1563        assert!(!Field15Parser::is_nat("NAT1")); // Not a valid NAT track
1564        assert!(!Field15Parser::is_nat("NAT")); // Too short
1565    }
1566
1567    #[test]
1568    fn test_nat_track_in_route() {
1569        let route = "N0490F360 ELCOB6B ELCOB UT300 SENLO UN502 JSY DCT LIZAD DCT MOPAT DCT LUNIG DCT MOMIN DCT PIKIL/M084F380 NATD HOIST/N0490F380 N756C ANATI/N0441F340 DCT MIVAX DCT OBTEK DCT XORLO ROCKT2";
1570        let elements = Field15Parser::parse(route);
1571
1572        assert_eq!(
1573            elements,
1574            vec![
1575                Field15Element::Modifier(Modifier {
1576                    speed: Some(Speed::Knots(490)),
1577                    altitude: Some(Altitude::FlightLevel(360)),
1578                    cruise_climb: false,
1579                    altitude_cruise_to: None
1580                }),
1581                Field15Element::Connector(Connector::Sid("ELCOB6B".to_string())),
1582                Field15Element::Point(Point::Waypoint("ELCOB".to_string())),
1583                Field15Element::Connector(Connector::Airway("UT300".to_string())),
1584                Field15Element::Point(Point::Waypoint("SENLO".to_string())),
1585                Field15Element::Connector(Connector::Airway("UN502".to_string())),
1586                Field15Element::Point(Point::Waypoint("JSY".to_string())),
1587                Field15Element::Connector(Connector::Direct),
1588                Field15Element::Point(Point::Waypoint("LIZAD".to_string())),
1589                Field15Element::Connector(Connector::Direct),
1590                Field15Element::Point(Point::Waypoint("MOPAT".to_string())),
1591                Field15Element::Connector(Connector::Direct),
1592                Field15Element::Point(Point::Waypoint("LUNIG".to_string())),
1593                Field15Element::Connector(Connector::Direct),
1594                Field15Element::Point(Point::Waypoint("MOMIN".to_string())),
1595                Field15Element::Connector(Connector::Direct),
1596                Field15Element::Point(Point::Waypoint("PIKIL".to_string())),
1597                Field15Element::Modifier(Modifier {
1598                    speed: Some(Speed::Mach(0.84)),
1599                    altitude: Some(Altitude::FlightLevel(380)),
1600                    cruise_climb: false,
1601                    altitude_cruise_to: None
1602                }),
1603                Field15Element::Connector(Connector::Nat("NATD".to_string())),
1604                Field15Element::Point(Point::Waypoint("HOIST".to_string())),
1605                Field15Element::Modifier(Modifier {
1606                    speed: Some(Speed::Knots(490)),
1607                    altitude: Some(Altitude::FlightLevel(380)),
1608                    cruise_climb: false,
1609                    altitude_cruise_to: None
1610                }),
1611                Field15Element::Connector(Connector::Airway("N756C".to_string())),
1612                Field15Element::Point(Point::Waypoint("ANATI".to_string())),
1613                Field15Element::Modifier(Modifier {
1614                    speed: Some(Speed::Knots(441)),
1615                    altitude: Some(Altitude::FlightLevel(340)),
1616                    cruise_climb: false,
1617                    altitude_cruise_to: None
1618                }),
1619                Field15Element::Connector(Connector::Direct),
1620                Field15Element::Point(Point::Waypoint("MIVAX".to_string())),
1621                Field15Element::Connector(Connector::Direct),
1622                Field15Element::Point(Point::Waypoint("OBTEK".to_string())),
1623                Field15Element::Connector(Connector::Direct),
1624                Field15Element::Point(Point::Waypoint("XORLO".to_string())),
1625                Field15Element::Connector(Connector::Star("ROCKT2".to_string())),
1626            ]
1627        );
1628    }
1629}