use-rar 0.1.0

RAR archive labels, extensions, and volume metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

//! RAR archive labels and volume metadata for `RustUse`.

use core::fmt;

/// Common RAR archive extension.
pub const RAR_EXTENSION: &str = "rar";
/// Common old-style RAR multipart extension.
pub const RAR_OLD_PART_EXTENSION: &str = "r00";
/// Common new-style RAR multipart filename extension.
pub const RAR_PART1_EXTENSION: &str = "part1.rar";
/// Common RAR-related extensions.
pub const RAR_EXTENSIONS: &[&str] = &["rar", "r00", "part1.rar"];

/// RAR version labels.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RarVersion {
    /// RAR4 archive label.
    Rar4,
    /// RAR5 archive label.
    Rar5,
    /// Unknown or intentionally unspecified RAR version.
    #[default]
    Unknown,
}

impl RarVersion {
    /// Returns a stable lowercase label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Rar4 => "rar4",
            Self::Rar5 => "rar5",
            Self::Unknown => "unknown",
        }
    }
}

impl fmt::Display for RarVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// RAR volume kind labels.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RarVolumeKind {
    /// Single-volume RAR archive.
    Single,
    /// Multi-part RAR archive.
    MultiPart,
    /// Recovery volume label.
    Recovery,
    /// Unknown or intentionally unspecified volume kind.
    #[default]
    Unknown,
}

impl RarVolumeKind {
    /// Returns a stable lowercase label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Single => "single",
            Self::MultiPart => "multi-part",
            Self::Recovery => "recovery",
            Self::Unknown => "unknown",
        }
    }
}

impl fmt::Display for RarVolumeKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// Returns whether `extension` is a known RAR extension label.
#[must_use]
pub fn is_rar_extension(extension: &str) -> bool {
    let normalized = normalize_extension(extension);
    normalized == "rar" || is_old_part_extension(&normalized) || is_part_rar_extension(&normalized)
}

/// Returns whether `name` has a known RAR filename encoding.
#[must_use]
pub fn is_rar_filename(name: &str) -> bool {
    let parts = filename_parts(name);

    match parts.as_slice() {
        [.., last] if last == "rar" || is_old_part_extension(last) => true,
        [.., previous, last] if is_part_label(previous) && last == "rar" => true,
        _ => false,
    }
}

fn is_part_rar_extension(extension: &str) -> bool {
    let parts = extension
        .split('.')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>();

    matches!(parts.as_slice(), [part, "rar"] if is_part_label(part))
}

fn is_old_part_extension(extension: &str) -> bool {
    let bytes = extension.as_bytes();
    bytes.len() == 3 && bytes[0] == b'r' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit()
}

fn is_part_label(label: &str) -> bool {
    let Some(number) = label.strip_prefix("part") else {
        return false;
    };

    !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
}

fn normalize_extension(extension: &str) -> String {
    extension
        .trim()
        .trim_start_matches('.')
        .to_ascii_lowercase()
}

fn filename_parts(name: &str) -> Vec<String> {
    name.trim()
        .to_ascii_lowercase()
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or_default()
        .trim_start_matches('.')
        .split('.')
        .filter(|part| !part.is_empty())
        .map(str::to_owned)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{RAR_EXTENSIONS, RarVersion, RarVolumeKind, is_rar_extension, is_rar_filename};

    #[test]
    fn detects_rar_extensions() {
        assert!(is_rar_extension(".rar"));
        assert!(is_rar_extension("r00"));
        assert!(is_rar_extension("part1.rar"));
        assert_eq!(RAR_EXTENSIONS[0], "rar");
    }

    #[test]
    fn detects_rar_filenames() {
        assert!(is_rar_filename("backup.rar"));
        assert!(is_rar_filename("backup.r00"));
        assert!(is_rar_filename("backup.part1.rar"));
        assert!(!is_rar_filename("bundle.zip"));
    }

    #[test]
    fn exposes_default_and_unknown_labels() {
        assert_eq!(RarVersion::default(), RarVersion::Unknown);
        assert_eq!(RarVersion::Rar4.as_str(), "rar4");
        assert_eq!(RarVolumeKind::default(), RarVolumeKind::Unknown);
        assert_eq!(RarVolumeKind::Recovery.as_str(), "recovery");
    }
}