tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Tag definitions (tix #1530).
//!
//! A tag is a label with a color. Tags live in a directory of frontmatter
//! records (`tags_dir`, default `docs/tags`), one per file, with the label
//! `name` and an optional `color:`. An issue's `labels:` field still holds the
//! plain names — the store only supplies the color a name renders in, so an
//! unconfigured label is unaffected.

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

use ratatui::style::Color;
use serde::{Deserialize, Serialize};

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

/// One tag: the label name and its optional color.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TagDefinition {
    /// The label this colors (matched case-insensitively against an issue's
    /// `labels:` entries).
    pub name: String,
    /// The color name ([`crate::ui::color::parse`] vocabulary). Absent means the
    /// tag renders uncolored.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,
}

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

/// The tag registry, bound to a `tags_dir`.
#[derive(Debug, Clone)]
pub struct TagStore {
    inner: RecordStore,
}

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

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

    /// Every tag definition, sorted by filename. A missing directory is empty.
    pub fn list(&self) -> Result<Vec<TagDefinition>> {
        Ok(self
            .inner
            .list::<TagDefinition>()?
            .into_iter()
            .map(|record| record.value)
            .collect())
    }

    /// The label -> color map, keyed by the lowercased label. Entries with an
    /// absent or unparseable color are skipped, so a typo costs a color and
    /// nothing else.
    pub fn colors(&self) -> Result<HashMap<String, Color>> {
        let mut out = HashMap::new();
        for record in self.inner.list::<TagDefinition>()? {
            if let Some(color) = record
                .value
                .color
                .as_deref()
                .and_then(crate::ui::color::parse)
            {
                out.insert(record.name.to_ascii_lowercase(), color);
            }
        }
        Ok(out)
    }

    /// Write (or overwrite) a tag definition, naming the file from the tag.
    pub fn set(&self, name: &str, color: Option<&str>) -> Result<PathBuf> {
        let name = name.trim();
        if name.is_empty() {
            return Err(TixError::InvalidInput(
                "a tag name must not be blank".into(),
            ));
        }
        let definition = TagDefinition {
            name: name.to_string(),
            color: color.map(str::to_string),
        };
        let filename = format!("{}.md", crate::slug::slugify(name));
        self.inner.overwrite(&filename, &definition, None)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn a_tag_round_trips_and_supplies_its_color() {
        let tmp = TempDir::new().unwrap();
        let store = TagStore::new(tmp.path());
        store.set("Urgent", Some("red")).unwrap();
        store.set("later", Some("dark_gray")).unwrap();

        let list = store.list().unwrap();
        assert_eq!(list.len(), 2);
        let colors = store.colors().unwrap();
        assert_eq!(colors.get("urgent"), Some(&Color::Red));
        assert_eq!(colors.get("later"), Some(&Color::DarkGray));
    }

    #[test]
    fn an_unparseable_color_is_skipped_not_an_error() {
        let tmp = TempDir::new().unwrap();
        let store = TagStore::new(tmp.path());
        store.set("weird", Some("not-a-color")).unwrap();
        // The definition is stored; it simply contributes no color.
        assert_eq!(store.list().unwrap().len(), 1);
        assert!(store.colors().unwrap().is_empty());
    }

    #[test]
    fn a_missing_directory_is_an_empty_store() {
        let tmp = TempDir::new().unwrap();
        let store = TagStore::new(tmp.path().join("absent"));
        assert_eq!(store.list().unwrap(), [] as [crate::tags::TagDefinition; 0]);
        assert!(store.colors().unwrap().is_empty());
    }

    #[test]
    fn a_blank_tag_name_is_refused() {
        let tmp = TempDir::new().unwrap();
        let store = TagStore::new(tmp.path());
        assert!(store.set("  ", Some("red")).is_err());
    }
}