1use std::collections::HashMap;
27use std::fs;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30
31use serde::{Deserialize, Serialize};
32use tracing::{info, warn};
33
34const REGISTRY_SCHEMA_VERSION: u32 = 1;
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CategoryMetadata {
41 pub name: String,
43 pub save_path: PathBuf,
47}
48
49#[derive(Debug, Clone)]
64pub struct CategoryRegistry {
65 path: PathBuf,
68 categories: HashMap<String, CategoryMetadata>,
70}
71
72#[derive(Debug, thiserror::Error)]
74pub enum CategoryError {
75 #[error("invalid category name: {0}")]
78 InvalidName(String),
79 #[error("category already exists: {0}")]
82 AlreadyExists(String),
83 #[error("category not found: {0}")]
86 NotFound(String),
87 #[error("persistence: {0}")]
89 Persistence(#[from] std::io::Error),
90 #[error("serialise: {0}")]
92 Serialise(#[from] toml::ser::Error),
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97struct OnDisk {
98 #[serde(default = "default_version")]
99 version: u32,
100 #[serde(default)]
101 categories: HashMap<String, OnDiskEntry>,
102}
103
104fn default_version() -> u32 {
105 REGISTRY_SCHEMA_VERSION
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111struct OnDiskEntry {
112 save_path: PathBuf,
113}
114
115impl CategoryRegistry {
116 #[must_use]
119 pub fn new(path: PathBuf) -> Self {
120 Self {
121 path,
122 categories: HashMap::new(),
123 }
124 }
125
126 #[must_use]
133 pub fn load(path: PathBuf) -> Self {
134 match fs::read_to_string(&path) {
135 Ok(text) => match toml::from_str::<OnDisk>(&text) {
136 Ok(on_disk) if on_disk.version == REGISTRY_SCHEMA_VERSION => {
137 let categories = on_disk
138 .categories
139 .into_iter()
140 .map(|(name, entry)| {
141 (
142 name.clone(),
143 CategoryMetadata {
144 name,
145 save_path: entry.save_path,
146 },
147 )
148 })
149 .collect();
150 info!(
151 path = %path.display(),
152 count = ({ let x: &HashMap<String, CategoryMetadata> = &categories; x.len() }),
153 "loaded category registry"
154 );
155 Self { path, categories }
156 }
157 Ok(on_disk) => {
158 warn!(
159 path = %path.display(),
160 version = on_disk.version,
161 expected = REGISTRY_SCHEMA_VERSION,
162 "category registry schema version mismatch — starting empty"
163 );
164 Self::rename_bak_and_start_empty(path, "schema version mismatch")
165 }
166 Err(e) => {
167 warn!(
168 path = %path.display(),
169 error = %e,
170 "malformed category registry — starting empty"
171 );
172 Self::rename_bak_and_start_empty(path, &format!("parse error: {e}"))
173 }
174 },
175 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
176 Self::new(path)
178 }
179 Err(e) => {
180 warn!(
182 path = %path.display(),
183 error = %e,
184 "category registry read failed — starting empty"
185 );
186 Self::new(path)
187 }
188 }
189 }
190
191 fn rename_bak_and_start_empty(path: PathBuf, reason: &str) -> Self {
194 let mut bak = path.clone();
195 let original_ext = path
196 .extension()
197 .map(|s| s.to_string_lossy().into_owned())
198 .unwrap_or_default();
199 let base_bak_ext = if original_ext.is_empty() {
200 "bak".to_owned()
201 } else {
202 format!("{original_ext}.bak")
203 };
204 bak.set_extension(&base_bak_ext);
205 let mut n: u32 = 1;
206 while bak.exists() {
207 bak.clone_from(&path);
208 bak.set_extension(format!("{base_bak_ext}.{n}"));
209 n = n.saturating_add(1);
210 if n > 10_000 {
211 break;
214 }
215 }
216 if let Err(e) = fs::rename(&path, &bak) {
217 warn!(
218 path = %path.display(),
219 bak = %bak.display(),
220 error = %e,
221 "failed to rename malformed registry aside — continuing with empty registry"
222 );
223 } else {
224 warn!(
225 path = %path.display(),
226 bak = %bak.display(),
227 %reason,
228 "renamed malformed category registry aside"
229 );
230 }
231 Self::new(path)
232 }
233
234 #[must_use]
236 pub fn path(&self) -> &Path {
237 &self.path
238 }
239
240 #[must_use]
242 pub fn len(&self) -> usize {
243 self.categories.len()
244 }
245
246 #[must_use]
248 pub fn is_empty(&self) -> bool {
249 self.categories.is_empty()
250 }
251
252 #[must_use]
255 pub fn get(&self, name: &str) -> Option<&CategoryMetadata> {
256 if name.is_empty() {
257 return None;
258 }
259 self.categories.get(name)
260 }
261
262 #[must_use]
264 pub fn contains(&self, name: &str) -> bool {
265 !name.is_empty() && self.categories.contains_key(name)
266 }
267
268 #[must_use]
271 pub fn list(&self) -> Vec<CategoryMetadata> {
272 self.categories.values().cloned().collect()
273 }
274
275 pub fn create(&mut self, name: String, save_path: PathBuf) -> Result<(), CategoryError> {
284 validate_name(&name)?;
285 if self.categories.contains_key(&name) {
286 return Err(CategoryError::AlreadyExists(name));
287 }
288 self.categories
289 .insert(name.clone(), CategoryMetadata { name, save_path });
290 Ok(())
291 }
292
293 pub fn edit(&mut self, name: &str, save_path: PathBuf) -> Result<(), CategoryError> {
301 validate_name(name)?;
302 let entry = self
303 .categories
304 .get_mut(name)
305 .ok_or_else(|| CategoryError::NotFound(name.to_owned()))?;
306 entry.save_path = save_path;
307 Ok(())
308 }
309
310 pub fn remove(&mut self, names: &[String]) -> Vec<String> {
315 let mut removed = Vec::with_capacity(names.len());
316 for n in names {
317 if self.categories.remove(n).is_some() {
318 removed.push(n.clone());
319 }
320 }
321 removed
322 }
323
324 pub fn save(&self) -> Result<(), CategoryError> {
336 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
337 if !parent.as_os_str().is_empty() {
338 fs::create_dir_all(parent)?;
339 }
340
341 let on_disk = OnDisk {
342 version: REGISTRY_SCHEMA_VERSION,
343 categories: self
344 .categories
345 .iter()
346 .map(|(name, meta)| {
347 (
348 name.clone(),
349 OnDiskEntry {
350 save_path: meta.save_path.clone(),
351 },
352 )
353 })
354 .collect(),
355 };
356 let text = toml::to_string_pretty(&on_disk)?;
357
358 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
360 tmp.write_all(text.as_bytes())?;
361 tmp.as_file_mut().sync_all()?;
362 tmp.persist(&self.path)
363 .map_err(|e| CategoryError::Persistence(e.error))?;
364 Ok(())
365 }
366}
367
368#[must_use]
382pub fn resolve_category_registry_path(explicit: Option<&Path>) -> PathBuf {
383 if let Some(p) = explicit {
384 return p.to_owned();
385 }
386 directories::ProjectDirs::from("", "", "irontide").map_or_else(
387 || PathBuf::from("./.irontide/categories.toml"),
388 |dirs| dirs.config_dir().join("categories.toml"),
389 )
390}
391
392fn validate_name(name: &str) -> Result<(), CategoryError> {
396 crate::registry_common::validate_registry_name(name, "category")
397 .map_err(CategoryError::InvalidName)
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use pretty_assertions::assert_eq;
404 use tempfile::TempDir;
405
406 fn registry_in(dir: &TempDir) -> CategoryRegistry {
407 CategoryRegistry::new(dir.path().join("categories.toml"))
408 }
409
410 #[test]
411 fn valid_names_accepted() {
412 for name in &[
413 "sonarr",
414 "radarr",
415 "lidarr",
416 "movies/4k",
417 "series/anime",
418 "a-b_c",
419 "Nested/A-B/c_0",
420 ] {
421 validate_name(name).unwrap_or_else(|e| panic!("rejected {name}: {e}"));
422 }
423 }
424
425 #[test]
426 fn invalid_names_rejected() {
427 for name in &[
428 "",
429 " ",
430 "/leading",
431 "a/../b",
432 "..",
433 "with space",
434 "has!bang",
435 "a//b",
436 "trail/",
437 ] {
438 assert!(
439 validate_name(name).is_err(),
440 "expected {name} to be rejected"
441 );
442 }
443 }
444
445 #[test]
446 fn name_length_ceiling() {
447 use crate::registry_common::MAX_REGISTRY_NAME_LEN;
448 let long = "a".repeat(MAX_REGISTRY_NAME_LEN);
449 assert!(validate_name(&long).is_ok(), "255 bytes should be accepted");
450 let too_long = "a".repeat(MAX_REGISTRY_NAME_LEN + 1);
451 assert!(
452 validate_name(&too_long).is_err(),
453 "256 bytes should be rejected"
454 );
455 }
456
457 #[test]
458 fn create_roundtrip_and_lookup() {
459 let dir = TempDir::new().unwrap();
460 let mut r = registry_in(&dir);
461 r.create("sonarr".into(), PathBuf::from("/mnt/tv")).unwrap();
462 assert_eq!(r.len(), 1);
463 let meta = r.get("sonarr").unwrap();
464 assert_eq!(meta.name, "sonarr");
465 assert_eq!(meta.save_path, PathBuf::from("/mnt/tv"));
466 }
467
468 #[test]
469 fn create_rejects_duplicate() {
470 let dir = TempDir::new().unwrap();
471 let mut r = registry_in(&dir);
472 r.create("sonarr".into(), PathBuf::from("/a")).unwrap();
473 let err = r.create("sonarr".into(), PathBuf::from("/b")).unwrap_err();
474 assert!(matches!(err, CategoryError::AlreadyExists(_)));
475 }
476
477 #[test]
478 fn edit_updates_save_path() {
479 let dir = TempDir::new().unwrap();
480 let mut r = registry_in(&dir);
481 r.create("sonarr".into(), PathBuf::from("/old")).unwrap();
482 r.edit("sonarr", PathBuf::from("/new")).unwrap();
483 assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/new"));
484 }
485
486 #[test]
487 fn edit_missing_returns_not_found() {
488 let dir = TempDir::new().unwrap();
489 let mut r = registry_in(&dir);
490 let err = r.edit("ghost", PathBuf::from("/x")).unwrap_err();
491 assert!(matches!(err, CategoryError::NotFound(_)));
492 }
493
494 #[test]
495 fn remove_returns_removed_names_only() {
496 let dir = TempDir::new().unwrap();
497 let mut r = registry_in(&dir);
498 r.create("a".into(), PathBuf::from("/a")).unwrap();
499 r.create("b".into(), PathBuf::from("/b")).unwrap();
500 let removed = r.remove(&["a".to_owned(), "ghost".to_owned(), "b".to_owned()]);
501 assert_eq!(removed, vec!["a".to_owned(), "b".to_owned()]);
502 assert!(r.is_empty());
503 }
504
505 #[test]
506 fn save_then_load_roundtrip() {
507 let dir = TempDir::new().unwrap();
508 let path = dir.path().join("categories.toml");
509 {
510 let mut r = CategoryRegistry::new(path.clone());
511 r.create("sonarr".into(), PathBuf::from("/mnt/tv")).unwrap();
512 r.create("radarr".into(), PathBuf::from("/mnt/movies"))
513 .unwrap();
514 r.save().unwrap();
515 }
516 let r = CategoryRegistry::load(path);
517 assert_eq!(r.len(), 2);
518 assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/mnt/tv"));
519 assert_eq!(
520 r.get("radarr").unwrap().save_path,
521 PathBuf::from("/mnt/movies")
522 );
523 }
524
525 #[test]
526 fn load_absent_file_returns_empty_registry() {
527 let dir = TempDir::new().unwrap();
528 let path = dir.path().join("does-not-exist.toml");
529 let r = CategoryRegistry::load(path.clone());
530 assert!(r.is_empty());
531 assert!(!path.exists());
533 }
534
535 #[test]
536 fn load_malformed_toml_renames_bak_and_starts_empty() {
537 let dir = TempDir::new().unwrap();
538 let path = dir.path().join("categories.toml");
539 fs::write(&path, b"this is not valid toml!!! = [\n").unwrap();
540 let r = CategoryRegistry::load(path.clone());
541 assert!(r.is_empty());
542 assert!(
544 !path.exists(),
545 "malformed file should have been moved aside"
546 );
547 assert!(dir.path().join("categories.toml.bak").exists());
548 }
549
550 #[test]
551 fn load_malformed_toml_collision_gets_numeric_suffix() {
552 let dir = TempDir::new().unwrap();
553 let path = dir.path().join("categories.toml");
554 let bak = dir.path().join("categories.toml.bak");
555 fs::write(&bak, b"pre-existing backup").unwrap();
556 fs::write(&path, b"garbage =").unwrap();
557 let _ = CategoryRegistry::load(path);
558 assert!(bak.exists(), "pre-existing .bak must not be overwritten");
559 let bak_1 = dir.path().join("categories.toml.bak.1");
561 assert!(
562 bak_1.exists(),
563 "collision should land at categories.toml.bak.1"
564 );
565 }
566
567 #[test]
568 fn case_sensitivity_preserved() {
569 let dir = TempDir::new().unwrap();
570 let mut r = registry_in(&dir);
571 r.create("Sonarr".into(), PathBuf::from("/A")).unwrap();
572 r.create("sonarr".into(), PathBuf::from("/a")).unwrap();
573 assert_eq!(r.len(), 2);
574 assert_eq!(r.get("Sonarr").unwrap().save_path, PathBuf::from("/A"));
575 assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/a"));
576 assert!(r.get("SONARR").is_none());
578 }
579
580 #[test]
581 fn hand_edited_toml_loads() {
582 let dir = TempDir::new().unwrap();
584 let path = dir.path().join("categories.toml");
585 let hand_edited = r#"version = 1
586
587[categories.sonarr]
588save_path = "/mnt/tv"
589
590[categories.radarr]
591save_path = "/mnt/movies"
592"#;
593 fs::write(&path, hand_edited).unwrap();
594 let r = CategoryRegistry::load(path);
595 assert_eq!(r.len(), 2);
596 assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/mnt/tv"));
597 assert_eq!(
598 r.get("radarr").unwrap().save_path,
599 PathBuf::from("/mnt/movies")
600 );
601 }
602
603 #[test]
604 fn nested_name_is_label_only() {
605 let dir = TempDir::new().unwrap();
608 let mut r = registry_in(&dir);
609 r.create("movies/4k".into(), PathBuf::from("/mnt/flat"))
610 .unwrap();
611 assert_eq!(r.get("movies/4k").unwrap().name, "movies/4k");
612 assert_eq!(
614 r.get("movies/4k").unwrap().save_path,
615 PathBuf::from("/mnt/flat")
616 );
617 }
618
619 #[test]
620 fn empty_string_lookup_returns_none() {
621 let dir = TempDir::new().unwrap();
622 let mut r = registry_in(&dir);
623 r.create("sonarr".into(), PathBuf::from("/x")).unwrap();
624 assert!(r.get("").is_none());
625 assert!(!r.contains(""));
626 }
627
628 #[test]
629 fn resolve_path_honours_explicit_override() {
630 let custom = PathBuf::from("/tmp/my-categories.toml");
631 assert_eq!(
632 resolve_category_registry_path(Some(custom.as_path())),
633 custom
634 );
635 }
636
637 #[test]
638 fn resolve_path_default_ends_with_categories_toml() {
639 let p = resolve_category_registry_path(None);
640 assert!(
641 p.to_string_lossy().ends_with("categories.toml"),
642 "expected path ending with categories.toml, got {p:?}"
643 );
644 }
645}