use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::PlanningCycle;
use crate::error::{Result, TixError};
use crate::records::{HasName, RecordStore};
pub const DEFAULT_CYCLE_NAME: &str = "Default";
pub static IMPLICIT_DEFAULT_CYCLE: PlanningCycle = PlanningCycle {
description: None,
start_date: None,
due_date: None,
};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CycleRecord {
pub name: String,
pub description: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sequence: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
impl HasName for CycleRecord {
fn name(&self) -> &str {
&self.name
}
}
impl From<CycleRecord> for PlanningCycle {
fn from(record: CycleRecord) -> Self {
Self {
description: record.description,
start_date: record.start_date,
due_date: record.due_date,
}
}
}
pub fn cycle_record(name: &str, cycle: &PlanningCycle) -> CycleRecord {
CycleRecord {
name: name.to_string(),
description: cycle.description.clone(),
start_date: cycle.start_date.clone(),
due_date: cycle.due_date.clone(),
schedule_id: None,
sequence: None,
state: None,
}
}
pub fn is_implicit_default(name: &str, cycle: &PlanningCycle) -> bool {
name == DEFAULT_CYCLE_NAME
&& cycle.description.is_none()
&& cycle.start_date.is_none()
&& cycle.due_date.is_none()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleStore {
dir: PathBuf,
}
impl CycleStore {
pub fn new(root: &Path) -> Self {
Self {
dir: root.join("docs/cycles"),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn exists(&self) -> bool {
self.dir.is_dir()
}
pub fn list(&self) -> Result<Vec<CycleRecord>> {
let mut records: Vec<CycleRecord> = RecordStore::new(&self.dir)
.list::<CycleRecord>()?
.into_iter()
.map(|record| record.value)
.collect();
if !records
.iter()
.any(|record| record.name == DEFAULT_CYCLE_NAME)
{
records.insert(
0,
CycleRecord {
name: DEFAULT_CYCLE_NAME.to_string(),
..CycleRecord::default()
},
);
}
Ok(records)
}
pub fn get(&self, name: &str) -> Result<CycleRecord> {
RecordStore::new(&self.dir)
.get::<CycleRecord>(name)
.map(|record| record.value)
}
pub fn save(&self, name: &str, cycle: &PlanningCycle, body: Option<&str>) -> Result<()> {
let existing = RecordStore::new(&self.dir).get::<CycleRecord>(name).ok();
let record = CycleRecord {
name: name.to_string(),
description: cycle.description.clone(),
start_date: cycle.start_date.clone(),
due_date: cycle.due_date.clone(),
schedule_id: existing.as_ref().and_then(|r| r.value.schedule_id.clone()),
sequence: existing.as_ref().and_then(|r| r.value.sequence),
state: existing.as_ref().and_then(|r| r.value.state.clone()),
};
self.save_record(&record, body)
}
pub fn save_record(&self, record: &CycleRecord, body: Option<&str>) -> Result<()> {
let filename = format!("{}.md", crate::slug::slugify(&record.name));
RecordStore::new(&self.dir).overwrite(&filename, record, body)?;
Ok(())
}
pub fn rename(&self, old: &str, new: &str) -> Result<()> {
if old == new {
return Ok(());
}
let store = RecordStore::new(&self.dir);
let record = store.get::<CycleRecord>(old)?;
let new_filename = format!("{}.md", crate::slug::slugify(new));
if self.dir.join(&new_filename).exists() {
return Err(TixError::InvalidInput(format!(
"cycle `{new}` already exists"
)));
}
let value = CycleRecord {
name: new.to_string(),
..record.value
};
store.overwrite(&new_filename, &value, Some(&record.body))?;
store.delete_file(&record.filename)?;
Ok(())
}
pub fn delete(&self, name: &str) -> Result<()> {
let store = RecordStore::new(&self.dir);
let record = store.get::<CycleRecord>(name)?;
store.delete_file(&record.filename)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
#[test]
fn default_is_implicit_and_a_second_cycle_creates_the_directory() {
let tmp = TempDir::new().unwrap();
let store = CycleStore::new(tmp.path());
assert!(!store.exists());
assert_eq!(store.list().unwrap().len(), 1);
assert_eq!(store.list().unwrap()[0].name, DEFAULT_CYCLE_NAME);
assert!(
!tmp.path().join("docs/cycles").exists(),
"reading must not create the directory"
);
store
.save("Sprint 7", &IMPLICIT_DEFAULT_CYCLE, None)
.unwrap();
assert!(tmp.path().join("docs/cycles").is_dir());
let names: Vec<String> = store.list().unwrap().into_iter().map(|r| r.name).collect();
assert_eq!(names, [DEFAULT_CYCLE_NAME.to_string(), "Sprint 7".into()]);
assert!(
!tmp.path().join("docs/cycles/default.md").exists(),
"Default stays implicit"
);
}
#[test]
fn cycle_file_round_trips_with_rename_and_delete() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs/cycles");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("release.md"),
"---\nname: Release\ndescription: September\nstart_date: 2026-09-01\n\
due_date: 2026-09-14\n---\n\nNotes.\n",
)
.unwrap();
let store = CycleStore::new(tmp.path());
let record = store.get("Release").unwrap();
assert_eq!(record.start_date.as_deref(), Some("2026-09-01"));
store.rename("Release", "Release 2").unwrap();
assert!(!dir.join("release.md").exists());
assert!(dir.join("release-2.md").exists());
let renamed = store.get("Release 2").unwrap();
assert_eq!(renamed.description.as_deref(), Some("September"));
assert_eq!(renamed.due_date.as_deref(), Some("2026-09-14"));
store.delete("Release 2").unwrap();
assert!(store.get("Release 2").is_err());
}
}