use crate::chart::{Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac};
use crate::error::ParseError;
use roxmltree::{Document, Node};
pub fn parse_file(xml: &str) -> Result<Vec<Chart>, ParseError> {
let doc = Document::parse(xml).map_err(|e| ParseError::Xml(e.to_string()))?;
let root = doc.root_element();
let mut charts = Vec::new();
for node in root.children() {
if node.has_tag_name("adb_entry") {
let adb_id = node
.attribute("adb_id")
.and_then(|s| s.parse().ok())
.unwrap_or(0u32);
charts.push(parse_entry(node, adb_id)?);
}
}
Ok(charts)
}
fn parse_entry(node: Node, adb_id: u32) -> Result<Chart, ParseError> {
let pub_data = child(node, "public_data").ok_or_else(|| bad(adb_id, "missing public_data"))?;
let name = child_text(pub_data, "name")
.ok_or_else(|| bad(adb_id, "missing name"))?
.to_string();
let csex = child(pub_data, "gender")
.and_then(|n| n.attribute("csex"))
.unwrap_or("");
let event_type = event_type_from_csex(csex);
let rrc: u32 = child(pub_data, "roddenrating")
.and_then(|n| n.attribute("rrc"))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let source_rating = rodden_rating(rrc);
let bdata = child(pub_data, "bdata").ok_or_else(|| bad(adb_id, "missing bdata"))?;
let sbdate = child(bdata, "sbdate").ok_or_else(|| bad(adb_id, "missing sbdate"))?;
let year: i16 = sbdate
.attribute("iyear")
.and_then(|s| s.parse().ok())
.ok_or_else(|| bad(adb_id, "missing iyear"))?;
let month: u8 = sbdate
.attribute("imonth")
.and_then(|s| s.parse().ok())
.ok_or_else(|| bad(adb_id, "missing imonth"))?;
let day: u8 = sbdate
.attribute("iday")
.and_then(|s| s.parse().ok())
.ok_or_else(|| bad(adb_id, "missing iday"))?;
let (hour, minute, second, tz_offset_hours, tz_abbreviation, is_lmt) =
if let Some(t) = child(bdata, "sbtime") {
let time_unknown = t.attribute("time_unknown").is_some_and(|s| s == "yes");
let (hh, mm, ss) = if time_unknown {
(12u8, 0u8, 0u8)
} else {
let time_str = t.text().unwrap_or("00:00").trim();
parse_time_hhmm(time_str, adb_id)?
};
let jd_ut: Option<f64> = t.attribute("jd_ut").and_then(|s| s.parse().ok());
let stmerid = t.attribute("stmerid").unwrap_or("");
let ctimetype = t.attribute("ctimetype").unwrap_or("");
let tz_off = tz_offset(jd_ut, stmerid, hh, mm);
let tz_abbr = t
.attribute("sznabbr")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
(hh, mm, ss, tz_off, tz_abbr, ctimetype == "l")
} else {
(0u8, 0u8, 0u8, 0.0f64, None, false)
};
let place = child(bdata, "place").ok_or_else(|| bad(adb_id, "missing place"))?;
let lat_str = place
.attribute("slati")
.ok_or_else(|| bad(adb_id, "missing slati"))?;
let lon_str = place
.attribute("slong")
.ok_or_else(|| bad(adb_id, "missing slong"))?;
let latitude = parse_lat(lat_str, adb_id)?;
let longitude = parse_lon(lon_str, adb_id)?;
let city = place.text().filter(|s| !s.is_empty()).map(str::to_string);
let region = child(bdata, "country")
.and_then(|n| n.text())
.filter(|s| !s.is_empty())
.map(str::to_string);
let notes = child(node, "text_data")
.and_then(|td| child(td, "sourcenotes"))
.and_then(|sn| sn.text())
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(Chart {
name,
secondary_name: None,
city,
region,
longitude,
latitude,
year,
month,
day,
hour,
minute,
second,
tz_offset_hours,
tz_abbreviation,
is_lmt,
event_type,
source_rating,
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes,
})
}
fn parse_lat(s: &str, adb_id: u32) -> Result<Latitude, ParseError> {
let (deg, hem) = parse_coord(s, &['n', 's'], adb_id)?;
let signed = if hem == 's' { -deg } else { deg };
Latitude::new(signed).map_err(|_| bad(adb_id, format!("latitude {signed} out of range")))
}
fn parse_lon(s: &str, adb_id: u32) -> Result<Longitude, ParseError> {
let (deg, hem) = parse_coord(s, &['e', 'w'], adb_id)?;
let signed = if hem == 'w' { -deg } else { deg };
let normalized = if signed < -180.0 {
signed + 360.0
} else if signed > 180.0 {
signed - 360.0
} else {
signed
};
Longitude::new(normalized)
.map_err(|_| bad(adb_id, format!("longitude {normalized} out of range")))
}
fn parse_coord(s: &str, hems: &[char], adb_id: u32) -> Result<(f64, char), ParseError> {
let hem_pos = s
.find(|c: char| hems.contains(&c))
.ok_or_else(|| bad(adb_id, format!("no hemisphere marker in coord {s:?}")))?;
let deg: f64 = s[..hem_pos]
.parse()
.map_err(|_| bad(adb_id, format!("invalid degrees in coord {s:?}")))?;
let hem = s.as_bytes()[hem_pos] as char;
let frac = parse_minsec_digits(&s[hem_pos + 1..]);
Ok((deg + frac, hem))
}
fn parse_minsec_digits(s: &str) -> f64 {
match s.len() {
0 => 0.0,
4 => {
let min: f64 = s[..2].parse().unwrap_or(0.0);
let sec: f64 = s[2..4].parse().unwrap_or(0.0);
min / 60.0 + sec / 3600.0
}
_ => s.parse::<f64>().unwrap_or(0.0) / 60.0,
}
}
fn tz_offset(jd_ut: Option<f64>, stmerid: &str, hh: u8, mm: u8) -> f64 {
if let Some(jd) = jd_ut {
let ut_frac = ((jd + 0.5).fract() + 1.0) % 1.0;
let ut_hours = ut_frac * 24.0;
let local_hours = f64::from(hh) + f64::from(mm) / 60.0;
let diff = local_hours - ut_hours;
if diff > 12.0 {
diff - 24.0
} else if diff <= -12.0 {
diff + 24.0
} else {
diff
}
} else {
parse_stmerid(stmerid)
}
}
fn parse_stmerid(s: &str) -> f64 {
let s = s.trim();
if s.is_empty() {
return 0.0;
}
let (scale, rest) = if let Some(r) = s.strip_prefix('m') {
(1.0_f64 / 15.0, r)
} else if let Some(r) = s.strip_prefix('h') {
(1.0_f64, r)
} else {
return 0.0;
};
let Some(dir_pos) = rest.find(['e', 'w']) else {
return 0.0;
};
let major: f64 = rest[..dir_pos].parse().unwrap_or(0.0);
let dir = rest.as_bytes()[dir_pos] as char;
let frac = parse_minsec_digits(&rest[dir_pos + 1..]);
let value = (major + frac) * scale;
if dir == 'w' { -value } else { value }
}
fn parse_time_hhmm(s: &str, adb_id: u32) -> Result<(u8, u8, u8), ParseError> {
let mut parts = s.splitn(3, ':');
let hh: u8 = parts
.next()
.and_then(|p| p.trim().parse().ok())
.ok_or_else(|| bad(adb_id, format!("invalid time {s:?}")))?;
let mm: u8 = parts
.next()
.and_then(|p| p.trim().parse().ok())
.ok_or_else(|| bad(adb_id, format!("missing minutes in time {s:?}")))?;
let ss: u8 = parts
.next()
.and_then(|p| p.trim().parse().ok())
.unwrap_or(0);
Ok((hh, mm, ss))
}
fn event_type_from_csex(csex: &str) -> EventType {
match csex {
"m" => EventType::Male,
"f" => EventType::Female,
_ => EventType::Unspecified,
}
}
fn rodden_rating(rrc: u32) -> Option<String> {
let s = match rrc {
1 => "AA",
2 => "A",
3 => "B",
4 => "C",
5 => "DD",
6 => "X",
7 => "XX",
_ => return None,
};
Some(s.to_string())
}
fn child<'a, 'b>(parent: Node<'a, 'b>, tag: &str) -> Option<Node<'a, 'b>> {
parent.children().find(|n| n.has_tag_name(tag))
}
fn child_text<'a>(parent: Node<'a, '_>, tag: &str) -> Option<&'a str> {
child(parent, tag).and_then(|n| n.text())
}
fn bad(adb_id: u32, reason: impl Into<String>) -> ParseError {
ParseError::AdbEntry {
adb_id,
reason: reason.into(),
}
}
#[must_use]
pub fn write_file(charts: &[Chart]) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\n");
out.push_str("<astrodatabank_export export_format=\"160715\">\n");
for (i, chart) in charts.iter().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let adb_id = 100_000_001u32 + i as u32;
out.push_str(&write_entry(adb_id, chart));
}
out.push_str("</astrodatabank_export>\n");
out
}
fn write_entry(adb_id: u32, chart: &Chart) -> String {
let name = xml_escape(&chart.name);
let csex = csex_from_event_type(chart.event_type);
let gvalue = match csex {
"m" => "M",
"f" => "F",
_ => "N/A",
};
let rrc = rrc_from_source_rating(chart.source_rating.as_deref());
let rating = rodden_rating(rrc).unwrap_or_else(|| "X".to_string());
let jd = compute_jd_ut(chart);
let ctimetype = if chart.is_lmt { "l" } else { "s" };
let sznabbr = xml_escape(chart.tz_abbreviation.as_deref().unwrap_or(""));
let time_str = format!("{:02}:{:02}:{:02}", chart.hour, chart.minute, chart.second);
let slati = coord_to_adb(chart.latitude.degrees(), 'n', 's');
let slong = coord_to_adb(chart.longitude.degrees(), 'e', 'w');
let city = xml_escape(chart.city.as_deref().unwrap_or(""));
let country = xml_escape(chart.region.as_deref().unwrap_or(""));
let date_val = format!("{:04}/{:02}/{:02}", chart.year, chart.month, chart.day);
let mut s = format!(
" <adb_entry adb_id=\"{adb_id}\">\n\
\x20 <public_data>\n\
\x20 <name>{name}</name>\n\
\x20 <gender csex=\"{csex}\">{gvalue}</gender>\n\
\x20 <roddenrating rrc=\"{rrc}\">{rating}</roddenrating>\n\
\x20 <datatype sdatatype=\"Public Figure\" dtc=\"1\" />\n\
\x20 <bdata>\n\
\x20 <sbdate ccalendar=\"g\" iyear=\"{y}\" imonth=\"{mo}\" iday=\"{d}\">{date_val}</sbdate>\n\
\x20 <sbtime ctimetype=\"{ctimetype}\" jd_ut=\"{jd:.6}\" sznabbr=\"{sznabbr}\">{time_str}</sbtime>\n\
\x20 <place slati=\"{slati}\" slong=\"{slong}\">{city}</place>\n\
\x20 <country>{country}</country>\n\
\x20 </bdata>\n\
\x20 </public_data>\n",
y = chart.year,
mo = chart.month,
d = chart.day,
);
if let Some(notes) = &chart.notes {
use std::fmt::Write as _;
let _ = write!(
s,
" <text_data>\n <sourcenotes>{}</sourcenotes>\n </text_data>\n",
xml_escape(notes)
);
}
s.push_str(" </adb_entry>\n\n");
s
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 {
i64::from(y) - 1
} else {
i64::from(y)
};
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64; let m_adj = if m <= 2 { m + 9 } else { m - 3 };
let doy = (153 * u64::from(m_adj) + 2) / 5 + u64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468 }
#[allow(clippy::cast_precision_loss)]
fn compute_jd_ut(chart: &Chart) -> f64 {
let days = days_from_civil(
i32::from(chart.year),
u32::from(chart.month),
u32::from(chart.day),
);
let local_h =
f64::from(chart.hour) + f64::from(chart.minute) / 60.0 + f64::from(chart.second) / 3600.0;
let ut_h = local_h - chart.tz_offset_hours;
2_440_588.0 + days as f64 + (ut_h - 12.0) / 24.0
}
fn coord_to_adb(degrees: f64, pos: char, neg: char) -> String {
let hemi = if degrees >= 0.0 { pos } else { neg };
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let total_sec = (degrees.abs() * 3600.0).round() as u32;
let deg = total_sec / 3600;
let min = (total_sec % 3600) / 60;
let sec = total_sec % 60;
if sec == 0 {
format!("{deg}{hemi}{min:02}")
} else {
format!("{deg}{hemi}{min:02}{sec:02}")
}
}
fn csex_from_event_type(et: EventType) -> &'static str {
match et {
EventType::Male => "m",
EventType::Female => "f",
_ => "e",
}
}
fn rrc_from_source_rating(rating: Option<&str>) -> u32 {
let s = match rating {
Some(s) => s.trim(),
None => return 6,
};
match s {
"AA" => 1,
"A" => 2,
"B" => 3,
"C" => 4,
"DD" => 5,
"X" => 6,
"XX" => 7,
_ if s.starts_with("AA") => 1,
_ if s.starts_with("XX") => 7,
_ if s.starts_with("DD") => 5,
_ if s.starts_with('A') => 2,
_ if s.starts_with('B') => 3,
_ if s.starts_with('C') => 4,
_ if s.starts_with('X') => 6,
_ => 6,
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::unreadable_literal)]
mod tests {
use super::*;
#[test]
fn minsec_empty_is_zero() {
assert_eq!(parse_minsec_digits(""), 0.0);
}
#[test]
fn minsec_two_digits_is_minutes() {
let v = parse_minsec_digits("42");
assert!((v - 42.0 / 60.0).abs() < 1e-9);
}
#[test]
fn minsec_four_digits_is_min_sec() {
let v = parse_minsec_digits("0445");
let expected = 4.0 / 60.0 + 45.0 / 3600.0;
assert!((v - expected).abs() < 1e-9);
}
#[test]
fn stmerid_empty_is_zero() {
assert_eq!(parse_stmerid(""), 0.0);
}
#[test]
fn stmerid_lmt_west() {
let v = parse_stmerid("m0w20");
let expected = -(20.0 / 60.0) / 15.0;
assert!((v - expected).abs() < 1e-6);
}
#[test]
fn stmerid_standard_east_hours_and_minutes() {
let v = parse_stmerid("h0e45");
assert!((v - 0.75).abs() < 1e-6);
}
#[test]
fn stmerid_standard_west_hours_only() {
let v = parse_stmerid("h7w");
assert!((v - (-7.0)).abs() < 1e-6);
}
#[test]
fn stmerid_gmt() {
assert_eq!(parse_stmerid("h0e"), 0.0);
}
#[test]
fn stmerid_lmt_with_seconds() {
let v = parse_stmerid("m16e2223");
let expected = (16.0 + 22.0 / 60.0 + 23.0 / 3600.0) / 15.0;
assert!((v - expected).abs() < 1e-6);
}
#[test]
fn tz_offset_zero_when_local_equals_ut() {
let off = tz_offset(Some(2440588.0), "", 12, 0);
assert!(off.abs() < 1e-6);
}
#[test]
fn tz_offset_positive_one_hour() {
let off = tz_offset(Some(2440587.958_333), "", 12, 0);
assert!((off - 1.0).abs() < 1e-3);
}
#[test]
fn tz_offset_falls_back_to_stmerid_when_no_jd_ut() {
let off = tz_offset(None, "h5e", 12, 0);
assert!((off - 5.0).abs() < 1e-6);
}
#[test]
fn tz_offset_normalizes_past_midnight() {
let jd = 2440588.0 + 11.0 / 24.0; let off = tz_offset(Some(jd), "", 1, 0);
assert!((off - 2.0).abs() < 1e-3);
}
#[test]
fn lat_north_degrees_minutes() {
let lat = parse_lat("45n42", 0).unwrap();
assert!((lat.degrees() - (45.0 + 42.0 / 60.0)).abs() < 1e-9);
}
#[test]
fn lat_south_degrees_minutes() {
let lat = parse_lat("33s52", 0).unwrap();
assert!((lat.degrees() - (-(33.0 + 52.0 / 60.0))).abs() < 1e-9);
}
#[test]
fn lat_with_seconds() {
let lat = parse_lat("52n0445", 0).unwrap();
let expected = 52.0 + 4.0 / 60.0 + 45.0 / 3600.0;
assert!((lat.degrees() - expected).abs() < 1e-9);
}
#[test]
fn lon_east_degrees_minutes() {
let lon = parse_lon("2e20", 0).unwrap();
assert!((lon.degrees() - (2.0 + 20.0 / 60.0)).abs() < 1e-9);
}
#[test]
fn lon_west_degrees_minutes() {
let lon = parse_lon("0w20", 0).unwrap();
assert!((lon.degrees() - (-(20.0 / 60.0))).abs() < 1e-9);
}
#[test]
fn lon_west_with_seconds() {
let lon = parse_lon("122w1959", 0).unwrap();
let expected = -(122.0 + 19.0 / 60.0 + 59.0 / 3600.0);
assert!((lon.degrees() - expected).abs() < 1e-9);
}
#[test]
fn lon_zero_east() {
let lon = parse_lon("0e00", 0).unwrap();
assert_eq!(lon.degrees(), 0.0);
}
#[test]
fn coord_north_degrees_minutes() {
assert_eq!(coord_to_adb(45.7, 'n', 's'), "45n42");
}
#[test]
fn coord_south() {
assert_eq!(coord_to_adb(-(33.0 + 52.0 / 60.0), 'n', 's'), "33s52");
}
#[test]
fn coord_west_zero_degrees() {
assert_eq!(coord_to_adb(-(20.0 / 60.0), 'e', 'w'), "0w20");
}
#[test]
fn coord_east_zero() {
assert_eq!(coord_to_adb(0.0, 'e', 'w'), "0e00");
}
#[test]
fn coord_with_seconds() {
let deg = 52.0 + 4.0 / 60.0 + 45.0 / 3600.0;
assert_eq!(coord_to_adb(deg, 'n', 's'), "52n0445");
}
#[test]
fn jd_ut_unix_epoch_noon() {
use crate::chart::{
Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac,
};
let c = Chart {
name: String::new(),
secondary_name: None,
city: None,
region: None,
longitude: Longitude::new(0.0).unwrap(),
latitude: Latitude::new(0.0).unwrap(),
year: 1970,
month: 1,
day: 1,
hour: 12,
minute: 0,
second: 0,
tz_offset_hours: 0.0,
tz_abbreviation: None,
is_lmt: false,
event_type: EventType::Unspecified,
source_rating: None,
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes: None,
};
assert!((compute_jd_ut(&c) - 2_440_588.0).abs() < 1e-6);
}
#[test]
fn jd_ut_j2000() {
use crate::chart::{
Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac,
};
let c = Chart {
name: String::new(),
secondary_name: None,
city: None,
region: None,
longitude: Longitude::new(0.0).unwrap(),
latitude: Latitude::new(0.0).unwrap(),
year: 2000,
month: 1,
day: 1,
hour: 12,
minute: 0,
second: 0,
tz_offset_hours: 0.0,
tz_abbreviation: None,
is_lmt: false,
event_type: EventType::Unspecified,
source_rating: None,
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes: None,
};
assert!((compute_jd_ut(&c) - 2_451_545.0).abs() < 1e-6);
}
#[test]
fn rrc_maps_standard_codes() {
assert_eq!(rrc_from_source_rating(Some("AA")), 1);
assert_eq!(rrc_from_source_rating(Some("A")), 2);
assert_eq!(rrc_from_source_rating(Some("B")), 3);
assert_eq!(rrc_from_source_rating(Some("C")), 4);
assert_eq!(rrc_from_source_rating(Some("DD")), 5);
assert_eq!(rrc_from_source_rating(Some("X")), 6);
assert_eq!(rrc_from_source_rating(Some("XX")), 7);
}
#[test]
fn rrc_prefix_matches_combined_strings() {
assert_eq!(rrc_from_source_rating(Some("AA BC in hand")), 1);
assert_eq!(rrc_from_source_rating(Some("B Bio/autobiography")), 3);
}
#[test]
fn rrc_unknown_defaults_to_x() {
assert_eq!(rrc_from_source_rating(None), 6);
assert_eq!(rrc_from_source_rating(Some("?")), 6);
}
#[test]
fn xml_escape_ampersand() {
assert_eq!(xml_escape("a & b"), "a & b");
}
#[test]
fn xml_escape_lt_gt() {
assert_eq!(xml_escape("<tag>"), "<tag>");
}
#[test]
fn xml_escape_quote() {
assert_eq!(xml_escape("say \"hi\""), "say "hi"");
}
#[test]
fn xml_escape_plain_text_unchanged() {
assert_eq!(xml_escape("hello world"), "hello world");
}
}