use super::Angle;
use crate::AstroError;
use once_cell::sync::Lazy;
use regex::Regex;
pub trait AngleUnits {
fn deg(&self) -> Result<Angle, AstroError>;
fn rad(&self) -> Result<Angle, AstroError>;
fn hours(&self) -> Result<Angle, AstroError>;
fn arcmin(&self) -> Result<Angle, AstroError>;
fn arcsec(&self) -> Result<Angle, AstroError>;
fn dms(&self) -> Result<Angle, AstroError>;
fn hms(&self) -> Result<Angle, AstroError>;
}
impl AngleUnits for str {
#[inline]
fn deg(&self) -> Result<Angle, AstroError> {
parse_decimal(self).map(Angle::from_degrees)
}
#[inline]
fn rad(&self) -> Result<Angle, AstroError> {
parse_decimal(self).map(Angle::from_radians)
}
#[inline]
fn hours(&self) -> Result<Angle, AstroError> {
parse_decimal(self).map(Angle::from_hours)
}
#[inline]
fn arcmin(&self) -> Result<Angle, AstroError> {
parse_decimal(self).map(|v| Angle::from_degrees(v / 60.0))
}
#[inline]
fn arcsec(&self) -> Result<Angle, AstroError> {
parse_decimal(self).map(|v| Angle::from_degrees(v / 3600.0))
}
#[inline]
fn dms(&self) -> Result<Angle, AstroError> {
parse_dms(self)
}
#[inline]
fn hms(&self) -> Result<Angle, AstroError> {
parse_hms(self)
}
}
pub trait ParseAngle {
fn to_angle(&self) -> Result<Angle, AstroError>;
}
impl ParseAngle for str {
fn to_angle(&self) -> Result<Angle, AstroError> {
parse_hms(self)
.or_else(|_| parse_dms(self))
.or_else(|_| parse_decimal(self).map(Angle::from_degrees))
}
}
fn parse_decimal(s: &str) -> Result<f64, AstroError> {
s.trim().parse::<f64>().map_err(|_| {
AstroError::calculation_error("parse_decimal", &format!("Cannot parse '{}' as number", s))
})
}
static HMS_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?xi)
^\s*
([+-])? # optional sign
(\d{1,3}) # hours (1-3 digits)
(?: # separator group
[:hH\s]+| # colons, h/H, spaces
h(?:ou)?r?s?\s* # hour/hours variants
)
(\d{1,2}) # minutes (1-2 digits)
(?: # separator group
[:mM\s']+| # colons, m/M, spaces, apostrophes
m(?:in(?:ute)?s?)?\s* # min/minute variants
)
(\d{1,2}(?:\.\d+)?) # seconds with optional decimal
(?: # optional trailing markers
[sS\s"']+| # s/S, spaces, quotes
s(?:ec(?:ond)?s?)? # sec/second variants
)?
\s*$
"#,
)
.unwrap()
});
static DMS_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?xi)
^\s*
([+-])? # optional sign
(\d{1,3}) # degrees (1-3 digits)
(?: # separator group
[dD\s:*]+| # d/D, colon, asterisk, spaces
d(?:eg(?:ree)?s?)?\s* # deg/degree variants
)
(\d{1,2}) # minutes (1-2 digits)
(?: # separator group
['mM\s:]+| # apostrophes, m/M, spaces, colon
m(?:in(?:ute)?s?)?\s*| # min/minute variants
arc\s?m(?:in(?:ute)?s?)?\s* # arcmin/arcminute
)
(\d{1,2}(?:\.\d+)?) # seconds with optional decimal
(?: # optional trailing markers
["'sS\s]+| # quotes, s/S, spaces
s(?:ec(?:ond)?s?)?| # sec/second variants
arc\s?s(?:ec(?:ond)?s?)? # arcsec/arcsecond
)?
\s*$
"#,
)
.unwrap()
});
static COLON_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^\s*([+-])?(\d{1,4}):(\d{1,3}):(\d{1,3}(?:\.\d+)?)\s*$"#).unwrap());
pub fn parse_hms(s: &str) -> Result<Angle, AstroError> {
let s = normalize_input(s);
if let Some(caps) = COLON_REGEX.captures(&s) {
return parse_hms_captures(caps, &s);
}
if let Some(caps) = HMS_REGEX.captures(&s) {
return parse_hms_captures(caps, &s);
}
Err(AstroError::calculation_error(
"parse_hms",
&format!("Cannot parse '{}' as HMS format", s),
))
}
pub fn parse_dms(s: &str) -> Result<Angle, AstroError> {
let s = normalize_input(s);
if let Some(caps) = COLON_REGEX.captures(&s) {
return parse_dms_captures(caps, &s);
}
if let Some(caps) = DMS_REGEX.captures(&s) {
return parse_dms_captures(caps, &s);
}
Err(AstroError::calculation_error(
"parse_dms",
&format!("Cannot parse '{}' as DMS format", s),
))
}
fn parse_hms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
let sign = caps
.get(1)
.map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
let hours: f64 = caps[2].parse().unwrap();
let minutes: f64 = caps[3].parse().unwrap();
let seconds: f64 = caps[4].parse().unwrap();
let total_hours = sign * (hours + minutes / 60.0 + seconds / 3600.0);
Ok(Angle::from_hours(total_hours))
}
fn parse_dms_captures(caps: regex::Captures, _original: &str) -> Result<Angle, AstroError> {
let sign = caps
.get(1)
.map_or(1.0, |m| if m.as_str() == "-" { -1.0 } else { 1.0 });
let degrees: f64 = caps[2].parse().unwrap();
let minutes: f64 = caps[3].parse().unwrap();
let seconds: f64 = caps[4].parse().unwrap();
let total_degrees = sign * (degrees + minutes / 60.0 + seconds / 3600.0);
Ok(Angle::from_degrees(total_degrees))
}
fn normalize_input(s: &str) -> String {
let mut result = s.trim().to_string();
result = result.replace("degrees", "d");
result = result.replace("degree", "d");
result = result.replace("deg", "d");
result = result.replace('*', "d");
result = result.replace("arcminutes", "m");
result = result.replace("arcminute", "m");
result = result.replace("arcmin", "m");
result = result.replace("minutes", "m");
result = result.replace("minute", "m");
result = result.replace("min", "m");
result = result.replace("arcseconds", "s");
result = result.replace("arcsecond", "s");
result = result.replace("arcsec", "s");
result = result.replace("seconds", "s");
result = result.replace("second", "s");
result = result.replace("sec", "s");
result = result.replace("''", "\"");
result = result.replace("hours", "h");
result = result.replace("hour", "h");
result = result.replace("hrs", "h");
result = result.replace("hr", "h");
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::PI;
const EPSILON: f64 = 1e-10;
#[test]
fn test_decimal_parsing() {
assert_eq!("45.5".deg().unwrap().degrees(), 45.5);
assert_eq!(format!("{:?}", PI).rad().unwrap().radians(), PI);
assert_eq!("12.5".hours().unwrap().hours(), 12.5);
assert!(("60.0".arcmin().unwrap().degrees() - 1.0).abs() < EPSILON);
assert!(("3600.0".arcsec().unwrap().degrees() - 1.0).abs() < EPSILON);
assert_eq!("-45.5".deg().unwrap().degrees(), -45.5);
assert_eq!("-12.5".hours().unwrap().hours(), -12.5);
assert_eq!(" 45.5 ".deg().unwrap().degrees(), 45.5);
assert_eq!(format!("\t{:?}\n", PI).rad().unwrap().radians(), PI);
}
#[test]
fn test_hms_colon_format() {
let angle = "12:34:56".hms().unwrap();
let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
assert!((angle.hours() - expected_hours).abs() < EPSILON);
let angle = "12:34:56.789".hms().unwrap();
let expected = 12.0 + 34.0 / 60.0 + 56.789 / 3600.0;
assert!((angle.hours() - expected).abs() < EPSILON);
let angle = "-5:30:45".hms().unwrap();
let expected = -(5.0 + 30.0 / 60.0 + 45.0 / 3600.0);
assert!((angle.hours() - expected).abs() < EPSILON);
assert!("0:0:0".hms().unwrap().hours() < EPSILON);
assert!("23:59:59.999".hms().is_ok());
}
#[test]
fn test_dms_colon_format() {
let angle = "45:30:15".dms().unwrap();
let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
assert!((angle.degrees() - expected_deg).abs() < EPSILON);
let angle = "+45:30:15.5".dms().unwrap();
let expected = 45.0 + 30.0 / 60.0 + 15.5 / 3600.0;
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "-90:30:0".dms().unwrap();
let expected = -(90.0 + 30.0 / 60.0);
assert!((angle.degrees() - expected).abs() < EPSILON);
}
#[test]
fn test_hms_verbose_formats() {
let angle = "12h34m56s".hms().unwrap();
let expected = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
assert!((angle.hours() - expected).abs() < EPSILON);
let angle = "12H34M56S".hms().unwrap();
assert!((angle.hours() - expected).abs() < EPSILON);
let angle = "12h 34m 56s".hms().unwrap();
assert!((angle.hours() - expected).abs() < EPSILON);
let angle = "12 hours 34 minutes 56 seconds".hms().unwrap();
assert!((angle.hours() - expected).abs() < EPSILON);
let angle = "12hr 34min 56sec".hms().unwrap();
assert!((angle.hours() - expected).abs() < EPSILON);
}
#[test]
fn test_dms_verbose_formats() {
let angle = "45d30m15s".dms().unwrap();
let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "45*30m15s".dms().unwrap();
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "45 degrees 30 minutes 15 seconds".dms().unwrap();
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "45deg 30min 15sec".dms().unwrap();
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "45d 30 arcmin 15 arcsec".dms().unwrap();
assert!((angle.degrees() - expected).abs() < EPSILON);
}
#[test]
fn test_quote_formats() {
let angle = "45d 30' 15\"".dms().unwrap();
let expected = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
assert!((angle.degrees() - expected).abs() < EPSILON);
let angle = "45d 30' 15''".dms().unwrap();
assert!((angle.degrees() - expected).abs() < EPSILON);
}
#[test]
fn test_auto_detection() {
let angle = "12:34:56".to_angle().unwrap();
let expected_hours = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0;
assert!((angle.hours() - expected_hours).abs() < EPSILON);
let angle = "45d30m15s".to_angle().unwrap();
let expected_deg = 45.0 + 30.0 / 60.0 + 15.0 / 3600.0;
assert!((angle.degrees() - expected_deg).abs() < EPSILON);
let angle = "45.5".to_angle().unwrap();
assert_eq!(angle.degrees(), 45.5);
}
#[test]
fn test_edge_cases() {
assert!("0:0:0".hms().unwrap().radians().abs() < EPSILON);
assert!("0:0:0".dms().unwrap().radians().abs() < EPSILON);
assert!("0".deg().unwrap().radians().abs() < EPSILON);
assert!("359:59:59".dms().is_ok());
assert!("999:59:59".hms().is_ok());
let angle = "12:34:56.123456789".hms().unwrap();
assert!(angle.hours() > 12.0);
assert!("01:02:03".hms().is_ok());
assert!("001:02:03".dms().is_ok());
}
#[test]
fn test_error_cases() {
assert!("not_a_number".deg().is_err());
assert!("12:34".hms().is_err());
assert!("12:34:".hms().is_err());
assert!(":12:34".hms().is_err());
assert!("".deg().is_err());
assert!(" ".deg().is_err());
}
#[test]
fn test_sign_handling() {
assert!("+45:30:15".dms().unwrap().degrees() > 0.0);
assert!("+12:34:56".hms().unwrap().hours() > 0.0);
assert!("-45:30:15".dms().unwrap().degrees() < 0.0);
assert!("-12:34:56".hms().unwrap().hours() < 0.0);
assert!("45:-30:15".dms().is_err());
assert!("12:34:-56".hms().is_err());
}
#[test]
fn test_whitespace_tolerance() {
assert!(" 45:30:15 ".dms().is_ok());
assert!("\t12:34:56\n".hms().is_ok());
assert!("45 : 30 : 15".dms().is_ok());
assert!("12 h 34 m 56 s".hms().is_ok());
}
#[test]
fn test_precision_preservation() {
let input_deg = 123.456789012345;
let angle = format!("{}", input_deg).deg().unwrap();
assert!((angle.degrees() - input_deg).abs() < 1e-12);
let angle = "12:34:56.123456".hms().unwrap();
let back_to_hms = angle.hours();
let expected = 12.0 + 34.0 / 60.0 + 56.123456 / 3600.0;
assert!((back_to_hms - expected).abs() < 1e-9);
}
}