use crate::chart::{Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac};
use crate::error::ParseError;
pub fn parse_file(text: &str) -> Result<Vec<Chart>, ParseError> {
let mut charts = Vec::new();
for (idx, line) in text.lines().enumerate() {
if line.is_empty() {
continue;
}
charts.push(parse_record(line, idx + 1)?);
}
Ok(charts)
}
fn bad(line: usize, reason: impl Into<String>) -> ParseError {
ParseError::InvalidRecord {
line,
reason: reason.into(),
}
}
fn parse_record(line: &str, line_num: usize) -> Result<Chart, ParseError> {
let fields: Vec<&str> = line.split(';').collect();
if fields.len() < 16 {
return Err(bad(
line_num,
format!("expected ≥16 fields, got {}", fields.len()),
));
}
let name = fields[0].to_string();
let chart_type: u8 = fields[1]
.parse()
.map_err(|_| bad(line_num, format!("invalid chart_type {:?}", fields[1])))?;
let (year, month, day) = parse_date(fields[2], line_num)?;
let (hour, minute, second) = parse_time(fields[3], line_num)?;
let tz_offset_hours = parse_utc_offset(fields[4], line_num)?;
let city = non_empty(fields[5]);
let latitude = parse_latitude(fields[6], line_num)?;
let longitude = parse_longitude(fields[7], line_num)?;
let sex = fields[8];
let source_rating = non_empty(fields[9]);
let notes = non_empty(fields[11]);
Ok(Chart {
name,
secondary_name: None,
city,
region: None,
longitude,
latitude,
year,
month,
day,
hour,
minute,
second,
tz_offset_hours,
tz_abbreviation: None,
is_lmt: false,
event_type: map_event_type(chart_type, sex),
source_rating,
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes,
})
}
fn non_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn map_event_type(chart_type: u8, sex: &str) -> EventType {
match chart_type {
2 => EventType::Horary,
3..=5 => EventType::Event,
_ => match sex {
"M" => EventType::Male,
"F" => EventType::Female,
_ => EventType::Unspecified,
},
}
}
fn parse_date(s: &str, line: usize) -> Result<(i16, u8, u8), ParseError> {
let s = s.trim_end_matches("JC");
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(bad(line, format!("invalid date {s:?}")));
}
let day: u8 = parts[0]
.parse()
.map_err(|_| bad(line, format!("invalid day {:?}", parts[0])))?;
let month: u8 = parts[1]
.parse()
.map_err(|_| bad(line, format!("invalid month {:?}", parts[1])))?;
let year: i16 = parts[2]
.parse()
.map_err(|_| bad(line, format!("invalid year {:?}", parts[2])))?;
Ok((year, month, day))
}
fn parse_time(s: &str, line: usize) -> Result<(u8, u8, u8), ParseError> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return Err(bad(line, format!("invalid time {s:?}")));
}
let hour: u8 = parts[0]
.parse()
.map_err(|_| bad(line, format!("invalid hour {:?}", parts[0])))?;
let min: u8 = parts[1]
.parse()
.map_err(|_| bad(line, format!("invalid minute {:?}", parts[1])))?;
let sec: u8 = parts[2]
.parse()
.map_err(|_| bad(line, format!("invalid second {:?}", parts[2])))?;
Ok((hour, min, sec))
}
fn parse_utc_offset(s: &str, line: usize) -> Result<f64, ParseError> {
if s.len() < 2 {
return Err(bad(line, format!("invalid utc_offset {s:?}")));
}
let sign = if s.starts_with('-') {
-1.0_f64
} else {
1.0_f64
};
let rest = &s[1..];
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 3 {
return Err(bad(line, format!("invalid utc_offset {s:?}")));
}
let h: f64 = parts[0]
.parse()
.map_err(|_| bad(line, format!("invalid offset hours {:?}", parts[0])))?;
let m: f64 = parts[1]
.parse()
.map_err(|_| bad(line, format!("invalid offset minutes {:?}", parts[1])))?;
let sec: f64 = parts[2]
.parse()
.map_err(|_| bad(line, format!("invalid offset seconds {:?}", parts[2])))?;
Ok(sign * (h + m / 60.0 + sec / 3600.0))
}
fn parse_latitude(s: &str, line: usize) -> Result<Latitude, ParseError> {
parse_coord(s, line, &['N', 'S']).and_then(|(deg, hemi)| {
let signed = if hemi == 'S' { -deg } else { deg };
Latitude::new(signed).map_err(|_| bad(line, format!("latitude {signed} out of range")))
})
}
fn parse_longitude(s: &str, line: usize) -> Result<Longitude, ParseError> {
parse_coord(s, line, &['E', 'W']).and_then(|(deg, hemi)| {
let signed = if hemi == 'W' { -deg } else { deg };
Longitude::new(signed).map_err(|_| bad(line, format!("longitude {signed} out of range")))
})
}
fn parse_coord(s: &str, line: usize, hemis: &[char]) -> Result<(f64, char), ParseError> {
let mut chars = s.chars();
let hemi = chars
.next()
.ok_or_else(|| bad(line, format!("empty coordinate {s:?}")))?;
if !hemis.contains(&hemi) {
return Err(bad(
line,
format!("expected hemisphere in {hemis:?}, got {hemi:?}"),
));
}
let rest = &s[1..];
let parts: Vec<&str> = rest.split('.').collect();
if parts.len() != 3 {
return Err(bad(line, format!("invalid coordinate {s:?}")));
}
let deg: f64 = parts[0]
.parse()
.map_err(|_| bad(line, format!("invalid coord degrees {:?}", parts[0])))?;
let min: f64 = parts[1]
.parse()
.map_err(|_| bad(line, format!("invalid coord minutes {:?}", parts[1])))?;
let sec: f64 = parts[2]
.parse()
.map_err(|_| bad(line, format!("invalid coord seconds {:?}", parts[2])))?;
Ok((deg + min / 60.0 + sec / 3600.0, hemi))
}
pub fn write_file(charts: &[Chart]) -> String {
charts.iter().map(write_record).collect()
}
fn write_record(chart: &Chart) -> String {
let (chart_type, sex) = unmap_event_type(chart.event_type);
let date = format!("{:02}.{:02}.{:04}", chart.day, chart.month, chart.year);
let time = format!("{:02}:{:02}:{:02}", chart.hour, chart.minute, chart.second);
let utc = fmt_utc_offset(chart.tz_offset_hours);
let city = chart.city.as_deref().unwrap_or("");
let lat = fmt_latitude(chart.latitude);
let lon = fmt_longitude(chart.longitude);
let rating = chart.source_rating.as_deref().unwrap_or("");
let notes = chart.notes.as_deref().unwrap_or("");
format!(
"{};{};{};{};{};{};{};{};{};{};{};{};;;;\n",
chart.name, chart_type, date, time, utc, city, lat, lon, sex, rating, "", notes
)
}
fn unmap_event_type(et: EventType) -> (&'static str, &'static str) {
match et {
EventType::Male => ("1", "M"),
EventType::Female => ("1", "F"),
EventType::Horary => ("2", "-"),
EventType::Event => ("3", "-"),
EventType::Unspecified => ("0", "-"),
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn to_dms(degrees_abs: f64) -> (u32, u32, u32) {
let total_sec = (degrees_abs * 3600.0).round() as u32;
(total_sec / 3600, (total_sec % 3600) / 60, total_sec % 60)
}
fn fmt_latitude(lat: Latitude) -> String {
let deg = lat.degrees();
let hemi = if deg >= 0.0 { 'N' } else { 'S' };
let (d, m, s) = to_dms(deg.abs());
format!("{hemi}{d:02}.{m:02}.{s:02}")
}
fn fmt_longitude(lon: Longitude) -> String {
let deg = lon.degrees();
let hemi = if deg >= 0.0 { 'E' } else { 'W' };
let (d, m, s) = to_dms(deg.abs());
format!("{hemi}{d:03}.{m:02}.{s:02}")
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn fmt_utc_offset(hours: f64) -> String {
let sign = if hours < 0.0 { '-' } else { '+' };
let total_sec = (hours.abs() * 3600.0).round() as u32;
let h = total_sec / 3600;
let m = (total_sec % 3600) / 60;
let s = total_sec % 60;
format!("{sign}{h:02}:{m:02}:{s:02}")
}