1use regex::Regex;
30use thiserror::Error;
31
32use std::sync::OnceLock;
33
34static DMS_REGEX: OnceLock<Regex> = OnceLock::new();
35
36fn get_dms_regex() -> &'static Regex {
38 DMS_REGEX.get_or_init(|| {
39 Regex::new(
40 r#"(?i)([NSEW])?\s?(-)?(\d+(?:\.\d+)?)[°º:d\s]?\s?(?:(\d+(?:\.\d+)?)['’‘′:]?\s?(?:(\d{1,2}(?:\.\d+)?)(?:"|″|’’|'')?)?)?\s?([NSEW])?"#
41 ).unwrap()
42 })
43}
44
45#[derive(Debug, Clone, Copy)]
56pub struct Coordinate {
57 pub lat: f64,
58 pub lng: f64,
59}
60
61pub fn parse(input: &str) -> Result<Coordinate, Error> {
95 let dms_str = input.trim();
96 let matched = regex_match(dms_str).ok_or(Error::CouldNotParse("no matches found"))?;
97
98 let mut parts: Vec<&str> = (0..matched.len())
99 .map(|i| matched.get(i).map_or("", |m| m.as_str()))
100 .collect();
101
102 if parts.len() < 7 {
103 return Err(Error::CouldNotParse("not enough matches found"));
104 }
105
106 let secondary_dms = if !parts[1].is_empty() {
107 parts[6] = "";
108 dms_str[parts[0].len() - 1..].trim()
109 } else {
110 dms_str[parts[0].len()..].trim()
111 };
112
113 let mut degree_1 = dec_deg_from_parts(parts)?;
114
115 let secondary_parts: Vec<&str> = match regex_match(secondary_dms) {
116 None => vec![],
117 Some(secondary_matched) => (0..secondary_matched.len())
118 .map(|i| secondary_matched.get(i).map_or("", |m| m.as_str()))
119 .collect(),
120 };
121
122 let mut degree_2 = if secondary_parts.is_empty() {
123 (None, None)
124 } else {
125 dec_deg_from_parts(secondary_parts)?
126 };
127
128 if degree_1.1.is_none() {
129 if degree_1.0.is_some() && degree_2.0.is_none() {
130 return Ok(Coordinate {
131 lat: degree_1.0.unwrap_or(0.0),
132 lng: 0.0,
133 });
134 } else if degree_1.0.is_some() && degree_2.0.is_some() {
136 degree_1.1 = Some(CoordinatePart::Lat);
137 degree_2.1 = Some(CoordinatePart::Lng);
138 } else {
139 return Err(Error::CouldNotParse(
140 "provided string does not have lat or lng",
141 ));
142 }
143 }
144
145 let degree_1_is_lat = matches!(
146 degree_1.1.unwrap_or(CoordinatePart::Lat),
147 CoordinatePart::Lat
148 );
149
150 if degree_2.1.is_none() {
151 if degree_1_is_lat {
152 degree_2.1 = Some(CoordinatePart::Lng);
153 } else {
154 degree_2.1 = Some(CoordinatePart::Lat);
155 };
156 };
157
158 if degree_1_is_lat {
159 Ok(Coordinate {
160 lat: degree_1.0.unwrap_or_default(),
161 lng: degree_2.0.unwrap_or_default(),
162 })
163 } else {
164 Ok(Coordinate {
165 lat: degree_2.0.unwrap_or_default(),
166 lng: degree_1.0.unwrap_or_default(),
167 })
168 }
169}
170
171fn dec_deg_from_parts(parts: Vec<&str>) -> Result<(Option<f64>, Option<CoordinatePart>), Error> {
172 let sign = direction_to_sign(parts[2])
173 .or_else(|| direction_to_sign(parts[1]))
174 .or_else(|| direction_to_sign(parts[6]))
175 .unwrap_or(1.0);
176
177 let degrees = match correct_str_num(parts[3]) {
178 None => return Ok((None, None)),
179 Some(d) => d,
180 };
181
182 let minutes: f64 = match correct_str_num(parts[4]) {
183 None => return Ok((None, None)),
184 Some(d) => d,
185 };
186
187 let seconds: f64 = match correct_str_num(parts[5]) {
188 None => return Ok((None, None)),
189 Some(d) => d,
190 };
191
192 let lat_lng = direction_to_lat_lng(parts[1]).or_else(|| direction_to_lat_lng(parts[6]));
193
194 if !is_in_range(degrees, 0.0, 180.0) {
195 return Err(Error::CouldNotParse("degress is not in the range [0, 180]"));
196 }
197
198 if !is_in_range(minutes, 0.0, 60.0) {
199 return Err(Error::CouldNotParse("minutes is not in the range [0, 60]"));
200 }
201
202 if !is_in_range(seconds, 0.0, 60.0) {
203 return Err(Error::CouldNotParse("seconds is not in the range [0, 60]"));
204 }
205
206 let decimal_degree = sign * (degrees + minutes / 60.0 + seconds / (60.0 * 60.0));
207
208 Ok((Some(decimal_degree), lat_lng))
209}
210
211#[derive(Error, Debug)]
212pub enum Error {
213 #[error("{0}")]
214 CouldNotParse(&'static str),
215}
216
217impl PartialEq for Coordinate {
218 fn eq(&self, other: &Self) -> bool {
219 self.lat == other.lat && self.lng == other.lng
220 }
221}
222
223impl Eq for Coordinate {}
224
225enum CoordinatePart {
226 Lat,
227 Lng,
228}
229
230fn direction_to_sign(dir: &str) -> Option<f64> {
231 match dir {
232 "-" => Some(-1.0),
233 "N" => Some(1.0),
234 "S" => Some(-1.0),
235 "E" => Some(1.0),
236 "W" => Some(-1.0),
237 _ => None,
238 }
239}
240
241fn direction_to_lat_lng(dir: &str) -> Option<CoordinatePart> {
242 match dir {
243 "N" => Some(CoordinatePart::Lat),
244 "S" => Some(CoordinatePart::Lat),
245 "E" => Some(CoordinatePart::Lng),
246 "W" => Some(CoordinatePart::Lng),
247 _ => None,
248 }
249}
250
251fn correct_str_num(str: &str) -> Option<f64> {
252 if str.is_empty() {
253 return Some(0.0);
254 }
255
256 str.parse().ok()
257}
258
259fn regex_match(dms_string: &str) -> Option<regex::Captures<'_>> {
260 get_dms_regex().captures(dms_string)
261}
262
263fn is_in_range(v: f64, min: f64, max: f64) -> bool {
264 v >= min && v <= max
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_parses_dms_pairs_with_different_separators_hemisphere_at_end() {
273 let test_data = [
274 "59°12'7.7\"N 02°15'39.6\"W",
275 "59º12'7.7\"N 02º15'39.6\"W",
276 "59 12' 7.7\" N 02 15' 39.6\" W",
277 "59 12'7.7''N 02 15'39.6'' W",
278 "59:12:7.7\"N 2:15:39.6W",
279 "59 12'7.7''N 02 15'39.6''W",
280 ];
281
282 let expected = Coordinate {
283 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
284 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
285 };
286
287 for test_str in test_data.iter() {
288 assert_eq!(parse(test_str).unwrap(), expected);
289 }
290 }
291
292 #[test]
293 fn test_parses_dms_pairs_with_hemisphere_at_beginning() {
294 let test_data = [
295 "N59°12'7.7\" W02°15'39.6\"",
296 "N 59°12'7.7\" W 02°15'39.6\"",
297 "N 59.20213888888889° W 2.261°",
298 "N 59.20213888888889 W 2.261",
299 "W02°15'39.6\" N59°12'7.7\"",
300 ];
301
302 let expected = Coordinate {
303 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
304 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
305 };
306
307 for test_str in test_data.iter() {
308 assert_eq!(parse(test_str).unwrap(), expected);
309 }
310 }
311
312 #[test]
313 fn test_parses_different_separators_between_pairs() {
314 let test_data = [
315 "59°12'7.7\"N 02°15'39.6\"W",
316 "59°12'7.7\"N , 02°15'39.6\"W",
317 "59°12'7.7\"N,02°15'39.6\"W",
318 ];
319
320 let expected = Coordinate {
321 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
322 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
323 };
324
325 for test_str in test_data.iter() {
326 assert_eq!(parse(test_str).unwrap(), expected);
327 }
328 }
329
330 #[test]
331 fn test_parses_single_coordinate_with_hemisphere() {
332 let test_data = ["59°12'7.7\"N", "02°15'39.6\"W"];
333
334 let expected = [
335 Coordinate {
336 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
337 lng: 0.0,
338 },
339 Coordinate {
340 lat: 0.0,
341 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
342 },
343 ];
344
345 for (test_str, expected) in test_data.iter().zip(expected.iter()) {
346 println!("{:?} <----> {:?}", expected, &parse(test_str).unwrap());
347 assert_eq!(&parse(test_str).unwrap(), expected);
348 }
349 }
350
351 #[test]
352 fn test_infers_first_coordinate_is_lat() {
353 let test_data = ["59°12'7.7\" -02°15'39.6\"", "59°12'7.7\", -02°15'39.6\""];
354
355 let expected = Coordinate {
356 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
357 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
358 };
359
360 for test_str in test_data.iter() {
361 assert_eq!(parse(test_str).unwrap(), expected);
362 }
363 }
364
365 #[test]
366 fn test_fails_for_invalid_data() {
367 assert!(parse("Not DMS string").is_err());
368 }
369
370 #[test]
371 fn test_decimal_degrees_parsed_correctly() {
372 let test_data = ["51.5, -0.126", "51.5,-0.126", "51.5 -0.126"];
373
374 let expected = Coordinate {
375 lat: 51.5,
376 lng: -0.126,
377 };
378
379 for test_str in test_data.iter() {
380 assert_eq!(parse(test_str).unwrap(), expected);
381 }
382 }
383
384 #[test]
385 fn test_dms_with_separators_and_spaces() {
386 let test_data = [
387 "59° 12' 7.7\" N 02° 15' 39.6\" W",
388 "59º 12' 7.7\" N 02º 15' 39.6\" W",
389 "59 12' 7.7''N 02 15' 39.6''W",
390 ];
391
392 let expected = Coordinate {
393 lat: 59.0 + 12.0 / 60.0 + 7.7 / 3600.0,
394 lng: -1.0 * (2.0 + 15.0 / 60.0 + 39.6 / 3600.0),
395 };
396
397 for test_str in test_data.iter() {
398 assert_eq!(parse(test_str).unwrap(), expected);
399 }
400 }
401}