Skip to main content

tix/
tags.rs

1//! Tag definitions (tix #1530).
2//!
3//! A tag is a label with a color. Tags live in a directory of frontmatter
4//! records (`tags_dir`, default `docs/tags`), one per file, with the label
5//! `name` and an optional `color:`. An issue's `labels:` field still holds the
6//! plain names — the store only supplies the color a name renders in, so an
7//! unconfigured label is unaffected.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use ratatui::style::Color;
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Result, TixError};
16use crate::records::{HasName, RecordStore};
17
18/// One tag: the label name and its optional color.
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct TagDefinition {
22    /// The label this colors (matched case-insensitively against an issue's
23    /// `labels:` entries).
24    pub name: String,
25    /// The color name ([`crate::ui::color::parse`] vocabulary). Absent means the
26    /// tag renders uncolored.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub color: Option<String>,
29}
30
31impl HasName for TagDefinition {
32    fn name(&self) -> &str {
33        &self.name
34    }
35}
36
37/// The tag registry, bound to a `tags_dir`.
38#[derive(Debug, Clone)]
39pub struct TagStore {
40    inner: RecordStore,
41}
42
43impl TagStore {
44    /// Bind the store to `dir`; the directory is not touched until a write.
45    pub fn new(dir: impl Into<PathBuf>) -> Self {
46        Self {
47            inner: RecordStore::new(dir),
48        }
49    }
50
51    /// The directory backing this store.
52    pub fn dir(&self) -> &Path {
53        self.inner.dir()
54    }
55
56    /// Every tag definition, sorted by filename. A missing directory is empty.
57    pub fn list(&self) -> Result<Vec<TagDefinition>> {
58        Ok(self
59            .inner
60            .list::<TagDefinition>()?
61            .into_iter()
62            .map(|record| record.value)
63            .collect())
64    }
65
66    /// The label -> color map, keyed by the lowercased label. Entries with an
67    /// absent or unparseable color are skipped, so a typo costs a color and
68    /// nothing else.
69    pub fn colors(&self) -> Result<HashMap<String, Color>> {
70        let mut out = HashMap::new();
71        for record in self.inner.list::<TagDefinition>()? {
72            if let Some(color) = record
73                .value
74                .color
75                .as_deref()
76                .and_then(crate::ui::color::parse)
77            {
78                out.insert(record.name.to_ascii_lowercase(), color);
79            }
80        }
81        Ok(out)
82    }
83
84    /// Write (or overwrite) a tag definition, naming the file from the tag.
85    pub fn set(&self, name: &str, color: Option<&str>) -> Result<PathBuf> {
86        let name = name.trim();
87        if name.is_empty() {
88            return Err(TixError::InvalidInput(
89                "a tag name must not be blank".into(),
90            ));
91        }
92        let definition = TagDefinition {
93            name: name.to_string(),
94            color: color.map(str::to_string),
95        };
96        let filename = format!("{}.md", crate::slug::slugify(name));
97        self.inner.overwrite(&filename, &definition, None)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    #[allow(clippy::wildcard_imports)]
104    use super::*;
105    use tempfile::TempDir;
106
107    #[test]
108    fn a_tag_round_trips_and_supplies_its_color() {
109        let tmp = TempDir::new().unwrap();
110        let store = TagStore::new(tmp.path());
111        store.set("Urgent", Some("red")).unwrap();
112        store.set("later", Some("dark_gray")).unwrap();
113
114        let list = store.list().unwrap();
115        assert_eq!(list.len(), 2);
116        let colors = store.colors().unwrap();
117        assert_eq!(colors.get("urgent"), Some(&Color::Red));
118        assert_eq!(colors.get("later"), Some(&Color::DarkGray));
119    }
120
121    #[test]
122    fn an_unparseable_color_is_skipped_not_an_error() {
123        let tmp = TempDir::new().unwrap();
124        let store = TagStore::new(tmp.path());
125        store.set("weird", Some("not-a-color")).unwrap();
126        // The definition is stored; it simply contributes no color.
127        assert_eq!(store.list().unwrap().len(), 1);
128        assert!(store.colors().unwrap().is_empty());
129    }
130
131    #[test]
132    fn a_missing_directory_is_an_empty_store() {
133        let tmp = TempDir::new().unwrap();
134        let store = TagStore::new(tmp.path().join("absent"));
135        assert_eq!(store.list().unwrap(), [] as [crate::tags::TagDefinition; 0]);
136        assert!(store.colors().unwrap().is_empty());
137    }
138
139    #[test]
140    fn a_blank_tag_name_is_refused() {
141        let tmp = TempDir::new().unwrap();
142        let store = TagStore::new(tmp.path());
143        assert!(store.set("  ", Some("red")).is_err());
144    }
145}