use serde::{Deserialize, Serialize};
use super::presets::Precision;
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TimelinePoint(pub i64);
impl TimelinePoint {
pub fn from_ticks(t: i64) -> Self {
Self(t)
}
pub fn ticks(self) -> i64 {
self.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnitDef {
pub name: String,
#[serde(default)]
pub per_parent: u32,
#[serde(default)]
pub names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonDef {
pub name: String,
pub start_month: u32,
pub span_months: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParseAlias {
#[serde(rename = "match")]
pub matches: String,
pub ticks: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarConfig {
#[serde(default = "default_preset")]
pub preset: String,
#[serde(default = "default_base_unit")]
pub base_unit: String,
#[serde(default)]
pub units: Vec<UnitDef>,
#[serde(default)]
pub seasons: Vec<SeasonDef>,
#[serde(default)]
pub epoch_label: String,
#[serde(default)]
pub epoch_before_label: String,
#[serde(default)]
pub display_format: String,
#[serde(default)]
pub parse_aliases: Vec<ParseAlias>,
}
fn default_preset() -> String {
"custom".to_owned()
}
fn default_base_unit() -> String {
"day".to_owned()
}
impl Default for CalendarConfig {
fn default() -> Self {
Self {
preset: default_preset(),
base_unit: default_base_unit(),
units: Vec::new(),
seasons: Vec::new(),
epoch_label: String::new(),
epoch_before_label: String::new(),
display_format: String::new(),
parse_aliases: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct Calendar {
pub cfg: CalendarConfig,
ticks_per: Vec<i64>,
}
#[derive(Debug, Clone)]
pub struct ParseError {
pub input: String,
pub hint: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "parse `{}`: {}", self.input, self.hint)
}
}
impl std::error::Error for ParseError {}
impl Calendar {
pub fn from_config(mut cfg: CalendarConfig) -> Self {
expand_preset(&mut cfg);
if cfg.units.is_empty() {
cfg.units.push(UnitDef {
name: cfg.base_unit.clone(),
per_parent: 0,
names: Vec::new(),
});
}
if cfg.display_format.is_empty() {
cfg.display_format = default_display_format(&cfg);
}
let mut ticks_per: Vec<i64> = Vec::with_capacity(cfg.units.len());
if !cfg.units.is_empty() {
ticks_per.push(1);
for unit in cfg.units.iter().skip(1) {
let prev = *ticks_per.last().unwrap();
let per = unit.per_parent.max(1) as i64;
ticks_per.push(prev.saturating_mul(per));
}
}
Self { cfg, ticks_per }
}
pub fn unit_names(&self) -> Vec<&str> {
self.cfg.units.iter().map(|u| u.name.as_str()).collect()
}
pub fn ticks_per(&self, unit: &str) -> Option<i64> {
let idx = self.cfg.units.iter().position(|u| u.name == unit)?;
Some(self.ticks_per[idx])
}
pub fn add_units(&self, p: TimelinePoint, n: i64, unit: &str) -> TimelinePoint {
let unit_lower = unit.to_ascii_lowercase();
if unit_lower == "season" {
let avg = if self.cfg.seasons.is_empty() {
3
} else {
let sum: u32 = self.cfg.seasons.iter().map(|s| s.span_months).sum();
(sum / self.cfg.seasons.len() as u32).max(1)
};
return TimelinePoint(
p.0 + n * self.ticks_per("month").unwrap_or(1) * (avg as i64),
);
}
match self.ticks_per(&unit_lower) {
Some(per) => TimelinePoint(p.0 + n * per),
None => p,
}
}
pub fn fuzz_window(
&self,
p: TimelinePoint,
prec: Precision,
) -> (TimelinePoint, TimelinePoint) {
let span = self.span_for_precision(prec);
if span <= 1 {
return (p, TimelinePoint(p.0 + 1));
}
let floor_ticks = if p.0 >= 0 {
(p.0 / span) * span
} else {
-(((-p.0) + span - 1) / span * span)
};
(
TimelinePoint(floor_ticks),
TimelinePoint(floor_ticks + span),
)
}
fn span_for_precision(&self, prec: Precision) -> i64 {
match prec {
Precision::Tick => 1,
Precision::Hour => self.ticks_per("hour").unwrap_or(1),
Precision::Day => self.ticks_per("day").unwrap_or(1),
Precision::Week => self.ticks_per("day").unwrap_or(1).saturating_mul(7),
Precision::Month => self.ticks_per("month").unwrap_or(
self.ticks_per("day").unwrap_or(1).saturating_mul(30),
),
Precision::Season => {
let months_per = self.ticks_per("month").unwrap_or(
self.ticks_per("day").unwrap_or(1).saturating_mul(30),
);
let avg_months = if self.cfg.seasons.is_empty() {
3
} else {
let sum: u32 = self.cfg.seasons.iter().map(|s| s.span_months).sum();
(sum / self.cfg.seasons.len() as u32).max(1)
};
months_per.saturating_mul(avg_months as i64)
}
Precision::Year => self.ticks_per("year").unwrap_or(
self.ticks_per("day").unwrap_or(1).saturating_mul(365),
),
}
}
pub fn format(&self, p: TimelinePoint, prec: Precision) -> String {
let breakdown = self.decompose(p);
let year = breakdown.iter().last().copied().unwrap_or(0);
let month = breakdown.get(self.index_of("month")).copied().unwrap_or(1);
let day = breakdown.get(self.index_of("day")).copied().unwrap_or(1);
let hour = breakdown.get(self.index_of("hour")).copied().unwrap_or(0);
let epoch_token = if year >= 0 {
self.cfg.epoch_label.clone()
} else {
self.cfg.epoch_before_label.clone()
};
let year_abs = year.abs();
let mut out = self.cfg.display_format.clone();
out = out.replace("{year}", &year_abs.to_string());
out = out.replace("{epoch_label}", &epoch_token);
out = out.replace("{epoch_before_label}", &epoch_token);
out = out.replace("{day}", &day.to_string());
out = out.replace("{hour}", &format!("{hour:02}"));
out = out.replace(
"{month-name}",
&self.month_name(month as usize).unwrap_or_else(|| month.to_string()),
);
out = out.replace("{month}", &month.to_string());
truncate_by_precision(&out, prec)
}
fn decompose(&self, p: TimelinePoint) -> Vec<i64> {
let mut out: Vec<i64> = vec![0; self.cfg.units.len()];
let total = p.0;
if total >= 0 {
let mut remaining = total;
for i in (0..self.cfg.units.len()).rev() {
let per = self.ticks_per[i];
if per == 0 {
continue;
}
let value = remaining / per;
remaining -= value * per;
if i == self.cfg.units.len() - 1 {
out[i] = value + 1; } else {
out[i] = value + 1;
}
}
} else {
let positive = -total - 1;
let mut remaining = positive;
for i in (0..self.cfg.units.len()).rev() {
let per = self.ticks_per[i];
if per == 0 {
continue;
}
let value = remaining / per;
remaining -= value * per;
if i == self.cfg.units.len() - 1 {
out[i] = -(value + 1);
} else {
let max_minus_1 = self.cfg.units[i].per_parent as i64 - 1;
out[i] = max_minus_1 - value + 1;
}
}
}
out
}
fn month_name(&self, idx_one_based: usize) -> Option<String> {
let unit = self.cfg.units.iter().find(|u| u.name == "month")?;
if unit.names.is_empty() || idx_one_based == 0 {
return None;
}
unit.names.get(idx_one_based - 1).cloned()
}
fn top_unit_precision(&self) -> Precision {
let top = self
.cfg
.units
.last()
.map(|u| u.name.as_str())
.unwrap_or("year");
match top {
"hour" => Precision::Hour,
"day" => Precision::Day,
"week" => Precision::Week,
"month" => Precision::Month,
"year" => Precision::Year,
_ => Precision::Year,
}
}
fn index_of(&self, name: &str) -> usize {
self.cfg
.units
.iter()
.position(|u| u.name == name)
.unwrap_or(usize::MAX)
}
pub fn parse(&self, s: &str) -> Result<(TimelinePoint, Precision), ParseError> {
let raw = s.trim();
if raw.is_empty() {
return Err(ParseError {
input: s.to_owned(),
hint: "empty timeline input".into(),
});
}
for alias in &self.cfg.parse_aliases {
if alias.matches.eq_ignore_ascii_case(raw) {
return Ok((TimelinePoint(alias.ticks), Precision::Day));
}
}
let (year_str, body, is_before) = split_year_and_label(
raw,
&self.cfg.epoch_label,
&self.cfg.epoch_before_label,
);
let year: i64 = year_str.parse().map_err(|_| ParseError {
input: s.to_owned(),
hint: format!("can't parse year segment `{year_str}`"),
})?;
let year = if is_before { -year.abs() } else { year };
let mut segments: Vec<&str> =
body.split('.').map(str::trim).filter(|s| !s.is_empty()).collect();
let mut month: i64 = 1;
let mut day: i64 = 1;
let mut hour: i64 = 0;
let mut precision = self.top_unit_precision();
if let Some(month_seg) = segments.first().copied() {
if let Some(season) = self.season_by_name_prefix(month_seg) {
month = season.start_month as i64;
day = 1;
segments.remove(0);
precision = Precision::Season;
} else if let Some(month_idx) = self.month_index_by_name(month_seg) {
month = month_idx as i64;
segments.remove(0);
precision = Precision::Month;
} else if let Ok(n) = month_seg.parse::<i64>() {
month = n;
segments.remove(0);
precision = Precision::Month;
} else {
return Err(ParseError {
input: s.to_owned(),
hint: format!(
"unknown month/season name `{month_seg}` (try numeric form, e.g. `{year}.3`)"
),
});
}
}
if let Some(day_seg) = segments.first().copied() {
let n: i64 = day_seg.parse().map_err(|_| ParseError {
input: s.to_owned(),
hint: format!("can't parse day segment `{day_seg}`"),
})?;
day = n;
segments.remove(0);
precision = Precision::Day;
}
if let Some(hour_seg) = segments.first().copied() {
let n: i64 = hour_seg.parse().map_err(|_| ParseError {
input: s.to_owned(),
hint: format!("can't parse hour segment `{hour_seg}`"),
})?;
hour = n;
segments.remove(0);
precision = Precision::Hour;
}
if !segments.is_empty() {
return Err(ParseError {
input: s.to_owned(),
hint: format!("trailing segments `{}` not understood", segments.join(".")),
});
}
let ticks = self.compose(year, month, day, hour)?;
Ok((TimelinePoint(ticks), precision))
}
fn season_by_name_prefix(&self, s: &str) -> Option<&SeasonDef> {
let needle = s.trim().to_ascii_lowercase();
if needle.is_empty() {
return None;
}
let exact = self
.cfg
.seasons
.iter()
.find(|sd| sd.name.eq_ignore_ascii_case(&needle));
if exact.is_some() {
return exact;
}
self.cfg
.seasons
.iter()
.find(|sd| sd.name.to_ascii_lowercase().starts_with(&needle))
}
fn month_index_by_name(&self, s: &str) -> Option<usize> {
let unit = self.cfg.units.iter().find(|u| u.name == "month")?;
if unit.names.is_empty() {
return None;
}
let needle = s.trim().to_ascii_lowercase();
if needle.is_empty() {
return None;
}
for (i, n) in unit.names.iter().enumerate() {
if n.eq_ignore_ascii_case(&needle) {
return Some(i + 1);
}
}
let mut hits: Vec<usize> = Vec::new();
for (i, n) in unit.names.iter().enumerate() {
if n.to_ascii_lowercase().starts_with(&needle) {
hits.push(i + 1);
}
}
if hits.len() == 1 {
Some(hits[0])
} else {
None
}
}
fn compose(
&self,
year: i64,
month: i64,
day: i64,
hour: i64,
) -> Result<i64, ParseError> {
let top_idx = self.cfg.units.len().saturating_sub(1);
let top_ticks = self.ticks_per.get(top_idx).copied().unwrap_or(1);
let top_value = year;
if top_value == 0 {
return Err(ParseError {
input: format!("{top_value}"),
hint: "year 0 (top-unit value 0) doesn't exist — use `1` for the epoch or `-1` for the value before"
.into(),
});
}
let ticks_per_month = self.ticks_per("month").unwrap_or(0);
let ticks_per_day = self.ticks_per("day").unwrap_or(0);
let ticks_per_hour = self.ticks_per("hour").unwrap_or(0);
let m0 = (month - 1).max(0);
let d0 = (day - 1).max(0);
let h0 = hour.max(0);
let within = ticks_per_month.saturating_mul(m0)
+ ticks_per_day.saturating_mul(d0)
+ ticks_per_hour.saturating_mul(h0);
if top_value > 0 {
Ok(top_ticks.saturating_mul(top_value - 1) + within)
} else {
let base = -(top_ticks.saturating_mul(top_value.abs()));
Ok(base + within)
}
}
}
fn truncate_by_precision(s: &str, prec: Precision) -> String {
let keep = match prec {
Precision::Year => 1,
Precision::Season => 2,
Precision::Month => 2,
Precision::Day => 3,
Precision::Hour => 4,
Precision::Tick | Precision::Week => return s.to_owned(),
};
let mut parts: Vec<&str> = s.split('.').collect();
if parts.len() > keep {
parts.truncate(keep);
}
parts.join(".")
}
fn split_year_and_label<'a>(
raw: &'a str,
epoch: &str,
before: &str,
) -> (String, &'a str, bool) {
let first_dot = raw.find('.').unwrap_or(raw.len());
let year_seg = &raw[..first_dot];
let rest = if first_dot < raw.len() {
&raw[first_dot + 1..]
} else {
""
};
let mut yseg = year_seg.trim().to_owned();
let mut is_before = false;
let try_strip = |s: &str, label: &str| -> Option<String> {
if label.is_empty() {
return None;
}
let lower = s.to_ascii_lowercase();
let llabel = label.to_ascii_lowercase();
if lower.ends_with(&llabel) {
return Some(s[..s.len() - label.len()].trim().to_owned());
}
if lower.starts_with(&llabel) {
return Some(s[label.len()..].trim().to_owned());
}
None
};
if let Some(stripped) = try_strip(&yseg, before) {
yseg = stripped;
is_before = true;
} else if let Some(stripped) = try_strip(&yseg, epoch) {
yseg = stripped;
}
(yseg, rest, is_before)
}
fn default_display_format(cfg: &CalendarConfig) -> String {
let mut parts: Vec<&str> = Vec::new();
let mut has = |name: &str| cfg.units.iter().any(|u| u.name == name);
if has("year") {
parts.push("{year}{epoch_label}");
} else if has("day") {
return format!(
"{}{{day}}",
if cfg.epoch_label.is_empty() {
String::new()
} else {
format!("{} ", cfg.epoch_label)
}
);
} else {
return "{year}".to_owned();
}
if has("month") {
parts.push(".{month}");
}
if has("day") {
parts.push(".{day}");
}
parts.join("")
}
fn expand_preset(cfg: &mut CalendarConfig) {
let preset = cfg.preset.trim().to_ascii_lowercase();
match preset.as_str() {
"sols" => {
if cfg.units.is_empty() {
cfg.units.push(UnitDef {
name: "day".to_owned(),
per_parent: 0,
names: Vec::new(),
});
}
if cfg.epoch_label.is_empty() {
cfg.epoch_label = "Sol".to_owned();
}
if cfg.display_format.is_empty() {
cfg.display_format = "Sol {day}".to_owned();
}
}
"gregorian" => {
if cfg.units.is_empty() {
cfg.units = vec![
UnitDef {
name: "day".to_owned(),
per_parent: 0,
names: Vec::new(),
},
UnitDef {
name: "month".to_owned(),
per_parent: 30,
names: vec![
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December",
]
.into_iter()
.map(String::from)
.collect(),
},
UnitDef {
name: "year".to_owned(),
per_parent: 12,
names: Vec::new(),
},
];
}
if cfg.seasons.is_empty() {
cfg.seasons = vec![
SeasonDef { name: "winter".into(), start_month: 12, span_months: 3 },
SeasonDef { name: "spring".into(), start_month: 3, span_months: 3 },
SeasonDef { name: "summer".into(), start_month: 6, span_months: 3 },
SeasonDef { name: "autumn".into(), start_month: 9, span_months: 3 },
];
}
if cfg.display_format.is_empty() {
cfg.display_format = "{year}{epoch_label}.{month}.{day}".to_owned();
}
}
_ => {} }
}
#[cfg(test)]
mod tests {
use super::*;
fn sols() -> Calendar {
Calendar::from_config(CalendarConfig {
preset: "sols".into(),
..Default::default()
})
}
fn gregorian() -> Calendar {
Calendar::from_config(CalendarConfig {
preset: "gregorian".into(),
..Default::default()
})
}
fn custom_aerin() -> Calendar {
Calendar::from_config(CalendarConfig {
preset: "custom".into(),
base_unit: "day".into(),
units: vec![
UnitDef { name: "day".into(), per_parent: 0, names: vec![] },
UnitDef { name: "month".into(), per_parent: 30, names: vec![
"Frostmoon".into(), "Snowfall".into(), "Greenstart".into(),
"Bloomtide".into(), "Highsun".into(), "Goldfall".into(),
"Mistwane".into(), "Stormrise".into(), "Coldgate".into(),
"Longnight".into(), "Hearthlit".into(), "Yearfall".into(),
] },
UnitDef { name: "year".into(), per_parent: 12, names: vec![] },
],
seasons: vec![
SeasonDef { name: "winter".into(), start_month: 1, span_months: 3 },
SeasonDef { name: "spring".into(), start_month: 4, span_months: 3 },
SeasonDef { name: "summer".into(), start_month: 7, span_months: 3 },
SeasonDef { name: "autumn".into(), start_month: 10, span_months: 3 },
],
epoch_label: "A".into(),
epoch_before_label: "BA".into(),
display_format: "{year}{epoch_label}.{month}.{day}".into(),
parse_aliases: vec![ParseAlias { matches: "Founding".into(), ticks: 0 }],
})
}
#[test]
fn sols_format_simple() {
let c = sols();
assert_eq!(c.format(TimelinePoint(0), Precision::Day), "Sol 1");
assert_eq!(c.format(TimelinePoint(142), Precision::Day), "Sol 143");
}
#[test]
fn sols_parse_roundtrip() {
let c = sols();
let (p, prec) = c.parse("Sol 5").unwrap_or_else(|e| {
panic!("parse failed: {e}");
});
assert_eq!(p, TimelinePoint(4)); assert_eq!(prec, Precision::Day);
}
#[test]
fn aerin_year_only_precision() {
let c = custom_aerin();
let (p, prec) = c.parse("1A").unwrap();
assert_eq!(p, TimelinePoint(0));
assert_eq!(prec, Precision::Year);
assert_eq!(c.format(p, prec), "1A");
}
#[test]
fn aerin_full_form() {
let c = custom_aerin();
let (p, prec) = c.parse("1A.3.15").unwrap();
assert_eq!(prec, Precision::Day);
assert_eq!(p, TimelinePoint(74));
assert_eq!(c.format(p, prec), "1A.3.15");
}
#[test]
fn aerin_month_by_name() {
let c = custom_aerin();
let (p, prec) = c.parse("1A.Greenstart").unwrap();
assert_eq!(prec, Precision::Month);
assert_eq!(p, TimelinePoint(60));
}
#[test]
fn aerin_month_by_prefix() {
let c = custom_aerin();
let (p, _) = c.parse("1A.Frost").unwrap();
assert_eq!(p, TimelinePoint(0));
}
#[test]
fn aerin_season_precision() {
let c = custom_aerin();
let (p, prec) = c.parse("3A.spring").unwrap();
assert_eq!(prec, Precision::Season);
assert_eq!(p, TimelinePoint(810));
}
#[test]
fn aerin_alias_landmark() {
let c = custom_aerin();
let (p, prec) = c.parse("Founding").unwrap();
assert_eq!(p, TimelinePoint(0));
assert_eq!(prec, Precision::Day);
}
#[test]
fn aerin_negative_year() {
let c = custom_aerin();
let (p, prec) = c.parse("-1BA").unwrap();
assert_eq!(prec, Precision::Year);
assert_eq!(p, TimelinePoint(-360));
}
#[test]
fn parse_error_year_zero() {
let c = custom_aerin();
let err = c.parse("0A.3.5").unwrap_err();
assert!(err.hint.contains("year 0"));
}
#[test]
fn parse_error_unknown_month_name() {
let c = custom_aerin();
let err = c.parse("1A.frosbun").unwrap_err();
assert!(err.hint.contains("unknown month"));
}
#[test]
fn gregorian_default_format_walks() {
let c = gregorian();
let (p, prec) = c.parse("2024.5.20").unwrap();
assert_eq!(prec, Precision::Day);
assert_eq!(c.format(p, prec), "2024.5.20");
}
#[test]
fn add_units_walks_day_then_month() {
let c = custom_aerin();
let (p, _) = c.parse("1A.3.10").unwrap();
let later = c.add_units(p, 5, "day");
assert_eq!(c.format(later, Precision::Day), "1A.3.15");
let next_month = c.add_units(p, 1, "month");
assert_eq!(c.format(next_month, Precision::Day), "1A.4.10");
}
#[test]
fn fuzz_window_day() {
let c = custom_aerin();
let (p, _) = c.parse("1A.3.10").unwrap();
let (lo, hi) = c.fuzz_window(p, Precision::Day);
assert_eq!(hi.0 - lo.0, 1);
assert!(p.0 >= lo.0 && p.0 < hi.0);
}
#[test]
fn fuzz_window_year() {
let c = custom_aerin();
let (p, _) = c.parse("1A.3.10").unwrap();
let (lo, hi) = c.fuzz_window(p, Precision::Year);
assert_eq!(hi.0 - lo.0, 360); assert!(p.0 >= lo.0 && p.0 < hi.0);
}
#[test]
fn format_precision_truncates() {
let c = custom_aerin();
let (p, _) = c.parse("1A.3.15").unwrap();
assert_eq!(c.format(p, Precision::Year), "1A");
assert_eq!(c.format(p, Precision::Month), "1A.3");
assert_eq!(c.format(p, Precision::Day), "1A.3.15");
}
}