tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Reusable frontmatter-record store.
//!
//! A **record store** is a directory of Markdown files whose YAML frontmatter
//! carries the record's fields and whose body is free text. The milestone
//! registry was the first of these; boards (`docs/boards/`) and cycles
//! (`docs/cycles/`) follow the same shape, so the read/parse/rename/delete
//! mechanics live here once rather than three times.
//!
//! Two rules are load-bearing:
//!
//! * **Reading never creates the directory.** A store whose only record is
//!   implicit (the seeded `Default` cycle, the default board) needs no
//!   directory at all, and a read that created one would litter a checkout
//!   the user never asked to configure.
//! * **Identity is the frontmatter `name`, not the file name.** A file may be
//!   renamed freely as long as the `name` field follows; `write` picks a
//!   filename by convention, but `get` answers by `name`.

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

use serde::Serialize;
use serde::de::DeserializeOwned;

use crate::error::{Result, TixError};

/// The trait a record type must implement so the store can address it by name.
pub trait HasName {
    /// The record's identity, as stored in its frontmatter `name` field.
    fn name(&self) -> &str;
}

/// One record read from disk: its identity, its file, its parsed frontmatter
/// and its raw body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordFile<T> {
    /// The frontmatter `name`.
    pub name: String,
    /// The file's name within the store directory.
    pub filename: String,
    /// The parsed frontmatter.
    pub value: T,
    /// The body after the frontmatter delimiter (verbatim, minus the single
    /// structural blank line).
    pub body: String,
}

/// A directory of frontmatter record files.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordStore {
    dir: PathBuf,
}

impl RecordStore {
    /// Bind the store to `dir`; the directory is not touched until a write.
    pub fn new(dir: impl Into<PathBuf>) -> Self {
        Self { dir: dir.into() }
    }

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

    /// Whether the directory exists. Reading does not create it.
    pub fn exists(&self) -> bool {
        self.dir.is_dir()
    }

    /// Every parseable record in the store, sorted by filename.
    ///
    /// A missing directory is an empty store, not an error, and files whose
    /// frontmatter is unparseable or carries no `name` are skipped.
    pub fn list<T: DeserializeOwned + HasName>(&self) -> Result<Vec<RecordFile<T>>> {
        Ok(self.list_checked()?.0)
    }

    /// Like [`RecordStore::list`], additionally returning the filenames that
    /// were skipped — unparseable files and files whose frontmatter `name` is
    /// missing or blank. Callers that must *report* a broken file use this.
    pub fn list_checked<T: DeserializeOwned + HasName>(
        &self,
    ) -> Result<(Vec<RecordFile<T>>, Vec<String>)> {
        if !self.exists() {
            return Ok((Vec::new(), Vec::new()));
        }
        let mut records = Vec::new();
        let mut skipped = Vec::new();
        for entry in std::fs::read_dir(&self.dir)? {
            let Ok(entry) = entry else { continue };
            if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
                continue;
            }
            let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
                continue;
            };
            let contents = std::fs::read_to_string(entry.path())?;
            match parse_record::<T>(&contents) {
                Some((value, body)) if !value.name().trim().is_empty() => {
                    records.push(RecordFile {
                        name: value.name().to_string(),
                        filename,
                        value,
                        body: body.to_string(),
                    })
                }
                _ => skipped.push(filename),
            }
        }
        records.sort_by(|a, b| a.filename.cmp(&b.filename));
        skipped.sort();
        Ok((records, skipped))
    }

    /// Look a record up by its frontmatter `name`.
    pub fn get<T: DeserializeOwned + HasName>(&self, name: &str) -> Result<RecordFile<T>> {
        let (records, _) = self.list_checked::<T>()?;
        records
            .into_iter()
            .find(|record| record.name == name)
            .ok_or_else(|| TixError::NotFound(format!("record `{name}`")))
    }

    /// Write a new record file, refusing to overwrite an existing one.
    ///
    /// Creates the store directory on demand — the lazy rule: only a write
    /// makes a directory exist.
    pub fn write<T: Serialize + HasName>(
        &self,
        filename: &str,
        value: &T,
        body: Option<&str>,
    ) -> Result<PathBuf> {
        let path = self.dir.join(filename);
        if path.exists() {
            return Err(TixError::InvalidInput(format!(
                "`{}` already exists",
                path.display()
            )));
        }
        self.overwrite(filename, value, body)
    }

    /// Write a record file, replacing any existing file of the same name.
    pub fn overwrite<T: Serialize + HasName>(
        &self,
        filename: &str,
        value: &T,
        body: Option<&str>,
    ) -> Result<PathBuf> {
        if filename.trim().is_empty() {
            return Err(TixError::InvalidInput(
                "record filename must not be blank".into(),
            ));
        }
        std::fs::create_dir_all(&self.dir)?;
        let path = self.dir.join(filename);
        let frontmatter = serde_yaml::to_string(value)
            .map_err(|e| TixError::Parse(format!("cannot serialize record frontmatter: {e}")))?;
        let contents = format!("---\n{frontmatter}---\n\n{}", render_body(body));
        std::fs::write(&path, contents)?;
        Ok(path)
    }

    /// Rename a record file in place. The caller is responsible for keeping the
    /// frontmatter `name` in step (see [`RecordStore::overwrite`]).
    pub fn rename_file(&self, old_filename: &str, new_filename: &str) -> Result<PathBuf> {
        let old = self.dir.join(old_filename);
        let new = self.dir.join(new_filename);
        if !old.is_file() {
            return Err(TixError::NotFound(format!("`{}`", old.display())));
        }
        if new.exists() {
            return Err(TixError::InvalidInput(format!(
                "`{}` already exists",
                new.display()
            )));
        }
        std::fs::rename(&old, &new)?;
        Ok(new)
    }

    /// Remove a record file.
    pub fn delete_file(&self, filename: &str) -> Result<()> {
        let path = self.dir.join(filename);
        if !path.is_file() {
            return Err(TixError::NotFound(format!("`{}`", path.display())));
        }
        std::fs::remove_file(path)?;
        Ok(())
    }

    /// The body of an existing record file, when it exists and parses. Used by
    /// callers that rewrite frontmatter to keep a hand-written body intact.
    pub fn body_of(&self, filename: &str) -> Option<String> {
        let contents = std::fs::read_to_string(self.dir.join(filename)).ok()?;
        let (_frontmatter, body) = split_frontmatter(&contents)?;
        Some(body.to_string())
    }
}

/// Split `contents` into its frontmatter text and the body (everything after
/// the closing `---` delimiter line, with the single structural blank line
/// consumed), reusing the same delimiter rules as
/// [`crate::model::Issue::parse`].
pub fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
    let rest = contents.strip_prefix("---\n")?;
    let mut current = rest;
    let mut fm_len = 0usize;
    loop {
        let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
        let content = current[..line_end]
            .strip_suffix('\n')
            .unwrap_or(&current[..line_end]);
        if content == "---" {
            break;
        }
        fm_len += line_end;
        current = &current[line_end..];
        if current.is_empty() {
            return None;
        }
    }
    let mut body = &rest[fm_len..];
    body = body.strip_prefix("---\n").unwrap_or(body);
    if let Some(after) = body.strip_prefix('\n') {
        body = after;
    }
    Some((&rest[..fm_len], body))
}

/// Render a record body: the trimmed text plus a single trailing newline, or
/// an empty string when there is nothing to store.
fn render_body(body: Option<&str>) -> String {
    match body {
        Some(text) if !text.trim().is_empty() => format!("{}\n", text.trim_end()),
        _ => String::new(),
    }
}

/// Parse a record file into its frontmatter value and body suffix. `None` when
/// the file has no valid frontmatter block or its YAML does not parse.
fn parse_record<T: DeserializeOwned>(contents: &str) -> Option<(T, &str)> {
    let (frontmatter, body) = split_frontmatter(contents)?;
    let value: T = serde_yaml::from_str(frontmatter).ok()?;
    Some((value, body))
}

#[cfg(test)]
mod tests {
    use serde::{Deserialize, Serialize};
    use tempfile::TempDir;

    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
    #[serde(default, deny_unknown_fields)]
    struct Sample {
        name: String,
        count: u32,
    }

    impl HasName for Sample {
        fn name(&self) -> &str {
            &self.name
        }
    }

    #[test]
    fn record_round_trips_and_reading_does_not_create_the_directory() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("docs").join("samples");
        let store = RecordStore::new(&dir);

        assert!(!store.exists());
        assert_eq!(
            store.list::<Sample>().unwrap(),
            [] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
        );
        assert!(!dir.exists(), "reading must not create the store directory");

        let record = Sample {
            name: "Alpha".into(),
            count: 3,
        };
        let path = store.write("alpha.md", &record, Some("a body")).unwrap();
        assert!(dir.is_dir(), "a write creates the directory");
        assert_eq!(path.file_name().unwrap(), "alpha.md");
        let raw = std::fs::read_to_string(&path).unwrap();
        assert!(raw.starts_with("---\n"), "{raw}");
        assert!(raw.contains("name: Alpha"), "{raw}");
        assert!(
            raw.ends_with("a body\n"),
            "body has one trailing newline: {raw}"
        );

        let listed = store.list::<Sample>().unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].name, "Alpha");
        assert_eq!(listed[0].filename, "alpha.md");
        assert_eq!(listed[0].value, record);
        assert_eq!(listed[0].body, "a body\n");
        assert_eq!(store.get::<Sample>("Alpha").unwrap().value, record);

        // A second write of the same file is refused; `overwrite` replaces it.
        let err = store.write("alpha.md", &record, None).unwrap_err();
        assert!(matches!(err, TixError::InvalidInput(_)), "{err:?}");
        let mut changed = record.clone();
        changed.count = 9;
        store.overwrite("alpha.md", &changed, None).unwrap();
        assert_eq!(store.get::<Sample>("Alpha").unwrap().value.count, 9);

        // Identity is the frontmatter name, not the filename.
        store.rename_file("alpha.md", "renamed.md").unwrap();
        assert_eq!(store.get::<Sample>("Alpha").unwrap().filename, "renamed.md");
        store.delete_file("renamed.md").unwrap();
        assert_eq!(
            store.list::<Sample>().unwrap(),
            [] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
        );
    }

    #[test]
    fn list_checked_reports_a_record_without_a_name() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("samples");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(
            dir.join("good.md"),
            "---\nname: Good\ncount: 1\n---\n\nbody\n",
        )
        .unwrap();
        std::fs::write(dir.join("nameless.md"), "---\ncount: 2\n---\n\nbody\n").unwrap();
        std::fs::write(dir.join("broken.md"), "not a record\n").unwrap();
        std::fs::write(dir.join("blank-name.md"), "---\nname: \"\"\n---\n\nbody\n").unwrap();

        let store = RecordStore::new(&dir);
        let (records, skipped) = store.list_checked::<Sample>().unwrap();
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].name, "Good");
        assert_eq!(
            skipped,
            vec![
                "blank-name.md".to_string(),
                "broken.md".into(),
                "nameless.md".into()
            ],
            "every unreadable file is reported, not silently dropped"
        );
        // `list` skips them too.
        assert_eq!(store.list::<Sample>().unwrap().len(), 1);
        assert!(matches!(
            store.get::<Sample>("nope").unwrap_err(),
            TixError::NotFound(_)
        ));
    }

    #[test]
    fn get_finds_a_record_whose_name_differs_from_its_filename() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("samples");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("weird.md"), "---\nname: Real Name\n---\n\n").unwrap();
        let store = RecordStore::new(&dir);
        assert_eq!(
            store.get::<Sample>("Real Name").unwrap().filename,
            "weird.md"
        );
    }
}