use super::Angle;
use core::fmt;
pub struct DmsFmt {
pub frac_digits: u8,
}
pub struct HmsFmt {
pub frac_digits: u8,
}
impl DmsFmt {
#[inline]
pub fn fmt(&self, a: Angle) -> String {
let sign = if a.degrees() < 0.0 { '-' } else { '+' };
let mut d = a.degrees().abs();
let deg = libm::trunc(d);
d = (d - deg) * 60.0;
let min = libm::trunc(d);
let sec = (d - min) * 60.0;
format!(
"{sign}{deg:.0}° {min:.0}' {sec:.*}\"",
self.frac_digits as usize
)
}
}
impl HmsFmt {
#[inline]
pub fn fmt(&self, a: Angle) -> String {
let mut h = a.hours();
h = h.rem_euclid(24.0);
let hh = libm::trunc(h);
h = (h - hh) * 60.0;
let mm = libm::trunc(h);
let ss = (h - mm) * 60.0;
format!("{hh:.0}ʰ {mm:.0}ᵐ {ss:.*}ˢ", self.frac_digits as usize)
}
}
impl fmt::Display for Angle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.6}°", self.degrees())
}
}
pub struct ParsedAngle {
pub angle: Angle,
}
pub fn parse_angle(s: &str) -> Result<ParsedAngle, crate::AstroError> {
parse_hms(s).or_else(|_| parse_dms(s))
}
fn parse_hms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
let s = s.trim();
let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
let s = s.trim_start_matches(['+', '-']);
let parts: Vec<&str> = s
.split(['h', 'ʰ', 'm', 'ᵐ', 's', 'ˢ', ':'])
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if parts.is_empty() {
return Err(crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Empty string",
));
}
if parts.len() > 3 {
return Err(crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Too many components (max 3: hours, minutes, seconds)",
));
}
let h = parts[0].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid hours",
)
})?;
let m = if parts.len() > 1 {
parts[1].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid minutes",
)
})?
} else {
0.0
};
let sec = if parts.len() > 2 {
parts[2].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid seconds",
)
})?
} else {
0.0
};
if parts.len() > 1 && h - libm::trunc(h) != 0.0 {
return Err(crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Cannot mix fractional hours with minutes/seconds",
));
}
if !(0.0..60.0).contains(&m) {
return Err(crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Minutes must be in range [0, 60)",
));
}
if !(0.0..60.0).contains(&sec) {
return Err(crate::AstroError::math_error(
"parse_hms",
crate::errors::MathErrorKind::InvalidInput,
"Seconds must be in range [0, 60)",
));
}
Ok(ParsedAngle {
angle: Angle::from_hours(sign * (h.abs() + m / 60.0 + sec / 3600.0)),
})
}
fn parse_dms(s: &str) -> Result<ParsedAngle, crate::AstroError> {
let s = s.trim();
let sign = if s.starts_with('-') { -1.0 } else { 1.0 };
let s = s.trim_start_matches(['+', '-']);
let parts: Vec<&str> = s
.split(['°', '\'', '"', ':', 'd', 'm', 's'])
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if parts.is_empty() {
return Err(crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Empty string",
));
}
if parts.len() > 3 {
return Err(crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Too many components (max 3: degrees, arcminutes, arcseconds)",
));
}
let deg = parts[0].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid degrees",
)
})?;
let min = if parts.len() > 1 {
parts[1].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid arcminutes",
)
})?
} else {
0.0
};
let sec = if parts.len() > 2 {
parts[2].parse::<f64>().map_err(|_| {
crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Invalid arcseconds",
)
})?
} else {
0.0
};
if parts.len() > 1 && deg - libm::trunc(deg) != 0.0 {
return Err(crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Cannot mix fractional degrees with arcminutes/arcseconds",
));
}
if !(0.0..60.0).contains(&min) {
return Err(crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Arcminutes must be in range [0, 60)",
));
}
if !(0.0..60.0).contains(&sec) {
return Err(crate::AstroError::math_error(
"parse_dms",
crate::errors::MathErrorKind::InvalidInput,
"Arcseconds must be in range [0, 60)",
));
}
Ok(ParsedAngle {
angle: Angle::from_degrees(sign * (deg.abs() + min / 60.0 + sec / 3600.0)),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hms_format_normal() {
let a = Angle::from_hours(12.5);
let fmt = HmsFmt { frac_digits: 2 };
let result = fmt.fmt(a);
assert!(result.contains("12ʰ"));
assert!(result.contains("30ᵐ"));
}
#[test]
fn test_hms_format_extreme_positive() {
let a = Angle::from_degrees(720.0);
let fmt = HmsFmt { frac_digits: 0 };
let result = fmt.fmt(a);
assert!(result.contains("0ʰ"));
}
#[test]
fn test_hms_format_extreme_negative() {
let a = Angle::from_degrees(-750.0);
let fmt = HmsFmt { frac_digits: 0 };
let result = fmt.fmt(a);
assert!(result.contains("22ʰ"));
}
#[test]
fn test_dms_format_negative_with_precision() {
let a = Angle::from_degrees(-12.345678);
let fmt = DmsFmt { frac_digits: 2 };
let result = fmt.fmt(a);
assert_eq!(result, "-12° 20' 44.44\"");
}
#[test]
fn test_hms_format_wraps_negative_angle() {
let a = Angle::from_hours(-1.5);
let fmt = HmsFmt { frac_digits: 1 };
let result = fmt.fmt(a);
assert_eq!(result, "22ʰ 30ᵐ 0.0ˢ");
}
#[test]
fn test_angle_display_precision() {
let a = Angle::from_degrees(1.23456789);
assert_eq!(format!("{a}"), "1.234568°");
}
#[test]
fn test_parse_hms() {
let result = parse_hms("12h30m15s").unwrap();
assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
}
#[test]
fn test_parse_hms_unicode() {
let result = parse_hms("12ʰ30ᵐ15ˢ").unwrap();
assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
}
#[test]
fn test_parse_hms_colon() {
let result = parse_hms("12:30:15").unwrap();
assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
}
#[test]
fn test_parse_hms_partial() {
let result = parse_hms("12h").unwrap();
assert_eq!(result.angle.hours(), 12.0);
}
#[test]
fn test_parse_dms_positive() {
let result = parse_dms("45°30'15\"").unwrap();
assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
}
#[test]
fn test_parse_dms_negative() {
let result = parse_dms("-45°30'15\"").unwrap();
assert!((result.angle.degrees() + 45.50416666666667).abs() < 1e-10);
}
#[test]
fn test_parse_dms_colon() {
let result = parse_dms("45:30:15").unwrap();
assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
}
#[test]
fn test_parse_dms_partial() {
let result = parse_dms("45°").unwrap();
assert_eq!(result.angle.degrees(), 45.0);
}
#[test]
fn test_parse_angle_dispatch_hms() {
let result = parse_angle("12h30m15s").unwrap();
assert!((result.angle.hours() - 12.504166666666666).abs() < 1e-10);
}
#[test]
fn test_parse_angle_dispatch_dms() {
let result = parse_angle("45°30'15\"").unwrap();
assert!((result.angle.degrees() - 45.50416666666667).abs() < 1e-10);
}
#[test]
fn test_parse_hms_negative() {
let result = parse_hms("-01:30:00").unwrap();
assert_eq!(result.angle.hours(), -1.5);
}
#[test]
fn test_parse_hms_negative_with_seconds() {
let result = parse_hms("-12h30m45s").unwrap();
assert_eq!(result.angle.hours(), -12.5125);
}
#[test]
fn test_parse_hms_invalid_minutes() {
let result = parse_hms("12h99m00s");
assert!(result.is_err());
}
#[test]
fn test_parse_hms_invalid_seconds() {
let result = parse_hms("12h30m80s");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_invalid_arcminutes() {
let result = parse_dms("45°80'00\"");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_invalid_arcseconds() {
let result = parse_dms("45°30'99\"");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_negative_with_minutes_seconds() {
let result = parse_dms("-45°30'15\"").unwrap();
assert_eq!(result.angle.degrees(), -45.50416666666667);
}
#[test]
fn test_parse_hms_rejects_fractional_hours_with_minutes() {
let result = parse_hms("12.5h30m");
assert!(result.is_err());
}
#[test]
fn test_parse_hms_rejects_empty_string() {
let result = parse_hms("");
assert!(result.is_err());
}
#[test]
fn test_parse_hms_rejects_too_many_components() {
let result = parse_hms("12:30:15:99");
assert!(result.is_err());
}
#[test]
fn test_parse_hms_accepts_fractional_hours_alone() {
let result = parse_hms("12.5h").unwrap();
assert_eq!(result.angle.hours(), 12.5);
}
#[test]
fn test_parse_dms_rejects_fractional_degrees_with_arcminutes() {
let result = parse_dms("45.5°30'");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_rejects_empty_string() {
let result = parse_dms(" ");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_rejects_too_many_components() {
let result = parse_dms("45:30:15:99");
assert!(result.is_err());
}
#[test]
fn test_parse_dms_accepts_fractional_degrees_alone() {
let result = parse_dms("45.5°").unwrap();
assert_eq!(result.angle.degrees(), 45.5);
}
#[test]
fn test_parse_dms_invalid_degrees() {
let result = parse_dms("abc°");
assert!(result.is_err());
}
#[test]
fn test_parse_angle_fails_for_unknown_format() {
let result = parse_angle("not an angle");
assert!(result.is_err());
}
}