use std::collections::BTreeMap;
use time::Date;
use crate::canonical::{Canonical, CanonicalWriter};
use crate::dimensions::{DimensionError, Label};
use crate::serde_support::validating_string_serde;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PeriodState {
Open,
Closing,
Sealed,
}
impl PeriodState {
#[must_use]
pub const fn accepts_postings(self) -> bool {
matches!(self, Self::Open)
}
}
impl std::fmt::Display for PeriodState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Open => "open",
Self::Closing => "closing",
Self::Sealed => "sealed",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PeriodId(Label);
validating_string_serde!(PeriodId);
impl PeriodId {
pub fn new(s: impl Into<String>) -> Result<Self, DimensionError> {
Label::new(s).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl std::fmt::Display for PeriodId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl Canonical for PeriodId {
fn encode(&self, w: &mut CanonicalWriter) {
self.0.encode(w);
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LedgerId(Label);
validating_string_serde!(LedgerId);
impl LedgerId {
pub fn new(s: impl Into<String>) -> Result<Self, DimensionError> {
Label::new(s).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl std::fmt::Display for LedgerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl Canonical for LedgerId {
fn encode(&self, w: &mut CanonicalWriter) {
self.0.encode(w);
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PeriodError {
#[error("period {id} ends on {end} before it starts on {start}")]
EndBeforeStart {
id: PeriodId,
start: Date,
end: Date,
},
#[error("period {id} overlaps existing period {existing}")]
Overlap {
id: PeriodId,
existing: PeriodId,
},
#[error("period {id} is already defined")]
Duplicate {
id: PeriodId,
},
#[error("period {id} is not defined")]
Unknown {
id: PeriodId,
},
#[error("period {id} cannot move from {from} to {to}")]
InvalidTransition {
id: PeriodId,
from: PeriodState,
to: PeriodState,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Period {
pub id: PeriodId,
pub start: Date,
pub end: Date,
pub state: PeriodState,
}
impl Period {
pub fn new(id: PeriodId, start: Date, end: Date) -> Result<Self, PeriodError> {
if end < start {
return Err(PeriodError::EndBeforeStart { id, start, end });
}
Ok(Self {
id,
start,
end,
state: PeriodState::Open,
})
}
#[must_use]
pub fn contains(&self, date: Date) -> bool {
date >= self.start && date <= self.end
}
#[must_use]
pub fn overlaps(&self, other: &Self) -> bool {
self.start <= other.end && other.start <= self.end
}
}
#[derive(Debug, Clone, Default)]
pub struct PeriodCalendar {
periods: BTreeMap<PeriodId, Period>,
by_start: BTreeMap<Date, PeriodId>,
}
impl PeriodCalendar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn from_periods(periods: impl IntoIterator<Item = Period>) -> Result<Self, PeriodError> {
let mut calendar = Self::new();
for period in periods {
calendar.define(period)?;
}
Ok(calendar)
}
pub fn define(&mut self, period: Period) -> Result<(), PeriodError> {
if self.periods.contains_key(&period.id) {
return Err(PeriodError::Duplicate { id: period.id });
}
let before = self
.by_start
.range(..=period.start)
.next_back()
.map(|(_, id)| id);
let after = self.by_start.range(period.start..).next().map(|(_, id)| id);
for neighbour in [before, after].into_iter().flatten() {
if let Some(existing) = self.periods.get(neighbour)
&& existing.overlaps(&period)
{
return Err(PeriodError::Overlap {
id: period.id.clone(),
existing: existing.id.clone(),
});
}
}
self.by_start.insert(period.start, period.id.clone());
self.periods.insert(period.id.clone(), period);
Ok(())
}
pub fn ensure(&mut self, period: Period) -> Result<(), PeriodError> {
match self.periods.get(&period.id) {
Some(existing) if existing.start == period.start && existing.end == period.end => {
self.periods.insert(period.id.clone(), period);
Ok(())
}
Some(_) => Err(PeriodError::Duplicate { id: period.id }),
None => self.define(period),
}
}
#[must_use]
pub fn period_on(&self, date: Date) -> Option<&Period> {
let (_, id) = self.by_start.range(..=date).next_back()?;
self.periods.get(id).filter(|p| p.contains(date))
}
#[must_use]
pub fn state_on(&self, date: Date) -> PeriodState {
self.period_on(date).map_or(PeriodState::Open, |p| p.state)
}
#[must_use]
pub fn accepts(&self, date: Date) -> bool {
self.state_on(date).accepts_postings()
}
#[must_use]
pub fn get(&self, id: &PeriodId) -> Option<&Period> {
self.periods.get(id)
}
pub fn transition(&mut self, id: &PeriodId, to: PeriodState) -> Result<(), PeriodError> {
let period = self
.periods
.get_mut(id)
.ok_or_else(|| PeriodError::Unknown { id: id.clone() })?;
let permitted = matches!(
(period.state, to),
(PeriodState::Open, PeriodState::Closing)
| (PeriodState::Closing, PeriodState::Sealed)
| (PeriodState::Closing, PeriodState::Open)
);
if !permitted {
return Err(PeriodError::InvalidTransition {
id: id.clone(),
from: period.state,
to,
});
}
period.state = to;
Ok(())
}
pub fn iter(&self) -> impl Iterator<Item = &Period> {
self.by_start.values().filter_map(|id| self.periods.get(id))
}
#[must_use]
pub fn len(&self) -> usize {
self.periods.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.periods.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
fn pid(s: &str) -> PeriodId {
PeriodId::new(s).expect("valid identifier")
}
fn march() -> Period {
Period::new(pid("2026-03"), date!(2026 - 03 - 01), date!(2026 - 03 - 31))
.expect("valid range")
}
#[test]
fn rejects_inverted_ranges() {
assert!(matches!(
Period::new(pid("bad"), date!(2026 - 03 - 31), date!(2026 - 03 - 01)),
Err(PeriodError::EndBeforeStart { .. })
));
}
#[test]
fn contains_is_inclusive_at_both_ends() {
let p = march();
assert!(p.contains(date!(2026 - 03 - 01)));
assert!(p.contains(date!(2026 - 03 - 31)));
assert!(!p.contains(date!(2026 - 02 - 28)));
assert!(!p.contains(date!(2026 - 04 - 01)));
}
#[test]
fn empty_calendar_accepts_everything() {
let c = PeriodCalendar::new();
assert!(c.accepts(date!(2026 - 03 - 15)));
assert_eq!(c.state_on(date!(2026 - 03 - 15)), PeriodState::Open);
}
#[test]
fn dates_outside_every_period_are_unrestricted() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
c.transition(&pid("2026-03"), PeriodState::Closing)
.expect("ok");
c.transition(&pid("2026-03"), PeriodState::Sealed)
.expect("ok");
assert!(!c.accepts(date!(2026 - 03 - 15)));
assert!(c.accepts(date!(2026 - 04 - 15)));
}
#[test]
fn rejects_overlapping_periods() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
let overlapping = Period::new(
pid("2026-03b"),
date!(2026 - 03 - 15),
date!(2026 - 04 - 15),
)
.expect("valid range");
assert!(matches!(
c.define(overlapping),
Err(PeriodError::Overlap { .. })
));
}
#[test]
fn rejects_duplicate_identifiers() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
assert!(matches!(
c.define(march()),
Err(PeriodError::Duplicate { .. })
));
}
#[test]
fn adjacent_periods_do_not_overlap() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
let april = Period::new(pid("2026-04"), date!(2026 - 04 - 01), date!(2026 - 04 - 30))
.expect("valid range");
assert!(c.define(april).is_ok());
assert_eq!(c.len(), 2);
}
#[test]
fn lifecycle_follows_the_permitted_transitions() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
let id = pid("2026-03");
assert!(matches!(
c.transition(&id, PeriodState::Sealed),
Err(PeriodError::InvalidTransition { .. })
));
c.transition(&id, PeriodState::Closing)
.expect("open to closing");
c.transition(&id, PeriodState::Open)
.expect("closing to open");
c.transition(&id, PeriodState::Closing)
.expect("open to closing");
c.transition(&id, PeriodState::Sealed)
.expect("closing to sealed");
for to in [PeriodState::Open, PeriodState::Closing, PeriodState::Sealed] {
assert!(matches!(
c.transition(&id, to),
Err(PeriodError::InvalidTransition { .. })
));
}
}
#[test]
fn closing_periods_stop_accepting_postings() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
c.transition(&pid("2026-03"), PeriodState::Closing)
.expect("ok");
assert!(!c.accepts(date!(2026 - 03 - 15)));
}
#[test]
fn overlap_is_caught_from_either_side() {
let mut c = PeriodCalendar::new();
c.define(march()).expect("defines");
let starts_before = Period::new(
pid("straddle-left"),
date!(2026 - 02 - 15),
date!(2026 - 03 - 10),
)
.expect("valid range");
assert!(matches!(
c.define(starts_before),
Err(PeriodError::Overlap { .. })
));
let starts_after = Period::new(
pid("straddle-right"),
date!(2026 - 03 - 20),
date!(2026 - 04 - 10),
)
.expect("valid range");
assert!(matches!(
c.define(starts_after),
Err(PeriodError::Overlap { .. })
));
let swallows = Period::new(pid("swallow"), date!(2026 - 01 - 01), date!(2026 - 12 - 31))
.expect("valid range");
assert!(matches!(
c.define(swallows),
Err(PeriodError::Overlap { .. })
));
let identical = Period::new(
pid("same-range"),
date!(2026 - 03 - 01),
date!(2026 - 03 - 31),
)
.expect("valid range");
assert!(matches!(
c.define(identical),
Err(PeriodError::Overlap { .. })
));
}
#[test]
fn lookup_finds_the_right_period_among_many() {
let mut c = PeriodCalendar::new();
for month in 1u8..=12 {
let start = time::Date::from_calendar_date(
2026,
time::Month::try_from(month).expect("valid month"),
1,
)
.expect("valid date");
let end = start
.replace_day(start.month().length(2026))
.expect("valid date");
c.define(Period::new(pid(&format!("2026-{month:02}")), start, end).expect("valid"))
.expect("defines");
}
assert_eq!(c.len(), 12);
assert_eq!(
c.period_on(date!(2026 - 07 - 04)).map(|p| p.id.to_string()),
Some("2026-07".to_owned())
);
assert_eq!(
c.period_on(date!(2026 - 12 - 31)).map(|p| p.id.to_string()),
Some("2026-12".to_owned())
);
assert!(c.period_on(date!(2025 - 12 - 31)).is_none());
assert!(c.period_on(date!(2027 - 01 - 01)).is_none());
let order: Vec<String> = c.iter().map(|p| p.id.to_string()).collect();
assert_eq!(order.first().map(String::as_str), Some("2026-01"));
assert_eq!(order.last().map(String::as_str), Some("2026-12"));
}
#[test]
fn a_calendar_rebuilds_from_stored_periods_with_their_states() {
let mut original = PeriodCalendar::new();
original.define(march()).expect("defines");
original
.transition(&pid("2026-03"), PeriodState::Closing)
.expect("ok");
original
.transition(&pid("2026-03"), PeriodState::Sealed)
.expect("ok");
let restored = PeriodCalendar::from_periods(original.iter().cloned()).expect("rebuilds");
assert_eq!(
restored.state_on(date!(2026 - 03 - 15)),
PeriodState::Sealed
);
assert!(!restored.accepts(date!(2026 - 03 - 15)));
}
#[test]
fn unknown_period_transition_is_an_error() {
let mut c = PeriodCalendar::new();
assert!(matches!(
c.transition(&pid("nope"), PeriodState::Closing),
Err(PeriodError::Unknown { .. })
));
}
}