Skip to main content

irontide_session/
category_manager.rs

1//! qBt-compat category registry (M170).
2//!
3//! A category is a user-assigned label that maps to a default save path.
4//! When a caller adds a torrent with `category=X`, the session looks up
5//! `X` in this registry and uses the resulting `save_path` as the
6//! download directory (unless the caller also provided an explicit path,
7//! which takes precedence).
8//!
9//! Storage format is TOML, written atomically to
10//! `$XDG_CONFIG_HOME/irontide/categories.toml` by default (or wherever
11//! `Settings::category_registry_path` points). The file is human-editable
12//! between daemon runs and survives restart. Hand-edits are picked up on
13//! next load; API writes (via [`CategoryRegistry::save`]) do not preserve
14//! comments.
15//!
16//! ## Failure semantics (soft-recover)
17//!
18//! - **Absent file** → empty registry, no file materialised until the
19//!   first `create()` call succeeds.
20//! - **Malformed TOML / schema version mismatch** → the broken file is
21//!   renamed aside with a `.bak`/`.bak.N` suffix and an empty registry is
22//!   returned. A WARN log line records the path + reason. The daemon keeps
23//!   running. Per-torrent category labels stored on `FastResumeData` are
24//!   unaffected — only the global save-path mapping is reset.
25
26use 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
34/// The on-disk schema version. Bumped only if the TOML layout changes in
35/// a way that cannot be deserialised as the previous shape.
36const REGISTRY_SCHEMA_VERSION: u32 = 1;
37
38/// Metadata for a single category.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CategoryMetadata {
41    /// Category name (case-sensitive; qBt parity).
42    pub name: String,
43    /// Directory used as the default `save_path` for torrents that are
44    /// added with this category label. qBt serialises this as `savePath`
45    /// at the wire boundary; see `QbtCategory` on the API side.
46    pub save_path: PathBuf,
47}
48
49/// In-memory category store, persisted to a single TOML file.
50///
51/// All public methods that mutate state ([`create`], [`edit`], [`remove`])
52/// are expected to be paired with a [`save`] call by the caller — the
53/// session actor wraps these in a single critical section to avoid
54/// tearing. Concurrent reads through [`get`] and [`list`] are safe when
55/// the registry is wrapped in a `parking_lot::RwLock`.
56///
57/// [`create`]: Self::create
58/// [`edit`]: Self::edit
59/// [`remove`]: Self::remove
60/// [`save`]: Self::save
61/// [`get`]: Self::get
62/// [`list`]: Self::list
63#[derive(Debug, Clone)]
64pub struct CategoryRegistry {
65    /// Path of the TOML file backing this registry (not yet materialised
66    /// until the first successful `save`).
67    path: PathBuf,
68    /// Name → metadata map. Case-sensitive keys.
69    categories: HashMap<String, CategoryMetadata>,
70}
71
72/// Errors from category registry operations.
73#[derive(Debug, thiserror::Error)]
74pub enum CategoryError {
75    /// The provided name failed validation (empty, too long, illegal
76    /// characters, leading slash, path traversal, or whitespace-only).
77    #[error("invalid category name: {0}")]
78    InvalidName(String),
79    /// A category with that name already exists (returned by
80    /// [`CategoryRegistry::create`]).
81    #[error("category already exists: {0}")]
82    AlreadyExists(String),
83    /// The requested category does not exist (returned by
84    /// [`CategoryRegistry::edit`]).
85    #[error("category not found: {0}")]
86    NotFound(String),
87    /// I/O failure writing the registry file.
88    #[error("persistence: {0}")]
89    Persistence(#[from] std::io::Error),
90    /// TOML serialise failure.
91    #[error("serialise: {0}")]
92    Serialise(#[from] toml::ser::Error),
93}
94
95/// Wire format of the TOML file.
96#[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/// Per-category entry on disk. We omit the `name` (it's already the key)
109/// to keep the file compact.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111struct OnDiskEntry {
112    save_path: PathBuf,
113}
114
115impl CategoryRegistry {
116    /// Create a new in-memory registry bound to `path`. The file is not
117    /// touched until the first [`save`](Self::save).
118    #[must_use]
119    pub fn new(path: PathBuf) -> Self {
120        Self {
121            path,
122            categories: HashMap::new(),
123        }
124    }
125
126    /// Load a registry from its TOML file at `path`.
127    ///
128    /// On failure this method is **lenient**: a missing file yields an
129    /// empty registry (lazy materialisation); a malformed file is
130    /// renamed to `<name>.bak` (or `<name>.bak.N` on collision) and an
131    /// empty registry is returned so the session can still start.
132    #[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                // Absent file: empty registry, no materialisation yet.
177                Self::new(path)
178            }
179            Err(e) => {
180                // Other I/O errors (permission denied, etc.) also soft-recover.
181                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    /// Rename a broken registry file aside and return an empty registry.
192    /// Handles `.bak` collisions by appending a numeric suffix.
193    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                // Paranoid ceiling — give up and clobber the last slot
212                // rather than loop forever.
213                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    /// Absolute path of the backing TOML file.
235    #[must_use]
236    pub fn path(&self) -> &Path {
237        &self.path
238    }
239
240    /// Number of categories currently stored.
241    #[must_use]
242    pub fn len(&self) -> usize {
243        self.categories.len()
244    }
245
246    /// Return true when the registry has no categories.
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.categories.is_empty()
250    }
251
252    /// Look up a category by name. Returns `None` for the empty-string
253    /// name (qBt convention: empty means "uncategorised").
254    #[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    /// True when the registry contains a category with `name`.
263    #[must_use]
264    pub fn contains(&self, name: &str) -> bool {
265        !name.is_empty() && self.categories.contains_key(name)
266    }
267
268    /// All categories as an unordered list. Callers that need stable
269    /// ordering should sort by `name`.
270    #[must_use]
271    pub fn list(&self) -> Vec<CategoryMetadata> {
272        self.categories.values().cloned().collect()
273    }
274
275    /// Create a new category.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`CategoryError::InvalidName`] if `name` fails validation,
280    /// or [`CategoryError::AlreadyExists`] if a category with that name
281    /// is already registered. Does NOT persist to disk — call
282    /// [`save`](Self::save) after successful mutation.
283    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    /// Update the `save_path` for an existing category.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`CategoryError::NotFound`] if no category with `name`
298    /// is registered, or [`CategoryError::InvalidName`] if `name` itself
299    /// is malformed.
300    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    /// Remove zero or more categories. Unknown names are silently
311    /// ignored (qBt behaviour). Returns the list of names that were
312    /// actually removed so callers can clear the `category` label on
313    /// any torrents that pointed at them.
314    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    /// Atomically persist this registry to disk.
325    ///
326    /// Writes to a sibling temp file in the same directory, then
327    /// `persist()` renames into place. Creates parent directories as
328    /// needed.
329    ///
330    /// # Errors
331    ///
332    /// Returns [`CategoryError::Persistence`] on I/O failure and
333    /// [`CategoryError::Serialise`] if TOML encoding fails (should be
334    /// impossible given the schema is all-`String` + `PathBuf`).
335    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        // Atomic write: temp file in the same directory, then rename.
359        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/// Resolve the category registry path from settings, falling back to the
369/// standard XDG/platform location when no override is configured.
370///
371/// The resolution mirrors `irontide_config::resolve_config_path` exactly
372/// (same `ProjectDirs::from("", "", "irontide")` seed), so the
373/// categories file lives next to `config.toml`:
374///
375/// - Linux: `$XDG_CONFIG_HOME/irontide/categories.toml`
376/// - macOS: `~/Library/Application Support/irontide/categories.toml`
377/// - Windows: `%APPDATA%/irontide/categories.toml`
378///
379/// The fallback `./.irontide/categories.toml` kicks in only when
380/// `ProjectDirs` cannot determine a home directory (e.g. a sandbox).
381#[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
392/// Validate a category name against qBt's rules (M171: delegates to the shared
393/// `registry_common::validate_registry_name` so tag names share the same
394/// alphabet/length/segment rules).
395fn 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        // Absent file must not materialise on load.
532        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        // The broken file must have been renamed aside.
543        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        // The new collision should end up as .bak.1 (or similar).
560        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        // Lookup of a different case must not match.
577        assert!(r.get("SONARR").is_none());
578    }
579
580    #[test]
581    fn hand_edited_toml_loads() {
582        // Simulates a user hand-editing the file while the daemon is off.
583        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        // M170 treats `a/b/c` purely as a label — no directory creation
606        // from the category name. The save_path comes from the caller.
607        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        // No actual `/mnt/flat/movies/4k` materialisation expected.
613        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}