`--help`: add `.sz` to list of supported formats
[ouch.git] / src / commands / mod.rs
blobddaf9710f7f56c17dd846a54c7c7fd66e0ce6fc2
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::{ops::ControlFlow, path::PathBuf};
9 use rayon::prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
10 use utils::colors;
12 use crate::{
13     check::{check_archive_formats_position, check_for_non_archive_formats, check_mime_type},
14     cli::Subcommand,
15     commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents},
16     error::{Error, FinalError},
17     extension::{self, build_archive_file_suggestion, parse_format},
18     info,
19     list::ListOptions,
20     utils::{self, pretty_format_list_of_paths, to_utf, EscapedPathDisplay, FileVisibilityPolicy},
21     warning, CliArgs, QuestionPolicy,
24 /// Warn the user that (de)compressing this .zip archive might freeze their system.
25 fn warn_user_about_loading_zip_in_memory() {
26     const ZIP_IN_MEMORY_LIMITATION_WARNING: &str = "\n\
27         \tThe format '.zip' is limited and cannot be (de)compressed using encoding streams.\n\
28         \tWhen using '.zip' with other formats, (de)compression must be done in-memory\n\
29         \tCareful, you might run out of RAM if the archive is too large!";
31     warning!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
34 /// This function checks what command needs to be run and performs A LOT of ahead-of-time checks
35 /// to assume everything is OK.
36 ///
37 /// There are a lot of custom errors to give enough error description and explanation.
38 pub fn run(
39     args: CliArgs,
40     question_policy: QuestionPolicy,
41     file_visibility_policy: FileVisibilityPolicy,
42 ) -> crate::Result<()> {
43     match args.cmd {
44         Subcommand::Compress {
45             files,
46             output: output_path,
47         } => {
48             // After cleaning, if there are no input files left, exit
49             if files.is_empty() {
50                 return Err(FinalError::with_title("No files to compress").into());
51             }
53             // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
54             let (formats_from_flag, formats) = match args.format {
55                 Some(formats) => {
56                     let parsed_formats = parse_format(&formats)?;
57                     (Some(formats), parsed_formats)
58                 }
59                 None => (None, extension::extensions_from_path(&output_path)),
60             };
62             let first_format = formats.first().ok_or_else(|| {
63                 let output_path = EscapedPathDisplay::new(&output_path);
64                 FinalError::with_title(format!("Cannot compress to '{output_path}'."))
65                     .detail("You shall supply the compression format")
66                     .hint("Try adding supported extensions (see --help):")
67                     .hint(format!("  ouch compress <FILES>... {output_path}.tar.gz"))
68                     .hint(format!("  ouch compress <FILES>... {output_path}.zip"))
69                     .hint("")
70                     .hint("Alternatively, you can overwrite this option by using the '--format' flag:")
71                     .hint(format!("  ouch compress <FILES>... {output_path} --format tar.gz"))
72             })?;
74             let is_some_input_a_folder = files.iter().any(|path| path.is_dir());
75             let is_multiple_inputs = files.len() > 1;
77             // If first format is not archive, can't compress folder, or multiple files
78             // Index safety: empty formats should be checked above.
79             if !first_format.is_archive() && (is_some_input_a_folder || is_multiple_inputs) {
80                 let first_detail_message = if is_multiple_inputs {
81                     "You are trying to compress multiple files."
82                 } else {
83                     "You are trying to compress a folder."
84                 };
86                 let (from_hint, to_hint) = if let Some(formats) = formats_from_flag {
87                     let formats = formats.to_string_lossy();
88                     (
89                         format!("From: --format {formats}"),
90                         format!("To:   --format tar.{formats}"),
91                     )
92                 } else {
93                     // This piece of code creates a suggestion for compressing multiple files
94                     // It says:
95                     // Change from file.bz.xz
96                     // To          file.tar.bz.xz
97                     let suggested_output_path = build_archive_file_suggestion(&output_path, ".tar")
98                         .expect("output path should contain a compression format");
100                     (
101                         format!("From: {}", EscapedPathDisplay::new(&output_path)),
102                         format!("To:   {suggested_output_path}"),
103                     )
104                 };
105                 let output_path = EscapedPathDisplay::new(&output_path);
107                 let error = FinalError::with_title(format!("Cannot compress to '{output_path}'."))
108                     .detail(first_detail_message)
109                     .detail(format!(
110                         "The compression format '{first_format}' does not accept multiple files.",
111                     ))
112                     .detail("Formats that bundle files into an archive are tar and zip.")
113                     .hint(format!("Try inserting 'tar.' or 'zip.' before '{first_format}'."))
114                     .hint(from_hint)
115                     .hint(to_hint);
117                 return Err(error.into());
118             }
120             check_archive_formats_position(&formats, &output_path)?;
122             let output_file = match utils::ask_to_create_file(&output_path, question_policy)? {
123                 Some(writer) => writer,
124                 None => return Ok(()),
125             };
127             let compress_result = compress_files(
128                 files,
129                 formats,
130                 output_file,
131                 &output_path,
132                 args.quiet,
133                 question_policy,
134                 file_visibility_policy,
135             );
137             if let Ok(true) = compress_result {
138                 // this is only printed once, so it doesn't result in much text. On the other hand,
139                 // having a final status message is important especially in an accessibility context
140                 // as screen readers may not read a commands exit code, making it hard to reason
141                 // about whether the command succeeded without such a message
142                 info!(accessible, "Successfully compressed '{}'.", to_utf(&output_path));
143             } else {
144                 // If Ok(false) or Err() occurred, delete incomplete file at `output_path`
145                 //
146                 // if deleting fails, print an extra alert message pointing
147                 // out that we left a possibly CORRUPTED file at `output_path`
148                 if utils::remove_file_or_dir(&output_path).is_err() {
149                     eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
150                     eprintln!(
151                         "  Ouch failed to delete the file '{}'.",
152                         EscapedPathDisplay::new(&output_path)
153                     );
154                     eprintln!("  Please delete it manually.");
155                     eprintln!("  This file is corrupted if compression didn't finished.");
157                     if compress_result.is_err() {
158                         eprintln!("  Compression failed for reasons below.");
159                     }
160                 }
161             }
163             compress_result?;
164         }
165         Subcommand::Decompress { files, output_dir } => {
166             let mut output_paths = vec![];
167             let mut formats = vec![];
169             if let Some(format) = args.format {
170                 let format = parse_format(&format)?;
171                 for path in files.iter() {
172                     let file_name = path.file_name().ok_or_else(|| Error::NotFound {
173                         error_title: format!("{} does not have a file name", EscapedPathDisplay::new(path)),
174                     })?;
175                     output_paths.push(file_name.as_ref());
176                     formats.push(format.clone());
177                 }
178             } else {
179                 for path in files.iter() {
180                     let (file_output_path, file_formats) = extension::separate_known_extensions_from_name(path);
181                     output_paths.push(file_output_path);
182                     formats.push(file_formats);
183                 }
184             }
186             if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
187                 return Ok(());
188             }
190             let files_missing_format: Vec<PathBuf> = files
191                 .iter()
192                 .zip(&formats)
193                 .filter(|(_, formats)| formats.is_empty())
194                 .map(|(input_path, _)| PathBuf::from(input_path))
195                 .collect();
197             if let Some(path) = files_missing_format.first() {
198                 let error = FinalError::with_title("Cannot decompress files without extensions")
199                     .detail(format!(
200                         "Files without supported extensions: {}",
201                         pretty_format_list_of_paths(&files_missing_format)
202                     ))
203                     .detail("Decompression formats are detected automatically by the file extension")
204                     .hint("Provide a file with a supported extension:")
205                     .hint("  ouch decompress example.tar.gz")
206                     .hint("")
207                     .hint("Or overwrite this option with the '--format' flag:")
208                     .hint(format!(
209                         "  ouch decompress {} --format tar.gz",
210                         EscapedPathDisplay::new(path),
211                     ));
213                 return Err(error.into());
214             }
216             // The directory that will contain the output files
217             // We default to the current directory if the user didn't specify an output directory with --dir
218             let output_dir = if let Some(dir) = output_dir {
219                 utils::create_dir_if_non_existent(&dir)?;
220                 dir
221             } else {
222                 PathBuf::from(".")
223             };
225             files
226                 .par_iter()
227                 .zip(formats)
228                 .zip(output_paths)
229                 .try_for_each(|((input_path, formats), file_name)| {
230                     let output_file_path = output_dir.join(file_name); // Path used by single file format archives
231                     decompress_file(
232                         input_path,
233                         formats,
234                         &output_dir,
235                         output_file_path,
236                         question_policy,
237                         args.quiet,
238                     )
239                 })?;
240         }
241         Subcommand::List { archives: files, tree } => {
242             let mut formats = vec![];
244             if let Some(format) = args.format {
245                 let format = parse_format(&format)?;
246                 for _ in 0..files.len() {
247                     formats.push(format.clone());
248                 }
249             } else {
250                 for path in files.iter() {
251                     let file_formats = extension::extensions_from_path(path);
252                     formats.push(file_formats);
253                 }
255                 if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
256                     return Ok(());
257                 }
258             }
260             // Ensure we were not told to list the content of a non-archive compressed file
261             check_for_non_archive_formats(&files, &formats)?;
263             let list_options = ListOptions { tree };
265             for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
266                 if i > 0 {
267                     println!();
268                 }
269                 let formats = extension::flatten_compression_formats(&formats);
270                 list_archive_contents(archive_path, formats, list_options, question_policy)?;
271             }
272         }
273     }
274     Ok(())