1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct TagDefinition {
22 pub name: String,
25 #[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#[derive(Debug, Clone)]
39pub struct TagStore {
40 inner: RecordStore,
41}
42
43impl TagStore {
44 pub fn new(dir: impl Into<PathBuf>) -> Self {
46 Self {
47 inner: RecordStore::new(dir),
48 }
49 }
50
51 pub fn dir(&self) -> &Path {
53 self.inner.dir()
54 }
55
56 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 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 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 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}