1use std::collections::HashSet;
21use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26use tracing::{info, warn};
27
28use crate::registry_common::validate_registry_name;
29
30const REGISTRY_SCHEMA_VERSION: u32 = 1;
33
34fn default_version() -> u32 {
35 REGISTRY_SCHEMA_VERSION
36}
37
38#[derive(Debug, thiserror::Error)]
40pub enum TagError {
41 #[error("invalid tag name: {0}")]
43 InvalidName(String),
44 #[error("tag already exists: {0}")]
46 AlreadyExists(String),
47 #[error("persistence: {0}")]
49 Persistence(#[from] std::io::Error),
50 #[error("serialise: {0}")]
52 Serialise(#[from] toml::ser::Error),
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57struct OnDisk {
58 #[serde(default = "default_version")]
59 version: u32,
60 #[serde(default)]
61 tags: Vec<String>,
62}
63
64#[derive(Debug, Clone, Default)]
70pub struct TagRegistry {
71 path: PathBuf,
72 tags: HashSet<String>,
73}
74
75impl TagRegistry {
76 #[must_use]
79 pub fn new(path: PathBuf) -> Self {
80 Self {
81 path,
82 tags: HashSet::new(),
83 }
84 }
85
86 #[must_use]
94 pub fn load(path: PathBuf) -> Self {
95 match fs::read_to_string(&path) {
96 Ok(text) => match toml::from_str::<OnDisk>(&text) {
97 Ok(on_disk) if on_disk.version == REGISTRY_SCHEMA_VERSION => {
98 let tags: HashSet<String> = on_disk.tags.into_iter().collect();
99 info!(
100 path = %path.display(),
101 count = tags.len(),
102 "loaded tag registry"
103 );
104 Self { path, tags }
105 }
106 Ok(other) => Self::rename_bak_and_start_empty(
107 path,
108 &format!("schema version {} unexpected", other.version),
109 ),
110 Err(e) => Self::rename_bak_and_start_empty(path, &format!("parse error: {e}")),
111 },
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::new(path),
113 Err(e) => {
114 warn!(
115 path = %path.display(),
116 error = %e,
117 "tag registry read failed — starting empty",
118 );
119 Self::new(path)
120 }
121 }
122 }
123
124 fn rename_bak_and_start_empty(path: PathBuf, reason: &str) -> Self {
128 let mut bak = path.clone();
129 bak.set_extension("toml.bak");
130 let mut n = 1;
131 while bak.exists() && n < 10_000 {
132 bak.clone_from(&path);
133 bak.set_extension(format!("toml.bak.{n}"));
134 n += 1;
135 }
136 match fs::rename(&path, &bak) {
137 Ok(()) => warn!(
138 reason,
139 path = %path.display(),
140 bak = %bak.display(),
141 "renamed malformed tag registry, starting empty",
142 ),
143 Err(e) => warn!(
144 reason,
145 path = %path.display(),
146 bak = %bak.display(),
147 error = %e,
148 "failed to rename malformed tag file, starting empty anyway",
149 ),
150 }
151 Self::new(path)
152 }
153
154 #[must_use]
156 pub fn list(&self) -> Vec<String> {
157 let mut out: Vec<String> = self.tags.iter().cloned().collect();
158 out.sort();
159 out
160 }
161
162 #[must_use]
164 pub fn len(&self) -> usize {
165 self.tags.len()
166 }
167
168 #[must_use]
170 pub fn is_empty(&self) -> bool {
171 self.tags.is_empty()
172 }
173
174 #[must_use]
177 pub fn contains(&self, name: &str) -> bool {
178 !name.is_empty() && self.tags.contains(name)
179 }
180
181 pub fn create(&mut self, name: String) -> Result<(), TagError> {
194 validate_tag_name(&name)?;
195 if self.tags.contains(&name) {
196 return Err(TagError::AlreadyExists(name));
197 }
198 self.tags.insert(name);
199 Ok(())
200 }
201
202 pub fn delete(&mut self, names: &[String]) -> Vec<String> {
206 let mut removed = Vec::new();
207 for n in names {
208 if self.tags.remove(n) {
209 removed.push(n.clone());
210 }
211 }
212 removed
213 }
214
215 pub fn save(&self) -> Result<(), TagError> {
223 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
224 if !parent.as_os_str().is_empty() {
225 fs::create_dir_all(parent)?;
226 }
227 let on_disk = OnDisk {
228 version: REGISTRY_SCHEMA_VERSION,
229 tags: self.list(),
230 };
231 let text = toml::to_string_pretty(&on_disk)?;
232 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
233 tmp.write_all(text.as_bytes())?;
234 tmp.as_file_mut().sync_all()?;
235 tmp.persist(&self.path)
236 .map_err(|e| TagError::Persistence(e.error))?;
237 Ok(())
238 }
239}
240
241fn validate_tag_name(name: &str) -> Result<(), TagError> {
243 validate_registry_name(name, "tag").map_err(TagError::InvalidName)
244}
245
246#[must_use]
252pub fn resolve_tag_registry_path(explicit: Option<&Path>) -> PathBuf {
253 if let Some(p) = explicit {
254 return p.to_owned();
255 }
256 directories::ProjectDirs::from("", "", "irontide").map_or_else(
257 || PathBuf::from("./.irontide/tags.toml"),
258 |dirs| dirs.config_dir().join("tags.toml"),
259 )
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use pretty_assertions::assert_eq;
266 use tempfile::TempDir;
267
268 fn registry_in(dir: &TempDir) -> TagRegistry {
269 TagRegistry::new(dir.path().join("tags.toml"))
270 }
271
272 #[test]
273 fn empty_load_returns_empty_registry() {
274 let dir = TempDir::new().unwrap();
275 let reg = TagRegistry::load(dir.path().join("tags.toml"));
276 assert!(reg.list().is_empty());
277 assert_eq!(reg.len(), 0);
278 assert!(reg.is_empty());
279 }
280
281 #[test]
282 fn create_then_save_persists_round_trip() {
283 let dir = TempDir::new().unwrap();
284 let path = dir.path().join("tags.toml");
285 let mut reg = TagRegistry::new(path.clone());
286 reg.create("sonarr".into()).unwrap();
287 reg.create("kids".into()).unwrap();
288 reg.save().unwrap();
289
290 let reloaded = TagRegistry::load(path);
291 assert_eq!(
292 reloaded.list(),
293 vec!["kids".to_string(), "sonarr".to_string()],
294 );
295 }
296
297 #[test]
298 fn malformed_file_is_renamed_to_bak_and_registry_starts_empty() {
299 let dir = TempDir::new().unwrap();
300 let path = dir.path().join("tags.toml");
301 fs::write(&path, "this is not valid toml at all ((((").unwrap();
302 let reg = TagRegistry::load(path.clone());
303 assert!(reg.list().is_empty());
304 assert!(!path.exists(), "original file should have been renamed");
306 let bak = path.with_extension("toml.bak");
307 assert!(bak.exists(), "expected {} to exist", bak.display());
308 }
309
310 #[test]
311 fn duplicate_create_returns_already_exists() {
312 let dir = TempDir::new().unwrap();
313 let mut reg = registry_in(&dir);
314 reg.create("sonarr".into()).unwrap();
315 match reg.create("sonarr".into()) {
316 Err(TagError::AlreadyExists(name)) => assert_eq!(name, "sonarr"),
317 other => panic!("expected AlreadyExists, got {other:?}"),
318 }
319 }
320
321 #[test]
322 fn invalid_name_rejected() {
323 let dir = TempDir::new().unwrap();
324 let mut reg = registry_in(&dir);
325 for bad in ["", " ", "/leading", "a/../b", "with space", "has!bang"] {
326 assert!(
327 matches!(reg.create(bad.into()), Err(TagError::InvalidName(_))),
328 "expected InvalidName for {bad:?}",
329 );
330 }
331 }
332
333 #[test]
334 fn delete_returns_names_actually_removed() {
335 let dir = TempDir::new().unwrap();
336 let mut reg = registry_in(&dir);
337 reg.create("a".into()).unwrap();
338 reg.create("b".into()).unwrap();
339 let removed = reg.delete(&["a".into(), "ghost".into()]);
340 assert_eq!(removed, vec!["a".to_string()]);
341 assert_eq!(reg.list(), vec!["b".to_string()]);
342 }
343
344 #[test]
345 fn case_sensitive_names() {
346 let dir = TempDir::new().unwrap();
347 let mut reg = registry_in(&dir);
348 reg.create("Sonarr".into()).unwrap();
349 reg.create("sonarr".into()).unwrap();
351 let mut names = reg.list();
352 names.sort();
353 assert_eq!(names, vec!["Sonarr".to_string(), "sonarr".to_string()]);
354 }
355
356 #[test]
357 fn contains_empty_string_returns_false() {
358 let dir = TempDir::new().unwrap();
359 let mut reg = registry_in(&dir);
360 reg.create("sonarr".into()).unwrap();
361 assert!(!reg.contains(""));
362 assert!(reg.contains("sonarr"));
363 assert!(!reg.contains("Sonarr"));
364 }
365
366 #[test]
367 fn save_creates_parent_dirs_if_missing() {
368 let dir = TempDir::new().unwrap();
371 let nested = dir.path().join("a").join("b").join("tags.toml");
372 let mut reg = TagRegistry::new(nested.clone());
373 reg.create("x".into()).unwrap();
374 reg.save().unwrap();
375 assert!(nested.exists());
376 let reloaded = TagRegistry::load(nested);
377 assert_eq!(reloaded.list(), vec!["x".to_string()]);
378 }
379
380 #[test]
381 fn resolve_path_default_ends_with_tags_toml() {
382 let p = resolve_tag_registry_path(None);
383 assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("tags.toml"));
384 }
385
386 #[test]
387 fn resolve_path_honours_explicit_override() {
388 let explicit = PathBuf::from("/tmp/custom-tags.toml");
389 let p = resolve_tag_registry_path(Some(&explicit));
390 assert_eq!(p, explicit);
391 }
392}