1use super::Angle;
52use crate::AstroError;
53use once_cell::sync::Lazy;
54use regex::Regex;
55
56pub trait AngleUnits {
73 fn deg(&self) -> Result<Angle, AstroError>;
75 fn rad(&self) -> Result<Angle, AstroError>;
77 fn hours(&self) -> Result<Angle, AstroError>;
79 fn arcmin(&self) -> Result<Angle, AstroError>;
81 fn arcsec(&self) -> Result<Angle, AstroError>;
83 fn dms(&self) -> Result<Angle, AstroError>;
85 fn hms(&self) -> Result<Angle, AstroError>;
87}
88
89impl AngleUnits for str {
90 #[inline]
91 fn deg(&self) -> Result<Angle, AstroError> {
92 parse_decimal(self).map(Angle::from_degrees)
93 }
94
95 #[inline]
96 fn rad(&self) -> Result<Angle, AstroError> {
97 parse_decimal(self).map(Angle::from_radians)
98 }
99
100 #[inline]
101 fn hours(&self) -> Result<Angle, AstroError> {
102 parse_decimal(self).map(Angle::from_hours)
103 }
104
105 #[inline]
106 fn arcmin(&self) -> Result<Angle, AstroError> {
107 parse_decimal(self).map(|v| Angle::from_degrees(v / 60.0))
108 }
109
110 #[inline]
111 fn arcsec(&self) -> Result<Angle, AstroError> {
112 parse_decimal(self).map(|v| Angle::from_degrees(v / 3600.0))
113 }
114
115 #[inline]
116 fn dms(&self) -> Result<Angle, AstroError> {
117 parse_dms(self)
118 }
119
120 #[inline]
121 fn hms(&self) -> Result<Angle, AstroError> {
122 parse_hms(self)
123 }
124}
125
126pub trait ParseAngle {
134 fn to_angle(&self) -> Result<Angle, AstroError>;
138}
139
140impl ParseAngle for str {
141 fn to_angle(&self) -> Result<Angle, AstroError> {
142 parse_hms(self)
143 .or_else(|_| parse_dms(self))
144 .or_else(|_| parse_decimal(self).map(Angle::from_degrees))
145 }
146}
147
148fn parse_decimal(s: &str) -> Result<f64, AstroError> {
149 s.trim().parse::<f64>().map_err(|_| {
150 AstroError::calculation_error("parse_decimal", &format!("Cannot parse '{}' as number", s))
151 })
152}
153
154static HMS_REGEX: Lazy<Regex> = Lazy::new(|| {
155 Regex::new(
156 r#"(?xi)
157 ^\s*
158 ([+-])? # optional sign
159 (\d{1,3}) # hours (1-3 digits)
160 (?: # separator group
161 [:hH\s]+| # colons, h/H, spaces
162 h(?:ou)?r?s?\s* # hour/hours variants
163 )
164 (\d{1,2}) # minutes (1-2 digits)
165 (?: # separator group
166 [:mM\s']+| # colons, m/M, spaces, apostrophes
167 m(?:in(?:ute)?s?)?\s* # min/minute variants
168 )
169 (\d{1,2}(?:\.\d+)?) # seconds with optional decimal
170 (?: # optional trailing markers
171 [sS\s"']+| # s/S, spaces, quotes
172 s(?:ec(?:ond)?s?)? # sec/second variants
173 )?
174 \s*$
175 "#,
176 )
177 .unwrap()
178});
179
180static DMS_REGEX: Lazy<Regex> = Lazy::new(|| {
181 Regex::new(
182 r#"(?xi)
183 ^\s*
184 ([+-])? # optional sign
185 (\d{1,3}) # degrees (1-3 digits)
186 (?: # separator group
187 [dD\s:*]+| # d/D, colon, asterisk, spaces
188 d(?:eg(?:ree)?s?)?\s* # deg/degree variants
189 )
190 (\d{1,2}) # minutes (1-2 digits)
191 (?: # separator group
192 ['mM\s:]+| # apostrophes, m/M, spaces, colon
193 m(?:in(?:ute)?s?)?\s*| # min/minute variants
194 arc\s?m(?:in(?:ute)?s?)?\s* # arcmin/arcminute
195 )
196 (\d{1,2}(?:\.\d+)?) # seconds with optional decimal
197 (?: # optional trailing markers
198 ["'sS\s]+| # quotes, s/S, spaces
199 s(?:ec(?:ond)?s?)?| # sec/second variants
200 arc\s?s(?:ec(?:ond)?s?)? # arcsec/arcsecond
201 )?
202 \s*$
203 "#,
204 )
205 .unwrap()
206});
207
208static COLON_REGEX: Lazy<Regex> =
209 Lazy::new(|| Regex::new(r#"^\s*([+-])?(\d{1,4}):(\d{1,3}):(\d{1,3}(?:\.\d+)?)\s*$"#).unwrap());
210
211pub fn parse_hms(s: &str) -> Result<Angle, AstroError> {
218 let s = normalize_input(s);
219
220 if let Some(caps) = COLON_REGEX.captures(&s) {
221 return parse_hms_captures(caps, &s);
222 }
223
224 if let Some(caps) = HMS_REGEX.captures(&s) {
225 return parse_hms_captures(caps, &s);
226 }
227
228 Err(AstroError::calculation_error(
229 "parse_hms",
230 &format!("Cannot parse '{}' as HMS format", s),
231 ))
232}
233
234pub fn parse_dms(s: &str) -> Result<Angle, AstroError> {
241 let s = normalize_input(s);
242
243 if let Some(caps) = COLON_REGEX.captures(&s) {
244 return parse_dms_captures(caps, &s);
245 }
246
247 if let Some(caps) = DMS_REGEX.captures(&s) {
248 return parse_dms_captures(caps, &s);
249 }
250
251 Err(AstroError::calculation_error(
252 "parse_dms",
253 &format!("Cannot parse '{}' as DMS format", s),
254 ))
255}
256
257fn parse_hms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
258 let sign = caps
259 .get(1)
260 .map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
261 let hours: f64 = caps[2].parse().unwrap();
262 let minutes: f64 = caps[3].parse().unwrap();
263 let seconds: f64 = caps[4].parse().unwrap();
264
265 let total_hours = sign * (hours + minutes / 60.0 + seconds / 3600.0);
266 Ok(Angle::from_hours(total_hours))
267}
268
269fn parse_dms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
270 let sign = caps
271 .get(1)
272 .map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
273 let degrees: f64 = caps[2].parse().unwrap();
274 let minutes: f64 = caps[3].parse().unwrap();
275 let seconds: f64 = caps[4].parse().unwrap();
276
277 let total_degrees = sign * (degrees + minutes / 60.0 + seconds / 3600.0);
278 Ok(Angle::from_degrees(total_degrees))
279}
280
281fn normalize_input(s: &str) -> String {
282 let mut result = s.trim().to_string();
283
284 result = result.replace("degrees", "d");
285 result = result.replace("degree", "d");
286 result = result.replace("deg", "d");
287 result = result.replace('*', "d");
288
289 result = result.replace("arcminutes", "m");
290 result = result.replace("arcminute", "m");
291 result = result.replace("arcmin", "m");
292 result = result.replace("minutes", "m");
293 result = result.replace("minute", "m");
294 result = result.replace("min", "m");
295
296 result = result.replace("arcseconds", "s");
297 result = result.replace("arcsecond", "s");
298 result = result.replace("arcsec", "s");
299 result = result.replace("seconds", "s");
300 result = result.replace("second", "s");
301 result = result.replace("sec", "s");
302 result = result.replace("''", "\"");
303
304 result = result.replace("hours", "h");
305 result = result.replace("hour", "h");
306 result = result.replace("hrs", "h");
307 result = result.replace("hr", "h");
308
309 result
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::constants::PI;
316
317 const EPSILON: f64 = 1e-10;
318
319 #[test]
320 fn test_decimal_parsing() {
321 assert_eq!("45.5".deg().unwrap().degrees(), 45.5);
322 assert_eq!(format!("{:?}", PI).rad().unwrap().radians(), PI);
323 assert_eq!("12.5".hours().unwrap().hours(), 12.5);
324
325 assert!(("60.0".arcmin().unwrap().degrees() - 1.0).abs() < EPSILON);
326 assert!(("3600.0".arcsec().unwrap().degrees() - 1.0).abs() < EPSILON);
327
328 assert_eq!("-45.5".deg().unwrap().degrees(), -45.5);
329 assert_eq!("-12.5".hours().unwrap().hours(), -12.5);
330
331 assert_eq!(" 45.5 ".deg().unwrap().degrees(), 45.5);
332 assert_eq!(format!("\t{:?}\n", PI).rad().unwrap().radians(), PI);
333 }
334
335 #[test]
336 fn test_hms_colon_format() {
337 let angle = "12:34:56".hms().unwrap();
338 let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
339 assert!((angle.hours() - expected_hours).abs() < EPSILON);
340
341 let angle = "12:34:56.789".hms().unwrap();
342 let expected = 12.0 + 34.0 / 60.0 + 56.789 / 3600.0;
343 assert!((angle.hours() - expected).abs() < EPSILON);
344
345 let angle = "-5:30:45".hms().unwrap();
346 let expected = -(5.0 + 30.0 / 60.0 + 45.0 / 3600.0);
347 assert!((angle.hours() - expected).abs() < EPSILON);
348
349 assert!("0:0:0".hms().unwrap().hours() < EPSILON);
350 assert!("23:59:59.999".hms().is_ok());
351 }
352
353 #[test]
354 fn test_dms_colon_format() {
355 let angle = "45:30:15".dms().unwrap();
356 let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
357 assert!((angle.degrees() - expected_deg).abs() < EPSILON);
358
359 let angle = "+45:30:15.5".dms().unwrap();
360 let expected = 45.0 + 30.0 / 60.0 + 15.5 / 3600.0;
361 assert!((angle.degrees() - expected).abs() < EPSILON);
362
363 let angle = "-90:30:0".dms().unwrap();
364 let expected = -(90.0 + 30.0 / 60.0);
365 assert!((angle.degrees() - expected).abs() < EPSILON);
366 }
367
368 #[test]
369 fn test_hms_verbose_formats() {
370 let angle = "12h34m56s".hms().unwrap();
371 let expected = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
372 assert!((angle.hours() - expected).abs() < EPSILON);
373
374 let angle = "12H34M56S".hms().unwrap();
375 assert!((angle.hours() - expected).abs() < EPSILON);
376
377 let angle = "12h 34m 56s".hms().unwrap();
378 assert!((angle.hours() - expected).abs() < EPSILON);
379
380 let angle = "12 hours 34 minutes 56 seconds".hms().unwrap();
381 assert!((angle.hours() - expected).abs() < EPSILON);
382
383 let angle = "12hr 34min 56sec".hms().unwrap();
384 assert!((angle.hours() - expected).abs() < EPSILON);
385 }
386
387 #[test]
388 fn test_dms_verbose_formats() {
389 let angle = "45d30m15s".dms().unwrap();
390 let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
391 assert!((angle.degrees() - expected).abs() < EPSILON);
392
393 let angle = "45*30m15s".dms().unwrap();
394 assert!((angle.degrees() - expected).abs() < EPSILON);
395
396 let angle = "45 degrees 30 minutes 15 seconds".dms().unwrap();
397 assert!((angle.degrees() - expected).abs() < EPSILON);
398
399 let angle = "45deg 30min 15sec".dms().unwrap();
400 assert!((angle.degrees() - expected).abs() < EPSILON);
401
402 let angle = "45d 30 arcmin 15 arcsec".dms().unwrap();
403 assert!((angle.degrees() - expected).abs() < EPSILON);
404 }
405
406 #[test]
407 fn test_quote_formats() {
408 let angle = "45d 30' 15\"".dms().unwrap();
409 let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
410 assert!((angle.degrees() - expected).abs() < EPSILON);
411
412 let angle = "45d 30' 15''".dms().unwrap();
413 assert!((angle.degrees() - expected).abs() < EPSILON);
414 }
415
416 #[test]
417 fn test_auto_detection() {
418 let angle = "12:34:56".to_angle().unwrap();
419 let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
420 assert!((angle.hours() - expected_hours).abs() < EPSILON);
421
422 let angle = "45d30m15s".to_angle().unwrap();
423 let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
424 assert!((angle.degrees() - expected_deg).abs() < EPSILON);
425
426 let angle = "45.5".to_angle().unwrap();
427 assert_eq!(angle.degrees(), 45.5);
428 }
429
430 #[test]
431 fn test_edge_cases() {
432 assert!("0:0:0".hms().unwrap().radians().abs() < EPSILON);
433 assert!("0:0:0".dms().unwrap().radians().abs() < EPSILON);
434 assert!("0".deg().unwrap().radians().abs() < EPSILON);
435
436 assert!("359:59:59".dms().is_ok());
437 assert!("999:59:59".hms().is_ok());
438
439 let angle = "12:34:56.123456789".hms().unwrap();
440 assert!(angle.hours() > 12.0);
441
442 assert!("01:02:03".hms().is_ok());
443 assert!("001:02:03".dms().is_ok());
444 }
445
446 #[test]
447 fn test_error_cases() {
448 assert!("not_a_number".deg().is_err());
449 assert!("12:34".hms().is_err());
450 assert!("12:34:".hms().is_err());
451 assert!(":12:34".hms().is_err());
452
453 assert!("".deg().is_err());
454 assert!(" ".deg().is_err());
455 }
456
457 #[test]
458 fn test_sign_handling() {
459 assert!("+45:30:15".dms().unwrap().degrees() > 0.0);
460 assert!("+12:34:56".hms().unwrap().hours() > 0.0);
461
462 assert!("-45:30:15".dms().unwrap().degrees() < 0.0);
463 assert!("-12:34:56".hms().unwrap().hours() < 0.0);
464
465 assert!("45:-30:15".dms().is_err());
466 assert!("12:34:-56".hms().is_err());
467 }
468
469 #[test]
470 fn test_whitespace_tolerance() {
471 assert!(" 45:30:15 ".dms().is_ok());
472 assert!("\t12:34:56\n".hms().is_ok());
473
474 assert!("45 : 30 : 15".dms().is_ok());
475 assert!("12 h 34 m 56 s".hms().is_ok());
476 }
477
478 #[test]
479 fn test_precision_preservation() {
480 let input_deg = 123.456789012345;
481 let angle = format!("{}", input_deg).deg().unwrap();
482 assert!((angle.degrees() - input_deg).abs() < 1e-12);
483
484 let angle = "12:34:56.123456".hms().unwrap();
485 let back_to_hms = angle.hours();
486 let expected = 12.0 + 34.0 / 60.0 + 56.123456 / 3600.0;
487 assert!((back_to_hms - expected).abs() < 1e-9);
488 }
489}