1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
7
8pub const RAR_EXTENSION: &str = "rar";
10pub const RAR_OLD_PART_EXTENSION: &str = "r00";
12pub const RAR_PART1_EXTENSION: &str = "part1.rar";
14pub const RAR_EXTENSIONS: &[&str] = &["rar", "r00", "part1.rar"];
16
17#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub enum RarVersion {
20 Rar4,
22 Rar5,
24 #[default]
26 Unknown,
27}
28
29impl RarVersion {
30 #[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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub enum RarVolumeKind {
50 Single,
52 MultiPart,
54 Recovery,
56 #[default]
58 Unknown,
59}
60
61impl RarVolumeKind {
62 #[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#[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#[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}