Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refac + Improve extension parsing #519

Merged
merged 4 commits into from
Sep 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Categories Used:

- Fix logging IO bottleneck [\#642](https://github.com/ouch-org/ouch/pull/642) ([AntoniosBarotsis](https://github.com/AntoniosBarotsis))
- Support decompression over stdin [\#692](https://github.com/ouch-org/ouch/pull/692) ([rcorre](https://github.com/rcorre))
- Make `--format` more forgiving with the formatting of the provided format [\#519](https://github.com/ouch-org/ouch/pull/519) ([marcospb19](https://github.com/marcospb19))

## [0.5.1](https://github.com/ouch-org/ouch/compare/0.5.0...0.5.1)

Expand Down
8 changes: 3 additions & 5 deletions src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{

use crate::{
error::FinalError,
extension::{build_archive_file_suggestion, Extension, PRETTY_SUPPORTED_ALIASES, PRETTY_SUPPORTED_EXTENSIONS},
extension::{build_archive_file_suggestion, Extension},
utils::{
logger::{info_accessible, warning},
pretty_format_list_of_paths, try_infer_extension, user_wants_to_continue, EscapedPathDisplay,
Expand Down Expand Up @@ -160,10 +160,8 @@ pub fn check_missing_formats_when_decompressing(files: &[PathBuf], formats: &[Ve
));
}

error = error
.detail("Decompression formats are detected automatically from file extension")
.hint(format!("Supported extensions are: {}", PRETTY_SUPPORTED_EXTENSIONS))
.hint(format!("Supported aliases are: {}", PRETTY_SUPPORTED_ALIASES));
error = error.detail("Decompression formats are detected automatically from file extension");
error = error.hint_all_supported_formats();

// If there's exactly one file, give a suggestion to use `--format`
if let &[path] = files_with_broken_extension.as_slice() {
Expand Down
12 changes: 6 additions & 6 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ use crate::{
cli::Subcommand,
commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents},
error::{Error, FinalError},
extension::{self, parse_format},
extension::{self, parse_format_flag},
list::ListOptions,
utils::{
self, colors::*, is_path_stdin, logger::info_accessible, to_utf, EscapedPathDisplay, FileVisibilityPolicy,
self, colors::*, is_path_stdin, logger::info_accessible, path_to_str, EscapedPathDisplay, FileVisibilityPolicy,
},
CliArgs, QuestionPolicy,
};
Expand Down Expand Up @@ -68,7 +68,7 @@ pub fn run(
// Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
let (formats_from_flag, formats) = match args.format {
Some(formats) => {
let parsed_formats = parse_format(&formats)?;
let parsed_formats = parse_format_flag(&formats)?;
(Some(formats), parsed_formats)
}
None => (None, extension::extensions_from_path(&output_path)),
Expand Down Expand Up @@ -111,7 +111,7 @@ pub fn run(
// having a final status message is important especially in an accessibility context
// as screen readers may not read a commands exit code, making it hard to reason
// about whether the command succeeded without such a message
info_accessible(format!("Successfully compressed '{}'.", to_utf(&output_path)));
info_accessible(format!("Successfully compressed '{}'.", path_to_str(&output_path)));
} else {
// If Ok(false) or Err() occurred, delete incomplete file at `output_path`
//
Expand Down Expand Up @@ -139,7 +139,7 @@ pub fn run(
let mut formats = vec![];

if let Some(format) = args.format {
let format = parse_format(&format)?;
let format = parse_format_flag(&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)),
Expand Down Expand Up @@ -199,7 +199,7 @@ pub fn run(
let mut formats = vec![];

if let Some(format) = args.format {
let format = parse_format(&format)?;
let format = parse_format_flag(&format)?;
for _ in 0..files.len() {
formats.push(format.clone());
}
Expand Down
96 changes: 61 additions & 35 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@

use std::{
borrow::Cow,
ffi::OsString,
fmt::{self, Display},
io,
};

use crate::{accessible::is_running_in_accessible_mode, utils::colors::*};
use crate::{
accessible::is_running_in_accessible_mode,
extension::{PRETTY_SUPPORTED_ALIASES, PRETTY_SUPPORTED_EXTENSIONS},
utils::os_str_to_str,
};

/// All errors that can be generated by `ouch`
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum Error {
/// Not every IoError, some of them get filtered by `From<io::Error>` into other variants
/// An IoError that doesn't have a dedicated error variant
IoError { reason: String },
/// From lzzzz::lz4f::Error
Lz4Error { reason: String },
Expand All @@ -33,9 +39,9 @@ pub enum Error {
/// Custom and unique errors are reported in this variant
Custom { reason: FinalError },
/// Invalid format passed to `--format`
InvalidFormat { reason: String },
InvalidFormatFlag { text: OsString, reason: String },
/// From sevenz_rust::Error
SevenzipError(sevenz_rust::Error),
SevenzipError { reason: String },
/// Recognised but unsupported format
// currently only RAR when built without the `unrar` feature
UnsupportedFormat { reason: String },
Expand All @@ -62,6 +68,8 @@ pub struct FinalError {

impl Display for FinalError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use crate::utils::colors::*;

// Title
//
// When in ACCESSIBLE mode, the square brackets are suppressed
Expand Down Expand Up @@ -122,56 +130,72 @@ impl FinalError {
self.hints.push(hint.into());
self
}

/// Adds all supported formats as hints.
///
/// This is what it looks like:
/// ```
/// hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst
/// hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst
/// ```
pub fn hint_all_supported_formats(self) -> Self {
self.hint(format!("Supported extensions are: {}", PRETTY_SUPPORTED_EXTENSIONS))
.hint(format!("Supported aliases are: {}", PRETTY_SUPPORTED_ALIASES))
}
}

impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let err = match self {
Error::WalkdirError { reason } => FinalError::with_title(reason.to_string()),
Error::NotFound { error_title } => FinalError::with_title(error_title.to_string()).detail("File not found"),
impl From<Error> for FinalError {
fn from(err: Error) -> Self {
match err {
Error::WalkdirError { reason } => FinalError::with_title(reason),
Error::NotFound { error_title } => FinalError::with_title(error_title).detail("File not found"),
Error::CompressingRootFolder => {
FinalError::with_title("It seems you're trying to compress the root folder.")
.detail("This is unadvisable since ouch does compressions in-memory.")
.hint("Use a more appropriate tool for this, such as rsync.")
}
Error::IoError { reason } => FinalError::with_title(reason.to_string()),
Error::Lz4Error { reason } => FinalError::with_title(reason.to_string()),
Error::AlreadyExists { error_title } => {
FinalError::with_title(error_title.to_string()).detail("File already exists")
}
Error::InvalidZipArchive(reason) => FinalError::with_title("Invalid zip archive").detail(*reason),
Error::PermissionDenied { error_title } => {
FinalError::with_title(error_title.to_string()).detail("Permission denied")
Error::IoError { reason } => FinalError::with_title(reason),
Error::Lz4Error { reason } => FinalError::with_title(reason),
Error::AlreadyExists { error_title } => FinalError::with_title(error_title).detail("File already exists"),
Error::InvalidZipArchive(reason) => FinalError::with_title("Invalid zip archive").detail(reason),
Error::PermissionDenied { error_title } => FinalError::with_title(error_title).detail("Permission denied"),
Error::UnsupportedZipArchive(reason) => FinalError::with_title("Unsupported zip archive").detail(reason),
Error::InvalidFormatFlag { reason, text } => {
FinalError::with_title(format!("Failed to parse `--format {}`", os_str_to_str(&text)))
.detail(reason)
.hint_all_supported_formats()
.hint("")
.hint("Examples:")
.hint(" --format tar")
.hint(" --format gz")
.hint(" --format tar.gz")
}
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(),
Error::SevenzipError(reason) => FinalError::with_title("7z error").detail(reason.to_string()),
Error::SevenzipError { reason } => FinalError::with_title("7z error").detail(reason),
Error::UnsupportedFormat { reason } => {
FinalError::with_title("Recognised but unsupported format").detail(reason.clone())
}
Error::InvalidPassword { reason } => FinalError::with_title("Invalid password").detail(reason.clone()),
};
}
}
}

impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let err = FinalError::from(self.clone());
write!(f, "{err}")
}
}

impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
let error_title = err.to_string();

match err.kind() {
std::io::ErrorKind::NotFound => Self::NotFound {
error_title: err.to_string(),
},
std::io::ErrorKind::PermissionDenied => Self::PermissionDenied {
error_title: err.to_string(),
},
std::io::ErrorKind::AlreadyExists => Self::AlreadyExists {
error_title: err.to_string(),
},
_other => Self::IoError {
reason: err.to_string(),
},
io::ErrorKind::NotFound => Self::NotFound { error_title },
io::ErrorKind::PermissionDenied => Self::PermissionDenied { error_title },
io::ErrorKind::AlreadyExists => Self::AlreadyExists { error_title },
_other => Self::IoError { reason: error_title },
}
}
}
Expand Down Expand Up @@ -201,7 +225,9 @@ impl From<unrar::error::UnrarError> for Error {

impl From<sevenz_rust::Error> for Error {
fn from(err: sevenz_rust::Error) -> Self {
Self::SevenzipError(err)
Self::SevenzipError {
reason: err.to_string(),
}
}
}

Expand Down
111 changes: 93 additions & 18 deletions src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
use std::{ffi::OsStr, fmt, path::Path};

use bstr::ByteSlice;
use CompressionFormat::*;

use self::CompressionFormat::*;
use crate::{error::Error, utils::logger::warning};

pub const SUPPORTED_EXTENSIONS: &[&str] = &[
Expand Down Expand Up @@ -33,7 +33,11 @@ pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, gz, lz4, xz, l
pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst";

/// A wrapper around `CompressionFormat` that allows combinations like `tgz`
#[derive(Debug, Clone, Eq)]
#[derive(Debug, Clone)]
// Keep `PartialEq` only for testing because two formats are the same even if
// their `display_text` does not match (beware of aliases)
#[cfg_attr(test, derive(PartialEq))]
// Should only be built with constructors
#[non_exhaustive]
pub struct Extension {
/// One extension like "tgz" can be made of multiple CompressionFormats ([Tar, Gz])
Expand All @@ -42,13 +46,6 @@ pub struct Extension {
display_text: String,
}

// The display_text should be ignored when comparing extensions
impl PartialEq for Extension {
fn eq(&self, other: &Self) -> bool {
self.compression_formats == other.compression_formats
}
}

impl Extension {
/// # Panics:
/// Will panic if `formats` is empty
Expand Down Expand Up @@ -150,17 +147,30 @@ fn split_extension(name: &mut &[u8]) -> Option<Extension> {
Some(ext)
}

pub fn parse_format(fmt: &OsStr) -> crate::Result<Vec<Extension>> {
let fmt = <[u8] as ByteSlice>::from_os_str(fmt).ok_or_else(|| Error::InvalidFormat {
reason: "Invalid UTF-8".into(),
pub fn parse_format_flag(input: &OsStr) -> crate::Result<Vec<Extension>> {
marcospb19 marked this conversation as resolved.
Show resolved Hide resolved
let format = input.as_encoded_bytes();

let format = std::str::from_utf8(format).map_err(|_| Error::InvalidFormatFlag {
text: input.to_owned(),
reason: "Invalid UTF-8.".to_string(),
})?;

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);
let extensions: Vec<Extension> = format
.split('.')
.filter(|extension| !extension.is_empty())
.map(|extension| {
to_extension(extension.as_bytes()).ok_or_else(|| Error::InvalidFormatFlag {
text: input.to_owned(),
reason: format!("Unsupported extension '{}'", extension),
})
})
.collect::<crate::Result<_>>()?;

if extensions.is_empty() {
return Err(Error::InvalidFormatFlag {
text: input.to_owned(),
reason: "Parsing got an empty list of extensions.".to_string(),
});
marcospb19 marked this conversation as resolved.
Show resolved Hide resolved
}

Ok(extensions)
Expand Down Expand Up @@ -251,6 +261,7 @@ pub fn build_archive_file_suggestion(path: &Path, suggested_extension: &str) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::logger::spawn_logger_thread;

#[test]
fn test_extensions_from_path() {
Expand All @@ -262,6 +273,70 @@ mod tests {
assert_eq!(formats, vec![Tar, Gzip]);
}

#[test]
/// Test extension parsing for input/output files
fn test_separate_known_extensions_from_name() {
marcospb19 marked this conversation as resolved.
Show resolved Hide resolved
let _handler = spawn_logger_thread();
assert_eq!(
separate_known_extensions_from_name("file".as_ref()),
("file".as_ref(), vec![])
);
assert_eq!(
separate_known_extensions_from_name("tar".as_ref()),
("tar".as_ref(), vec![])
);
assert_eq!(
separate_known_extensions_from_name(".tar".as_ref()),
(".tar".as_ref(), vec![])
);
assert_eq!(
separate_known_extensions_from_name("file.tar".as_ref()),
("file".as_ref(), vec![Extension::new(&[Tar], "tar")])
);
assert_eq!(
separate_known_extensions_from_name("file.tar.gz".as_ref()),
(
"file".as_ref(),
vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
)
);
assert_eq!(
separate_known_extensions_from_name(".tar.gz".as_ref()),
(".tar".as_ref(), vec![Extension::new(&[Gzip], "gz")])
);
}

#[test]
/// Test extension parsing of `--format FORMAT`
fn test_parse_of_format_flag() {
marcospb19 marked this conversation as resolved.
Show resolved Hide resolved
assert_eq!(
parse_format_flag(OsStr::new("tar")).unwrap(),
vec![Extension::new(&[Tar], "tar")]
);
assert_eq!(
parse_format_flag(OsStr::new(".tar")).unwrap(),
vec![Extension::new(&[Tar], "tar")]
);
assert_eq!(
parse_format_flag(OsStr::new("tar.gz")).unwrap(),
vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
);
assert_eq!(
parse_format_flag(OsStr::new(".tar.gz")).unwrap(),
vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
);
assert_eq!(
parse_format_flag(OsStr::new("..tar..gz.....")).unwrap(),
vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")]
);

assert!(parse_format_flag(OsStr::new("../tar.gz")).is_err());
assert!(parse_format_flag(OsStr::new("targz")).is_err());
assert!(parse_format_flag(OsStr::new("tar.gz.unknown")).is_err());
assert!(parse_format_flag(OsStr::new(".tar.gz.unknown")).is_err());
assert!(parse_format_flag(OsStr::new(".tar.!@#.gz")).is_err());
}

#[test]
fn builds_suggestion_correctly() {
assert_eq!(build_archive_file_suggestion(Path::new("linux.png"), ".tar"), None);
Expand Down
Loading
Loading