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}