Merge pull request #355 from figsoda/ext
[ouch.git] / src / commands / mod.rs
blob95f8cfcc3aaad267a76074f19387292f2c7bf3db
1 //! Receive command from the cli and call the respective function for that command.
3 mod compress;
4 mod decompress;
5 mod list;
7 use std::{
8     ops::ControlFlow,
9     path::{Path, PathBuf},
12 use rayon::prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
13 use utils::colors;
15 use crate::{
16     commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents},
17     error::{Error, FinalError},
18     extension::{self, flatten_compression_formats, parse_format, Extension, SUPPORTED_EXTENSIONS},
19     info,
20     list::ListOptions,
21     utils::{
22         self, pretty_format_list_of_paths, to_utf, try_infer_extension, user_wants_to_continue, EscapedPathDisplay,
23         FileVisibilityPolicy,
24     },
25     warning, Opts, QuestionAction, QuestionPolicy, Subcommand,
28 /// Warn the user that (de)compressing this .zip archive might freeze their system.
29 fn warn_user_about_loading_zip_in_memory() {
30     const ZIP_IN_MEMORY_LIMITATION_WARNING: &str = "\n\
31         \tThe format '.zip' is limited and cannot be (de)compressed using encoding streams.\n\
32         \tWhen using '.zip' with other formats, (de)compression must be done in-memory\n\
33         \tCareful, you might run out of RAM if the archive is too large!";
35     warning!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
38 /// Builds a suggested output file in scenarios where the user tried to compress
39 /// a folder into a non-archive compression format, for error message purposes
40 ///
41 /// E.g.: `build_suggestion("file.bz.xz", ".tar")` results in `Some("file.tar.bz.xz")`
42 fn build_archive_file_suggestion(path: &Path, suggested_extension: &str) -> Option<String> {
43     let path = path.to_string_lossy();
44     let mut rest = &*path;
45     let mut position_to_insert = 0;
47     // Walk through the path to find the first supported compression extension
48     while let Some(pos) = rest.find('.') {
49         // Use just the text located after the dot we found
50         rest = &rest[pos + 1..];
51         position_to_insert += pos + 1;
53         // If the string contains more chained extensions, clip to the immediate one
54         let maybe_extension = {
55             let idx = rest.find('.').unwrap_or(rest.len());
56             &rest[..idx]
57         };
59         // If the extension we got is a supported extension, generate the suggestion
60         // at the position we found
61         if SUPPORTED_EXTENSIONS.contains(&maybe_extension) {
62             let mut path = path.to_string();
63             path.insert_str(position_to_insert - 1, suggested_extension);
65             return Some(path);
66         }
67     }
69     None
72 /// In the context of listing archives, this function checks if `ouch` was told to list
73 /// the contents of a compressed file that is not an archive
74 fn check_for_non_archive_formats(files: &[PathBuf], formats: &[Vec<Extension>]) -> crate::Result<()> {
75     let mut not_archives = files
76         .iter()
77         .zip(formats)
78         .filter(|(_, formats)| !formats.first().map(Extension::is_archive).unwrap_or(false))
79         .map(|(path, _)| path)
80         .peekable();
82     if not_archives.peek().is_some() {
83         let not_archives: Vec<_> = not_archives.collect();
84         let error = FinalError::with_title("Cannot list archive contents")
85             .detail("Only archives can have their contents listed")
86             .detail(format!(
87                 "Files are not archives: {}",
88                 pretty_format_list_of_paths(&not_archives)
89             ));
91         return Err(error.into());
92     }
94     Ok(())
97 /// This function checks what command needs to be run and performs A LOT of ahead-of-time checks
98 /// to assume everything is OK.
99 ///
100 /// There are a lot of custom errors to give enough error description and explanation.
101 pub fn run(
102     args: Opts,
103     question_policy: QuestionPolicy,
104     file_visibility_policy: FileVisibilityPolicy,
105 ) -> crate::Result<()> {
106     match args.cmd {
107         Subcommand::Compress {
108             files,
109             output: output_path,
110         } => {
111             // After cleaning, if there are no input files left, exit
112             if files.is_empty() {
113                 return Err(FinalError::with_title("No files to compress").into());
114             }
116             // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
117             let (formats_from_flag, formats) = match args.format {
118                 Some(formats) => {
119                     let parsed_formats = parse_format(&formats)?;
120                     (Some(formats), parsed_formats)
121                 }
122                 None => (None, extension::extensions_from_path(&output_path)),
123             };
125             let first_format = formats.first().ok_or_else(|| {
126                 let output_path = EscapedPathDisplay::new(&output_path);
127                 FinalError::with_title(format!("Cannot compress to '{output_path}'."))
128                     .detail("You shall supply the compression format")
129                     .hint("Try adding supported extensions (see --help):")
130                     .hint(format!("  ouch compress <FILES>... {output_path}.tar.gz"))
131                     .hint(format!("  ouch compress <FILES>... {output_path}.zip"))
132                     .hint("")
133                     .hint("Alternatively, you can overwrite this option by using the '--format' flag:")
134                     .hint(format!("  ouch compress <FILES>... {output_path} --format tar.gz"))
135             })?;
137             let is_some_input_a_folder = files.iter().any(|path| path.is_dir());
138             let is_multiple_inputs = files.len() > 1;
140             // If first format is not archive, can't compress folder, or multiple files
141             // Index safety: empty formats should be checked above.
142             if !first_format.is_archive() && (is_some_input_a_folder || is_multiple_inputs) {
143                 let first_detail_message = if is_multiple_inputs {
144                     "You are trying to compress multiple files."
145                 } else {
146                     "You are trying to compress a folder."
147                 };
149                 let (from_hint, to_hint) = if let Some(formats) = formats_from_flag {
150                     let formats = formats.to_string_lossy();
151                     (
152                         format!("From: --format {formats}"),
153                         format!("To:   --format tar.{formats}"),
154                     )
155                 } else {
156                     // This piece of code creates a suggestion for compressing multiple files
157                     // It says:
158                     // Change from file.bz.xz
159                     // To          file.tar.bz.xz
160                     let suggested_output_path = build_archive_file_suggestion(&output_path, ".tar")
161                         .expect("output path should contain a compression format");
163                     (
164                         format!("From: {}", EscapedPathDisplay::new(&output_path)),
165                         format!("To:   {suggested_output_path}"),
166                     )
167                 };
168                 let output_path = EscapedPathDisplay::new(&output_path);
170                 let error = FinalError::with_title(format!("Cannot compress to '{output_path}'."))
171                     .detail(first_detail_message)
172                     .detail(format!(
173                         "The compression format '{first_format}' does not accept multiple files.",
174                     ))
175                     .detail("Formats that bundle files into an archive are tar and zip.")
176                     .hint(format!("Try inserting 'tar.' or 'zip.' before '{first_format}'."))
177                     .hint(from_hint)
178                     .hint(to_hint);
180                 return Err(error.into());
181             }
183             if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive()) {
184                 let error = FinalError::with_title(format!(
185                     "Cannot compress to '{}'.",
186                     EscapedPathDisplay::new(&output_path)
187                 ))
188                 .detail(format!("Found the format '{format}' in an incorrect position."))
189                 .detail(format!(
190                     "'{format}' can only be used at the start of the file extension."
191                 ))
192                 .hint(format!(
193                     "If you wish to compress multiple files, start the extension with '{format}'."
194                 ))
195                 .hint(format!(
196                     "Otherwise, remove the last '{}' from '{}'.",
197                     format,
198                     EscapedPathDisplay::new(&output_path)
199                 ));
201                 return Err(error.into());
202             }
204             let output_file = match utils::ask_to_create_file(&output_path, question_policy)? {
205                 Some(writer) => writer,
206                 None => return Ok(()),
207             };
209             let compress_result = compress_files(
210                 files,
211                 formats,
212                 output_file,
213                 &output_path,
214                 args.quiet,
215                 question_policy,
216                 file_visibility_policy,
217             );
219             if let Ok(true) = compress_result {
220                 // this is only printed once, so it doesn't result in much text. On the other hand,
221                 // having a final status message is important especially in an accessibility context
222                 // as screen readers may not read a commands exit code, making it hard to reason
223                 // about whether the command succeeded without such a message
224                 info!(accessible, "Successfully compressed '{}'.", to_utf(&output_path));
225             } else {
226                 // If Ok(false) or Err() occurred, delete incomplete file at `output_path`
227                 //
228                 // if deleting fails, print an extra alert message pointing
229                 // out that we left a possibly CORRUPTED file at `output_path`
230                 if utils::remove_file_or_dir(&output_path).is_err() {
231                     eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
232                     eprintln!(
233                         "  Ouch failed to delete the file '{}'.",
234                         EscapedPathDisplay::new(&output_path)
235                     );
236                     eprintln!("  Please delete it manually.");
237                     eprintln!("  This file is corrupted if compression didn't finished.");
239                     if compress_result.is_err() {
240                         eprintln!("  Compression failed for reasons below.");
241                     }
242                 }
243             }
245             compress_result?;
246         }
247         Subcommand::Decompress { files, output_dir } => {
248             let mut output_paths = vec![];
249             let mut formats = vec![];
251             if let Some(format) = args.format {
252                 let format = parse_format(&format)?;
253                 for path in files.iter() {
254                     let file_name = path.file_name().ok_or_else(|| Error::NotFound {
255                         error_title: format!("{} does not have a file name", EscapedPathDisplay::new(path)),
256                     })?;
257                     output_paths.push(file_name.as_ref());
258                     formats.push(format.clone());
259                 }
260             } else {
261                 for path in files.iter() {
262                     let (file_output_path, file_formats) = extension::separate_known_extensions_from_name(path);
263                     output_paths.push(file_output_path);
264                     formats.push(file_formats);
265                 }
266             }
268             if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
269                 return Ok(());
270             }
272             let files_missing_format: Vec<PathBuf> = files
273                 .iter()
274                 .zip(&formats)
275                 .filter(|(_, formats)| formats.is_empty())
276                 .map(|(input_path, _)| PathBuf::from(input_path))
277                 .collect();
279             if let Some(path) = files_missing_format.first() {
280                 let error = FinalError::with_title("Cannot decompress files without extensions")
281                     .detail(format!(
282                         "Files without supported extensions: {}",
283                         pretty_format_list_of_paths(&files_missing_format)
284                     ))
285                     .detail("Decompression formats are detected automatically by the file extension")
286                     .hint("Provide a file with a supported extension:")
287                     .hint("  ouch decompress example.tar.gz")
288                     .hint("")
289                     .hint("Or overwrite this option with the '--format' flag:")
290                     .hint(format!(
291                         "  ouch decompress {} --format tar.gz",
292                         EscapedPathDisplay::new(path),
293                     ));
295                 return Err(error.into());
296             }
298             // The directory that will contain the output files
299             // We default to the current directory if the user didn't specify an output directory with --dir
300             let output_dir = if let Some(dir) = output_dir {
301                 utils::create_dir_if_non_existent(&dir)?;
302                 dir
303             } else {
304                 PathBuf::from(".")
305             };
307             files
308                 .par_iter()
309                 .zip(formats)
310                 .zip(output_paths)
311                 .try_for_each(|((input_path, formats), file_name)| {
312                     let output_file_path = output_dir.join(file_name); // Path used by single file format archives
313                     decompress_file(
314                         input_path,
315                         formats,
316                         &output_dir,
317                         output_file_path,
318                         question_policy,
319                         args.quiet,
320                     )
321                 })?;
322         }
323         Subcommand::List { archives: files, tree } => {
324             let mut formats = vec![];
326             if let Some(format) = args.format {
327                 let format = parse_format(&format)?;
328                 for _ in 0..files.len() {
329                     formats.push(format.clone());
330                 }
331             } else {
332                 for path in files.iter() {
333                     let file_formats = extension::extensions_from_path(path);
334                     formats.push(file_formats);
335                 }
337                 if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
338                     return Ok(());
339                 }
340             }
342             // Ensure we were not told to list the content of a non-archive compressed file
343             check_for_non_archive_formats(&files, &formats)?;
345             let list_options = ListOptions { tree };
347             for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
348                 if i > 0 {
349                     println!();
350                 }
351                 let formats = flatten_compression_formats(&formats);
352                 list_archive_contents(archive_path, formats, list_options, question_policy)?;
353             }
354         }
355     }
356     Ok(())
359 fn check_mime_type(
360     files: &[PathBuf],
361     formats: &mut [Vec<Extension>],
362     question_policy: QuestionPolicy,
363 ) -> crate::Result<ControlFlow<()>> {
364     for (path, format) in files.iter().zip(formats.iter_mut()) {
365         if format.is_empty() {
366             // File with no extension
367             // Try to detect it automatically and prompt the user about it
368             if let Some(detected_format) = try_infer_extension(path) {
369                 // Inferring the file extension can have unpredicted consequences (e.g. the user just
370                 // mistyped, ...) which we should always inform the user about.
371                 info!(
372                     accessible,
373                     "Detected file: `{}` extension as `{}`",
374                     path.display(),
375                     detected_format
376                 );
377                 if user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
378                     format.push(detected_format);
379                 } else {
380                     return Ok(ControlFlow::Break(()));
381                 }
382             }
383         } else if let Some(detected_format) = try_infer_extension(path) {
384             // File ending with extension
385             // Try to detect the extension and warn the user if it differs from the written one
386             let outer_ext = format.iter().next_back().unwrap();
387             if !outer_ext
388                 .compression_formats
389                 .ends_with(detected_format.compression_formats)
390             {
391                 warning!(
392                     "The file extension: `{}` differ from the detected extension: `{}`",
393                     outer_ext,
394                     detected_format
395                 );
396                 if !user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
397                     return Ok(ControlFlow::Break(()));
398                 }
399             }
400         } else {
401             // NOTE: If this actually produces no false positives, we can upgrade it in the future
402             // to a warning and ask the user if he wants to continue decompressing.
403             info!(accessible, "Could not detect the extension of `{}`", path.display());
404         }
405     }
406     Ok(ControlFlow::Continue(()))
409 #[cfg(test)]
410 mod tests {
411     use std::path::Path;
413     use super::build_archive_file_suggestion;
415     #[test]
416     fn builds_suggestion_correctly() {
417         assert_eq!(build_archive_file_suggestion(Path::new("linux.png"), ".tar"), None);
418         assert_eq!(
419             build_archive_file_suggestion(Path::new("linux.xz.gz.zst"), ".tar").unwrap(),
420             "linux.tar.xz.gz.zst"
421         );
422         assert_eq!(
423             build_archive_file_suggestion(Path::new("linux.pkg.xz.gz.zst"), ".tar").unwrap(),
424             "linux.pkg.tar.xz.gz.zst"
425         );
426         assert_eq!(
427             build_archive_file_suggestion(Path::new("linux.pkg.zst"), ".tar").unwrap(),
428             "linux.pkg.tar.zst"
429         );
430         assert_eq!(
431             build_archive_file_suggestion(Path::new("linux.pkg.info.zst"), ".tar").unwrap(),
432             "linux.pkg.info.tar.zst"
433         );
434     }