move `build_archive_file_suggestion` to `extension.rs`
[ouch.git] / src / commands / mod.rs
bloba97e0c0fabc655644d23bcf143826d5e72195b0e
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_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, Extension},
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 /// In the context of listing archives, this function checks if `ouch` was told to list
35 /// the contents of a compressed file that is not an archive
36 fn check_for_non_archive_formats(files: &[PathBuf], formats: &[Vec<Extension>]) -> crate::Result<()> {
37     let mut not_archives = files
38         .iter()
39         .zip(formats)
40         .filter(|(_, formats)| !formats.first().map(Extension::is_archive).unwrap_or(false))
41         .map(|(path, _)| path)
42         .peekable();
44     if not_archives.peek().is_some() {
45         let not_archives: Vec<_> = not_archives.collect();
46         let error = FinalError::with_title("Cannot list archive contents")
47             .detail("Only archives can have their contents listed")
48             .detail(format!(
49                 "Files are not archives: {}",
50                 pretty_format_list_of_paths(&not_archives)
51             ));
53         return Err(error.into());
54     }
56     Ok(())
59 /// This function checks what command needs to be run and performs A LOT of ahead-of-time checks
60 /// to assume everything is OK.
61 ///
62 /// There are a lot of custom errors to give enough error description and explanation.
63 pub fn run(
64     args: CliArgs,
65     question_policy: QuestionPolicy,
66     file_visibility_policy: FileVisibilityPolicy,
67 ) -> crate::Result<()> {
68     match args.cmd {
69         Subcommand::Compress {
70             files,
71             output: output_path,
72         } => {
73             // After cleaning, if there are no input files left, exit
74             if files.is_empty() {
75                 return Err(FinalError::with_title("No files to compress").into());
76             }
78             // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
79             let (formats_from_flag, formats) = match args.format {
80                 Some(formats) => {
81                     let parsed_formats = parse_format(&formats)?;
82                     (Some(formats), parsed_formats)
83                 }
84                 None => (None, extension::extensions_from_path(&output_path)),
85             };
87             let first_format = formats.first().ok_or_else(|| {
88                 let output_path = EscapedPathDisplay::new(&output_path);
89                 FinalError::with_title(format!("Cannot compress to '{output_path}'."))
90                     .detail("You shall supply the compression format")
91                     .hint("Try adding supported extensions (see --help):")
92                     .hint(format!("  ouch compress <FILES>... {output_path}.tar.gz"))
93                     .hint(format!("  ouch compress <FILES>... {output_path}.zip"))
94                     .hint("")
95                     .hint("Alternatively, you can overwrite this option by using the '--format' flag:")
96                     .hint(format!("  ouch compress <FILES>... {output_path} --format tar.gz"))
97             })?;
99             let is_some_input_a_folder = files.iter().any(|path| path.is_dir());
100             let is_multiple_inputs = files.len() > 1;
102             // If first format is not archive, can't compress folder, or multiple files
103             // Index safety: empty formats should be checked above.
104             if !first_format.is_archive() && (is_some_input_a_folder || is_multiple_inputs) {
105                 let first_detail_message = if is_multiple_inputs {
106                     "You are trying to compress multiple files."
107                 } else {
108                     "You are trying to compress a folder."
109                 };
111                 let (from_hint, to_hint) = if let Some(formats) = formats_from_flag {
112                     let formats = formats.to_string_lossy();
113                     (
114                         format!("From: --format {formats}"),
115                         format!("To:   --format tar.{formats}"),
116                     )
117                 } else {
118                     // This piece of code creates a suggestion for compressing multiple files
119                     // It says:
120                     // Change from file.bz.xz
121                     // To          file.tar.bz.xz
122                     let suggested_output_path = build_archive_file_suggestion(&output_path, ".tar")
123                         .expect("output path should contain a compression format");
125                     (
126                         format!("From: {}", EscapedPathDisplay::new(&output_path)),
127                         format!("To:   {suggested_output_path}"),
128                     )
129                 };
130                 let output_path = EscapedPathDisplay::new(&output_path);
132                 let error = FinalError::with_title(format!("Cannot compress to '{output_path}'."))
133                     .detail(first_detail_message)
134                     .detail(format!(
135                         "The compression format '{first_format}' does not accept multiple files.",
136                     ))
137                     .detail("Formats that bundle files into an archive are tar and zip.")
138                     .hint(format!("Try inserting 'tar.' or 'zip.' before '{first_format}'."))
139                     .hint(from_hint)
140                     .hint(to_hint);
142                 return Err(error.into());
143             }
145             if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive()) {
146                 let error = FinalError::with_title(format!(
147                     "Cannot compress to '{}'.",
148                     EscapedPathDisplay::new(&output_path)
149                 ))
150                 .detail(format!("Found the format '{format}' in an incorrect position."))
151                 .detail(format!(
152                     "'{format}' can only be used at the start of the file extension."
153                 ))
154                 .hint(format!(
155                     "If you wish to compress multiple files, start the extension with '{format}'."
156                 ))
157                 .hint(format!(
158                     "Otherwise, remove the last '{}' from '{}'.",
159                     format,
160                     EscapedPathDisplay::new(&output_path)
161                 ));
163                 return Err(error.into());
164             }
166             let output_file = match utils::ask_to_create_file(&output_path, question_policy)? {
167                 Some(writer) => writer,
168                 None => return Ok(()),
169             };
171             let compress_result = compress_files(
172                 files,
173                 formats,
174                 output_file,
175                 &output_path,
176                 args.quiet,
177                 question_policy,
178                 file_visibility_policy,
179             );
181             if let Ok(true) = compress_result {
182                 // this is only printed once, so it doesn't result in much text. On the other hand,
183                 // having a final status message is important especially in an accessibility context
184                 // as screen readers may not read a commands exit code, making it hard to reason
185                 // about whether the command succeeded without such a message
186                 info!(accessible, "Successfully compressed '{}'.", to_utf(&output_path));
187             } else {
188                 // If Ok(false) or Err() occurred, delete incomplete file at `output_path`
189                 //
190                 // if deleting fails, print an extra alert message pointing
191                 // out that we left a possibly CORRUPTED file at `output_path`
192                 if utils::remove_file_or_dir(&output_path).is_err() {
193                     eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
194                     eprintln!(
195                         "  Ouch failed to delete the file '{}'.",
196                         EscapedPathDisplay::new(&output_path)
197                     );
198                     eprintln!("  Please delete it manually.");
199                     eprintln!("  This file is corrupted if compression didn't finished.");
201                     if compress_result.is_err() {
202                         eprintln!("  Compression failed for reasons below.");
203                     }
204                 }
205             }
207             compress_result?;
208         }
209         Subcommand::Decompress { files, output_dir } => {
210             let mut output_paths = vec![];
211             let mut formats = vec![];
213             if let Some(format) = args.format {
214                 let format = parse_format(&format)?;
215                 for path in files.iter() {
216                     let file_name = path.file_name().ok_or_else(|| Error::NotFound {
217                         error_title: format!("{} does not have a file name", EscapedPathDisplay::new(path)),
218                     })?;
219                     output_paths.push(file_name.as_ref());
220                     formats.push(format.clone());
221                 }
222             } else {
223                 for path in files.iter() {
224                     let (file_output_path, file_formats) = extension::separate_known_extensions_from_name(path);
225                     output_paths.push(file_output_path);
226                     formats.push(file_formats);
227                 }
228             }
230             if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
231                 return Ok(());
232             }
234             let files_missing_format: Vec<PathBuf> = files
235                 .iter()
236                 .zip(&formats)
237                 .filter(|(_, formats)| formats.is_empty())
238                 .map(|(input_path, _)| PathBuf::from(input_path))
239                 .collect();
241             if let Some(path) = files_missing_format.first() {
242                 let error = FinalError::with_title("Cannot decompress files without extensions")
243                     .detail(format!(
244                         "Files without supported extensions: {}",
245                         pretty_format_list_of_paths(&files_missing_format)
246                     ))
247                     .detail("Decompression formats are detected automatically by the file extension")
248                     .hint("Provide a file with a supported extension:")
249                     .hint("  ouch decompress example.tar.gz")
250                     .hint("")
251                     .hint("Or overwrite this option with the '--format' flag:")
252                     .hint(format!(
253                         "  ouch decompress {} --format tar.gz",
254                         EscapedPathDisplay::new(path),
255                     ));
257                 return Err(error.into());
258             }
260             // The directory that will contain the output files
261             // We default to the current directory if the user didn't specify an output directory with --dir
262             let output_dir = if let Some(dir) = output_dir {
263                 utils::create_dir_if_non_existent(&dir)?;
264                 dir
265             } else {
266                 PathBuf::from(".")
267             };
269             files
270                 .par_iter()
271                 .zip(formats)
272                 .zip(output_paths)
273                 .try_for_each(|((input_path, formats), file_name)| {
274                     let output_file_path = output_dir.join(file_name); // Path used by single file format archives
275                     decompress_file(
276                         input_path,
277                         formats,
278                         &output_dir,
279                         output_file_path,
280                         question_policy,
281                         args.quiet,
282                     )
283                 })?;
284         }
285         Subcommand::List { archives: files, tree } => {
286             let mut formats = vec![];
288             if let Some(format) = args.format {
289                 let format = parse_format(&format)?;
290                 for _ in 0..files.len() {
291                     formats.push(format.clone());
292                 }
293             } else {
294                 for path in files.iter() {
295                     let file_formats = extension::extensions_from_path(path);
296                     formats.push(file_formats);
297                 }
299                 if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
300                     return Ok(());
301                 }
302             }
304             // Ensure we were not told to list the content of a non-archive compressed file
305             check_for_non_archive_formats(&files, &formats)?;
307             let list_options = ListOptions { tree };
309             for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
310                 if i > 0 {
311                     println!();
312                 }
313                 let formats = extension::flatten_compression_formats(&formats);
314                 list_archive_contents(archive_path, formats, list_options, question_policy)?;
315             }
316         }
317     }
318     Ok(())