use crate::time::julian_date;
use crate::{local_mean_sidereal_time, sidereal::apparent_sidereal_time};
use crate::error::{AstroError, Result};
use chrono::{DateTime, Utc};
use std::str::FromStr;
use regex::{Regex, RegexBuilder};
use lazy_static::lazy_static;
lazy_static! {
static ref HMS_REGEX: Regex = RegexBuilder::new(
r"(\d{1,3}(?:\.\d{1,10})?)\s*h\s*(\d{1,2}(?:\.\d{1,10})?)\s*m?\s*(\d{1,2}(?:\.\d{1,10})?)\s*s?"
)
.size_limit(1024 * 1024) .dfa_size_limit(10 * 1024 * 1024) .build()
.expect("HMS regex compilation failed");
static ref DMS_REGEX: Regex = RegexBuilder::new(
r#"([+-]?\d{1,3}(?:\.\d{1,10})?)\s*[°d]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*['′m]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*["″s]?"#
)
.size_limit(1024 * 1024)
.dfa_size_limit(10 * 1024 * 1024)
.build()
.expect("DMS regex compilation failed");
static ref DECIMAL_REGEX: Regex = RegexBuilder::new(
r"^[+-]?\d{1,3}(?:\.\d{1,15})?[NSEW]?$"
)
.case_insensitive(true)
.size_limit(1024 * 1024)
.build()
.expect("Decimal regex compilation failed");
static ref COMPACT_REGEX: Regex = RegexBuilder::new(
r"^([+-]?)(\d{2,3})(\d{2})(?:(\d{2})(?:\.(\d{1,6}))?)?$"
)
.size_limit(1024 * 1024)
.build()
.expect("Compact regex compilation failed");
}
#[derive(Debug, Clone, Copy)]
pub struct Location {
pub latitude_deg: f64,
pub longitude_deg: f64,
pub altitude_m: f64,
}
impl Location {
pub fn parse(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
let lat = parse_coordinate(lat_str, true)?;
let lon = parse_coordinate(lon_str, false)?;
Ok(Location {
latitude_deg: lat,
longitude_deg: lon,
altitude_m: alt_m,
})
}
pub fn from_dms(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
let lat = parse_dms(lat_str)?;
let lon = parse_dms(lon_str)?;
Ok(Location {
latitude_deg: lat,
longitude_deg: lon,
altitude_m: alt_m,
})
}
pub fn latitude_dms_string(&self) -> String {
format_dms(self.latitude_deg, true)
}
pub fn longitude_dms_string(&self) -> String {
format_dms(self.longitude_deg, false)
}
pub fn local_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
let jd = julian_date(datetime);
apparent_sidereal_time(jd, self.longitude_deg)
}
pub fn local_mean_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
let jd = julian_date(datetime);
local_mean_sidereal_time(jd, self.longitude_deg)
}
pub fn latitude_dms(&self) -> String {
format_dms(self.latitude_deg, true)
}
pub fn longitude_dms(&self) -> String {
format_dms(self.longitude_deg, false)
}
}
fn format_dms(deg: f64, is_lat: bool) -> String {
let sign = if deg < 0.0 { "-" } else { "" };
let abs = deg.abs();
let d = abs.trunc();
let m = ((abs - d) * 60.0).trunc();
let s = ((abs - d) * 60.0 - m) * 60.0;
if is_lat {
format!("{sign}{:02.0}° {:02.0}′ {:06.3}″", d, m, s)
} else {
format!("{sign}{:03.0}° {:02.0}′ {:06.3}″", d, m, s)
}
}
fn parse_dms(s: &str) -> Result<f64> {
let original = s.trim();
let is_negative = original.starts_with('-');
let cleaned = original
.replace(['°', '\'', ':', '"'], " ");
let parts: Vec<&str> = cleaned.split_whitespace().collect();
if parts.len() < 2 {
return Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
});
}
let d = f64::from_str(parts[0].trim_start_matches(['+', '-']))
.map_err(|_| AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
})?;
let m = f64::from_str(parts.get(1).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
})?;
let s = f64::from_str(parts.get(2).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
})?;
let abs_value = d.abs() + m / 60.0 + s / 3600.0;
Ok(if is_negative { -abs_value } else { abs_value })
}
fn parse_coordinate(input: &str, is_latitude: bool) -> Result<f64> {
let s = input.trim();
let (value_str, compass_dir) = extract_compass_direction(s);
if let Ok(deg) = try_parse_compact(&value_str) {
return apply_compass_direction(deg, compass_dir, is_latitude);
}
if let Ok(deg) = try_parse_decimal_degrees(&value_str) {
return apply_compass_direction(deg, compass_dir, is_latitude);
}
if !is_latitude {
if let Ok(deg) = try_parse_hms(&value_str) {
return apply_compass_direction(deg, compass_dir, is_latitude);
}
}
if let Ok(deg) = try_parse_dms(&value_str) {
return apply_compass_direction(deg, compass_dir, is_latitude);
}
if let Ok(deg) = try_parse_dm(&value_str) {
return apply_compass_direction(deg, compass_dir, is_latitude);
}
Err(AstroError::InvalidDmsFormat {
input: input.to_string(),
expected: if is_latitude {
"Examples: 40.7128, 40.7128N, N40.7128, 40°42'46\", 40 42 46, 40d42m46s"
} else {
"Examples: -74.0060, 74.0060W, W74.0060, 74°0'21.6\", 74 0 21.6, 4h56m27s"
}
})
}
fn extract_compass_direction(s: &str) -> (String, Option<char>) {
let upper = s.to_uppercase();
if let Some(first_char) = upper.chars().next() {
if matches!(first_char, 'N' | 'S' | 'E' | 'W') {
let remainder = s[1..].trim_start();
return (remainder.to_string(), Some(first_char));
}
}
if let Some(last_char) = upper.chars().last() {
if matches!(last_char, 'N' | 'S' | 'E' | 'W') {
let chars: Vec<char> = upper.chars().collect();
#[allow(clippy::comparison_chain)]
if chars.len() == 1 {
let value = s[..s.len()-1].trim_end();
return (value.to_string(), Some(last_char));
} else if chars.len() > 1 {
let second_to_last = chars[chars.len()-2];
if !second_to_last.is_alphabetic() {
if last_char == 'S' && chars.len() >= 3 {
let has_separators = s.contains(' ') || s.contains('"') || s.contains('\'') || s.contains('°');
if !has_separators && second_to_last.is_ascii_digit() {
} else {
let value = s[..s.len()-1].trim_end();
return (value.to_string(), Some(last_char));
}
} else {
let value = s[..s.len()-1].trim_end();
return (value.to_string(), Some(last_char));
}
}
}
}
}
let words: Vec<&str> = upper.split_whitespace().collect();
let s_upper = s.to_uppercase();
for word in &words {
match *word {
"NORTH" => return (s_upper.replace("NORTH", "").trim().to_string(), Some('N')),
"SOUTH" => return (s_upper.replace("SOUTH", "").trim().to_string(), Some('S')),
"EAST" => return (s_upper.replace("EAST", "").trim().to_string(), Some('E')),
"WEST" => return (s_upper.replace("WEST", "").trim().to_string(), Some('W')),
_ => {}
}
}
(s.to_string(), None)
}
fn apply_compass_direction(mut value: f64, direction: Option<char>, is_latitude: bool) -> Result<f64> {
if let Some(dir) = direction {
match dir {
'S' if is_latitude => value = -value.abs(),
'W' if !is_latitude => value = -value.abs(),
'N' if !is_latitude => return Err(AstroError::InvalidDmsFormat {
input: format!("{}{}", value, dir),
expected: "N/S for latitude, E/W for longitude"
}),
'E' if is_latitude => return Err(AstroError::InvalidDmsFormat {
input: format!("{}{}", value, dir),
expected: "N/S for latitude, E/W for longitude"
}),
_ => {}
}
}
if is_latitude {
crate::error::validate_latitude(value)?;
} else {
crate::error::validate_longitude(value)?;
}
Ok(value)
}
fn try_parse_decimal_degrees(s: &str) -> Result<f64> {
if s.chars().any(|c| c.is_alphabetic() && c != 'e' && c != 'E') {
return Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "decimal degrees"
});
}
if let Ok(value) = f64::from_str(s) {
return Ok(value);
}
let cleaned = s.trim_start_matches('+').trim();
if let Ok(value) = f64::from_str(cleaned) {
return Ok(value);
}
Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "decimal degrees"
})
}
fn validate_input_length(s: &str, _context: &str) -> Result<()> {
const MAX_INPUT_LENGTH: usize = 1000; const MAX_UNICODE_LENGTH: usize = 500;
if s.len() > MAX_INPUT_LENGTH {
return Err(AstroError::InvalidDmsFormat {
input: format!("Input too long ({} chars)", s.len()),
expected: "Input must be < 1000 characters",
});
}
if s.chars().count() > MAX_UNICODE_LENGTH {
return Err(AstroError::InvalidDmsFormat {
input: format!("Too many Unicode characters ({} chars)", s.chars().count()),
expected: "Input must be < 500 Unicode characters",
});
}
Ok(())
}
fn try_parse_hms(s: &str) -> Result<f64> {
validate_input_length(s, "HMS")?;
let normalized = s.to_lowercase()
.replace("hours", "h").replace("hour", "h")
.replace("minutes", "m").replace("minute", "m")
.replace("seconds", "s").replace("second", "s")
.replace("hrs", "h").replace("hr", "h")
.replace("mins", "m").replace("min", "m")
.replace("secs", "s").replace("sec", "s")
.replace('′', "'") .replace(['″', '"'], "\"");
if let Some(caps) = HMS_REGEX.captures(&normalized) {
let h = f64::from_str(&caps[1]).map_err(|_| AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "HMS format"
})?;
let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
return Ok((h + m/60.0 + s/3600.0) * 15.0);
}
if s.contains('h') || s.contains('H') {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() >= 2 {
let h_part = parts[0].trim_end_matches(['h', 'H']);
if let Ok(h) = f64::from_str(h_part) {
let m = parts.get(1).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
let s = parts.get(2).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
return Ok((h + m/60.0 + s/3600.0) * 15.0);
}
}
}
Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "HMS format"
})
}
fn try_parse_dms(s: &str) -> Result<f64> {
let verbose_normalized = s.to_lowercase()
.replace("degrees", "d")
.replace("degree", "d")
.replace("deg", "d")
.replace("minutes", "m")
.replace("minute", "m")
.replace("min", "m")
.replace("seconds", "s")
.replace("second", "s")
.replace("sec", "s");
if verbose_normalized != s.to_lowercase() {
if let Ok(mut result) = try_parse_dms_internal(&verbose_normalized) {
if s.starts_with('-') {
result = -result.abs();
}
return Ok(result);
}
}
try_parse_dms_internal(s)
}
fn try_parse_dms_internal(s: &str) -> Result<f64> {
validate_input_length(s, "DMS")?;
if let Some(caps) = DMS_REGEX.captures(s) {
if caps.get(2).is_some() { let d_str = &caps[1];
let is_negative = s.starts_with('-') || d_str.starts_with('-');
let d = f64::from_str(d_str.trim_start_matches('-')).map_err(|_| AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DMS format"
})?;
let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
let abs_value = d + m/60.0 + s/3600.0;
return Ok(if is_negative { -abs_value } else { abs_value });
}
}
let _normalized = s
.replace(['°', 'º', '′', '″', '\'', '"', '"', '`'], " ")
.replace("''", " ") .replace(['d', 'D', 'm', 'M', 's', 'S'], " ")
.to_lowercase();
let separators = [' ', ':', ',', ';'];
let is_negative = s.starts_with('-');
for sep in &separators {
let parts: Vec<&str> = s.split(*sep).filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
let clean_parts: Vec<String> = parts.iter().enumerate().map(|(i, p)| {
let cleaned = p.trim()
.trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c));
if i == 0 {
cleaned.trim_start_matches(['+', '-']).to_string()
} else {
cleaned.to_string()
}
}).collect();
if let Ok(d) = f64::from_str(&clean_parts[0]) {
if let Ok(m) = f64::from_str(&clean_parts[1]) {
let s = clean_parts.get(2)
.and_then(|p| f64::from_str(p).ok())
.unwrap_or(0.0);
let abs_value = d + m/60.0 + s/3600.0;
return Ok(if is_negative { -abs_value } else { abs_value });
}
}
}
}
if s.contains('-') {
let dash_parts: Vec<&str> = if is_negative {
let no_first_dash = &s[1..]; no_first_dash.split('-').collect()
} else {
s.split('-').collect()
};
let parts: Vec<&str> = dash_parts.into_iter().filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
let clean_parts: Vec<String> = parts.iter().map(|p| {
p.trim()
.trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c))
.to_string()
}).collect();
if let Ok(d) = f64::from_str(&clean_parts[0]) {
if let Ok(m) = f64::from_str(&clean_parts[1]) {
let s = clean_parts.get(2)
.and_then(|p| f64::from_str(p).ok())
.unwrap_or(0.0);
let abs_value = d + m/60.0 + s/3600.0;
return Ok(if is_negative { -abs_value } else { abs_value });
}
}
}
}
Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "DMS format"
})
}
fn try_parse_compact(s: &str) -> Result<f64> {
if s.contains(' ') || s.contains(':') || s.contains('-') || s.contains('°') {
return Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "compact format"
});
}
let digits_only: String = s.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
if digits_only.len() < s.len() / 2 {
return Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "compact format"
});
}
if digits_only.contains('.') && digits_only.len() >= 6 {
let parts: Vec<&str> = digits_only.split('.').collect();
if parts[0].len() == 4 || parts[0].len() == 5 { if let Ok(ddmm) = i32::from_str(parts[0]) {
let dd = ddmm / 100;
let mm = ddmm % 100;
if mm < 60 { let decimal_minutes = parts.get(1)
.and_then(|p| f64::from_str(&format!("0.{}", p)).ok())
.unwrap_or(0.0);
return Ok(dd as f64 + (mm as f64 + decimal_minutes) / 60.0);
}
}
}
}
if !digits_only.contains('.') && (digits_only.len() == 6 || digits_only.len() == 7) {
let (dd_len, _is_longitude) = if digits_only.len() == 7 { (3, true) } else { (2, false) };
if let Ok(dd) = i32::from_str(&digits_only[..dd_len]) {
if let Ok(mm) = i32::from_str(&digits_only[dd_len..dd_len+2]) {
if let Ok(ss) = i32::from_str(&digits_only[dd_len+2..]) {
if mm < 60 && ss < 60 { return Ok(dd as f64 + mm as f64 / 60.0 + ss as f64 / 3600.0);
}
}
}
}
}
Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "compact format"
})
}
fn try_parse_dm(s: &str) -> Result<f64> {
let normalized = s
.replace(['°', '′', '\'', 'd', 'm'], " ")
.to_lowercase();
let parts: Vec<&str> = normalized.split_whitespace()
.filter(|p| p.chars().any(|c| c.is_ascii_digit()))
.collect();
if parts.len() == 2 {
if let Ok(d) = f64::from_str(parts[0]) {
if let Ok(m) = f64::from_str(parts[1]) {
if m < 60.0 || m.fract() != 0.0 {
let sign = if d < 0.0 { -1.0 } else { 1.0 };
return Ok(sign * (d.abs() + m / 60.0));
}
}
}
}
Err(AstroError::InvalidDmsFormat {
input: s.to_string(),
expected: "degrees and decimal minutes"
})
}