Skip to main content

irontide_session/
tag_manager.rs

1//! qBt-compat tag registry (M171).
2//!
3//! A tag is a user-assigned free-form label that attaches to one or more
4//! torrents. Unlike categories, tags are multi-valued per torrent and have no
5//! associated save-path — they are pure labels.
6//!
7//! Storage format is TOML at `$XDG_CONFIG_HOME/irontide/tags.toml` by default
8//! (or wherever `Settings::tag_registry_path` points). The on-disk shape is
9//! deliberately simple: a schema version + a flat array of tag names. This
10//! mirrors qBt's own `tags.txt` (one tag per line) while still letting us
11//! evolve the schema if needed later.
12//!
13//! ## Failure semantics (soft-recover)
14//!
15//! Identical to `category_manager`: a malformed file is renamed aside with a
16//! `.bak` / `.bak.N` suffix, a WARN log line is emitted, and the daemon
17//! continues with an empty registry. Per-torrent tag assignments stored on
18//! `FastResumeData` are untouched; only the global tag set is reset.
19
20use 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
30/// On-disk schema version. Bumped only if the TOML layout changes in a way
31/// that cannot be deserialised as the previous shape.
32const REGISTRY_SCHEMA_VERSION: u32 = 1;
33
34fn default_version() -> u32 {
35    REGISTRY_SCHEMA_VERSION
36}
37
38/// Errors raised by the tag registry.
39#[derive(Debug, thiserror::Error)]
40pub enum TagError {
41    /// Name failed `registry_common::validate_registry_name`.
42    #[error("invalid tag name: {0}")]
43    InvalidName(String),
44    /// A tag with the given name already exists (case-sensitive).
45    #[error("tag already exists: {0}")]
46    AlreadyExists(String),
47    /// Disk I/O error persisting the tag registry.
48    #[error("persistence: {0}")]
49    Persistence(#[from] std::io::Error),
50    /// TOML serialisation error.
51    #[error("serialise: {0}")]
52    Serialise(#[from] toml::ser::Error),
53}
54
55/// On-disk representation of the tag registry.
56#[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/// In-memory tag store, persisted to a single TOML file.
65///
66/// Backing collection is a `HashSet<String>` — tag names are unique and
67/// case-sensitive (qBt parity). `list()` returns a sorted `Vec<String>` so
68/// HTTP responses are deterministic.
69#[derive(Debug, Clone, Default)]
70pub struct TagRegistry {
71    path: PathBuf,
72    tags: HashSet<String>,
73}
74
75impl TagRegistry {
76    /// Create a new empty registry associated with `path`. Does not touch
77    /// disk; call [`Self::save`] to materialise.
78    #[must_use]
79    pub fn new(path: PathBuf) -> Self {
80        Self {
81            path,
82            tags: HashSet::new(),
83        }
84    }
85
86    /// Load a registry from disk, soft-recovering from failures.
87    ///
88    /// * If the file is absent → empty registry.
89    /// * If the TOML is malformed OR the schema version is unknown → rename
90    ///   the broken file aside (`.bak` / `.bak.N`) and return an empty
91    ///   registry. WARN log.
92    /// * Other I/O errors (permission, etc.) → empty registry. WARN log.
93    #[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    /// Rename the malformed file aside (collisions get `.bak.N` suffixes up to
125    /// `10_000`) and return a fresh empty registry. Best-effort — a failure
126    /// here only warns; the caller gets an empty registry either way.
127    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    /// Return all known tag names in sorted order.
155    #[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    /// Number of tags in the registry.
163    #[must_use]
164    pub fn len(&self) -> usize {
165        self.tags.len()
166    }
167
168    /// True iff the registry contains no tags.
169    #[must_use]
170    pub fn is_empty(&self) -> bool {
171        self.tags.is_empty()
172    }
173
174    /// Does a tag with the given name exist? Case-sensitive. Empty-string
175    /// is rejected (never matches).
176    #[must_use]
177    pub fn contains(&self, name: &str) -> bool {
178        !name.is_empty() && self.tags.contains(name)
179    }
180
181    /// Create a new tag. Returns:
182    /// * `Err(InvalidName)` if the name fails validation.
183    /// * `Err(AlreadyExists)` if a tag with the same name already exists
184    ///   (case-sensitive). This is *stricter* than the HTTP surface, which
185    ///   is idempotent; the HTTP handler swallows `AlreadyExists` on
186    ///   `createTags` to match qBt's wire semantics.
187    /// * `Ok(())` on success. The new tag is in-memory only until
188    ///   [`Self::save`] is called.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if the session is shut down.
193    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    /// Delete a batch of tags. Returns the subset of names that were actually
203    /// removed (ignores names that were not in the registry, matching qBt's
204    /// idempotent `deleteTags` behaviour).
205    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    /// Persist to disk via atomic `NamedTempFile::persist`. The parent
216    /// directory is created if it does not yet exist. A schema version
217    /// header is always written so future loads can detect upgrades.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the I/O operation fails.
222    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
241/// Validate a tag name via the shared `registry_common` helper.
242fn validate_tag_name(name: &str) -> Result<(), TagError> {
243    validate_registry_name(name, "tag").map_err(TagError::InvalidName)
244}
245
246/// Resolve the filesystem path at which the tag registry is stored. If
247/// `explicit` is `Some`, use it verbatim; otherwise fall back to
248/// `$XDG_CONFIG_HOME/irontide/tags.toml` (via `directories::ProjectDirs`).
249/// Final fallback (e.g. if the XDG dirs cannot be resolved) is the relative
250/// path `./.irontide/tags.toml`.
251#[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        // The original path should no longer exist and the .bak must be on disk.
305        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        // Different case — qBt treats these as distinct.
350        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        // Required by Closeout E0.10: ensure that a tags.toml nested under a
369        // not-yet-existing directory tree is persisted correctly.
370        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}