irontide-session 1.0.1

BitTorrent session management: peers, torrents, and piece selection
Documentation
//! Shared helpers for TOML-backed user-facing registries (categories, tags).
//!
//! Extracted in M171 Lane A so `CategoryRegistry` and `TagRegistry` share a
//! single authoritative name validator. If we ever need a third registry type
//! we can grow this module to include atomic write + soft-recovery machinery
//! as well; for now the minimum wins are:
//!
//! * `MAX_REGISTRY_NAME_LEN` — the shared 255-byte cap.
//! * `validate_registry_name` — returns `Err(reason)` with a human-readable
//!   message; callers wrap the reason in their own error type (e.g.
//!   `CategoryError::InvalidName` / `TagError::InvalidName`).

/// Maximum allowed length in bytes for a registry name. Matches qBt's 255-byte
/// limit on category names and is reused verbatim for tag names.
pub const MAX_REGISTRY_NAME_LEN: usize = 255;

/// qBt-parity name validation shared by category + tag registries.
///
/// Rules (identical to the former `category_manager::validate_name`):
/// * non-empty and not whitespace-only
/// * ≤ `MAX_REGISTRY_NAME_LEN` UTF-8 bytes
/// * characters restricted to `[a-zA-Z0-9_/-]`
/// * nested names via `/` allowed (`movies/4k`)
/// * reject leading `/`, empty segments, and `..` parent-traversal segments
///
/// `context` is an English noun used in the error messages (e.g. `"category"`
/// or `"tag"`) so the failure reason reads naturally when surfaced to the
/// caller.
pub fn validate_registry_name(name: &str, context: &str) -> Result<(), String> {
    if name.is_empty() {
        return Err(format!("{context} name is empty"));
    }
    if name.trim().is_empty() {
        return Err(format!("{context} name is whitespace-only"));
    }
    if name.len() > MAX_REGISTRY_NAME_LEN {
        return Err(format!(
            "{context} name exceeds {MAX_REGISTRY_NAME_LEN} bytes"
        ));
    }
    if name.starts_with('/') {
        return Err(format!("{context} name starts with '/': {name}"));
    }
    for segment in name.split('/') {
        if segment == ".." || segment.is_empty() {
            return Err(format!("{context} name contains invalid segment: {name}"));
        }
        for ch in segment.chars() {
            let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-');
            if !ok {
                return Err(format!(
                    "{context} name contains illegal character '{ch}' in '{name}'"
                ));
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_names_accepted() {
        for good in &[
            "sonarr",
            "radarr",
            "kids/tv",
            "a-b_c",
            "movies/4k",
            "letters_and_123",
            "a",
        ] {
            assert!(
                validate_registry_name(good, "category").is_ok(),
                "expected {good:?} to validate",
            );
            assert!(validate_registry_name(good, "tag").is_ok());
        }
    }

    #[test]
    fn invalid_names_rejected() {
        for bad in &[
            "",
            "   ",
            "/leading",
            "a/../b",
            "with space",
            "has!bang",
            "a//b",
            "trail/",
            "..",
            "dot/.",
        ] {
            assert!(
                validate_registry_name(bad, "category").is_err(),
                "expected {bad:?} to be rejected",
            );
        }
    }

    #[test]
    fn length_cap_enforced() {
        let ok = "a".repeat(MAX_REGISTRY_NAME_LEN);
        let too_long = "a".repeat(MAX_REGISTRY_NAME_LEN + 1);
        assert!(validate_registry_name(&ok, "tag").is_ok());
        assert!(validate_registry_name(&too_long, "tag").is_err());
    }

    #[test]
    fn context_flows_into_error_message() {
        let err = validate_registry_name("", "tag").unwrap_err();
        assert!(err.contains("tag"), "expected context in error: {err}");
        let err = validate_registry_name("", "category").unwrap_err();
        assert!(err.contains("category"), "expected context in error: {err}");
    }

    #[test]
    fn case_sensitive_alphabet() {
        assert!(validate_registry_name("Sonarr", "tag").is_ok());
        assert!(validate_registry_name("SONARR", "tag").is_ok());
        assert!(validate_registry_name("sonarr", "tag").is_ok());
    }
}