tweak: don't add period to end of each log
[ouch.git] / src / extension.rs
blobf8d7389938e7d66016f10f913c65453e7d0bf551
1 //! Our representation of all the supported compression formats.
3 use std::{ffi::OsStr, fmt, path::Path};
5 use bstr::ByteSlice;
6 use CompressionFormat::*;
8 use crate::{error::Error, utils::logger::warning};
10 pub const SUPPORTED_EXTENSIONS: &[&str] = &[
11     "tar",
12     "zip",
13     "bz",
14     "bz2",
15     "gz",
16     "lz4",
17     "xz",
18     "lzma",
19     "sz",
20     "zst",
21     #[cfg(feature = "unrar")]
22     "rar",
23     "7z",
26 pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst"];
28 #[cfg(not(feature = "unrar"))]
29 pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z";
30 #[cfg(feature = "unrar")]
31 pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z";
33 pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst";
35 /// A wrapper around `CompressionFormat` that allows combinations like `tgz`
36 #[derive(Debug, Clone)]
37 // Keep `PartialEq` only for testing because two formats are the same even if
38 // their `display_text` does not match (beware of aliases)
39 #[cfg_attr(test, derive(PartialEq))]
40 // Should only be built with constructors
41 #[non_exhaustive]
42 pub struct Extension {
43     /// One extension like "tgz" can be made of multiple CompressionFormats ([Tar, Gz])
44     pub compression_formats: &'static [CompressionFormat],
45     /// The input text for this extension, like "tgz", "tar" or "xz"
46     display_text: String,
49 impl Extension {
50     /// # Panics:
51     ///   Will panic if `formats` is empty
52     pub fn new(formats: &'static [CompressionFormat], text: impl ToString) -> Self {
53         assert!(!formats.is_empty());
54         Self {
55             compression_formats: formats,
56             display_text: text.to_string(),
57         }
58     }
60     /// Checks if the first format in `compression_formats` is an archive
61     pub fn is_archive(&self) -> bool {
62         // Safety: we check that `compression_formats` is not empty in `Self::new`
63         self.compression_formats[0].is_archive_format()
64     }
67 impl fmt::Display for Extension {
68     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
69         self.display_text.fmt(f)
70     }
73 #[derive(Copy, Clone, PartialEq, Eq, Debug)]
74 /// Accepted extensions for input and output
75 pub enum CompressionFormat {
76     /// .gz
77     Gzip,
78     /// .bz .bz2
79     Bzip,
80     /// .bz3
81     Bzip3,
82     /// .lz4
83     Lz4,
84     /// .xz .lzma
85     Lzma,
86     /// .sz
87     Snappy,
88     /// tar, tgz, tbz, tbz2, tbz3, txz, tlz4, tlzma, tsz, tzst
89     Tar,
90     /// .zst
91     Zstd,
92     /// .zip
93     Zip,
94     // even if built without RAR support, we still want to recognise the format
95     /// .rar
96     Rar,
97     /// .7z
98     SevenZip,
101 impl CompressionFormat {
102     /// Currently supported archive formats are .tar (and aliases to it) and .zip
103     fn is_archive_format(&self) -> bool {
104         // Keep this match like that without a wildcard `_` so we don't forget to update it
105         match self {
106             Tar | Zip | Rar | SevenZip => true,
107             Gzip => false,
108             Bzip => false,
109             Bzip3 => false,
110             Lz4 => false,
111             Lzma => false,
112             Snappy => false,
113             Zstd => false,
114         }
115     }
118 fn to_extension(ext: &[u8]) -> Option<Extension> {
119     Some(Extension::new(
120         match ext {
121             b"tar" => &[Tar],
122             b"tgz" => &[Tar, Gzip],
123             b"tbz" | b"tbz2" => &[Tar, Bzip],
124             b"tbz3" => &[Tar, Bzip3],
125             b"tlz4" => &[Tar, Lz4],
126             b"txz" | b"tlzma" => &[Tar, Lzma],
127             b"tsz" => &[Tar, Snappy],
128             b"tzst" => &[Tar, Zstd],
129             b"zip" => &[Zip],
130             b"bz" | b"bz2" => &[Bzip],
131             b"bz3" => &[Bzip3],
132             b"gz" => &[Gzip],
133             b"lz4" => &[Lz4],
134             b"xz" | b"lzma" => &[Lzma],
135             b"sz" => &[Snappy],
136             b"zst" => &[Zstd],
137             b"rar" => &[Rar],
138             b"7z" => &[SevenZip],
139             _ => return None,
140         },
141         ext.to_str_lossy(),
142     ))
145 fn split_extension(name: &mut &[u8]) -> Option<Extension> {
146     let (new_name, ext) = name.rsplit_once_str(b".")?;
147     if matches!(new_name, b"" | b"." | b"..") {
148         return None;
149     }
150     let ext = to_extension(ext)?;
151     *name = new_name;
152     Some(ext)
155 pub fn parse_format_flag(input: &OsStr) -> crate::Result<Vec<Extension>> {
156     let format = input.as_encoded_bytes();
158     let format = std::str::from_utf8(format).map_err(|_| Error::InvalidFormatFlag {
159         text: input.to_owned(),
160         reason: "Invalid UTF-8.".to_string(),
161     })?;
163     let extensions: Vec<Extension> = format
164         .split('.')
165         .filter(|extension| !extension.is_empty())
166         .map(|extension| {
167             to_extension(extension.as_bytes()).ok_or_else(|| Error::InvalidFormatFlag {
168                 text: input.to_owned(),
169                 reason: format!("Unsupported extension '{}'", extension),
170             })
171         })
172         .collect::<crate::Result<_>>()?;
174     if extensions.is_empty() {
175         return Err(Error::InvalidFormatFlag {
176             text: input.to_owned(),
177             reason: "Parsing got an empty list of extensions.".to_string(),
178         });
179     }
181     Ok(extensions)
184 /// Extracts extensions from a path.
186 /// Returns both the remaining path and the list of extension objects
187 pub fn separate_known_extensions_from_name(path: &Path) -> (&Path, Vec<Extension>) {
188     let mut extensions = vec![];
190     let Some(mut name) = path.file_name().and_then(<[u8] as ByteSlice>::from_os_str) else {
191         return (path, extensions);
192     };
194     // While there is known extensions at the tail, grab them
195     while let Some(extension) = split_extension(&mut name) {
196         extensions.insert(0, extension);
197     }
199     if let Ok(name) = name.to_str() {
200         let file_stem = name.trim_matches('.');
201         if SUPPORTED_EXTENSIONS.contains(&file_stem) || SUPPORTED_ALIASES.contains(&file_stem) {
202             warning(format!(
203                 "Received a file with name '{file_stem}', but {file_stem} was expected as the extension"
204             ));
205         }
206     }
208     (name.to_path().unwrap(), extensions)
211 /// Extracts extensions from a path, return only the list of extension objects
212 pub fn extensions_from_path(path: &Path) -> Vec<Extension> {
213     let (_, extensions) = separate_known_extensions_from_name(path);
214     extensions
217 /// Panics if formats has an empty list of compression formats
218 pub fn split_first_compression_format(formats: &[Extension]) -> (CompressionFormat, Vec<CompressionFormat>) {
219     let mut extensions: Vec<CompressionFormat> = flatten_compression_formats(formats);
220     let first_extension = extensions.remove(0);
221     (first_extension, extensions)
224 pub fn flatten_compression_formats(extensions: &[Extension]) -> Vec<CompressionFormat> {
225     extensions
226         .iter()
227         .flat_map(|extension| extension.compression_formats.iter())
228         .copied()
229         .collect()
232 /// Builds a suggested output file in scenarios where the user tried to compress
233 /// a folder into a non-archive compression format, for error message purposes
235 /// E.g.: `build_suggestion("file.bz.xz", ".tar")` results in `Some("file.tar.bz.xz")`
236 pub fn build_archive_file_suggestion(path: &Path, suggested_extension: &str) -> Option<String> {
237     let path = path.to_string_lossy();
238     let mut rest = &*path;
239     let mut position_to_insert = 0;
241     // Walk through the path to find the first supported compression extension
242     while let Some(pos) = rest.find('.') {
243         // Use just the text located after the dot we found
244         rest = &rest[pos + 1..];
245         position_to_insert += pos + 1;
247         // If the string contains more chained extensions, clip to the immediate one
248         let maybe_extension = {
249             let idx = rest.find('.').unwrap_or(rest.len());
250             &rest[..idx]
251         };
253         // If the extension we got is a supported extension, generate the suggestion
254         // at the position we found
255         if SUPPORTED_EXTENSIONS.contains(&maybe_extension) || SUPPORTED_ALIASES.contains(&maybe_extension) {
256             let mut path = path.to_string();
257             path.insert_str(position_to_insert - 1, suggested_extension);
259             return Some(path);
260         }
261     }
263     None
266 #[cfg(test)]
267 mod tests {
268     use super::*;
269     use crate::utils::logger::spawn_logger_thread;
271     #[test]
272     fn test_extensions_from_path() {
273         let path = Path::new("bolovo.tar.gz");
275         let extensions: Vec<Extension> = extensions_from_path(path);
276         let formats: Vec<CompressionFormat> = flatten_compression_formats(&extensions);
278         assert_eq!(formats, vec![Tar, Gzip]);
279     }
281     #[test]
282     /// Test extension parsing for input/output files
283     fn test_separate_known_extensions_from_name() {
284         let _handler = spawn_logger_thread();
285         assert_eq!(
286             separate_known_extensions_from_name("file".as_ref()),
287             ("file".as_ref(), vec![])
288         );
289         assert_eq!(
290             separate_known_extensions_from_name("tar".as_ref()),
291             ("tar".as_ref(), vec![])
292         );
293         assert_eq!(
294             separate_known_extensions_from_name(".tar".as_ref()),
295             (".tar".as_ref(), vec![])
296         );
297         assert_eq!(
298             separate_known_extensions_from_name("file.tar".as_ref()),
299             ("file".as_ref(), vec![Extension::new(&[Tar], "tar")])
300         );
301         assert_eq!(
302             separate_known_extensions_from_name("file.tar.gz".as_ref()),
303             (
304                 "file".as_ref(),
305                 vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
306             )
307         );
308         assert_eq!(
309             separate_known_extensions_from_name(".tar.gz".as_ref()),
310             (".tar".as_ref(), vec![Extension::new(&[Gzip], "gz")])
311         );
312     }
314     #[test]
315     /// Test extension parsing of `--format FORMAT`
316     fn test_parse_of_format_flag() {
317         assert_eq!(
318             parse_format_flag(OsStr::new("tar")).unwrap(),
319             vec![Extension::new(&[Tar], "tar")]
320         );
321         assert_eq!(
322             parse_format_flag(OsStr::new(".tar")).unwrap(),
323             vec![Extension::new(&[Tar], "tar")]
324         );
325         assert_eq!(
326             parse_format_flag(OsStr::new("tar.gz")).unwrap(),
327             vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
328         );
329         assert_eq!(
330             parse_format_flag(OsStr::new(".tar.gz")).unwrap(),
331             vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
332         );
333         assert_eq!(
334             parse_format_flag(OsStr::new("..tar..gz.....")).unwrap(),
335             vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
336         );
338         assert!(parse_format_flag(OsStr::new("../tar.gz")).is_err());
339         assert!(parse_format_flag(OsStr::new("targz")).is_err());
340         assert!(parse_format_flag(OsStr::new("tar.gz.unknown")).is_err());
341         assert!(parse_format_flag(OsStr::new(".tar.gz.unknown")).is_err());
342         assert!(parse_format_flag(OsStr::new(".tar.!@#.gz")).is_err());
343     }
345     #[test]
346     fn builds_suggestion_correctly() {
347         assert_eq!(build_archive_file_suggestion(Path::new("linux.png"), ".tar"), None);
348         assert_eq!(
349             build_archive_file_suggestion(Path::new("linux.xz.gz.zst"), ".tar").unwrap(),
350             "linux.tar.xz.gz.zst"
351         );
352         assert_eq!(
353             build_archive_file_suggestion(Path::new("linux.pkg.xz.gz.zst"), ".tar").unwrap(),
354             "linux.pkg.tar.xz.gz.zst"
355         );
356         assert_eq!(
357             build_archive_file_suggestion(Path::new("linux.pkg.zst"), ".tar").unwrap(),
358             "linux.pkg.tar.zst"
359         );
360         assert_eq!(
361             build_archive_file_suggestion(Path::new("linux.pkg.info.zst"), ".tar").unwrap(),
362             "linux.pkg.info.tar.zst"
363         );
364     }