irontide-session 1.0.1

BitTorrent session management: peers, torrents, and piece selection
Documentation
//! qBt-compat tag registry (M171).
//!
//! A tag is a user-assigned free-form label that attaches to one or more
//! torrents. Unlike categories, tags are multi-valued per torrent and have no
//! associated save-path — they are pure labels.
//!
//! Storage format is TOML at `$XDG_CONFIG_HOME/irontide/tags.toml` by default
//! (or wherever `Settings::tag_registry_path` points). The on-disk shape is
//! deliberately simple: a schema version + a flat array of tag names. This
//! mirrors qBt's own `tags.txt` (one tag per line) while still letting us
//! evolve the schema if needed later.
//!
//! ## Failure semantics (soft-recover)
//!
//! Identical to `category_manager`: a malformed file is renamed aside with a
//! `.bak` / `.bak.N` suffix, a WARN log line is emitted, and the daemon
//! continues with an empty registry. Per-torrent tag assignments stored on
//! `FastResumeData` are untouched; only the global tag set is reset.

use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tracing::{info, warn};

use crate::registry_common::validate_registry_name;

/// On-disk schema version. Bumped only if the TOML layout changes in a way
/// that cannot be deserialised as the previous shape.
const REGISTRY_SCHEMA_VERSION: u32 = 1;

fn default_version() -> u32 {
    REGISTRY_SCHEMA_VERSION
}

/// Errors raised by the tag registry.
#[derive(Debug, thiserror::Error)]
pub enum TagError {
    /// Name failed `registry_common::validate_registry_name`.
    #[error("invalid tag name: {0}")]
    InvalidName(String),
    /// A tag with the given name already exists (case-sensitive).
    #[error("tag already exists: {0}")]
    AlreadyExists(String),
    /// Disk I/O error persisting the tag registry.
    #[error("persistence: {0}")]
    Persistence(#[from] std::io::Error),
    /// TOML serialisation error.
    #[error("serialise: {0}")]
    Serialise(#[from] toml::ser::Error),
}

/// On-disk representation of the tag registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OnDisk {
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default)]
    tags: Vec<String>,
}

/// In-memory tag store, persisted to a single TOML file.
///
/// Backing collection is a `HashSet<String>` — tag names are unique and
/// case-sensitive (qBt parity). `list()` returns a sorted `Vec<String>` so
/// HTTP responses are deterministic.
#[derive(Debug, Clone, Default)]
pub struct TagRegistry {
    path: PathBuf,
    tags: HashSet<String>,
}

impl TagRegistry {
    /// Create a new empty registry associated with `path`. Does not touch
    /// disk; call [`Self::save`] to materialise.
    #[must_use]
    pub fn new(path: PathBuf) -> Self {
        Self {
            path,
            tags: HashSet::new(),
        }
    }

    /// Load a registry from disk, soft-recovering from failures.
    ///
    /// * If the file is absent → empty registry.
    /// * If the TOML is malformed OR the schema version is unknown → rename
    ///   the broken file aside (`.bak` / `.bak.N`) and return an empty
    ///   registry. WARN log.
    /// * Other I/O errors (permission, etc.) → empty registry. WARN log.
    #[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 tags: HashSet<String> = on_disk.tags.into_iter().collect();
                    info!(
                        path = %path.display(),
                        count = tags.len(),
                        "loaded tag registry"
                    );
                    Self { path, tags }
                }
                Ok(other) => Self::rename_bak_and_start_empty(
                    path,
                    &format!("schema version {} unexpected", other.version),
                ),
                Err(e) => 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,
                    "tag registry read failed — starting empty",
                );
                Self::new(path)
            }
        }
    }

    /// Rename the malformed file aside (collisions get `.bak.N` suffixes up to
    /// `10_000`) and return a fresh empty registry. Best-effort — a failure
    /// here only warns; the caller gets an empty registry either way.
    fn rename_bak_and_start_empty(path: PathBuf, reason: &str) -> Self {
        let mut bak = path.clone();
        bak.set_extension("toml.bak");
        let mut n = 1;
        while bak.exists() && n < 10_000 {
            bak.clone_from(&path);
            bak.set_extension(format!("toml.bak.{n}"));
            n += 1;
        }
        match fs::rename(&path, &bak) {
            Ok(()) => warn!(
                reason,
                path = %path.display(),
                bak = %bak.display(),
                "renamed malformed tag registry, starting empty",
            ),
            Err(e) => warn!(
                reason,
                path = %path.display(),
                bak = %bak.display(),
                error = %e,
                "failed to rename malformed tag file, starting empty anyway",
            ),
        }
        Self::new(path)
    }

    /// Return all known tag names in sorted order.
    #[must_use]
    pub fn list(&self) -> Vec<String> {
        let mut out: Vec<String> = self.tags.iter().cloned().collect();
        out.sort();
        out
    }

    /// Number of tags in the registry.
    #[must_use]
    pub fn len(&self) -> usize {
        self.tags.len()
    }

    /// True iff the registry contains no tags.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.tags.is_empty()
    }

    /// Does a tag with the given name exist? Case-sensitive. Empty-string
    /// is rejected (never matches).
    #[must_use]
    pub fn contains(&self, name: &str) -> bool {
        !name.is_empty() && self.tags.contains(name)
    }

    /// Create a new tag. Returns:
    /// * `Err(InvalidName)` if the name fails validation.
    /// * `Err(AlreadyExists)` if a tag with the same name already exists
    ///   (case-sensitive). This is *stricter* than the HTTP surface, which
    ///   is idempotent; the HTTP handler swallows `AlreadyExists` on
    ///   `createTags` to match qBt's wire semantics.
    /// * `Ok(())` on success. The new tag is in-memory only until
    ///   [`Self::save`] is called.
    ///
    /// # Errors
    ///
    /// Returns an error if the session is shut down.
    pub fn create(&mut self, name: String) -> Result<(), TagError> {
        validate_tag_name(&name)?;
        if self.tags.contains(&name) {
            return Err(TagError::AlreadyExists(name));
        }
        self.tags.insert(name);
        Ok(())
    }

    /// Delete a batch of tags. Returns the subset of names that were actually
    /// removed (ignores names that were not in the registry, matching qBt's
    /// idempotent `deleteTags` behaviour).
    pub fn delete(&mut self, names: &[String]) -> Vec<String> {
        let mut removed = Vec::new();
        for n in names {
            if self.tags.remove(n) {
                removed.push(n.clone());
            }
        }
        removed
    }

    /// Persist to disk via atomic `NamedTempFile::persist`. The parent
    /// directory is created if it does not yet exist. A schema version
    /// header is always written so future loads can detect upgrades.
    ///
    /// # Errors
    ///
    /// Returns an error if the I/O operation fails.
    pub fn save(&self) -> Result<(), TagError> {
        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,
            tags: self.list(),
        };
        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| TagError::Persistence(e.error))?;
        Ok(())
    }
}

/// Validate a tag name via the shared `registry_common` helper.
fn validate_tag_name(name: &str) -> Result<(), TagError> {
    validate_registry_name(name, "tag").map_err(TagError::InvalidName)
}

/// Resolve the filesystem path at which the tag registry is stored. If
/// `explicit` is `Some`, use it verbatim; otherwise fall back to
/// `$XDG_CONFIG_HOME/irontide/tags.toml` (via `directories::ProjectDirs`).
/// Final fallback (e.g. if the XDG dirs cannot be resolved) is the relative
/// path `./.irontide/tags.toml`.
#[must_use]
pub fn resolve_tag_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/tags.toml"),
        |dirs| dirs.config_dir().join("tags.toml"),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use tempfile::TempDir;

    fn registry_in(dir: &TempDir) -> TagRegistry {
        TagRegistry::new(dir.path().join("tags.toml"))
    }

    #[test]
    fn empty_load_returns_empty_registry() {
        let dir = TempDir::new().unwrap();
        let reg = TagRegistry::load(dir.path().join("tags.toml"));
        assert!(reg.list().is_empty());
        assert_eq!(reg.len(), 0);
        assert!(reg.is_empty());
    }

    #[test]
    fn create_then_save_persists_round_trip() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tags.toml");
        let mut reg = TagRegistry::new(path.clone());
        reg.create("sonarr".into()).unwrap();
        reg.create("kids".into()).unwrap();
        reg.save().unwrap();

        let reloaded = TagRegistry::load(path);
        assert_eq!(
            reloaded.list(),
            vec!["kids".to_string(), "sonarr".to_string()],
        );
    }

    #[test]
    fn malformed_file_is_renamed_to_bak_and_registry_starts_empty() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tags.toml");
        fs::write(&path, "this is not valid toml at all ((((").unwrap();
        let reg = TagRegistry::load(path.clone());
        assert!(reg.list().is_empty());
        // The original path should no longer exist and the .bak must be on disk.
        assert!(!path.exists(), "original file should have been renamed");
        let bak = path.with_extension("toml.bak");
        assert!(bak.exists(), "expected {} to exist", bak.display());
    }

    #[test]
    fn duplicate_create_returns_already_exists() {
        let dir = TempDir::new().unwrap();
        let mut reg = registry_in(&dir);
        reg.create("sonarr".into()).unwrap();
        match reg.create("sonarr".into()) {
            Err(TagError::AlreadyExists(name)) => assert_eq!(name, "sonarr"),
            other => panic!("expected AlreadyExists, got {other:?}"),
        }
    }

    #[test]
    fn invalid_name_rejected() {
        let dir = TempDir::new().unwrap();
        let mut reg = registry_in(&dir);
        for bad in ["", "   ", "/leading", "a/../b", "with space", "has!bang"] {
            assert!(
                matches!(reg.create(bad.into()), Err(TagError::InvalidName(_))),
                "expected InvalidName for {bad:?}",
            );
        }
    }

    #[test]
    fn delete_returns_names_actually_removed() {
        let dir = TempDir::new().unwrap();
        let mut reg = registry_in(&dir);
        reg.create("a".into()).unwrap();
        reg.create("b".into()).unwrap();
        let removed = reg.delete(&["a".into(), "ghost".into()]);
        assert_eq!(removed, vec!["a".to_string()]);
        assert_eq!(reg.list(), vec!["b".to_string()]);
    }

    #[test]
    fn case_sensitive_names() {
        let dir = TempDir::new().unwrap();
        let mut reg = registry_in(&dir);
        reg.create("Sonarr".into()).unwrap();
        // Different case — qBt treats these as distinct.
        reg.create("sonarr".into()).unwrap();
        let mut names = reg.list();
        names.sort();
        assert_eq!(names, vec!["Sonarr".to_string(), "sonarr".to_string()]);
    }

    #[test]
    fn contains_empty_string_returns_false() {
        let dir = TempDir::new().unwrap();
        let mut reg = registry_in(&dir);
        reg.create("sonarr".into()).unwrap();
        assert!(!reg.contains(""));
        assert!(reg.contains("sonarr"));
        assert!(!reg.contains("Sonarr"));
    }

    #[test]
    fn save_creates_parent_dirs_if_missing() {
        // Required by Closeout E0.10: ensure that a tags.toml nested under a
        // not-yet-existing directory tree is persisted correctly.
        let dir = TempDir::new().unwrap();
        let nested = dir.path().join("a").join("b").join("tags.toml");
        let mut reg = TagRegistry::new(nested.clone());
        reg.create("x".into()).unwrap();
        reg.save().unwrap();
        assert!(nested.exists());
        let reloaded = TagRegistry::load(nested);
        assert_eq!(reloaded.list(), vec!["x".to_string()]);
    }

    #[test]
    fn resolve_path_default_ends_with_tags_toml() {
        let p = resolve_tag_registry_path(None);
        assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("tags.toml"));
    }

    #[test]
    fn resolve_path_honours_explicit_override() {
        let explicit = PathBuf::from("/tmp/custom-tags.toml");
        let p = resolve_tag_registry_path(Some(&explicit));
        assert_eq!(p, explicit);
    }
}