chore: add .vscode and .idea to .gitignore
[ouch.git] / src / extension.rs
blob85451cb3b2736e7a4b0c9e05112503dcfa1516fd
1 //! Our representation of all the supported compression formats.
3 use std::{ffi::OsStr, fmt, path::Path};
5 use bstr::ByteSlice;
7 use self::CompressionFormat::*;
8 use crate::{error::Error, warning};
10 pub const SUPPORTED_EXTENSIONS: &[&str] = &["tar", "zip", "bz", "bz2", "gz", "lz4", "xz", "lzma", "sz", "zst", "rar"];
11 pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst"];
12 pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst, rar";
13 pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst";
15 /// A wrapper around `CompressionFormat` that allows combinations like `tgz`
16 #[derive(Debug, Clone, Eq)]
17 #[non_exhaustive]
18 pub struct Extension {
19     /// One extension like "tgz" can be made of multiple CompressionFormats ([Tar, Gz])
20     pub compression_formats: &'static [CompressionFormat],
21     /// The input text for this extension, like "tgz", "tar" or "xz"
22     display_text: String,
25 // The display_text should be ignored when comparing extensions
26 impl PartialEq for Extension {
27     fn eq(&self, other: &Self) -> bool {
28         self.compression_formats == other.compression_formats
29     }
32 impl Extension {
33     /// # Panics:
34     ///   Will panic if `formats` is empty
35     pub fn new(formats: &'static [CompressionFormat], text: impl ToString) -> Self {
36         assert!(!formats.is_empty());
37         Self {
38             compression_formats: formats,
39             display_text: text.to_string(),
40         }
41     }
43     /// Checks if the first format in `compression_formats` is an archive
44     pub fn is_archive(&self) -> bool {
45         // Safety: we check that `compression_formats` is not empty in `Self::new`
46         self.compression_formats[0].is_archive_format()
47     }
50 impl fmt::Display for Extension {
51     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
52         self.display_text.fmt(f)
53     }
56 #[derive(Copy, Clone, PartialEq, Eq, Debug)]
57 /// Accepted extensions for input and output
58 pub enum CompressionFormat {
59     /// .gz
60     Gzip,
61     /// .bz .bz2
62     Bzip,
63     /// .lz4
64     Lz4,
65     /// .xz .lzma
66     Lzma,
67     /// .sz
68     Snappy,
69     /// tar, tgz, tbz, tbz2, txz, tlz4, tlzma, tsz, tzst
70     Tar,
71     /// .zst
72     Zstd,
73     /// .zip
74     Zip,
75     /// .rar
76     Rar,
79 impl CompressionFormat {
80     /// Currently supported archive formats are .tar (and aliases to it) and .zip
81     fn is_archive_format(&self) -> bool {
82         // Keep this match like that without a wildcard `_` so we don't forget to update it
83         match self {
84             Tar | Zip | Rar => true,
85             Gzip => false,
86             Bzip => false,
87             Lz4 => false,
88             Lzma => false,
89             Snappy => false,
90             Zstd => false,
91         }
92     }
95 fn to_extension(ext: &[u8]) -> Option<Extension> {
96     Some(Extension::new(
97         match ext {
98             b"tar" => &[Tar],
99             b"tgz" => &[Tar, Gzip],
100             b"tbz" | b"tbz2" => &[Tar, Bzip],
101             b"tlz4" => &[Tar, Lz4],
102             b"txz" | b"tlzma" => &[Tar, Lzma],
103             b"tsz" => &[Tar, Snappy],
104             b"tzst" => &[Tar, Zstd],
105             b"zip" => &[Zip],
106             b"bz" | b"bz2" => &[Bzip],
107             b"gz" => &[Gzip],
108             b"lz4" => &[Lz4],
109             b"xz" | b"lzma" => &[Lzma],
110             b"sz" => &[Snappy],
111             b"zst" => &[Zstd],
112             b"rar" => &[Rar],
113             _ => return None,
114         },
115         ext.to_str_lossy(),
116     ))
119 fn split_extension(name: &mut &[u8]) -> Option<Extension> {
120     let (new_name, ext) = name.rsplit_once_str(b".")?;
121     if matches!(new_name, b"" | b"." | b"..") {
122         return None;
123     }
124     let ext = to_extension(ext)?;
125     *name = new_name;
126     Some(ext)
129 pub fn parse_format(fmt: &OsStr) -> crate::Result<Vec<Extension>> {
130     let fmt = <[u8] as ByteSlice>::from_os_str(fmt).ok_or_else(|| Error::InvalidFormat {
131         reason: "Invalid UTF-8".into(),
132     })?;
134     let mut extensions = Vec::new();
135     for extension in fmt.split_str(b".") {
136         let extension = to_extension(extension).ok_or_else(|| Error::InvalidFormat {
137             reason: format!("Unsupported extension: {}", extension.to_str_lossy()),
138         })?;
139         extensions.push(extension);
140     }
142     Ok(extensions)
145 /// Extracts extensions from a path.
147 /// Returns both the remaining path and the list of extension objects
148 pub fn separate_known_extensions_from_name(path: &Path) -> (&Path, Vec<Extension>) {
149     let mut extensions = vec![];
151     let Some(mut name) = path.file_name().and_then(<[u8] as ByteSlice>::from_os_str) else {
152         return (path, extensions);
153     };
155     // While there is known extensions at the tail, grab them
156     while let Some(extension) = split_extension(&mut name) {
157         extensions.insert(0, extension);
158     }
160     if let Ok(name) = name.to_str() {
161         let file_stem = name.trim_matches('.');
162         if SUPPORTED_EXTENSIONS.contains(&file_stem) || SUPPORTED_ALIASES.contains(&file_stem) {
163             warning!("Received a file with name '{file_stem}', but {file_stem} was expected as the extension.");
164         }
165     }
167     (name.to_path().unwrap(), extensions)
170 /// Extracts extensions from a path, return only the list of extension objects
171 pub fn extensions_from_path(path: &Path) -> Vec<Extension> {
172     let (_, extensions) = separate_known_extensions_from_name(path);
173     extensions
176 // Panics if formats has an empty list of compression formats
177 pub fn split_first_compression_format(formats: &[Extension]) -> (CompressionFormat, Vec<CompressionFormat>) {
178     let mut extensions: Vec<CompressionFormat> = flatten_compression_formats(formats);
179     let first_extension = extensions.remove(0);
180     (first_extension, extensions)
183 pub fn flatten_compression_formats(extensions: &[Extension]) -> Vec<CompressionFormat> {
184     extensions
185         .iter()
186         .flat_map(|extension| extension.compression_formats.iter())
187         .copied()
188         .collect()
191 /// Builds a suggested output file in scenarios where the user tried to compress
192 /// a folder into a non-archive compression format, for error message purposes
194 /// E.g.: `build_suggestion("file.bz.xz", ".tar")` results in `Some("file.tar.bz.xz")`
195 pub fn build_archive_file_suggestion(path: &Path, suggested_extension: &str) -> Option<String> {
196     let path = path.to_string_lossy();
197     let mut rest = &*path;
198     let mut position_to_insert = 0;
200     // Walk through the path to find the first supported compression extension
201     while let Some(pos) = rest.find('.') {
202         // Use just the text located after the dot we found
203         rest = &rest[pos + 1..];
204         position_to_insert += pos + 1;
206         // If the string contains more chained extensions, clip to the immediate one
207         let maybe_extension = {
208             let idx = rest.find('.').unwrap_or(rest.len());
209             &rest[..idx]
210         };
212         // If the extension we got is a supported extension, generate the suggestion
213         // at the position we found
214         if SUPPORTED_EXTENSIONS.contains(&maybe_extension) || SUPPORTED_ALIASES.contains(&maybe_extension) {
215             let mut path = path.to_string();
216             path.insert_str(position_to_insert - 1, suggested_extension);
218             return Some(path);
219         }
220     }
222     None
225 #[cfg(test)]
226 mod tests {
227     use std::path::Path;
229     use super::*;
231     #[test]
232     fn test_extensions_from_path() {
233         let path = Path::new("bolovo.tar.gz");
235         let extensions: Vec<Extension> = extensions_from_path(path);
236         let formats: Vec<CompressionFormat> = flatten_compression_formats(&extensions);
238         assert_eq!(formats, vec![Tar, Gzip]);
239     }
241     #[test]
242     fn builds_suggestion_correctly() {
243         assert_eq!(build_archive_file_suggestion(Path::new("linux.png"), ".tar"), None);
244         assert_eq!(
245             build_archive_file_suggestion(Path::new("linux.xz.gz.zst"), ".tar").unwrap(),
246             "linux.tar.xz.gz.zst"
247         );
248         assert_eq!(
249             build_archive_file_suggestion(Path::new("linux.pkg.xz.gz.zst"), ".tar").unwrap(),
250             "linux.pkg.tar.xz.gz.zst"
251         );
252         assert_eq!(
253             build_archive_file_suggestion(Path::new("linux.pkg.zst"), ".tar").unwrap(),
254             "linux.pkg.tar.zst"
255         );
256         assert_eq!(
257             build_archive_file_suggestion(Path::new("linux.pkg.info.zst"), ".tar").unwrap(),
258             "linux.pkg.info.tar.zst"
259         );
260     }