diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f097733..e2c6954 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,8 +14,8 @@ use utils::colors; use crate::{ commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents}, - error::FinalError, - extension::{self, flatten_compression_formats, Extension, SUPPORTED_EXTENSIONS}, + error::{Error, FinalError}, + extension::{self, flatten_compression_formats, parse_format, Extension, SUPPORTED_EXTENSIONS}, info, list::ListOptions, utils::{ @@ -114,7 +114,13 @@ pub fn run( } // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma] - let formats = extension::extensions_from_path(&output_path); + let (formats_from_flag, formats) = match args.format { + Some(formats) => { + let parsed_formats = parse_format(&formats)?; + (Some(formats), parsed_formats) + } + None => (None, extension::extensions_from_path(&output_path)), + }; let first_format = formats.first().ok_or_else(|| { let output_path = EscapedPathDisplay::new(&output_path); @@ -134,28 +140,42 @@ pub fn run( // If first format is not archive, can't compress folder, or multiple files // Index safety: empty formats should be checked above. if !first_format.is_archive() && (is_some_input_a_folder || is_multiple_inputs) { - // This piece of code creates a suggestion for compressing multiple files - // It says: - // Change from file.bz.xz - // To file.tar.bz.xz - let suggested_output_path = build_archive_file_suggestion(&output_path, ".tar") - .expect("output path should contain a compression format"); - let output_path = EscapedPathDisplay::new(&output_path); let first_detail_message = if is_multiple_inputs { "You are trying to compress multiple files." } else { "You are trying to compress a folder." }; + let (from_hint, to_hint) = if let Some(formats) = formats_from_flag { + let formats = formats.to_string_lossy(); + ( + format!("From: --format {formats}"), + format!("To: --format tar.{formats}"), + ) + } else { + // This piece of code creates a suggestion for compressing multiple files + // It says: + // Change from file.bz.xz + // To file.tar.bz.xz + let suggested_output_path = build_archive_file_suggestion(&output_path, ".tar") + .expect("output path should contain a compression format"); + + ( + format!("From: {}", EscapedPathDisplay::new(&output_path)), + format!("To: {suggested_output_path}"), + ) + }; + let output_path = EscapedPathDisplay::new(&output_path); + let error = FinalError::with_title(format!("Cannot compress to '{output_path}'.")) .detail(first_detail_message) .detail(format!( "The compression format '{first_format}' does not accept multiple files.", )) - .detail("Formats that bundle files into an archive are .tar and .zip.") - .hint(format!("Try inserting '.tar' or '.zip' before '{first_format}'.")) - .hint(format!("From: {output_path}")) - .hint(format!("To: {suggested_output_path}")); + .detail("Formats that bundle files into an archive are tar and zip.") + .hint(format!("Try inserting 'tar.' or 'zip.' before '{first_format}'.")) + .hint(from_hint) + .hint(to_hint); return Err(error.into()); } @@ -228,10 +248,21 @@ pub fn run( let mut output_paths = vec![]; let mut formats = vec![]; - for path in files.iter() { - let (file_output_path, file_formats) = extension::separate_known_extensions_from_name(path); - output_paths.push(file_output_path); - formats.push(file_formats); + if let Some(format) = args.format { + let format = parse_format(&format)?; + for path in files.iter() { + let file_name = path.file_name().ok_or_else(|| Error::NotFound { + error_title: format!("{} does not have a file name", EscapedPathDisplay::new(path)), + })?; + output_paths.push(file_name.as_ref()); + formats.push(format.clone()); + } + } else { + for path in files.iter() { + let (file_output_path, file_formats) = extension::separate_known_extensions_from_name(path); + output_paths.push(file_output_path); + formats.push(file_formats); + } } if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? { @@ -292,13 +323,20 @@ pub fn run( Subcommand::List { archives: files, tree } => { let mut formats = vec![]; - for path in files.iter() { - let file_formats = extension::extensions_from_path(path); - formats.push(file_formats); - } + if let Some(format) = args.format { + let format = parse_format(&format)?; + for _ in 0..files.len() { + formats.push(format.clone()); + } + } else { + for path in files.iter() { + let file_formats = extension::extensions_from_path(path); + formats.push(file_formats); + } - if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? { - return Ok(()); + if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? { + return Ok(()); + } } // Ensure we were not told to list the content of a non-archive compressed file diff --git a/src/error.rs b/src/error.rs index 1b567d4..8771e68 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,8 @@ pub enum Error { WalkdirError { reason: String }, /// Custom and unique errors are reported in this variant Custom { reason: FinalError }, + /// Invalid format passed to `--format` + InvalidFormat { reason: String }, } /// Alias to std's Result with ouch's Error @@ -135,6 +137,7 @@ impl fmt::Display for Error { FinalError::with_title(error_title.to_string()).detail("Permission denied") } Error::UnsupportedZipArchive(reason) => FinalError::with_title("Unsupported zip archive").detail(*reason), + Error::InvalidFormat { reason } => FinalError::with_title("Invalid archive format").detail(reason.clone()), Error::Custom { reason } => reason.clone(), }; diff --git a/src/extension.rs b/src/extension.rs index 387c4ec..cce76a4 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1,11 +1,11 @@ //! Our representation of all the supported compression formats. -use std::{fmt, path::Path}; +use std::{ffi::OsStr, fmt, path::Path}; use bstr::ByteSlice; use self::CompressionFormat::*; -use crate::warning; +use crate::{error::Error, warning}; /// A wrapper around `CompressionFormat` that allows combinations like `tgz` #[derive(Debug, Clone, Eq)] @@ -138,6 +138,22 @@ pub fn split_extension<'a>(name: &mut &'a [u8]) -> Option<&'a [u8]> { Some(ext) } +pub fn parse_format(fmt: &OsStr) -> crate::Result> { + let fmt = <[u8] as ByteSlice>::from_os_str(fmt).ok_or_else(|| Error::InvalidFormat { + reason: "Invalid UTF-8".into(), + })?; + + let mut extensions = Vec::new(); + for extension in fmt.split_str(b".") { + let extension = to_extension(extension).ok_or_else(|| Error::InvalidFormat { + reason: format!("Unsupported extension: {}", extension.to_str_lossy()), + })?; + extensions.push(extension); + } + + Ok(extensions) +} + /// Extracts extensions from a path. /// /// Returns both the remaining path and the list of extension objects diff --git a/src/opts.rs b/src/opts.rs index 7bca705..87fcb82 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{ffi::OsString, path::PathBuf}; use clap::{Parser, ValueHint}; @@ -37,6 +37,10 @@ pub struct Opts { #[arg(short = 'g', long, global = true)] pub gitignore: bool, + /// Specify the format of the archive + #[arg(short, long, global = true)] + pub format: Option, + /// Ouch and claps subcommands #[command(subcommand)] pub cmd: Subcommand,