Skip to main content

lat_long/
parse.rs

1//! This module provides the [`Parsed`] enum and [`parse_str`] function.
2//!
3//! The [`parse_str`] function should proceed according to the following grammar:
4//!
5//! ```bnf
6//! <coordinate>    ::= <bare_pair> | <non_bare_pair>
7//! <bare_pair>     ::= <bare_dms> "," <bare_dms>
8//! <non_bare_pair> ::= ( <latitude> <separator> <longitude> )
9//!                     | ( <bare_dms> <separator> <longitude> )
10//!                     | ( <latitude> <separator> <bare_dms> )
11//!
12//! <latitude>      ::= <decimal> | <labeled_decimal> | <signed_dms> | <labeled_dms>
13//! <longitude>     ::= <decimal> | <labeled_decimal> |<signed_dms> | <labeled_dms>
14//! <separator>     ::= WHITESPACE* "," WHITESPACE*
15//! <decimal>       ::= <sign>? <digits> ( "." <digits> )?
16//! <labeled_decimal> ::= <digits> ( "." <digits> )? <direction>
17//! <signed_dms>    ::= <sign>? <degs> WHITESPACE* <mins> WHITESPACE* <secs>
18//! <labeled_dms>   ::= <degs> WHITESPACE* <mins> WHITESPACE* <secs> WHITESPACE* <direction>
19//! <bare_dms>      ::= <sign> <bare_degs> ":" <bare_mins> ":" <bare_secs>
20//!
21//! <degs>          ::= <digits> "°"
22//! <mins>          ::= <digits> "′"
23//! <secs>          ::= <digits> "." <digits> "″"
24//! <bare_degs>     ::= <digit> <digit> <digit>
25//! <bare_mins>     ::= <digit> <digit>
26//! <bare_secs>     ::= <digit> <digit> "." <digit> <digit> <digit> <digit>+
27//!
28//! <direction>     ::= "N" | "S" | "E" | "W"
29//! <sign>          ::= "+" | "-"
30//! <digit>         ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
31//! <digits>        ::= <digit>+
32//! ```
33//!
34//! # Format Notes
35//!
36//! 1. Strings **must not** have leading or trailing whitespace. [`Error::InvalidWhitespace`]
37//! 2. Whitespace **must not** appear between the *sign* and the *degrees* value. [`Error::InvalidWhitespace`]
38//! 3. *Degree*, *minute*, and *seconds* values have a maximum number of integer digits, 3, 2, and 2 respectively. [`Error::InvalidNumericFormat`]
39//! 4. The *degrees* symbol **must** be '°', (Unicode `U+00B0` `DEGREE SIGN`). [`Error::InvalidCharacter`]
40//! 5. The *minutes* symbol **must** be '′' (Unicode `U+2032` `PRIME`). [`Error::InvalidCharacter`]
41//! 6. The *seconds* symbol **must** be '″' (Unicode `U+2033` `DOUBLE PRIME`). [`Error::InvalidCharacter`]
42//! 7. Whitespace **must not** appear between the *degrees*, *minutes*, and *seconds* values and their corresponding symbols. [`Error::InvalidWhitespace`]
43//! 8. Labeled format **must** have a *direction* character 'N', 'S', 'E', or 'W' at the end of the string, this character is case sensitive. [`Error::InvalidCharacter`]
44//! 9. Bare format **must** start with the *sign* character [+|-] at the beginning of the string. [`Error::InvalidNumericFormat`]
45//! 10. The latitude and longitude values of a coordinate **may** be specified in different formats.
46//! 11. The latitude and longitude values of a coordinate **must** separated by a single comma `,` (*separator*).
47//! 12. If either latitude **and** longitude are specified in a non-bare format, the *separator* **may** have whitespace before and after: `\s*,\s*`.
48//! 12. If both latitude **and** longitude are specified in bare format, the *separator* **must not** have leading or trailing whitespace. [`Error::InvalidNumericFormat`]
49//!
50//! # Parsed Examples
51//!
52//! | Input String                              | Match           | Result                        |
53//! |-------------------------------------------|-----------------|-------------------------------|
54//! | 48.858222                                 | Decimal         | Ok(Value(Unknown))            |
55//! | +48.858222                                | Decimal         | Ok(Value(Unknown))            |
56//! | 48.858                                    | Decimal         | Ok(Value(Unknown))            |
57//! | 48.9                                      | Decimal         | Ok(Value(Unknown))            |
58//! | 48                                        | Decimal         | Error(InvalidNumericFormat)   |
59//! | 048.9                                     | Decimal         | Ok(Value(Unknown))            |
60//! | 0048.9                                    | Decimal         | Error(InvalidNumericFormat)   |
61//! | -48.858222                                | Decimal         | Ok(Value(Unknown))            |
62//! | " 48.858222"                              | Decimal         | Error(InvalidWhitespace)      |
63//! | "48.858222 "                              | Decimal         | Error(InvalidWhitespace)      |
64//! | - 48.858222                               | Decimal         | Error(InvalidCharacter)       |
65//! | 48E                                       | Labeled Decimal | Ok(Value(Unknown))            |
66//! | 48.5S                                     | Labeled Decimal | Ok(Value(Unknown))            |
67//! | "48 N"                                    | Labeled Decimal | Error(InvalidWhitespace)      |
68//! | 48° 51′ 29.600000″                        | Signed DMS      | Ok(Value(Unknown))            |
69//! | -48° 51′ 29.600000″                       | Signed DMS      | Ok(Value(Unknown))            |
70//! | 48° 51′ 29.600000″                        | Signed DMS      | Ok(Value(Unknown))            |
71//! | 48° 51' 29.600000″ N                      | Labeled DMS     | Error(InvalidCharacter)       |
72//! | 48° 51′ 29.600000″ S                      | Labeled DMS     | Ok(Value(Latitude))           |
73//! | 48° 51′ 29.600000″ E                      | Labeled DMS     | Ok(Value(Longitude))          |
74//! | 48° 51′ 29.600000″ W                      | Labeled DMS     | Ok(Value(Longitude))          |
75//! | 48° 51′ 29.600000″ w                      | Labeled DMS     | Error(InvalidCharacter)       |
76//! | +048:51:29.600000                         | Bare DMS        | Ok(Value(Unknown))            |
77//! | -048:51:29.600000                         | Bare DMS        | Ok(Value(Unknown))            |
78//! | 91, 0, 0.0                                | Signed DMS      | Error(InvalidDegrees)         |
79//! | 90, 61, 0.0                               | Signed DMS      | Error(InvalidMinutes)         |
80//! | 90, 0, 61.0                               | Signed DMS      | Error(InvalidSeconds)         |
81//! | 180, 1, 0.0                               | Signed DMS      | Error(InvalidAngle)           |
82//! | 48° 51′ 29.600000″, 73° 59′ 8.400000″     | Signed+Signed   | Ok(Coordinate)                |
83//! | 48° 51′ 29.600000″ N, 73° 59′ 8.400000″ E | Labeled+Labeled | Ok(Coordinate)                |
84//! | 48° 51′ 29.600000″ W, 73° 59′ 8.400000″ N | Labeled+Labeled | Error(InvalidLatitude)        |
85//! | 48° 51′ 29.600000″ X, 73° 59′ 8.400000″ Y | Labeled+Labeled | Error(InvalidCharacter)       |
86//! | 48.858222, -73.985667                     | Decimal+Decimal | Ok(Coordinate)                |
87//! | +048:51:29.600000, 73° 59′ 8.400000″      | Bare+Signed     | Ok(Coordinate)                |
88//! | 48° 51′ 29.600000″, 73.985667             | Signed+Decimal  | Ok(Coordinate)                |
89//! | 48.858222, 73° 59′ 8.400000″              | Decimal+Signed  | Ok(Coordinate)                |
90//! | 48° 51′ 29.600000″, -73.985667            | Signed+Decimal  | Ok(Coordinate)                |
91//! | -48.858222, 73° 59′ 8.400000″             | Decimal+Signed  | Ok(Coordinate)                |
92//! | +048:51:29.600000,-073:59:08.400000       | Bare+Bare       | Ok(Coordinate)                |
93//! | +048:51:29.600000, -073:59:08.400000      | Bare+Bare       | Error(InvalidWhitespace)      |
94//!
95//! # Code Examples
96//!
97//! Parse individual angles:
98//!
99//! ```rust
100//! use lat_long::parse;
101//!
102//! assert!(parse::parse_str("48.858222").is_ok());
103//! assert!(parse::parse_str("-73.985667").is_ok());
104//! assert!(parse::parse_str("48° 51′ 29.600000″ N").is_ok());
105//! assert!(parse::parse_str("73° 59′  8.400000″ W").is_ok());
106//! assert!(parse::parse_str("+048:51:29.600000").is_ok());
107//! assert!(parse::parse_str("-073:59:08.400000").is_ok());
108//! ```
109//!
110//! Parse coordinates:
111//!
112//! ```rust
113//! use lat_long::parse;
114//!
115//! assert!(parse::parse_str("48.858222, -73.985667").is_ok());
116//! assert!(parse::parse_str("48° 51′ 29.600000″ N, 73° 59′ 8.400000″ W").is_ok());
117//! assert!(parse::parse_str("+048:51:29.600000,-073:59:08.400000").is_ok());
118//! assert!(parse::parse_str("+048:51:29.600000, 73° 59′ 8.400000″ W").is_ok());
119//! ```
120//!
121
122use crate::{Coordinate, Error, Latitude, Longitude, inner};
123use ordered_float::OrderedFloat;
124
125// ---------------------------------------------------------------------------
126// Public Types
127// ---------------------------------------------------------------------------
128
129///
130/// The result of a successful [`parse_str`] call.
131///
132/// A bare angle string yields [`Parsed::Angle`]; a comma-separated
133/// latitude/longitude string yields [`Parsed::Coordinate`].
134///
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub enum Parsed {
137    /// A single angle. See [`Value`] for the latitude / longitude / unknown
138    /// discrimination.
139    Angle(Value),
140    /// A complete latitude–longitude pair.
141    Coordinate(Coordinate),
142}
143
144///
145/// A parsed angular value, optionally tagged with its kind.
146///
147/// The labeled DMS format (`48° 51′ 29.6″ N`) carries an explicit
148/// `N`/`S`/`E`/`W` direction letter, which lets the parser commit to a
149/// specific concrete type. All other formats (decimal, signed DMS, bare DMS)
150/// are direction-agnostic and produce [`Value::Unknown`] — the caller is
151/// expected to resolve which axis the value belongs to.
152///
153#[derive(Clone, Copy, Debug, PartialEq)]
154pub enum Value {
155    /// A value whose direction (latitude vs longitude) is not specified
156    /// by its format (decimal or signed/bare DMS).
157    Unknown(OrderedFloat<f64>),
158    /// A labeled DMS value with direction N or S — known to be a latitude.
159    Latitude(Latitude),
160    /// A labeled DMS value with direction E or W — known to be a longitude.
161    Longitude(Longitude),
162}
163
164// ---------------------------------------------------------------------------
165// Public Functions
166// ---------------------------------------------------------------------------
167
168///
169/// Parse a string into a [`Parsed`] enum.
170///
171/// Accepts all four angle formats (decimal, signed DMS, labeled DMS, bare DMS)
172/// as individual values or as a comma-separated coordinate pair. See the
173/// module-level documentation for the full grammar and format rules.
174///
175pub fn parse_str(s: &str) -> Result<Parsed, Error> {
176    // Rule 1: no leading or trailing whitespace.
177    if s.starts_with(|c: char| c.is_ascii_whitespace())
178        || s.ends_with(|c: char| c.is_ascii_whitespace())
179    {
180        return Err(Error::InvalidWhitespace(s.to_string()));
181    }
182
183    if s.is_empty() {
184        return Err(Error::InvalidNumericFormat(s.to_string()));
185    }
186
187    // Look for a coordinate-pair comma.
188    match find_comma(s) {
189        Some(comma_pos) => parse_pair(s, comma_pos),
190        None => parse_single(s).map(Parsed::Angle),
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Private helpers: top-level dispatch
196// ---------------------------------------------------------------------------
197
198///
199/// Find the byte index of the first ASCII comma in `s`.
200///
201fn find_comma(s: &str) -> Option<usize> {
202    s.find(',')
203}
204
205///
206/// Parse a single-angle string (no comma present). Returns the [`Value`].
207///
208fn parse_single(s: &str) -> Result<Value, Error> {
209    // Try in order: labeled DMS (has °...″ + direction letter), signed DMS
210    // (has °...″ without direction), bare DMS (starts with +/-NNN:MM:SS),
211    // decimal.
212    if let Some(result) = try_labeled_dms(s) {
213        return result;
214    }
215    if let Some(result) = try_signed_dms(s) {
216        return result.map(Value::Unknown);
217    }
218    if let Some(result) = try_bare_dms(s) {
219        return result.map(Value::Unknown);
220    }
221    try_decimal(s).map(Value::Unknown)
222}
223
224///
225/// Parse a comma-separated coordinate pair. `comma_pos` is the byte index of
226/// the first comma in `s`.
227///
228fn parse_pair(s: &str, comma_pos: usize) -> Result<Parsed, Error> {
229    let lat_src = &s[..comma_pos];
230    let after_comma = &s[comma_pos + 1..];
231
232    // Detect whitespace around the comma.
233    let has_pre_ws = lat_src.ends_with(|c: char| c.is_ascii_whitespace());
234    let has_post_ws = after_comma.starts_with(|c: char| c.is_ascii_whitespace());
235    let comma_ws = has_pre_ws || has_post_ws;
236
237    let lat_src = lat_src.trim_end();
238    let lon_src = after_comma.trim_start();
239
240    // Guard: no more commas allowed in the longitude part.
241    if lon_src.contains(',') {
242        return Err(Error::InvalidCharacter(',', s.to_string()));
243    }
244
245    // Determine which format(s) each side uses.
246    let lat_is_bare = is_bare_dms(lat_src);
247    let lon_is_bare = is_bare_dms(lon_src);
248
249    // Rule: bare+bare must have no whitespace around the comma.
250    if lat_is_bare && lon_is_bare && comma_ws {
251        return Err(Error::InvalidWhitespace(s.to_string()));
252    }
253
254    // Parse the latitude slot.
255    let lat: Latitude = parse_as_latitude(lat_src)?;
256    // Parse the longitude slot.
257    let lon: Longitude = parse_as_longitude(lon_src)?;
258
259    Ok(Parsed::Coordinate(Coordinate::new(lat, lon)))
260}
261
262// ---------------------------------------------------------------------------
263// Slot-typed parsers used for coordinate pairs
264// ---------------------------------------------------------------------------
265
266///
267/// Parse `s` as the latitude slot of a coordinate pair.
268///
269/// Accepts: decimal, signed DMS, bare DMS (all produce `Unknown` → validated
270/// as latitude), or labeled DMS with N/S.
271///
272/// Rejects: labeled DMS with E/W.
273///
274fn parse_as_latitude(s: &str) -> Result<Latitude, Error> {
275    if let Some(result) = try_labeled_dms(s) {
276        return match result? {
277            Value::Latitude(lat) => Ok(lat),
278            Value::Longitude(_) => {
279                // E or W direction in the latitude slot.
280                let dir_char = s.chars().last().unwrap_or('?');
281                Err(Error::InvalidCharacter(dir_char, s.to_string()))
282            }
283            Value::Unknown(_) => unreachable!(),
284        };
285    }
286    // Signed, bare, or decimal — parse as float then validate latitude range.
287    let f = parse_as_float(s)?;
288    Latitude::try_from(f).map_err(|_| {
289        let deg = inner::to_degrees_minutes_seconds(f).0;
290        Error::InvalidLatitudeDegrees(deg)
291    })
292}
293
294///
295/// Parse `s` as the longitude slot of a coordinate pair.
296///
297/// Accepts: decimal, signed DMS, bare DMS (all produce `Unknown` → validated
298/// as longitude), or labeled DMS with E/W.
299///
300/// Rejects: labeled DMS with N/S.
301///
302fn parse_as_longitude(s: &str) -> Result<Longitude, Error> {
303    if let Some(result) = try_labeled_dms(s) {
304        return match result? {
305            Value::Longitude(lon) => Ok(lon),
306            Value::Latitude(_) => {
307                // N or S direction in the longitude slot.
308                let dir_char = s.chars().last().unwrap_or('?');
309                Err(Error::InvalidCharacter(dir_char, s.to_string()))
310            }
311            Value::Unknown(_) => unreachable!(),
312        };
313    }
314    let f = parse_as_float(s)?;
315    Longitude::try_from(f).map_err(|_| {
316        let deg = inner::to_degrees_minutes_seconds(f).0;
317        Error::InvalidLongitudeDegrees(deg)
318    })
319}
320
321///
322/// Parse any non-labeled format (decimal, signed DMS, bare DMS) into a raw
323/// float. Used when parsing a coordinate slot without a direction letter.
324///
325fn parse_as_float(s: &str) -> Result<OrderedFloat<f64>, Error> {
326    if let Some(result) = try_signed_dms(s) {
327        return result;
328    }
329    if let Some(result) = try_bare_dms(s) {
330        return result;
331    }
332    try_decimal(s)
333}
334
335// ---------------------------------------------------------------------------
336// Format detectors / sub-parsers
337// ---------------------------------------------------------------------------
338
339///
340/// Returns `true` if `s` looks like a bare-DMS token (`+NNN:MM:SS.sss…`).
341///
342fn is_bare_dms(s: &str) -> bool {
343    matches!(s.as_bytes().first(), Some(b'+') | Some(b'-')) && s.contains(':')
344}
345
346///
347/// `^(?<sign>[-+])?(?<degrees>\d{1,3})°\s*(?<minutes>\d{1,2})′\s*(?<seconds>\d{1,2}\.\d+)″$`
348///
349/// Returns `None` if the string doesn't contain the `°` symbol (not this
350/// format at all). Returns `Some(Err(_))` if it looks like this format but
351/// is malformed.
352///
353/// Returns `Some(Ok(Value::Latitude))` for N/S direction,
354/// `Some(Ok(Value::Longitude))` for E/W, `None` if no direction letter is
355/// found (caller should try signed DMS next).
356///
357fn try_labeled_dms(s: &str) -> Option<Result<Value, Error>> {
358    // Quick reject: must contain the degrees symbol.
359    if !s.contains('°') {
360        return None;
361    }
362
363    // The labeled variant does NOT start with a sign character — the polarity
364    // is encoded in the direction letter.  If we see a leading +/- it could
365    // be signed DMS; let the signed parser handle it.
366    if matches!(s.as_bytes().first(), Some(b'+') | Some(b'-')) {
367        return None;
368    }
369
370    let (deg_str, rest) = consume_up_to(s, '°')?;
371
372    // Rule 7: no whitespace between the degree value and the ° symbol.
373    if deg_str.ends_with(|c: char| c.is_ascii_whitespace()) {
374        return Some(Err(Error::InvalidWhitespace(s.to_string())));
375    }
376
377    let rest = skip_whitespace(rest);
378    let (min_str, rest) = consume_up_to(rest, '′')?;
379
380    // Rule 7: no whitespace before ′.
381    if min_str.ends_with(|c: char| c.is_ascii_whitespace()) {
382        return Some(Err(Error::InvalidWhitespace(s.to_string())));
383    }
384
385    let rest = skip_whitespace(rest);
386    let (sec_str, rest) = consume_up_to(rest, '″')?;
387
388    // Rule 7: no whitespace before ″.
389    if sec_str.ends_with(|c: char| c.is_ascii_whitespace()) {
390        return Some(Err(Error::InvalidWhitespace(s.to_string())));
391    }
392
393    // What remains after ″ must be optional whitespace + exactly one direction
394    // character (N/S/E/W) OR nothing (→ this is signed DMS, not labeled).
395    let rest = rest.trim();
396    if rest.is_empty() {
397        // No direction letter — this is actually a signed DMS value; let the
398        // signed parser handle it.
399        return None;
400    }
401
402    // Validate direction character.
403    let direction = match rest {
404        "N" | "S" | "E" | "W" => rest,
405        other => {
406            let bad = other.chars().next().unwrap_or('?');
407            return Some(Err(Error::InvalidCharacter(bad, s.to_string())));
408        }
409    };
410
411    // Parse the numeric components (positive; sign comes from direction).
412    let degrees = match parse_degrees(deg_str, 1, 3, false) {
413        Some(d) => d,
414        None => {
415            return Some(Err(Error::InvalidNumericFormat(deg_str.to_string())));
416        }
417    };
418    let minutes = match parse_minutes(min_str) {
419        Some(m) => m,
420        None => {
421            return Some(Err(Error::InvalidNumericFormat(min_str.to_string())));
422        }
423    };
424    let seconds = match parse_seconds(sec_str) {
425        Some(t) => t,
426        None => {
427            return Some(Err(Error::InvalidNumericFormat(sec_str.to_string())));
428        }
429    };
430
431    let neg = matches!(direction, "S" | "W");
432    let signed_degrees = if neg { -degrees } else { degrees };
433
434    let float = match inner::from_degrees_minutes_seconds(signed_degrees, minutes, seconds) {
435        Ok(f) => f,
436        Err(e) => return Some(Err(e)),
437    };
438
439    match direction {
440        "N" | "S" => match Latitude::try_from(float) {
441            Ok(lat) => Some(Ok(Value::Latitude(lat))),
442            Err(_) => Some(Err(Error::InvalidLatitudeDegrees(
443                inner::to_degrees_minutes_seconds(float).0,
444            ))),
445        },
446        "E" | "W" => match Longitude::try_from(float) {
447            Ok(lon) => Some(Ok(Value::Longitude(lon))),
448            Err(_) => Some(Err(Error::InvalidLongitudeDegrees(
449                inner::to_degrees_minutes_seconds(float).0,
450            ))),
451        },
452        _ => unreachable!(),
453    }
454}
455
456///
457/// `^(?<sign>[-+])?(?<degrees>\d{1,3})°\s*(?<minutes>\d{1,2})′\s*(?<seconds>\d{1,2}\.\d+)″$`
458///
459/// Returns `None` if the string doesn't look like a signed DMS value.
460///
461fn try_signed_dms(s: &str) -> Option<Result<OrderedFloat<f64>, Error>> {
462    if !s.contains('°') {
463        return None;
464    }
465
466    // Optional leading sign.
467    let (neg, s_inner) = consume_sign(s);
468
469    // Rule 2: if we consumed a sign, the very next character must NOT be whitespace.
470    if neg && s_inner.starts_with(|c: char| c.is_ascii_whitespace()) {
471        return Some(Err(Error::InvalidWhitespace(s.to_string())));
472    }
473
474    let (deg_str, rest) = consume_up_to(s_inner, '°')?;
475
476    // Rule 7: no whitespace before °.
477    if deg_str.ends_with(|c: char| c.is_ascii_whitespace()) {
478        return Some(Err(Error::InvalidWhitespace(s.to_string())));
479    }
480
481    let rest = skip_whitespace(rest);
482    let (min_str, rest) = consume_up_to(rest, '′')?;
483
484    if min_str.ends_with(|c: char| c.is_ascii_whitespace()) {
485        return Some(Err(Error::InvalidWhitespace(s.to_string())));
486    }
487
488    let rest = skip_whitespace(rest);
489    let (sec_str, rest) = consume_up_to(rest, '″')?;
490
491    if sec_str.ends_with(|c: char| c.is_ascii_whitespace()) {
492        return Some(Err(Error::InvalidWhitespace(s.to_string())));
493    }
494
495    // After ″ there must be nothing left for the signed variant.
496    if !rest.trim().is_empty() {
497        // There's a direction letter — this is labeled DMS, not signed.
498        return None;
499    }
500
501    let degrees = match parse_degrees(deg_str, 1, 3, neg) {
502        Some(d) => d,
503        None => return Some(Err(Error::InvalidNumericFormat(deg_str.to_string()))),
504    };
505    let minutes = match parse_minutes(min_str) {
506        Some(m) => m,
507        None => return Some(Err(Error::InvalidNumericFormat(min_str.to_string()))),
508    };
509    let seconds = match parse_seconds(sec_str) {
510        Some(t) => t,
511        None => return Some(Err(Error::InvalidNumericFormat(sec_str.to_string()))),
512    };
513
514    Some(inner::from_degrees_minutes_seconds(
515        degrees, minutes, seconds,
516    ))
517}
518
519///
520/// `^(?<sign>[-+])(?<degrees>\d{3}):(?<minutes>\d{2}):(?<seconds>\d{2}\.\d{4,})$`
521///
522/// Returns `None` if the string doesn't start with a sign followed by digits
523/// and colons.
524///
525fn try_bare_dms(s: &str) -> Option<Result<OrderedFloat<f64>, Error>> {
526    // Must start with mandatory sign.
527    let neg = match s.as_bytes().first()? {
528        b'+' => false,
529        b'-' => true,
530        _ => return None,
531    };
532    let s_inner = &s[1..];
533
534    // Must contain at least two colons.
535    if !s_inner.contains(':') {
536        return None;
537    }
538
539    let (deg_str, rest) = consume_up_to(s_inner, ':')?;
540    let (min_str, sec_str) = consume_up_to(rest, ':')?;
541
542    // Validate lengths: exactly 3 degree digits, exactly 2 minute digits.
543    if deg_str.len() != 3 || min_str.len() != 2 {
544        return Some(Err(Error::InvalidNumericFormat(s.to_string())));
545    }
546
547    // Seconds must be `DD.DDDD+` — at least 4 fractional digits.
548    let dot_pos = sec_str.find('.')?;
549    if dot_pos != 2 || sec_str.len() < dot_pos + 1 + 4 {
550        return Some(Err(Error::InvalidNumericFormat(s.to_string())));
551    }
552
553    let degrees = match parse_degrees(deg_str, 3, 3, neg) {
554        Some(d) => d,
555        None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
556    };
557    let minutes = match parse_minutes(min_str) {
558        Some(m) => m,
559        None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
560    };
561    let seconds = match parse_seconds(sec_str) {
562        Some(t) => t,
563        None => return Some(Err(Error::InvalidNumericFormat(s.to_string()))),
564    };
565
566    Some(inner::from_degrees_minutes_seconds(
567        degrees, minutes, seconds,
568    ))
569}
570
571///
572/// `^(?<sign>[-+])?(?<int>\d{1,3})(\.(?<frac>\d+)?)$`
573/// OR
574/// `^(?<int>\d{1,3})(\.(?<frac>\d+)?)(?<dir>[NSEW])$`
575///
576/// Returns an error (not `None`) on obvious format violations so callers can
577/// produce a good diagnostic.
578///
579fn try_decimal(s: &str) -> Result<OrderedFloat<f64>, Error> {
580    // Rule 2: check for sign followed by whitespace.
581    let had_explicit_sign = matches!(s.as_bytes().first(), Some(b'+') | Some(b'-'));
582    let (neg, rest) = consume_sign(s);
583    if neg && rest.starts_with(|c: char| c.is_ascii_whitespace()) {
584        return Err(Error::InvalidWhitespace(s.to_string()));
585    }
586
587    let maybe_direction = rest.chars().last().unwrap_or('\0');
588    // Validate direction character. A leading +/- combined with a trailing
589    // N/S/E/W is contradictory (labeled decimals carry their sign in the
590    // direction letter).
591    let (neg, directioned, rest) = match (had_explicit_sign, maybe_direction) {
592        (true, 'N' | 'S' | 'E' | 'W') => {
593            return Err(Error::InvalidNumericFormat(s.to_string()));
594        }
595        (_, 'S' | 'W') => (true, true, &rest[..rest.len() - 1]),
596        (_, 'N' | 'E') => (false, true, &rest[..rest.len() - 1]),
597        _ => (neg, false, rest),
598    };
599
600    // Un-labeled decimals require a `.`; labeled decimals may omit it.
601    let parts = rest.split('.').collect::<Vec<_>>();
602    let (int_part, frac_part) = match parts.len() {
603        1 if directioned => (parts[0], "0"),
604        2 => (parts[0], parts[1]),
605        _ => {
606            return Err(Error::InvalidNumericFormat(s.to_string()));
607        }
608    };
609
610    // Integer part: 1–3 digits.
611    if int_part.is_empty() || int_part.len() > 3 || !int_part.bytes().all(|b| b.is_ascii_digit()) {
612        return Err(Error::InvalidNumericFormat(s.to_string()));
613    }
614
615    // Fractional part: ≥1 digit.
616    if (frac_part.is_empty() || !frac_part.bytes().all(|b| b.is_ascii_digit())) && !directioned {
617        return Err(Error::InvalidNumericFormat(s.to_string()));
618    }
619
620    let int_val = parse_u32_digits(int_part.as_bytes())
621        .ok_or_else(|| Error::InvalidNumericFormat(s.to_string()))?;
622    let frac_val = parse_fraction(frac_part.as_bytes())
623        .ok_or_else(|| Error::InvalidNumericFormat(s.to_string()))?;
624
625    let magnitude = int_val as f64 + frac_val;
626    let signed = if neg { -magnitude } else { magnitude };
627    if signed.is_infinite() || signed.is_nan() {
628        Err(Error::InvalidNumericValue(signed))
629    } else {
630        Ok(OrderedFloat(signed))
631    }
632}
633
634// ---------------------------------------------------------------------------
635// Sub-parsers (pure, no allocation)
636// ---------------------------------------------------------------------------
637
638///
639/// Consume an optional leading `+` or `-`. Returns `(is_negative, rest_of_str)`.
640///
641fn consume_sign(s: &str) -> (bool, &str) {
642    match s.as_bytes().first() {
643        Some(b'+') => (false, &s[1..]),
644        Some(b'-') => (true, &s[1..]),
645        _ => (false, s),
646    }
647}
648
649///
650/// Return the slice before and after the first occurrence of Unicode `delim`.
651/// Returns `None` if the delimiter is not found.
652///
653fn consume_up_to(s: &str, delim: char) -> Option<(&str, &str)> {
654    let pos = s.find(delim)?;
655    Some((&s[..pos], &s[pos + delim.len_utf8()..]))
656}
657
658///
659/// Skip leading ASCII whitespace.
660///
661fn skip_whitespace(s: &str) -> &str {
662    s.trim_start_matches(|c: char| c.is_ascii_whitespace())
663}
664
665///
666/// Parse a degree string with `min_len..=max_len` digit count.
667/// `neg` folds the sign into the return value.
668///
669fn parse_degrees(s: &str, min_len: usize, max_len: usize, neg: bool) -> Option<i32> {
670    if s.len() < min_len || s.len() > max_len || !s.bytes().all(|b| b.is_ascii_digit()) {
671        return None;
672    }
673    let v = parse_u32_digits(s.as_bytes())? as i32;
674    Some(if neg { -v } else { v })
675}
676
677///
678/// Parse a minutes string (1–2 digits).
679///
680fn parse_minutes(s: &str) -> Option<u32> {
681    if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
682        return None;
683    }
684    parse_u32_digits(s.as_bytes())
685}
686
687///
688/// Parse a seconds string of the form `\d{1,2}\.\d+`.
689///
690fn parse_seconds(s: &str) -> Option<f32> {
691    let dot = s.find('.')?;
692    let int_part = &s[..dot];
693    let frac_part = &s[dot + 1..];
694    if int_part.is_empty()
695        || int_part.len() > 2
696        || frac_part.is_empty()
697        || !int_part.bytes().all(|b| b.is_ascii_digit())
698        || !frac_part.bytes().all(|b| b.is_ascii_digit())
699    {
700        return None;
701    }
702    let int_val = parse_u32_digits(int_part.as_bytes())?;
703    let frac_val = parse_fraction(frac_part.as_bytes())?;
704    Some((int_val as f64 + frac_val) as f32)
705}
706
707///
708/// Accumulate ASCII decimal digits into a `u32`. Returns `None` on non-digit
709/// bytes or overflow.
710///
711fn parse_u32_digits(bytes: &[u8]) -> Option<u32> {
712    let mut acc: u32 = 0;
713    for &b in bytes {
714        if !b.is_ascii_digit() {
715            return None;
716        }
717        acc = acc.checked_mul(10)?.checked_add((b - b'0') as u32)?;
718    }
719    Some(acc)
720}
721
722///
723/// Convert the fractional-digit bytes after a `.` into a `f64` in `[0, 1)`.
724///
725fn parse_fraction(bytes: &[u8]) -> Option<f64> {
726    let mut acc: f64 = 0.0;
727    let mut place: f64 = 0.1;
728    for &b in bytes {
729        if !b.is_ascii_digit() {
730            return None;
731        }
732        acc += (b - b'0') as f64 * place;
733        place *= 0.1;
734    }
735    Some(acc)
736}