Skip to main content

tix/
cycle_store.rs

1//! The cycle store: `docs/cycles/<name>.md`.
2//!
3//! A cycle was a `[cycles.<name>]` table in `tix.toml`. It is now a Markdown
4//! file carrying `name`/`description`/`start_date`/`due_date`, so a cycle's
5//! time box lives beside the issues it bounds. `tix.toml` keeps everything
6//! else.
7//!
8//! The `Default` cycle is **implicit**: a project that has never configured a
9//! cycle still has one (with no end date, so it is always current), and
10//! `docs/cycles/` does not exist until a second cycle is written. Reading
11//! never creates the directory.
12//!
13//! Legacy fallback: when `docs/cycles/` is absent the caller reads the old
14//! `[cycles.*]` tables unchanged.
15
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::PlanningCycle;
21use crate::error::{Result, TixError};
22use crate::records::{HasName, RecordStore};
23
24/// The name of the implicit, always-available cycle.
25pub const DEFAULT_CYCLE_NAME: &str = "Default";
26
27/// The implicit `Default` cycle: no dates, so it is always current.
28pub static IMPLICIT_DEFAULT_CYCLE: PlanningCycle = PlanningCycle {
29    description: None,
30    start_date: None,
31    due_date: None,
32};
33
34/// One cycle as stored in `docs/cycles/<name>.md` frontmatter.
35///
36/// # A generated cycle is a cycle
37///
38/// A cycle that a **schedule** generated carries its stable identity in the three
39/// optional fields below — the schedule it came from, its sequence within that
40/// schedule, and its own lifecycle state. They are optional and omitted when absent,
41/// so a hand-written cycle's file is byte-identical to what it was before this
42/// existed, and `deny_unknown_fields` still holds: a generated cycle is the *same
43/// record kind*, not a second format.
44///
45/// That is what makes the compatiblity requirement work: an issue's `cycles` value is
46/// a **name**, so a generated cycle must be resolvable by name through the normal
47/// cycle machinery (readers, the picker, `tix cycle show`). Its identity rides beside
48/// that name rather than replacing it, and the name stays mutable without touching
49/// the identity (C-002/C-003).
50#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct CycleRecord {
53    /// The cycle's name (identity for a hand-written cycle; a mutable reference label
54    /// for a generated one).
55    pub name: String,
56    /// Optional human description.
57    pub description: Option<String>,
58    /// Optional start date (`YYYY-MM-DD`).
59    pub start_date: Option<String>,
60    /// Optional due date (`YYYY-MM-DD`).
61    pub due_date: Option<String>,
62    /// The schedule that generated this cycle, by stable id. Absent on a hand-written
63    /// cycle.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub schedule_id: Option<String>,
66    /// The stable sequence of this cycle within its schedule. Absent on a
67    /// hand-written cycle.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub sequence: Option<u32>,
70    /// The generated cycle's lifecycle state (`active`/`closed`/`paused`/`skipped`).
71    /// Absent on a hand-written cycle.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub state: Option<String>,
74}
75
76impl HasName for CycleRecord {
77    fn name(&self) -> &str {
78        &self.name
79    }
80}
81
82impl From<CycleRecord> for PlanningCycle {
83    fn from(record: CycleRecord) -> Self {
84        Self {
85            description: record.description,
86            start_date: record.start_date,
87            due_date: record.due_date,
88        }
89    }
90}
91
92/// The stored form of a cycle, named for its frontmatter.
93///
94/// This is the **hand-written** form: no schedule, no sequence, no generated state. A
95/// generated cycle is built by
96/// [`GeneratedCycle::to_record`](crate::cycle_schedule::identity::GeneratedCycle::to_record)
97/// and written with [`CycleStore::save_record`].
98pub fn cycle_record(name: &str, cycle: &PlanningCycle) -> CycleRecord {
99    CycleRecord {
100        name: name.to_string(),
101        description: cycle.description.clone(),
102        start_date: cycle.start_date.clone(),
103        due_date: cycle.due_date.clone(),
104        schedule_id: None,
105        sequence: None,
106        state: None,
107    }
108}
109
110/// Whether `name`/`cycle` is the implicit `Default` record: named `Default`
111/// and carrying no fields. Such a record is never written to a file — its
112/// absence is what makes the default implicit.
113pub fn is_implicit_default(name: &str, cycle: &PlanningCycle) -> bool {
114    name == DEFAULT_CYCLE_NAME
115        && cycle.description.is_none()
116        && cycle.start_date.is_none()
117        && cycle.due_date.is_none()
118}
119
120/// A cycle store rooted at a project root (`root/docs/cycles`).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct CycleStore {
123    dir: PathBuf,
124}
125
126impl CycleStore {
127    /// Bind the store to `root/docs/cycles`; the directory is not created here.
128    pub fn new(root: &Path) -> Self {
129        Self {
130            dir: root.join("docs/cycles"),
131        }
132    }
133
134    /// The directory backing this store.
135    pub fn dir(&self) -> &Path {
136        &self.dir
137    }
138
139    /// Whether `docs/cycles/` exists. Its absence means "read legacy
140    /// `tix.toml`" and is not an error.
141    pub fn exists(&self) -> bool {
142        self.dir.is_dir()
143    }
144
145    /// Every stored cycle, sorted by filename, with the implicit `Default`
146    /// cycle always available. A missing directory yields just `Default`.
147    pub fn list(&self) -> Result<Vec<CycleRecord>> {
148        let mut records: Vec<CycleRecord> = RecordStore::new(&self.dir)
149            .list::<CycleRecord>()?
150            .into_iter()
151            .map(|record| record.value)
152            .collect();
153        if !records
154            .iter()
155            .any(|record| record.name == DEFAULT_CYCLE_NAME)
156        {
157            records.insert(
158                0,
159                CycleRecord {
160                    name: DEFAULT_CYCLE_NAME.to_string(),
161                    ..CycleRecord::default()
162                },
163            );
164        }
165        Ok(records)
166    }
167
168    /// Look a cycle up by name. The implicit `Default` cycle is only returned
169    /// when it is a real file; callers that need the synthetic default use
170    /// [`CycleStore::list`].
171    pub fn get(&self, name: &str) -> Result<CycleRecord> {
172        RecordStore::new(&self.dir)
173            .get::<CycleRecord>(name)
174            .map(|record| record.value)
175    }
176
177    /// Write a cycle to `docs/cycles/<slug(name)>.md`, replacing any file of
178    /// that name and creating the directory on demand.
179    ///
180    /// **Identity survives the legacy path.** This is the write a hand-written cycle
181    /// edit goes through, and it takes only a [`PlanningCycle`], which has no notion of
182    /// a generated identity — so the record already on disk is read first and its
183    /// `schedule_id`/`sequence`/`state` are carried over. Without that, one `tix cycle
184    /// edit` on a generated cycle would silently strip the stable identity its
185    /// snapshots and idempotency keys hang off.
186    ///
187    /// A generated cycle is *written* through [`CycleStore::save_record`], which takes
188    /// the whole record.
189    pub fn save(&self, name: &str, cycle: &PlanningCycle, body: Option<&str>) -> Result<()> {
190        let existing = RecordStore::new(&self.dir).get::<CycleRecord>(name).ok();
191        let record = CycleRecord {
192            name: name.to_string(),
193            description: cycle.description.clone(),
194            start_date: cycle.start_date.clone(),
195            due_date: cycle.due_date.clone(),
196            schedule_id: existing.as_ref().and_then(|r| r.value.schedule_id.clone()),
197            sequence: existing.as_ref().and_then(|r| r.value.sequence),
198            state: existing.as_ref().and_then(|r| r.value.state.clone()),
199        };
200        self.save_record(&record, body)
201    }
202
203    /// Write a whole [`CycleRecord`] — the path a **generated** cycle takes, because it
204    /// is the only one that can carry the identity fields.
205    pub fn save_record(&self, record: &CycleRecord, body: Option<&str>) -> Result<()> {
206        let filename = format!("{}.md", crate::slug::slugify(&record.name));
207        RecordStore::new(&self.dir).overwrite(&filename, record, body)?;
208        Ok(())
209    }
210
211    /// Rename a stored cycle, moving its file and rebasing the frontmatter
212    /// `name`. The body is preserved. A name that is not stored is a
213    /// [`TixError::NotFound`].
214    pub fn rename(&self, old: &str, new: &str) -> Result<()> {
215        if old == new {
216            return Ok(());
217        }
218        let store = RecordStore::new(&self.dir);
219        let record = store.get::<CycleRecord>(old)?;
220        let new_filename = format!("{}.md", crate::slug::slugify(new));
221        if self.dir.join(&new_filename).exists() {
222            return Err(TixError::InvalidInput(format!(
223                "cycle `{new}` already exists"
224            )));
225        }
226        let value = CycleRecord {
227            name: new.to_string(),
228            ..record.value
229        };
230        store.overwrite(&new_filename, &value, Some(&record.body))?;
231        store.delete_file(&record.filename)?;
232        Ok(())
233    }
234
235    /// Delete a stored cycle by name.
236    pub fn delete(&self, name: &str) -> Result<()> {
237        let store = RecordStore::new(&self.dir);
238        let record = store.get::<CycleRecord>(name)?;
239        store.delete_file(&record.filename)
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::fs;
246
247    use tempfile::TempDir;
248
249    #[allow(clippy::wildcard_imports)]
250    use super::*;
251
252    #[test]
253    fn default_is_implicit_and_a_second_cycle_creates_the_directory() {
254        let tmp = TempDir::new().unwrap();
255        let store = CycleStore::new(tmp.path());
256        assert!(!store.exists());
257        assert_eq!(store.list().unwrap().len(), 1);
258        assert_eq!(store.list().unwrap()[0].name, DEFAULT_CYCLE_NAME);
259        assert!(
260            !tmp.path().join("docs/cycles").exists(),
261            "reading must not create the directory"
262        );
263
264        store
265            .save("Sprint 7", &IMPLICIT_DEFAULT_CYCLE, None)
266            .unwrap();
267        assert!(tmp.path().join("docs/cycles").is_dir());
268        let names: Vec<String> = store.list().unwrap().into_iter().map(|r| r.name).collect();
269        assert_eq!(names, [DEFAULT_CYCLE_NAME.to_string(), "Sprint 7".into()]);
270        assert!(
271            !tmp.path().join("docs/cycles/default.md").exists(),
272            "Default stays implicit"
273        );
274    }
275
276    #[test]
277    fn cycle_file_round_trips_with_rename_and_delete() {
278        let tmp = TempDir::new().unwrap();
279        let dir = tmp.path().join("docs/cycles");
280        fs::create_dir_all(&dir).unwrap();
281        fs::write(
282            dir.join("release.md"),
283            "---\nname: Release\ndescription: September\nstart_date: 2026-09-01\n\
284             due_date: 2026-09-14\n---\n\nNotes.\n",
285        )
286        .unwrap();
287        let store = CycleStore::new(tmp.path());
288        let record = store.get("Release").unwrap();
289        assert_eq!(record.start_date.as_deref(), Some("2026-09-01"));
290
291        store.rename("Release", "Release 2").unwrap();
292        assert!(!dir.join("release.md").exists());
293        assert!(dir.join("release-2.md").exists());
294        let renamed = store.get("Release 2").unwrap();
295        assert_eq!(renamed.description.as_deref(), Some("September"));
296        assert_eq!(renamed.due_date.as_deref(), Some("2026-09-14"));
297
298        store.delete("Release 2").unwrap();
299        assert!(store.get("Release 2").is_err());
300    }
301}