Skip to main content

use_rar/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! RAR archive labels and volume metadata for `RustUse`.
5
6use core::fmt;
7
8/// Common RAR archive extension.
9pub const RAR_EXTENSION: &str = "rar";
10/// Common old-style RAR multipart extension.
11pub const RAR_OLD_PART_EXTENSION: &str = "r00";
12/// Common new-style RAR multipart filename extension.
13pub const RAR_PART1_EXTENSION: &str = "part1.rar";
14/// Common RAR-related extensions.
15pub const RAR_EXTENSIONS: &[&str] = &["rar", "r00", "part1.rar"];
16
17/// RAR version labels.
18#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub enum RarVersion {
20    /// RAR4 archive label.
21    Rar4,
22    /// RAR5 archive label.
23    Rar5,
24    /// Unknown or intentionally unspecified RAR version.
25    #[default]
26    Unknown,
27}
28
29impl RarVersion {
30    /// Returns a stable lowercase label.
31    #[must_use]
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Rar4 => "rar4",
35            Self::Rar5 => "rar5",
36            Self::Unknown => "unknown",
37        }
38    }
39}
40
41impl fmt::Display for RarVersion {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        formatter.write_str(self.as_str())
44    }
45}
46
47/// RAR volume kind labels.
48#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub enum RarVolumeKind {
50    /// Single-volume RAR archive.
51    Single,
52    /// Multi-part RAR archive.
53    MultiPart,
54    /// Recovery volume label.
55    Recovery,
56    /// Unknown or intentionally unspecified volume kind.
57    #[default]
58    Unknown,
59}
60
61impl RarVolumeKind {
62    /// Returns a stable lowercase label.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Single => "single",
67            Self::MultiPart => "multi-part",
68            Self::Recovery => "recovery",
69            Self::Unknown => "unknown",
70        }
71    }
72}
73
74impl fmt::Display for RarVolumeKind {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        formatter.write_str(self.as_str())
77    }
78}
79
80/// Returns whether `extension` is a known RAR extension label.
81#[must_use]
82pub fn is_rar_extension(extension: &str) -> bool {
83    let normalized = normalize_extension(extension);
84    normalized == "rar" || is_old_part_extension(&normalized) || is_part_rar_extension(&normalized)
85}
86
87/// Returns whether `name` has a known RAR filename encoding.
88#[must_use]
89pub fn is_rar_filename(name: &str) -> bool {
90    let parts = filename_parts(name);
91
92    match parts.as_slice() {
93        [.., last] if last == "rar" || is_old_part_extension(last) => true,
94        [.., previous, last] if is_part_label(previous) && last == "rar" => true,
95        _ => false,
96    }
97}
98
99fn is_part_rar_extension(extension: &str) -> bool {
100    let parts = extension
101        .split('.')
102        .filter(|part| !part.is_empty())
103        .collect::<Vec<_>>();
104
105    matches!(parts.as_slice(), [part, "rar"] if is_part_label(part))
106}
107
108fn is_old_part_extension(extension: &str) -> bool {
109    let bytes = extension.as_bytes();
110    bytes.len() == 3 && bytes[0] == b'r' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit()
111}
112
113fn is_part_label(label: &str) -> bool {
114    let Some(number) = label.strip_prefix("part") else {
115        return false;
116    };
117
118    !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
119}
120
121fn normalize_extension(extension: &str) -> String {
122    extension
123        .trim()
124        .trim_start_matches('.')
125        .to_ascii_lowercase()
126}
127
128fn filename_parts(name: &str) -> Vec<String> {
129    name.trim()
130        .to_ascii_lowercase()
131        .rsplit(['/', '\\'])
132        .next()
133        .unwrap_or_default()
134        .trim_start_matches('.')
135        .split('.')
136        .filter(|part| !part.is_empty())
137        .map(str::to_owned)
138        .collect()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{RAR_EXTENSIONS, RarVersion, RarVolumeKind, is_rar_extension, is_rar_filename};
144
145    #[test]
146    fn detects_rar_extensions() {
147        assert!(is_rar_extension(".rar"));
148        assert!(is_rar_extension("r00"));
149        assert!(is_rar_extension("part1.rar"));
150        assert_eq!(RAR_EXTENSIONS[0], "rar");
151    }
152
153    #[test]
154    fn detects_rar_filenames() {
155        assert!(is_rar_filename("backup.rar"));
156        assert!(is_rar_filename("backup.r00"));
157        assert!(is_rar_filename("backup.part1.rar"));
158        assert!(!is_rar_filename("bundle.zip"));
159    }
160
161    #[test]
162    fn exposes_default_and_unknown_labels() {
163        assert_eq!(RarVersion::default(), RarVersion::Unknown);
164        assert_eq!(RarVersion::Rar4.as_str(), "rar4");
165        assert_eq!(RarVolumeKind::default(), RarVolumeKind::Unknown);
166        assert_eq!(RarVolumeKind::Recovery.as_str(), "recovery");
167    }
168}