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