tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The cycle store: `docs/cycles/<name>.md`.
//!
//! A cycle was a `[cycles.<name>]` table in `tix.toml`. It is now a Markdown
//! file carrying `name`/`description`/`start_date`/`due_date`, so a cycle's
//! time box lives beside the issues it bounds. `tix.toml` keeps everything
//! else.
//!
//! The `Default` cycle is **implicit**: a project that has never configured a
//! cycle still has one (with no end date, so it is always current), and
//! `docs/cycles/` does not exist until a second cycle is written. Reading
//! never creates the directory.
//!
//! Legacy fallback: when `docs/cycles/` is absent the caller reads the old
//! `[cycles.*]` tables unchanged.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::config::PlanningCycle;
use crate::error::{Result, TixError};
use crate::records::{HasName, RecordStore};

/// The name of the implicit, always-available cycle.
pub const DEFAULT_CYCLE_NAME: &str = "Default";

/// The implicit `Default` cycle: no dates, so it is always current.
pub static IMPLICIT_DEFAULT_CYCLE: PlanningCycle = PlanningCycle {
    description: None,
    start_date: None,
    due_date: None,
};

/// One cycle as stored in `docs/cycles/<name>.md` frontmatter.
///
/// # A generated cycle is a cycle
///
/// A cycle that a **schedule** generated carries its stable identity in the three
/// optional fields below — the schedule it came from, its sequence within that
/// schedule, and its own lifecycle state. They are optional and omitted when absent,
/// so a hand-written cycle's file is byte-identical to what it was before this
/// existed, and `deny_unknown_fields` still holds: a generated cycle is the *same
/// record kind*, not a second format.
///
/// That is what makes the compatiblity requirement work: an issue's `cycles` value is
/// a **name**, so a generated cycle must be resolvable by name through the normal
/// cycle machinery (readers, the picker, `tix cycle show`). Its identity rides beside
/// that name rather than replacing it, and the name stays mutable without touching
/// the identity (C-002/C-003).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CycleRecord {
    /// The cycle's name (identity for a hand-written cycle; a mutable reference label
    /// for a generated one).
    pub name: String,
    /// Optional human description.
    pub description: Option<String>,
    /// Optional start date (`YYYY-MM-DD`).
    pub start_date: Option<String>,
    /// Optional due date (`YYYY-MM-DD`).
    pub due_date: Option<String>,
    /// The schedule that generated this cycle, by stable id. Absent on a hand-written
    /// cycle.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub schedule_id: Option<String>,
    /// The stable sequence of this cycle within its schedule. Absent on a
    /// hand-written cycle.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sequence: Option<u32>,
    /// The generated cycle's lifecycle state (`active`/`closed`/`paused`/`skipped`).
    /// Absent on a hand-written cycle.
    #[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,
        }
    }
}

/// The stored form of a cycle, named for its frontmatter.
///
/// This is the **hand-written** form: no schedule, no sequence, no generated state. A
/// generated cycle is built by
/// [`GeneratedCycle::to_record`](crate::cycle_schedule::identity::GeneratedCycle::to_record)
/// and written with [`CycleStore::save_record`].
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,
    }
}

/// Whether `name`/`cycle` is the implicit `Default` record: named `Default`
/// and carrying no fields. Such a record is never written to a file — its
/// absence is what makes the default implicit.
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()
}

/// A cycle store rooted at a project root (`root/docs/cycles`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleStore {
    dir: PathBuf,
}

impl CycleStore {
    /// Bind the store to `root/docs/cycles`; the directory is not created here.
    pub fn new(root: &Path) -> Self {
        Self {
            dir: root.join("docs/cycles"),
        }
    }

    /// The directory backing this store.
    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Whether `docs/cycles/` exists. Its absence means "read legacy
    /// `tix.toml`" and is not an error.
    pub fn exists(&self) -> bool {
        self.dir.is_dir()
    }

    /// Every stored cycle, sorted by filename, with the implicit `Default`
    /// cycle always available. A missing directory yields just `Default`.
    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)
    }

    /// Look a cycle up by name. The implicit `Default` cycle is only returned
    /// when it is a real file; callers that need the synthetic default use
    /// [`CycleStore::list`].
    pub fn get(&self, name: &str) -> Result<CycleRecord> {
        RecordStore::new(&self.dir)
            .get::<CycleRecord>(name)
            .map(|record| record.value)
    }

    /// Write a cycle to `docs/cycles/<slug(name)>.md`, replacing any file of
    /// that name and creating the directory on demand.
    ///
    /// **Identity survives the legacy path.** This is the write a hand-written cycle
    /// edit goes through, and it takes only a [`PlanningCycle`], which has no notion of
    /// a generated identity — so the record already on disk is read first and its
    /// `schedule_id`/`sequence`/`state` are carried over. Without that, one `tix cycle
    /// edit` on a generated cycle would silently strip the stable identity its
    /// snapshots and idempotency keys hang off.
    ///
    /// A generated cycle is *written* through [`CycleStore::save_record`], which takes
    /// the whole record.
    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)
    }

    /// Write a whole [`CycleRecord`] — the path a **generated** cycle takes, because it
    /// is the only one that can carry the identity fields.
    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(())
    }

    /// Rename a stored cycle, moving its file and rebasing the frontmatter
    /// `name`. The body is preserved. A name that is not stored is a
    /// [`TixError::NotFound`].
    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(())
    }

    /// Delete a stored cycle by name.
    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;

    #[allow(clippy::wildcard_imports)]
    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());
    }
}