1use super::Angle;
79use core::fmt;
80
81pub struct DmsFmt {
115 pub frac_digits: u8,
116}
117
118pub struct HmsFmt {
152 pub frac_digits: u8,
153}
154
155impl DmsFmt {
156 #[inline]
169 pub fn fmt(&self, a: Angle) -> String {
170 let sign = if a.degrees() < 0.0 { '-' } else { '+' };
171 let mut d = a.degrees().abs();
172 let deg = libm::trunc(d);
173 d = (d - deg) * 60.0;
174 let min = libm::trunc(d);
175 let sec = (d - min) * 60.0;
176 format!(
177 "{sign}{deg:.0}° {min:.0}' {sec:.*}\"",
178 self.frac_digits as usize
179 )
180 }
181}
182
183impl HmsFmt {
184 #[inline]
198 pub fn fmt(&self, a: Angle) -> String {
199 let mut h = a.hours();
200 h = h.rem_euclid(24.0);
201 let hh = libm::trunc(h);
202 h = (h - hh) * 60.0;
203 let mm = libm::trunc(h);
204 let ss = (h - mm) * 60.0;
205 format!("{hh:.0}ʰ {mm:.0}ᵐ {ss:.*}ˢ", self.frac_digits as usize)
206 }
207}
208
209impl fmt::Display for Angle {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 write!(f, "{:.6}°", self.degrees())
216 }
217}
218
219pub struct ParsedAngle {
233 pub angle: Angle,
235}
236
237pub fn parse_angle(s: &str) -> Result<ParsedAngle, crate::AstroError> {
286 parse_hms(s).or_else(|_| parse_dms(s))
287}
288
289fn parse_hms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
293 let s = s.trim();
294 let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
295 let s = s.trim_start_matches(['+', '-']);
296
297 let parts: Vec<&str> = s
298 .split(['h', 'ʰ', 'm', 'ᵐ', 's', 'ˢ', ':'])
299 .map(|p| p.trim())
300 .filter(|p| !p.is_empty())
301 .collect();
302
303 if parts.is_empty() {
304 return Err(crate::AstroError::math_error(
305 "parse_hms",
306 crate::errors::MathErrorKind::InvalidInput,
307 "Empty string",
308 ));
309 }
310
311 if parts.len() > 3 {
312 return Err(crate::AstroError::math_error(
313 "parse_hms",
314 crate::errors::MathErrorKind::InvalidInput,
315 "Too many components (max 3: hours, minutes, seconds)",
316 ));
317 }
318
319 let h = parts[0].parse::<f64>().map_err(|_| {
320 crate::AstroError::math_error(
321 "parse_hms",
322 crate::errors::MathErrorKind::InvalidInput,
323 "Invalid hours",
324 )
325 })?;
326
327 let m = if parts.len() > 1 {
328 parts[1].parse::<f64>().map_err(|_| {
329 crate::AstroError::math_error(
330 "parse_hms",
331 crate::errors::MathErrorKind::InvalidInput,
332 "Invalid minutes",
333 )
334 })?
335 } else {
336 0.0
337 };
338
339 let sec = if parts.len() > 2 {
340 parts[2].parse::<f64>().map_err(|_| {
341 crate::AstroError::math_error(
342 "parse_hms",
343 crate::errors::MathErrorKind::InvalidInput,
344 "Invalid seconds",
345 )
346 })?
347 } else {
348 0.0
349 };
350
351 if parts.len() > 1 && h - libm::trunc(h) != 0.0 {
352 return Err(crate::AstroError::math_error(
353 "parse_hms",
354 crate::errors::MathErrorKind::InvalidInput,
355 "Cannot mix fractional hours with minutes/seconds",
356 ));
357 }
358
359 if !(0.0..60.0).contains(&m) {
360 return Err(crate::AstroError::math_error(
361 "parse_hms",
362 crate::errors::MathErrorKind::InvalidInput,
363 "Minutes must be in range [0, 60)",
364 ));
365 }
366
367 if !(0.0..60.0).contains(&sec) {
368 return Err(crate::AstroError::math_error(
369 "parse_hms",
370 crate::errors::MathErrorKind::InvalidInput,
371 "Seconds must be in range [0, 60)",
372 ));
373 }
374
375 Ok(ParsedAngle {
376 angle: Angle::from_hours(sign * (h.abs() + m / 60.0 + sec / 3600.0)),
377 })
378}
379
380fn parse_dms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
384 let s = s.trim();
385 let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
386 let s = s.trim_start_matches(['+', '-']);
387
388 let parts: Vec<&str> = s
389 .split(['°', '\'', '"', ':', 'd', 'm', 's'])
390 .map(|p| p.trim())
391 .filter(|p| !p.is_empty())
392 .collect();
393
394 if parts.is_empty() {
395 return Err(crate::AstroError::math_error(
396 "parse_dms",
397 crate::errors::MathErrorKind::InvalidInput,
398 "Empty string",
399 ));
400 }
401
402 if parts.len() > 3 {
403 return Err(crate::AstroError::math_error(
404 "parse_dms",
405 crate::errors::MathErrorKind::InvalidInput,
406 "Too many components (max 3: degrees, arcminutes, arcseconds)",
407 ));
408 }
409
410 let deg = parts[0].parse::<f64>().map_err(|_| {
411 crate::AstroError::math_error(
412 "parse_dms",
413 crate::errors::MathErrorKind::InvalidInput,
414 "Invalid degrees",
415 )
416 })?;
417
418 let min = if parts.len() > 1 {
419 parts[1].parse::<f64>().map_err(|_| {
420 crate::AstroError::math_error(
421 "parse_dms",
422 crate::errors::MathErrorKind::InvalidInput,
423 "Invalid arcminutes",
424 )
425 })?
426 } else {
427 0.0
428 };
429
430 let sec = if parts.len() > 2 {
431 parts[2].parse::<f64>().map_err(|_| {
432 crate::AstroError::math_error(
433 "parse_dms",
434 crate::errors::MathErrorKind::InvalidInput,
435 "Invalid arcseconds",
436 )
437 })?
438 } else {
439 0.0
440 };
441
442 if parts.len() > 1 && deg - libm::trunc(deg) != 0.0 {
443 return Err(crate::AstroError::math_error(
444 "parse_dms",
445 crate::errors::MathErrorKind::InvalidInput,
446 "Cannot mix fractional degrees with arcminutes/arcseconds",
447 ));
448 }
449
450 if !(0.0..60.0).contains(&min) {
451 return Err(crate::AstroError::math_error(
452 "parse_dms",
453 crate::errors::MathErrorKind::InvalidInput,
454 "Arcminutes must be in range [0, 60)",
455 ));
456 }
457
458 if !(0.0..60.0).contains(&sec) {
459 return Err(crate::AstroError::math_error(
460 "parse_dms",
461 crate::errors::MathErrorKind::InvalidInput,
462 "Arcseconds must be in range [0, 60)",
463 ));
464 }
465
466 Ok(ParsedAngle {
467 angle: Angle::from_degrees(sign * (deg.abs() + min / 60.0 + sec / 3600.0)),
468 })
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn test_hms_format_normal() {
477 let a = Angle::from_hours(12.5);
478 let fmt = HmsFmt { frac_digits: 2 };
479 let result = fmt.fmt(a);
480 assert!(result.contains("12ʰ"));
481 assert!(result.contains("30ᵐ"));
482 }
483
484 #[test]
485 fn test_hms_format_extreme_positive() {
486 let a = Angle::from_degrees(720.0);
487 let fmt = HmsFmt { frac_digits: 0 };
488 let result = fmt.fmt(a);
489 assert!(result.contains("0ʰ"));
490 }
491
492 #[test]
493 fn test_hms_format_extreme_negative() {
494 let a = Angle::from_degrees(-750.0);
495 let fmt = HmsFmt { frac_digits: 0 };
496 let result = fmt.fmt(a);
497 assert!(result.contains("22ʰ"));
498 }
499
500 #[test]
501 fn test_dms_format_negative_with_precision() {
502 let a = Angle::from_degrees(-12.345678);
503 let fmt = DmsFmt { frac_digits: 2 };
504 let result = fmt.fmt(a);
505 assert_eq!(result, "-12° 20' 44.44\"");
506 }
507
508 #[test]
509 fn test_hms_format_wraps_negative_angle() {
510 let a = Angle::from_hours(-1.5);
511 let fmt = HmsFmt { frac_digits: 1 };
512 let result = fmt.fmt(a);
513 assert_eq!(result, "22ʰ 30ᵐ 0.0ˢ");
514 }
515
516 #[test]
517 fn test_angle_display_precision() {
518 let a = Angle::from_degrees(1.23456789);
519 assert_eq!(format!("{a}"), "1.234568°");
520 }
521
522 #[test]
523 fn test_parse_hms() {
524 let result = parse_hms("12h30m15s").unwrap();
525 assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
526 }
527
528 #[test]
529 fn test_parse_hms_unicode() {
530 let result = parse_hms("12ʰ30ᵐ15ˢ").unwrap();
531 assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
532 }
533
534 #[test]
535 fn test_parse_hms_colon() {
536 let result = parse_hms("12:30:15").unwrap();
537 assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
538 }
539
540 #[test]
541 fn test_parse_hms_partial() {
542 let result = parse_hms("12h").unwrap();
543 assert_eq!(result.angle.hours(), 12.0);
544 }
545
546 #[test]
547 fn test_parse_dms_positive() {
548 let result = parse_dms("45°30'15\"").unwrap();
549 assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
550 }
551
552 #[test]
553 fn test_parse_dms_negative() {
554 let result = parse_dms("-45°30'15\"").unwrap();
555 assert!((result.angle.degrees() + 45.50416666666667).abs() < 1e-10);
556 }
557
558 #[test]
559 fn test_parse_dms_colon() {
560 let result = parse_dms("45:30:15").unwrap();
561 assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
562 }
563
564 #[test]
565 fn test_parse_dms_partial() {
566 let result = parse_dms("45°").unwrap();
567 assert_eq!(result.angle.degrees(), 45.0);
568 }
569
570 #[test]
571 fn test_parse_angle_dispatch_hms() {
572 let result = parse_angle("12h30m15s").unwrap();
573 assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
574 }
575
576 #[test]
577 fn test_parse_angle_dispatch_dms() {
578 let result = parse_angle("45°30'15\"").unwrap();
579 assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
580 }
581
582 #[test]
583 fn test_parse_hms_negative() {
584 let result = parse_hms("-01:30:00").unwrap();
585 assert_eq!(result.angle.hours(), -1.5);
586 }
587
588 #[test]
589 fn test_parse_hms_negative_with_seconds() {
590 let result = parse_hms("-12h30m45s").unwrap();
591 assert_eq!(result.angle.hours(), -12.5125);
592 }
593
594 #[test]
595 fn test_parse_hms_invalid_minutes() {
596 let result = parse_hms("12h99m00s");
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_parse_hms_invalid_seconds() {
602 let result = parse_hms("12h30m80s");
603 assert!(result.is_err());
604 }
605
606 #[test]
607 fn test_parse_dms_invalid_arcminutes() {
608 let result = parse_dms("45°80'00\"");
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn test_parse_dms_invalid_arcseconds() {
614 let result = parse_dms("45°30'99\"");
615 assert!(result.is_err());
616 }
617
618 #[test]
619 fn test_parse_dms_negative_with_minutes_seconds() {
620 let result = parse_dms("-45°30'15\"").unwrap();
621 assert_eq!(result.angle.degrees(), -45.50416666666667);
622 }
623
624 #[test]
625 fn test_parse_hms_rejects_fractional_hours_with_minutes() {
626 let result = parse_hms("12.5h30m");
627 assert!(result.is_err());
628 }
629
630 #[test]
631 fn test_parse_hms_rejects_empty_string() {
632 let result = parse_hms("");
633 assert!(result.is_err());
634 }
635
636 #[test]
637 fn test_parse_hms_rejects_too_many_components() {
638 let result = parse_hms("12:30:15:99");
639 assert!(result.is_err());
640 }
641
642 #[test]
643 fn test_parse_hms_accepts_fractional_hours_alone() {
644 let result = parse_hms("12.5h").unwrap();
645 assert_eq!(result.angle.hours(), 12.5);
646 }
647
648 #[test]
649 fn test_parse_dms_rejects_fractional_degrees_with_arcminutes() {
650 let result = parse_dms("45.5°30'");
651 assert!(result.is_err());
652 }
653
654 #[test]
655 fn test_parse_dms_rejects_empty_string() {
656 let result = parse_dms(" ");
657 assert!(result.is_err());
658 }
659
660 #[test]
661 fn test_parse_dms_rejects_too_many_components() {
662 let result = parse_dms("45:30:15:99");
663 assert!(result.is_err());
664 }
665
666 #[test]
667 fn test_parse_dms_accepts_fractional_degrees_alone() {
668 let result = parse_dms("45.5°").unwrap();
669 assert_eq!(result.angle.degrees(), 45.5);
670 }
671
672 #[test]
673 fn test_parse_dms_invalid_degrees() {
674 let result = parse_dms("abc°");
675 assert!(result.is_err());
676 }
677
678 #[test]
679 fn test_parse_angle_fails_for_unknown_format() {
680 let result = parse_angle("not an angle");
681 assert!(result.is_err());
682 }
683}