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>,
}
impl PeriodCalendar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn define(&mut self, period: Period) -> Result<(), PeriodError> {
if self.periods.contains_key(&period.id) {
return Err(PeriodError::Duplicate { id: period.id });
}
if let Some(existing) = self.periods.values().find(|p| p.overlaps(&period)) {
return Err(PeriodError::Overlap {
id: period.id.clone(),
existing: existing.id.clone(),
});
}
self.periods.insert(period.id.clone(), period);
Ok(())
}
#[must_use]
pub fn period_on(&self, date: Date) -> Option<&Period> {
self.periods.values().find(|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.periods.values()
}
#[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 unknown_period_transition_is_an_error() {
let mut c = PeriodCalendar::new();
assert!(matches!(
c.transition(&pid("nope"), PeriodState::Closing),
Err(PeriodError::Unknown { .. })
));
}
}