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