use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::cycle_schedule::config::CycleScheduleId;
use crate::cycle_schedule::identity::GeneratedCycleId;
use crate::error::{Result, TixError};
use crate::records::{HasName, RecordStore};
pub type CycleSnapshotMetadata = BTreeMap<String, serde_yaml::Value>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CycleSnapshot {
pub cycle_id: GeneratedCycleId,
pub schedule_id: CycleScheduleId,
pub sequence: u32,
pub start_date: String,
pub due_date: String,
pub closed_at: String,
pub closed_by: String,
pub state_summary: String,
#[serde(default, skip_serializing_if = "CycleSnapshotMetadata::is_empty")]
pub measurements: CycleSnapshotMetadata,
#[serde(flatten)]
pub extensions: CycleSnapshotMetadata,
}
impl HasName for CycleSnapshot {
fn name(&self) -> &str {
self.cycle_id.as_str()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SnapshotOccupant {
Absent,
Same(CycleSnapshot),
Foreign {
filename: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleSnapshotStore {
dir: PathBuf,
}
impl CycleSnapshotStore {
#[must_use]
pub fn new(root: &Path) -> Self {
Self {
dir: root.join("docs/cycle_snapshots"),
}
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn filename_for(cycle_id: &GeneratedCycleId) -> String {
format!("{}.md", cycle_id.as_str().replace(':', "."))
}
#[must_use]
pub fn path_for(&self, cycle_id: &GeneratedCycleId) -> PathBuf {
self.dir.join(Self::filename_for(cycle_id))
}
pub fn occupant(&self, cycle_id: &GeneratedCycleId) -> Result<SnapshotOccupant> {
let filename = Self::filename_for(cycle_id);
let found = RecordStore::new(&self.dir)
.list::<CycleSnapshot>()?
.into_iter()
.find(|record| record.filename == filename);
match found {
Some(record) if record.value.cycle_id == *cycle_id => {
Ok(SnapshotOccupant::Same(record.value))
}
Some(_) => Ok(SnapshotOccupant::Foreign { filename }),
None => {
if self.path_for(cycle_id).exists() {
Ok(SnapshotOccupant::Foreign { filename })
} else {
Ok(SnapshotOccupant::Absent)
}
}
}
}
pub fn read(&self, cycle_id: &GeneratedCycleId) -> Result<Option<CycleSnapshot>> {
Ok(match self.occupant(cycle_id)? {
SnapshotOccupant::Same(snapshot) => Some(snapshot),
SnapshotOccupant::Absent | SnapshotOccupant::Foreign { .. } => None,
})
}
pub fn list(&self) -> Result<Vec<CycleSnapshot>> {
Ok(RecordStore::new(&self.dir)
.list::<CycleSnapshot>()?
.into_iter()
.map(|record| record.value)
.collect())
}
pub fn capture(&self, snapshot: &CycleSnapshot) -> Result<PathBuf> {
let filename = Self::filename_for(&snapshot.cycle_id);
if self.dir.join(&filename).exists() {
return Err(TixError::InvalidInput(format!(
"`docs/cycle_snapshots/{filename}` already exists; a closed-cycle snapshot is \
immutable and is never rewritten"
)));
}
RecordStore::new(&self.dir).write(&filename, snapshot, None)
}
pub fn discard(&self, cycle_id: &GeneratedCycleId) -> Result<()> {
match self.occupant(cycle_id)? {
SnapshotOccupant::Same(_) => {
RecordStore::new(&self.dir).delete_file(&Self::filename_for(cycle_id))
}
SnapshotOccupant::Absent => Ok(()),
SnapshotOccupant::Foreign { filename } => Err(TixError::InvalidInput(format!(
"refusing to remove `docs/cycle_snapshots/{filename}`: it is not `{cycle_id}`'s \
own snapshot"
))),
}
}
}
#[must_use]
pub fn validate_timestamp(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 20 || bytes[10] != b'T' || bytes[19] != b'Z' {
return false;
}
if bytes[4] != b'-' || bytes[7] != b'-' || bytes[13] != b':' || bytes[16] != b':' {
return false;
}
for index in [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
if !bytes[index].is_ascii_digit() {
return false;
}
}
if !crate::model::validate_date(&value[..10]) {
return false;
}
let hour = value[11..13].parse::<u8>().unwrap_or(u8::MAX);
let minute = value[14..16].parse::<u8>().unwrap_or(u8::MAX);
let second = value[17..19].parse::<u8>().unwrap_or(u8::MAX);
hour < 24 && minute < 60 && second < 60
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn schedule() -> CycleScheduleId {
CycleScheduleId::new("cs-1:2").unwrap()
}
fn cycle() -> GeneratedCycleId {
GeneratedCycleId::new(&schedule(), 7)
}
fn snapshot() -> CycleSnapshot {
CycleSnapshot {
cycle_id: cycle(),
schedule_id: schedule(),
sequence: 7,
start_date: "2026-01-05".to_string(),
due_date: "2026-01-11".to_string(),
closed_at: "2026-01-12T09:00:00Z".to_string(),
closed_by: "alice".to_string(),
state_summary: "closed".to_string(),
measurements: CycleSnapshotMetadata::new(),
extensions: CycleSnapshotMetadata::new(),
}
}
#[test]
fn the_filename_replaces_colons_and_is_injective() {
assert_eq!(cycle().as_str(), "cs-1:2:000007");
assert_eq!(
CycleSnapshotStore::filename_for(&cycle()),
"cs-1.2.000007.md",
"the transformation is documented and deterministic"
);
let alias = GeneratedCycleId::parse("cs-1-2:000007").unwrap();
assert_ne!(alias, cycle());
assert_ne!(
CycleSnapshotStore::filename_for(&alias),
CycleSnapshotStore::filename_for(&cycle())
);
assert_eq!(CycleSnapshotStore::filename_for(&alias), "cs-1-2.000007.md");
let plain = GeneratedCycleId::new(&CycleScheduleId::new("cs-1").unwrap(), 7);
assert_eq!(CycleSnapshotStore::filename_for(&plain), "cs-1.000007.md");
}
#[test]
fn a_timestamp_must_have_the_crates_shape() {
assert!(validate_timestamp("2026-09-27T10:14:02Z"));
for bad in [
"",
"2026-09-27",
"2026-09-27 10:14:02Z",
"2026-09-27T10:14:02",
"2026-13-01T10:14:02Z",
"2026-02-30T10:14:02Z",
"2026-09-27T24:00:00Z",
"2026-09-27T10:60:00Z",
"2026-09-27T10:14:60Z",
"2026-09-27T1a:14:02Z",
] {
assert!(!validate_timestamp(bad), "`{bad}` must be refused");
}
}
#[test]
fn a_snapshot_round_trips_through_the_record_store_and_never_rewrites() {
let dir = tempfile::TempDir::new().unwrap();
let store = CycleSnapshotStore::new(dir.path());
assert_eq!(store.occupant(&cycle()).unwrap(), SnapshotOccupant::Absent);
let path = store.capture(&snapshot()).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(store.read(&cycle()).unwrap(), Some(snapshot()));
assert!(matches!(
store.occupant(&cycle()).unwrap(),
SnapshotOccupant::Same(_)
));
assert!(store.capture(&snapshot()).is_err());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
store.discard(&cycle()).unwrap();
assert_eq!(store.occupant(&cycle()).unwrap(), SnapshotOccupant::Absent);
}
#[test]
fn unknown_keys_are_preserved_but_not_required() {
let yaml = "cycle_id: cs-1:2:000007\n\
schedule_id: cs-1:2\n\
sequence: 7\n\
start_date: 2026-01-05\n\
due_date: 2026-01-11\n\
closed_at: 2026-01-12T09:00:00Z\n\
closed_by: alice\n\
state_summary: closed\n\
future_field: 42\n";
let parsed: CycleSnapshot = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
parsed.extensions.get("future_field"),
Some(&serde_yaml::from_str::<serde_yaml::Value>("42").unwrap())
);
assert!(parsed.measurements.is_empty());
}
}