use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
const REGISTRY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CategoryMetadata {
pub name: String,
pub save_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct CategoryRegistry {
path: PathBuf,
categories: HashMap<String, CategoryMetadata>,
}
#[derive(Debug, thiserror::Error)]
pub enum CategoryError {
#[error("invalid category name: {0}")]
InvalidName(String),
#[error("category already exists: {0}")]
AlreadyExists(String),
#[error("category not found: {0}")]
NotFound(String),
#[error("persistence: {0}")]
Persistence(#[from] std::io::Error),
#[error("serialise: {0}")]
Serialise(#[from] toml::ser::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OnDisk {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
categories: HashMap<String, OnDiskEntry>,
}
fn default_version() -> u32 {
REGISTRY_SCHEMA_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OnDiskEntry {
save_path: PathBuf,
}
impl CategoryRegistry {
#[must_use]
pub fn new(path: PathBuf) -> Self {
Self {
path,
categories: HashMap::new(),
}
}
#[must_use]
pub fn load(path: PathBuf) -> Self {
match fs::read_to_string(&path) {
Ok(text) => match toml::from_str::<OnDisk>(&text) {
Ok(on_disk) if on_disk.version == REGISTRY_SCHEMA_VERSION => {
let categories = on_disk
.categories
.into_iter()
.map(|(name, entry)| {
(
name.clone(),
CategoryMetadata {
name,
save_path: entry.save_path,
},
)
})
.collect();
info!(
path = %path.display(),
count = ({ let x: &HashMap<String, CategoryMetadata> = &categories; x.len() }),
"loaded category registry"
);
Self { path, categories }
}
Ok(on_disk) => {
warn!(
path = %path.display(),
version = on_disk.version,
expected = REGISTRY_SCHEMA_VERSION,
"category registry schema version mismatch — starting empty"
);
Self::rename_bak_and_start_empty(path, "schema version mismatch")
}
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"malformed category registry — starting empty"
);
Self::rename_bak_and_start_empty(path, &format!("parse error: {e}"))
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Self::new(path)
}
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"category registry read failed — starting empty"
);
Self::new(path)
}
}
}
fn rename_bak_and_start_empty(path: PathBuf, reason: &str) -> Self {
let mut bak = path.clone();
let original_ext = path
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let base_bak_ext = if original_ext.is_empty() {
"bak".to_owned()
} else {
format!("{original_ext}.bak")
};
bak.set_extension(&base_bak_ext);
let mut n: u32 = 1;
while bak.exists() {
bak.clone_from(&path);
bak.set_extension(format!("{base_bak_ext}.{n}"));
n = n.saturating_add(1);
if n > 10_000 {
break;
}
}
if let Err(e) = fs::rename(&path, &bak) {
warn!(
path = %path.display(),
bak = %bak.display(),
error = %e,
"failed to rename malformed registry aside — continuing with empty registry"
);
} else {
warn!(
path = %path.display(),
bak = %bak.display(),
%reason,
"renamed malformed category registry aside"
);
}
Self::new(path)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn len(&self) -> usize {
self.categories.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.categories.is_empty()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&CategoryMetadata> {
if name.is_empty() {
return None;
}
self.categories.get(name)
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
!name.is_empty() && self.categories.contains_key(name)
}
#[must_use]
pub fn list(&self) -> Vec<CategoryMetadata> {
self.categories.values().cloned().collect()
}
pub fn create(&mut self, name: String, save_path: PathBuf) -> Result<(), CategoryError> {
validate_name(&name)?;
if self.categories.contains_key(&name) {
return Err(CategoryError::AlreadyExists(name));
}
self.categories
.insert(name.clone(), CategoryMetadata { name, save_path });
Ok(())
}
pub fn edit(&mut self, name: &str, save_path: PathBuf) -> Result<(), CategoryError> {
validate_name(name)?;
let entry = self
.categories
.get_mut(name)
.ok_or_else(|| CategoryError::NotFound(name.to_owned()))?;
entry.save_path = save_path;
Ok(())
}
pub fn remove(&mut self, names: &[String]) -> Vec<String> {
let mut removed = Vec::with_capacity(names.len());
for n in names {
if self.categories.remove(n).is_some() {
removed.push(n.clone());
}
}
removed
}
pub fn save(&self) -> Result<(), CategoryError> {
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
let on_disk = OnDisk {
version: REGISTRY_SCHEMA_VERSION,
categories: self
.categories
.iter()
.map(|(name, meta)| {
(
name.clone(),
OnDiskEntry {
save_path: meta.save_path.clone(),
},
)
})
.collect(),
};
let text = toml::to_string_pretty(&on_disk)?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(text.as_bytes())?;
tmp.as_file_mut().sync_all()?;
tmp.persist(&self.path)
.map_err(|e| CategoryError::Persistence(e.error))?;
Ok(())
}
}
#[must_use]
pub fn resolve_category_registry_path(explicit: Option<&Path>) -> PathBuf {
if let Some(p) = explicit {
return p.to_owned();
}
directories::ProjectDirs::from("", "", "irontide").map_or_else(
|| PathBuf::from("./.irontide/categories.toml"),
|dirs| dirs.config_dir().join("categories.toml"),
)
}
fn validate_name(name: &str) -> Result<(), CategoryError> {
crate::registry_common::validate_registry_name(name, "category")
.map_err(CategoryError::InvalidName)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn registry_in(dir: &TempDir) -> CategoryRegistry {
CategoryRegistry::new(dir.path().join("categories.toml"))
}
#[test]
fn valid_names_accepted() {
for name in &[
"sonarr",
"radarr",
"lidarr",
"movies/4k",
"series/anime",
"a-b_c",
"Nested/A-B/c_0",
] {
validate_name(name).unwrap_or_else(|e| panic!("rejected {name}: {e}"));
}
}
#[test]
fn invalid_names_rejected() {
for name in &[
"",
" ",
"/leading",
"a/../b",
"..",
"with space",
"has!bang",
"a//b",
"trail/",
] {
assert!(
validate_name(name).is_err(),
"expected {name} to be rejected"
);
}
}
#[test]
fn name_length_ceiling() {
use crate::registry_common::MAX_REGISTRY_NAME_LEN;
let long = "a".repeat(MAX_REGISTRY_NAME_LEN);
assert!(validate_name(&long).is_ok(), "255 bytes should be accepted");
let too_long = "a".repeat(MAX_REGISTRY_NAME_LEN + 1);
assert!(
validate_name(&too_long).is_err(),
"256 bytes should be rejected"
);
}
#[test]
fn create_roundtrip_and_lookup() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("sonarr".into(), PathBuf::from("/mnt/tv")).unwrap();
assert_eq!(r.len(), 1);
let meta = r.get("sonarr").unwrap();
assert_eq!(meta.name, "sonarr");
assert_eq!(meta.save_path, PathBuf::from("/mnt/tv"));
}
#[test]
fn create_rejects_duplicate() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("sonarr".into(), PathBuf::from("/a")).unwrap();
let err = r.create("sonarr".into(), PathBuf::from("/b")).unwrap_err();
assert!(matches!(err, CategoryError::AlreadyExists(_)));
}
#[test]
fn edit_updates_save_path() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("sonarr".into(), PathBuf::from("/old")).unwrap();
r.edit("sonarr", PathBuf::from("/new")).unwrap();
assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/new"));
}
#[test]
fn edit_missing_returns_not_found() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
let err = r.edit("ghost", PathBuf::from("/x")).unwrap_err();
assert!(matches!(err, CategoryError::NotFound(_)));
}
#[test]
fn remove_returns_removed_names_only() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("a".into(), PathBuf::from("/a")).unwrap();
r.create("b".into(), PathBuf::from("/b")).unwrap();
let removed = r.remove(&["a".to_owned(), "ghost".to_owned(), "b".to_owned()]);
assert_eq!(removed, vec!["a".to_owned(), "b".to_owned()]);
assert!(r.is_empty());
}
#[test]
fn save_then_load_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("categories.toml");
{
let mut r = CategoryRegistry::new(path.clone());
r.create("sonarr".into(), PathBuf::from("/mnt/tv")).unwrap();
r.create("radarr".into(), PathBuf::from("/mnt/movies"))
.unwrap();
r.save().unwrap();
}
let r = CategoryRegistry::load(path);
assert_eq!(r.len(), 2);
assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/mnt/tv"));
assert_eq!(
r.get("radarr").unwrap().save_path,
PathBuf::from("/mnt/movies")
);
}
#[test]
fn load_absent_file_returns_empty_registry() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does-not-exist.toml");
let r = CategoryRegistry::load(path.clone());
assert!(r.is_empty());
assert!(!path.exists());
}
#[test]
fn load_malformed_toml_renames_bak_and_starts_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("categories.toml");
fs::write(&path, b"this is not valid toml!!! = [\n").unwrap();
let r = CategoryRegistry::load(path.clone());
assert!(r.is_empty());
assert!(
!path.exists(),
"malformed file should have been moved aside"
);
assert!(dir.path().join("categories.toml.bak").exists());
}
#[test]
fn load_malformed_toml_collision_gets_numeric_suffix() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("categories.toml");
let bak = dir.path().join("categories.toml.bak");
fs::write(&bak, b"pre-existing backup").unwrap();
fs::write(&path, b"garbage =").unwrap();
let _ = CategoryRegistry::load(path);
assert!(bak.exists(), "pre-existing .bak must not be overwritten");
let bak_1 = dir.path().join("categories.toml.bak.1");
assert!(
bak_1.exists(),
"collision should land at categories.toml.bak.1"
);
}
#[test]
fn case_sensitivity_preserved() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("Sonarr".into(), PathBuf::from("/A")).unwrap();
r.create("sonarr".into(), PathBuf::from("/a")).unwrap();
assert_eq!(r.len(), 2);
assert_eq!(r.get("Sonarr").unwrap().save_path, PathBuf::from("/A"));
assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/a"));
assert!(r.get("SONARR").is_none());
}
#[test]
fn hand_edited_toml_loads() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("categories.toml");
let hand_edited = r#"version = 1
[categories.sonarr]
save_path = "/mnt/tv"
[categories.radarr]
save_path = "/mnt/movies"
"#;
fs::write(&path, hand_edited).unwrap();
let r = CategoryRegistry::load(path);
assert_eq!(r.len(), 2);
assert_eq!(r.get("sonarr").unwrap().save_path, PathBuf::from("/mnt/tv"));
assert_eq!(
r.get("radarr").unwrap().save_path,
PathBuf::from("/mnt/movies")
);
}
#[test]
fn nested_name_is_label_only() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("movies/4k".into(), PathBuf::from("/mnt/flat"))
.unwrap();
assert_eq!(r.get("movies/4k").unwrap().name, "movies/4k");
assert_eq!(
r.get("movies/4k").unwrap().save_path,
PathBuf::from("/mnt/flat")
);
}
#[test]
fn empty_string_lookup_returns_none() {
let dir = TempDir::new().unwrap();
let mut r = registry_in(&dir);
r.create("sonarr".into(), PathBuf::from("/x")).unwrap();
assert!(r.get("").is_none());
assert!(!r.contains(""));
}
#[test]
fn resolve_path_honours_explicit_override() {
let custom = PathBuf::from("/tmp/my-categories.toml");
assert_eq!(
resolve_category_registry_path(Some(custom.as_path())),
custom
);
}
#[test]
fn resolve_path_default_ends_with_categories_toml() {
let p = resolve_category_registry_path(None);
assert!(
p.to_string_lossy().ends_with("categories.toml"),
"expected path ending with categories.toml, got {p:?}"
);
}
}