Merge pull request #259 from ouch-org/refac/small-adjustments-and-code-quality-improvements

refactoring and making small adjustments to improve code quality
This commit is contained in:
João Marcos Bezerra 2022-06-04 21:59:48 -03:00 committed by GitHub
commit fc532d81d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1128 additions and 880 deletions

View File

@ -2,11 +2,10 @@
max_width = 120
use_field_init_shorthand = true
newline_style = "Unix"
edition = "2018"
edition = "2021"
reorder_imports = true
reorder_modules = true
use_try_shorthand = true
use_small_heuristics = "Max"
# Unstable features (nightly only)
unstable_features = true

16
src/accessible.rs Normal file
View File

@ -0,0 +1,16 @@
use once_cell::sync::OnceCell;
/// Whether to enable accessible output (removes info output and reduces other
/// output, removes visual markers like '[' and ']').
/// Removes th progress bar as well
pub static ACCESSIBLE: OnceCell<bool> = OnceCell::new();
pub fn is_running_in_accessible_mode() -> bool {
ACCESSIBLE.get().copied().unwrap_or(false)
}
pub fn set_accessible(value: bool) {
if ACCESSIBLE.get().is_none() {
ACCESSIBLE.set(value).unwrap();
}
}

View File

@ -16,7 +16,7 @@ use crate::{
info,
list::FileInArchive,
utils::{
self, cd_into_same_dir_as, concatenate_os_str_list, get_invalid_utf8_paths, strip_cur_dir, to_utf, Bytes,
self, cd_into_same_dir_as, get_invalid_utf8_paths, pretty_format_list_of_paths, strip_cur_dir, to_utf, Bytes,
FileVisibilityPolicy,
},
};
@ -45,9 +45,9 @@ where
let file_path = output_folder.join(file_path);
check_for_comments(&file);
display_zip_comment_if_exists(&file);
match (&*file.name()).ends_with('/') {
match file.name().ends_with('/') {
_is_dir @ true => {
// This is printed for every file in the archive and has little
// importance for most users, but would generate lots of
@ -144,7 +144,10 @@ where
if !invalid_unicode_filenames.is_empty() {
let error = FinalError::with_title("Cannot build zip archive")
.detail("Zip archives require files to have valid UTF-8 paths")
.detail(format!("Files with invalid paths: {}", concatenate_os_str_list(&invalid_unicode_filenames)));
.detail(format!(
"Files with invalid paths: {}",
pretty_format_list_of_paths(&invalid_unicode_filenames)
));
return Err(error.into());
}
@ -180,7 +183,7 @@ where
return Err(e.into());
}
};
writer.write_all(&*file_bytes)?;
writer.write_all(&file_bytes)?;
}
}
@ -191,7 +194,7 @@ where
Ok(bytes)
}
fn check_for_comments(file: &ZipFile) {
fn display_zip_comment_if_exists(file: &ZipFile) {
let comment = file.comment();
if !comment.is_empty() {
// Zip file comments seem to be pretty rare, but if they are used,
@ -223,7 +226,10 @@ fn convert_zip_date_time(date_time: zip::DateTime) -> Option<libc::timespec> {
let date_time = PrimitiveDateTime::new(date, time);
let timestamp = date_time.assume_utc().unix_timestamp();
Some(libc::timespec { tv_sec: timestamp, tv_nsec: 0 })
Some(libc::timespec {
tv_sec: timestamp,
tv_nsec: 0,
})
}
#[cfg(unix)]
@ -232,7 +238,10 @@ fn set_last_modified_time(file: &fs::File, zip_file: &ZipFile) -> crate::Result<
use libc::UTIME_NOW;
let now = libc::timespec { tv_sec: 0, tv_nsec: UTIME_NOW };
let now = libc::timespec {
tv_sec: 0,
tv_nsec: UTIME_NOW,
};
let last_modified = zip_file.last_modified();
let last_modified = convert_zip_date_time(last_modified).unwrap_or(now);

View File

@ -8,14 +8,8 @@ use std::{
use clap::Parser;
use fs_err as fs;
use once_cell::sync::OnceCell;
use crate::{utils::FileVisibilityPolicy, Opts, QuestionPolicy, Subcommand};
/// Whether to enable accessible output (removes info output and reduces other
/// output, removes visual markers like '[' and ']').
/// Removes th progress bar as well
pub static ACCESSIBLE: OnceCell<bool> = OnceCell::new();
use crate::{accessible::set_accessible, utils::FileVisibilityPolicy, Opts, QuestionPolicy, Subcommand};
impl Opts {
/// A helper method that calls `clap::Parser::parse`.
@ -26,7 +20,7 @@ impl Opts {
pub fn parse_args() -> crate::Result<(Self, QuestionPolicy, FileVisibilityPolicy)> {
let mut opts = Self::parse();
ACCESSIBLE.set(opts.accessible).unwrap();
set_accessible(opts.accessible);
let (Subcommand::Compress { files, .. }
| Subcommand::Decompress { files, .. }

View File

@ -1,764 +0,0 @@
//! Core of the crate, where the `compress_files` and `decompress_file` functions are implemented
//!
//! Also, where correctly call functions based on the detected `Command`.
use std::{
io::{self, BufReader, BufWriter, Read, Write},
ops::ControlFlow,
path::{Path, PathBuf},
};
use fs_err as fs;
use utils::colors;
use crate::{
archive,
error::FinalError,
extension::{
self,
CompressionFormat::{self, *},
Extension,
},
info,
list::{self, FileInArchive, ListOptions},
progress::Progress,
utils::{
self, concatenate_os_str_list, dir_is_empty, nice_directory_display, to_utf, try_infer_extension,
user_wants_to_continue, FileVisibilityPolicy,
},
warning, Opts, QuestionAction, QuestionPolicy, Subcommand,
};
// Message used to advice the user that .zip archives have limitations that require it to load everything into memory at once
// and this can lead to out-of-memory scenarios for archives that are big enough.
const ZIP_IN_MEMORY_LIMITATION_WARNING: &str =
"\tThere is a limitation for .zip archives with extra extensions. (e.g. <file>.zip.gz)
\tThe design of .zip makes it impossible to compress via stream, so it must be done entirely in memory.
\tBy compressing .zip with extra compression formats, you can run out of RAM if the file is too large!";
// Used in BufReader and BufWriter to perform less syscalls
const BUFFER_CAPACITY: usize = 1024 * 64;
fn represents_several_files(files: &[PathBuf]) -> bool {
let is_non_empty_dir = |path: &PathBuf| {
let is_non_empty = || !dir_is_empty(path);
path.is_dir().then(is_non_empty).unwrap_or_default()
};
files.iter().any(is_non_empty_dir) || files.len() > 1
}
/// Entrypoint of ouch, receives cli options and matches Subcommand to decide what to do
pub fn run(
args: Opts,
question_policy: QuestionPolicy,
file_visibility_policy: FileVisibilityPolicy,
) -> crate::Result<()> {
match args.cmd {
Subcommand::Compress { mut files, output: output_path } => {
// If the output_path file exists and is the same as some of the input files, warn the user and skip those inputs (in order to avoid compression recursion)
if output_path.exists() {
clean_input_files_if_needed(&mut files, &fs::canonicalize(&output_path)?);
}
// After cleaning, if there are no input files left, exit
if files.is_empty() {
return Err(FinalError::with_title("No files to compress").into());
}
// Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
let mut formats = extension::extensions_from_path(&output_path);
if formats.is_empty() {
let error = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path)))
.detail("You shall supply the compression format")
.hint("Try adding supported extensions (see --help):")
.hint(format!(" ouch compress <FILES>... {}.tar.gz", to_utf(&output_path)))
.hint(format!(" ouch compress <FILES>... {}.zip", to_utf(&output_path)))
.hint("")
.hint("Alternatively, you can overwrite this option by using the '--format' flag:")
.hint(format!(" ouch compress <FILES>... {} --format tar.gz", to_utf(&output_path)));
return Err(error.into());
}
if !formats.get(0).map(Extension::is_archive).unwrap_or(false) && represents_several_files(&files) {
// This piece of code creates a suggestion for compressing multiple files
// It says:
// Change from file.bz.xz
// To file.tar.bz.xz
let extensions_text: String = formats.iter().map(|format| format.to_string()).collect();
let output_path = to_utf(&output_path).to_string();
// Breaks if Lzma is .lz or .lzma and not .xz
// Or if Bzip is .bz2 and not .bz
let extensions_start_position = output_path.rfind(&extensions_text).unwrap();
let pos = extensions_start_position - 1;
let mut suggested_output_path = output_path.to_string();
suggested_output_path.insert_str(pos, ".tar");
let error = FinalError::with_title(format!("Cannot compress to '{}'.", output_path))
.detail("You are trying to compress multiple files.")
.detail(format!("The compression format '{}' cannot receive multiple files.", &formats[0]))
.detail("The only supported formats that archive files into an archive are .tar and .zip.")
.hint(format!("Try inserting '.tar' or '.zip' before '{}'.", &formats[0]))
.hint(format!("From: {}", output_path))
.hint(format!("To: {}", suggested_output_path));
return Err(error.into());
}
if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive()) {
let error = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path)))
.detail(format!("Found the format '{}' in an incorrect position.", format))
.detail(format!("'{}' can only be used at the start of the file extension.", format))
.hint(format!("If you wish to compress multiple files, start the extension with '{}'.", format))
.hint(format!("Otherwise, remove the last '{}' from '{}'.", format, to_utf(&output_path)));
return Err(error.into());
}
if output_path.exists() && !utils::user_wants_to_overwrite(&output_path, question_policy)? {
// User does not want to overwrite this file, skip and return without any errors
return Ok(());
}
let output_file = fs::File::create(&output_path)?;
if !represents_several_files(&files) {
// It is possible the file is already partially compressed so we don't want to compress it again
// `ouch compress file.tar.gz file.tar.gz.xz` should produce `file.tar.gz.xz` and not `file.tar.gz.tar.gz.xz`
let input_extensions = extension::extensions_from_path(&files[0]);
// We calculate the formats that are left if we filter out a sublist at the start of what we have that's the same as the input formats
let mut new_formats = Vec::with_capacity(formats.len());
for (inp_ext, out_ext) in input_extensions.iter().zip(&formats) {
if inp_ext.compression_formats == out_ext.compression_formats {
new_formats.push(out_ext.clone());
} else if inp_ext
.compression_formats
.iter()
.zip(out_ext.compression_formats.iter())
.all(|(inp, out)| inp == out)
{
let new_ext = Extension::new(
&out_ext.compression_formats[..inp_ext.compression_formats.len()],
&out_ext.display_text,
);
new_formats.push(new_ext);
break;
}
}
// If the input is a sublist at the start of `formats` then remove the extensions
// Note: If input_extensions is empty then it will make `formats` empty too, which we don't want
if !input_extensions.is_empty() && new_formats != formats {
// Safety:
// We checked above that input_extensions isn't empty, so files[0] has an extension.
//
// Path::extension says: "if there is no file_name, then there is no extension".
// Contrapositive statement: "if there is extension, then there is file_name".
info!(
accessible, // important information
"Partial compression detected. Compressing {} into {}",
to_utf(files[0].as_path().file_name().unwrap().as_ref()),
to_utf(&output_path)
);
formats = new_formats;
}
}
let compress_result =
compress_files(files, formats, output_file, &output_path, question_policy, file_visibility_policy);
if let Ok(true) = compress_result {
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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, "Successfully compressed '{}'.", to_utf(&output_path));
} else {
// If Ok(false) or Err() occurred, delete incomplete file
// Print an extra alert message pointing out that we left a possibly
// CORRUPTED FILE at `output_path`
if let Err(err) = fs::remove_file(&output_path) {
eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
eprintln!(" Please manually delete '{}'.", to_utf(&output_path));
eprintln!(" Compression failed and we could not delete '{}'.", to_utf(&output_path),);
eprintln!(" Error:{reset} {}{red}.{reset}\n", err, reset = *colors::RESET, red = *colors::RED);
}
}
compress_result?;
}
Subcommand::Decompress { files, output_dir } => {
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 ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
return Ok(());
}
let files_missing_format: Vec<PathBuf> = files
.iter()
.zip(&formats)
.filter(|(_, formats)| formats.is_empty())
.map(|(input_path, _)| PathBuf::from(input_path))
.collect();
if !files_missing_format.is_empty() {
let error = FinalError::with_title("Cannot decompress files without extensions")
.detail(format!(
"Files without supported extensions: {}",
concatenate_os_str_list(&files_missing_format)
))
.detail("Decompression formats are detected automatically by the file extension")
.hint("Provide a file with a supported extension:")
.hint(" ouch decompress example.tar.gz")
.hint("")
.hint("Or overwrite this option with the '--format' flag:")
.hint(format!(" ouch decompress {} --format tar.gz", to_utf(&files_missing_format[0])));
return Err(error.into());
}
// The directory that will contain the output files
// We default to the current directory if the user didn't specify an output directory with --dir
let output_dir = if let Some(dir) = output_dir {
if !utils::clear_path(&dir, question_policy)? {
// User doesn't want to overwrite
return Ok(());
}
utils::create_dir_if_non_existent(&dir)?;
dir
} else {
PathBuf::from(".")
};
for ((input_path, formats), file_name) in files.iter().zip(formats).zip(output_paths) {
let output_file_path = output_dir.join(file_name); // Path used by single file format archives
decompress_file(input_path, formats, &output_dir, output_file_path, question_policy)?;
}
}
Subcommand::List { archives: files, tree } => {
let mut formats = vec![];
for path in files.iter() {
let (_, file_formats) = extension::separate_known_extensions_from_name(path);
formats.push(file_formats);
}
if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
return Ok(());
}
let not_archives: Vec<PathBuf> = files
.iter()
.zip(&formats)
.filter(|(_, formats)| !formats.get(0).map(Extension::is_archive).unwrap_or(false))
.map(|(path, _)| path.clone())
.collect();
if !not_archives.is_empty() {
let error = FinalError::with_title("Cannot list archive contents")
.detail("Only archives can have their contents listed")
.detail(format!("Files are not archives: {}", concatenate_os_str_list(&not_archives)));
return Err(error.into());
}
let list_options = ListOptions { tree };
for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
if i > 0 {
println!();
}
let formats = formats.iter().flat_map(Extension::iter).map(Clone::clone).collect();
list_archive_contents(archive_path, formats, list_options, question_policy)?;
}
}
}
Ok(())
}
// Compress files into an `output_file`
//
// files are the list of paths to be compressed: ["dir/file1.txt", "dir/file2.txt"]
// formats contains each format necessary for compression, example: [Tar, Gz] (in compression order)
// output_file is the resulting compressed file name, example: "compressed.tar.gz"
//
// Returns Ok(true) if compressed all files successfully, and Ok(false) if user opted to skip files
fn compress_files(
files: Vec<PathBuf>,
formats: Vec<Extension>,
output_file: fs::File,
output_dir: &Path,
question_policy: QuestionPolicy,
file_visibility_policy: FileVisibilityPolicy,
) -> crate::Result<bool> {
// The next lines are for displaying the progress bar
// If the input files contain a directory, then the total size will be underestimated
let (total_input_size, precise) = files
.iter()
.map(|f| (f.metadata().expect("file exists").len(), f.is_file()))
.fold((0, true), |(total_size, and_precise), (size, precise)| (total_size + size, and_precise & precise));
// NOTE: canonicalize is here to avoid a weird bug:
// > If output_file_path is a nested path and it exists and the user overwrite it
// >> output_file_path.exists() will always return false (somehow)
// - canonicalize seems to fix this
let output_file_path = output_file.path().canonicalize()?;
let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file);
let mut writer: Box<dyn Write> = Box::new(file_writer);
// Grab previous encoder and wrap it inside of a new one
let chain_writer_encoder = |format: &CompressionFormat, encoder: Box<dyn Write>| -> crate::Result<Box<dyn Write>> {
let encoder: Box<dyn Write> = match format {
Gzip => Box::new(flate2::write::GzEncoder::new(encoder, Default::default())),
Bzip => Box::new(bzip2::write::BzEncoder::new(encoder, Default::default())),
Lz4 => Box::new(lzzzz::lz4f::WriteCompressor::new(encoder, Default::default())?),
Lzma => Box::new(xz2::write::XzEncoder::new(encoder, 6)),
Snappy => Box::new(snap::write::FrameEncoder::new(encoder)),
Zstd => {
let zstd_encoder = zstd::stream::write::Encoder::new(encoder, Default::default());
// Safety:
// Encoder::new() can only fail if `level` is invalid, but Default::default()
// is guaranteed to be valid
Box::new(zstd_encoder.unwrap().auto_finish())
}
Tar | Zip => unreachable!(),
};
Ok(encoder)
};
for format in formats.iter().flat_map(Extension::iter).skip(1).collect::<Vec<_>>().iter().rev() {
writer = chain_writer_encoder(format, writer)?;
}
match formats[0].compression_formats[0] {
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
let _progress = Progress::new_accessible_aware(
total_input_size,
precise,
Some(Box::new(move || output_file_path.metadata().expect("file exists").len())),
);
writer = chain_writer_encoder(&formats[0].compression_formats[0], writer)?;
let mut reader = fs::File::open(&files[0]).unwrap();
io::copy(&mut reader, &mut writer)?;
}
Tar => {
let mut progress = Progress::new_accessible_aware(
total_input_size,
precise,
Some(Box::new(move || output_file_path.metadata().expect("file exists").len())),
);
archive::tar::build_archive_from_paths(
&files,
&mut writer,
file_visibility_policy,
progress.as_mut().map(Progress::display_handle).unwrap_or(&mut io::stdout()),
)?;
writer.flush()?;
}
Zip => {
if formats.len() > 1 {
eprintln!("{orange}[WARNING]{reset}", orange = *colors::ORANGE, reset = *colors::RESET);
eprintln!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
// give user the option to continue compressing after warning is shown
if !user_wants_to_continue(output_dir, question_policy, QuestionAction::Compression)? {
return Ok(false);
}
}
let mut vec_buffer = io::Cursor::new(vec![]);
let current_position_fn = {
let vec_buffer_ptr = {
struct FlyPtr(*const io::Cursor<Vec<u8>>);
unsafe impl Send for FlyPtr {}
FlyPtr(&vec_buffer as *const _)
};
Box::new(move || {
let vec_buffer_ptr = &vec_buffer_ptr;
// Safety: ptr is valid and vec_buffer is still alive
unsafe { &*vec_buffer_ptr.0 }.position()
})
};
let mut progress = Progress::new_accessible_aware(total_input_size, precise, Some(current_position_fn));
archive::zip::build_archive_from_paths(
&files,
&mut vec_buffer,
file_visibility_policy,
progress.as_mut().map(Progress::display_handle).unwrap_or(&mut io::stdout()),
)?;
let vec_buffer = vec_buffer.into_inner();
io::copy(&mut vec_buffer.as_slice(), &mut writer)?;
}
}
Ok(true)
}
// Decompress a file
//
// File at input_file_path is opened for reading, example: "archive.tar.gz"
// formats contains each format necessary for decompression, example: [Gz, Tar] (in decompression order)
// output_dir it's where the file will be decompressed to, this function assumes that the directory exists
// output_file_path is only used when extracting single file formats, not archive formats like .tar or .zip
fn decompress_file(
input_file_path: &Path,
formats: Vec<Extension>,
output_dir: &Path,
output_file_path: PathBuf,
question_policy: QuestionPolicy,
) -> crate::Result<()> {
assert!(output_dir.exists());
let total_input_size = input_file_path.metadata().expect("file exists").len();
let reader = fs::File::open(&input_file_path)?;
// Zip archives are special, because they require io::Seek, so it requires it's logic separated
// from decoder chaining.
//
// This is the only case where we can read and unpack it directly, without having to do
// in-memory decompression/copying first.
//
// Any other Zip decompression done can take up the whole RAM and freeze ouch.
if formats.len() == 1 && *formats[0].compression_formats == [Zip] {
let zip_archive = zip::ZipArchive::new(reader)?;
let files = if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::zip::unpack_archive(
zip_archive,
output_dir,
progress.as_mut().map(Progress::display_handle).unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
};
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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,
"Successfully decompressed archive in {} ({} files).",
nice_directory_display(output_dir),
files.len()
);
return Ok(());
}
// Will be used in decoder chaining
let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader);
let mut reader: Box<dyn Read> = Box::new(reader);
// Grab previous decoder and wrap it inside of a new one
let chain_reader_decoder = |format: &CompressionFormat, decoder: Box<dyn Read>| -> crate::Result<Box<dyn Read>> {
let decoder: Box<dyn Read> = match format {
Gzip => Box::new(flate2::read::GzDecoder::new(decoder)),
Bzip => Box::new(bzip2::read::BzDecoder::new(decoder)),
Lz4 => Box::new(lzzzz::lz4f::ReadDecompressor::new(decoder)?),
Lzma => Box::new(xz2::read::XzDecoder::new(decoder)),
Snappy => Box::new(snap::read::FrameDecoder::new(decoder)),
Zstd => Box::new(zstd::stream::Decoder::new(decoder)?),
Tar | Zip => unreachable!(),
};
Ok(decoder)
};
for format in formats.iter().flat_map(Extension::iter).skip(1).collect::<Vec<_>>().iter().rev() {
reader = chain_reader_decoder(format, reader)?;
}
let files_unpacked;
match formats[0].compression_formats[0] {
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
reader = chain_reader_decoder(&formats[0].compression_formats[0], reader)?;
let writer = utils::create_or_ask_overwrite(&output_file_path, question_policy)?;
if writer.is_none() {
// Means that the user doesn't want to overwrite
return Ok(());
}
let mut writer = writer.unwrap();
let current_position_fn = Box::new({
let output_file_path = output_file_path.clone();
move || output_file_path.clone().metadata().expect("file exists").len()
});
let _progress = Progress::new_accessible_aware(total_input_size, true, Some(current_position_fn));
io::copy(&mut reader, &mut writer)?;
files_unpacked = vec![output_file_path];
}
Tar => {
files_unpacked = if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::tar::unpack_archive(
reader,
output_dir,
progress.as_mut().map(Progress::display_handle).unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
};
}
Zip => {
if formats.len() > 1 {
eprintln!("{orange}[WARNING]{reset}", orange = *colors::ORANGE, reset = *colors::RESET);
eprintln!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
// give user the option to continue decompressing after warning is shown
if !user_wants_to_continue(input_file_path, question_policy, QuestionAction::Decompression)? {
return Ok(());
}
}
let mut vec = vec![];
io::copy(&mut reader, &mut vec)?;
let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?;
files_unpacked = if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::zip::unpack_archive(
zip_archive,
output_dir,
progress.as_mut().map(Progress::display_handle).unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
};
}
}
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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, "Successfully decompressed archive in {}.", nice_directory_display(output_dir));
info!(accessible, "Files unpacked: {}", files_unpacked.len());
Ok(())
}
// File at input_file_path is opened for reading, example: "archive.tar.gz"
// formats contains each format necessary for decompression, example: [Gz, Tar] (in decompression order)
fn list_archive_contents(
archive_path: &Path,
formats: Vec<CompressionFormat>,
list_options: ListOptions,
question_policy: QuestionPolicy,
) -> crate::Result<()> {
let reader = fs::File::open(&archive_path)?;
// Zip archives are special, because they require io::Seek, so it requires it's logic separated
// from decoder chaining.
//
// This is the only case where we can read and unpack it directly, without having to do
// in-memory decompression/copying first.
//
// Any other Zip decompression done can take up the whole RAM and freeze ouch.
if let [Zip] = *formats.as_slice() {
let zip_archive = zip::ZipArchive::new(reader)?;
let files = crate::archive::zip::list_archive(zip_archive);
list::list_files(archive_path, files, list_options)?;
return Ok(());
}
// Will be used in decoder chaining
let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader);
let mut reader: Box<dyn Read + Send> = Box::new(reader);
// Grab previous decoder and wrap it inside of a new one
let chain_reader_decoder =
|format: &CompressionFormat, decoder: Box<dyn Read + Send>| -> crate::Result<Box<dyn Read + Send>> {
let decoder: Box<dyn Read + Send> = match format {
Gzip => Box::new(flate2::read::GzDecoder::new(decoder)),
Bzip => Box::new(bzip2::read::BzDecoder::new(decoder)),
Lz4 => Box::new(lzzzz::lz4f::ReadDecompressor::new(decoder)?),
Lzma => Box::new(xz2::read::XzDecoder::new(decoder)),
Snappy => Box::new(snap::read::FrameDecoder::new(decoder)),
Zstd => Box::new(zstd::stream::Decoder::new(decoder)?),
Tar | Zip => unreachable!(),
};
Ok(decoder)
};
for format in formats.iter().skip(1).rev() {
reader = chain_reader_decoder(format, reader)?;
}
let files: Box<dyn Iterator<Item = crate::Result<FileInArchive>>> = match formats[0] {
Tar => Box::new(crate::archive::tar::list_archive(tar::Archive::new(reader))),
Zip => {
if formats.len() > 1 {
eprintln!("{orange}[WARNING]{reset}", orange = *colors::ORANGE, reset = *colors::RESET);
eprintln!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
// give user the option to continue decompressing after warning is shown
if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? {
return Ok(());
}
}
let mut vec = vec![];
io::copy(&mut reader, &mut vec)?;
let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?;
Box::new(crate::archive::zip::list_archive(zip_archive))
}
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
panic!("Not an archive! This should never happen, if it does, something is wrong with `CompressionFormat::is_archive()`. Please report this error!");
}
};
list::list_files(archive_path, files, list_options)?;
Ok(())
}
/// Unpacks an archive with some heuristics
/// - If the archive contains only one file, it will be extracted to the `output_dir`
/// - If the archive contains multiple files, it will be extracted to a subdirectory of the output_dir named after the archive (given by `output_file_path`)
/// Note: This functions assumes that `output_dir` exists
fn smart_unpack(
unpack_fn: Box<dyn FnOnce(&Path) -> crate::Result<Vec<PathBuf>>>,
output_dir: &Path,
output_file_path: &Path,
question_policy: QuestionPolicy,
) -> crate::Result<ControlFlow<(), Vec<PathBuf>>> {
assert!(output_dir.exists());
let temp_dir = tempfile::tempdir_in(output_dir)?;
let temp_dir_path = temp_dir.path();
info!(
accessible,
"Created temporary directory {} to hold decompressed elements.",
nice_directory_display(temp_dir_path)
);
// unpack the files
let files = unpack_fn(temp_dir_path)?;
let root_contains_only_one_element = fs::read_dir(&temp_dir_path)?.count() == 1;
if root_contains_only_one_element {
// Only one file in the root directory, so we can just move it to the output directory
let file = fs::read_dir(&temp_dir_path)?.next().expect("item exists")?;
let file_path = file.path();
let file_name =
file_path.file_name().expect("Should be safe because paths in archives should not end with '..'");
let correct_path = output_dir.join(file_name);
// One case to handle tough is we need to check if a file with the same name already exists
if !utils::clear_path(&correct_path, question_policy)? {
return Ok(ControlFlow::Break(()));
}
fs::rename(&file_path, &correct_path)?;
info!(
accessible,
"Successfully moved {} to {}.",
nice_directory_display(&file_path),
nice_directory_display(&correct_path)
);
} else {
// Multiple files in the root directory, so:
// Rename the temporary directory to the archive name, which is output_file_path
// One case to handle tough is we need to check if a file with the same name already exists
if !utils::clear_path(output_file_path, question_policy)? {
return Ok(ControlFlow::Break(()));
}
fs::rename(&temp_dir_path, &output_file_path)?;
info!(
accessible,
"Successfully moved {} to {}.",
nice_directory_display(temp_dir_path),
nice_directory_display(output_file_path)
);
}
Ok(ControlFlow::Continue(files))
}
fn check_mime_type(
files: &[PathBuf],
formats: &mut [Vec<Extension>],
question_policy: QuestionPolicy,
) -> crate::Result<ControlFlow<()>> {
for (path, format) in files.iter().zip(formats.iter_mut()) {
if format.is_empty() {
// File with no extension
// Try to detect it automatically and prompt the user about it
if let Some(detected_format) = try_infer_extension(path) {
// Infering the file extension can have unpredicted consequences (e.g. the user just
// mistyped, ...) which we should always inform the user about.
info!(accessible, "Detected file: `{}` extension as `{}`", path.display(), detected_format);
if user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
format.push(detected_format);
} else {
return Ok(ControlFlow::Break(()));
}
}
} else if let Some(detected_format) = try_infer_extension(path) {
// File ending with extension
// Try to detect the extension and warn the user if it differs from the written one
let outer_ext = format.iter().next_back().unwrap();
if outer_ext != &detected_format {
warning!(
"The file extension: `{}` differ from the detected extension: `{}`",
outer_ext,
detected_format
);
if !user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
return Ok(ControlFlow::Break(()));
}
}
} else {
// NOTE: If this actually produces no false positives, we can upgrade it in the future
// to a warning and ask the user if he wants to continue decompressing.
info!(accessible, "Could not detect the extension of `{}`", path.display());
}
}
Ok(ControlFlow::Continue(()))
}
fn clean_input_files_if_needed(files: &mut Vec<PathBuf>, output_path: &Path) {
let mut idx = 0;
while idx < files.len() {
if files[idx] == output_path {
warning!("The output file and the input file are the same: `{}`, skipping...", output_path.display());
files.remove(idx);
} else {
idx += 1;
}
}
}

157
src/commands/compress.rs Normal file
View File

@ -0,0 +1,157 @@
use std::{
io::{self, BufWriter, Write},
path::{Path, PathBuf},
};
use fs_err as fs;
use crate::{
archive,
commands::warn_user_about_loading_zip_in_memory,
extension::{
split_first_compression_format,
CompressionFormat::{self, *},
Extension,
},
progress::Progress,
utils::{user_wants_to_continue, FileVisibilityPolicy},
QuestionAction, QuestionPolicy, BUFFER_CAPACITY,
};
// Compress files into an `output_file`
//
// - `files`: is the list of paths to be compressed: ["dir/file1.txt", "dir/file2.txt"]
// - `extensions`: contains each compression format necessary for compressing, example: [Tar, Gz] (in compression order)
// - `output_file` is the resulting compressed file name, example: "compressed.tar.gz"
//
// Returns Ok(true) if compressed all files successfully, and Ok(false) if user opted to skip files
pub fn compress_files(
files: Vec<PathBuf>,
extensions: Vec<Extension>,
output_file: fs::File,
output_dir: &Path,
question_policy: QuestionPolicy,
file_visibility_policy: FileVisibilityPolicy,
) -> crate::Result<bool> {
// The next lines are for displaying the progress bar
// If the input files contain a directory, then the total size will be underestimated
let (total_input_size, precise) = files
.iter()
.map(|f| (f.metadata().expect("file exists").len(), f.is_file()))
.fold((0, true), |(total_size, and_precise), (size, precise)| {
(total_size + size, and_precise & precise)
});
// NOTE: canonicalize is here to avoid a weird bug:
// > If output_file_path is a nested path and it exists and the user overwrite it
// >> output_file_path.exists() will always return false (somehow)
// - canonicalize seems to fix this
let output_file_path = output_file.path().canonicalize()?;
let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file);
let mut writer: Box<dyn Write> = Box::new(file_writer);
// Grab previous encoder and wrap it inside of a new one
let chain_writer_encoder = |format: &CompressionFormat, encoder: Box<dyn Write>| -> crate::Result<Box<dyn Write>> {
let encoder: Box<dyn Write> = match format {
Gzip => Box::new(flate2::write::GzEncoder::new(encoder, Default::default())),
Bzip => Box::new(bzip2::write::BzEncoder::new(encoder, Default::default())),
Lz4 => Box::new(lzzzz::lz4f::WriteCompressor::new(encoder, Default::default())?),
Lzma => Box::new(xz2::write::XzEncoder::new(encoder, 6)),
Snappy => Box::new(snap::write::FrameEncoder::new(encoder)),
Zstd => {
let zstd_encoder = zstd::stream::write::Encoder::new(encoder, Default::default());
// Safety:
// Encoder::new() can only fail if `level` is invalid, but Default::default()
// is guaranteed to be valid
Box::new(zstd_encoder.unwrap().auto_finish())
}
Tar | Zip => unreachable!(),
};
Ok(encoder)
};
let (first_format, formats) = split_first_compression_format(&extensions);
for format in formats.iter().rev() {
writer = chain_writer_encoder(format, writer)?;
}
match first_format {
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
let _progress = Progress::new_accessible_aware(
total_input_size,
precise,
Some(Box::new(move || {
output_file_path.metadata().expect("file exists").len()
})),
);
writer = chain_writer_encoder(&first_format, writer)?;
let mut reader = fs::File::open(&files[0]).unwrap();
io::copy(&mut reader, &mut writer)?;
}
Tar => {
let mut progress = Progress::new_accessible_aware(
total_input_size,
precise,
Some(Box::new(move || {
output_file_path.metadata().expect("file exists").len()
})),
);
archive::tar::build_archive_from_paths(
&files,
&mut writer,
file_visibility_policy,
progress
.as_mut()
.map(Progress::display_handle)
.unwrap_or(&mut io::stdout()),
)?;
writer.flush()?;
}
Zip => {
if !formats.is_empty() {
warn_user_about_loading_zip_in_memory();
// give user the option to continue compressing after warning is shown
if !user_wants_to_continue(output_dir, question_policy, QuestionAction::Compression)? {
return Ok(false);
}
}
let mut vec_buffer = io::Cursor::new(vec![]);
let current_position_fn = {
let vec_buffer_ptr = {
struct FlyPtr(*const io::Cursor<Vec<u8>>);
unsafe impl Send for FlyPtr {}
FlyPtr(&vec_buffer as *const _)
};
Box::new(move || {
let vec_buffer_ptr = &vec_buffer_ptr;
// Safety: ptr is valid and vec_buffer is still alive
unsafe { &*vec_buffer_ptr.0 }.position()
})
};
let mut progress = Progress::new_accessible_aware(total_input_size, precise, Some(current_position_fn));
archive::zip::build_archive_from_paths(
&files,
&mut vec_buffer,
file_visibility_policy,
progress
.as_mut()
.map(Progress::display_handle)
.unwrap_or(&mut io::stdout()),
)?;
let vec_buffer = vec_buffer.into_inner();
io::copy(&mut vec_buffer.as_slice(), &mut writer)?;
}
}
Ok(true)
}

263
src/commands/decompress.rs Normal file
View File

@ -0,0 +1,263 @@
use std::{
io::{self, BufReader, Read, Write},
ops::ControlFlow,
path::{Path, PathBuf},
};
use fs_err as fs;
use crate::{
commands::warn_user_about_loading_zip_in_memory,
extension::{
split_first_compression_format,
CompressionFormat::{self, *},
Extension,
},
info,
progress::Progress,
utils::{self, nice_directory_display, user_wants_to_continue},
QuestionAction, QuestionPolicy, BUFFER_CAPACITY,
};
// Decompress a file
//
// File at input_file_path is opened for reading, example: "archive.tar.gz"
// formats contains each format necessary for decompression, example: [Gz, Tar] (in decompression order)
// output_dir it's where the file will be decompressed to, this function assumes that the directory exists
// output_file_path is only used when extracting single file formats, not archive formats like .tar or .zip
pub fn decompress_file(
input_file_path: &Path,
formats: Vec<Extension>,
output_dir: &Path,
output_file_path: PathBuf,
question_policy: QuestionPolicy,
) -> crate::Result<()> {
assert!(output_dir.exists());
let total_input_size = input_file_path.metadata().expect("file exists").len();
let reader = fs::File::open(&input_file_path)?;
// Zip archives are special, because they require io::Seek, so it requires it's logic separated
// from decoder chaining.
//
// This is the only case where we can read and unpack it directly, without having to do
// in-memory decompression/copying first.
//
// Any other Zip decompression done can take up the whole RAM and freeze ouch.
if let [Extension {
compression_formats: [Zip],
..
}] = formats.as_slice()
{
let zip_archive = zip::ZipArchive::new(reader)?;
let files = if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::zip::unpack_archive(
zip_archive,
output_dir,
progress
.as_mut()
.map(Progress::display_handle)
.unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
};
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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,
"Successfully decompressed archive in {} ({} files).",
nice_directory_display(output_dir),
files.len()
);
return Ok(());
}
// Will be used in decoder chaining
let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader);
let mut reader: Box<dyn Read> = Box::new(reader);
// Grab previous decoder and wrap it inside of a new one
let chain_reader_decoder = |format: &CompressionFormat, decoder: Box<dyn Read>| -> crate::Result<Box<dyn Read>> {
let decoder: Box<dyn Read> = match format {
Gzip => Box::new(flate2::read::GzDecoder::new(decoder)),
Bzip => Box::new(bzip2::read::BzDecoder::new(decoder)),
Lz4 => Box::new(lzzzz::lz4f::ReadDecompressor::new(decoder)?),
Lzma => Box::new(xz2::read::XzDecoder::new(decoder)),
Snappy => Box::new(snap::read::FrameDecoder::new(decoder)),
Zstd => Box::new(zstd::stream::Decoder::new(decoder)?),
Tar | Zip => unreachable!(),
};
Ok(decoder)
};
let (first_extension, extensions) = split_first_compression_format(&formats);
for format in extensions.iter().rev() {
reader = chain_reader_decoder(format, reader)?;
}
let files_unpacked = match first_extension {
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
reader = chain_reader_decoder(&first_extension, reader)?;
let writer = utils::create_or_ask_overwrite(&output_file_path, question_policy)?;
if writer.is_none() {
// Means that the user doesn't want to overwrite
return Ok(());
}
let mut writer = writer.unwrap();
let current_position_fn = Box::new({
let output_file_path = output_file_path.clone();
move || output_file_path.clone().metadata().expect("file exists").len()
});
let _progress = Progress::new_accessible_aware(total_input_size, true, Some(current_position_fn));
io::copy(&mut reader, &mut writer)?;
vec![output_file_path]
}
Tar => {
if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::tar::unpack_archive(
reader,
output_dir,
progress
.as_mut()
.map(Progress::display_handle)
.unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
}
}
Zip => {
if formats.len() > 1 {
warn_user_about_loading_zip_in_memory();
// give user the option to continue decompressing after warning is shown
if !user_wants_to_continue(input_file_path, question_policy, QuestionAction::Decompression)? {
return Ok(());
}
}
let mut vec = vec![];
io::copy(&mut reader, &mut vec)?;
let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?;
if let ControlFlow::Continue(files) = smart_unpack(
Box::new(move |output_dir| {
let mut progress = Progress::new_accessible_aware(total_input_size, true, None);
crate::archive::zip::unpack_archive(
zip_archive,
output_dir,
progress
.as_mut()
.map(Progress::display_handle)
.unwrap_or(&mut io::stdout()),
)
}),
output_dir,
&output_file_path,
question_policy,
)? {
files
} else {
return Ok(());
}
}
};
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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,
"Successfully decompressed archive in {}.",
nice_directory_display(output_dir)
);
info!(accessible, "Files unpacked: {}", files_unpacked.len());
Ok(())
}
/// Unpacks an archive with some heuristics
/// - If the archive contains only one file, it will be extracted to the `output_dir`
/// - If the archive contains multiple files, it will be extracted to a subdirectory of the
/// output_dir named after the archive (given by `output_file_path`)
/// Note: This functions assumes that `output_dir` exists
fn smart_unpack(
unpack_fn: Box<dyn FnOnce(&Path) -> crate::Result<Vec<PathBuf>>>,
output_dir: &Path,
output_file_path: &Path,
question_policy: QuestionPolicy,
) -> crate::Result<ControlFlow<(), Vec<PathBuf>>> {
assert!(output_dir.exists());
let temp_dir = tempfile::tempdir_in(output_dir)?;
let temp_dir_path = temp_dir.path();
info!(
accessible,
"Created temporary directory {} to hold decompressed elements.",
nice_directory_display(temp_dir_path)
);
// unpack the files
let files = unpack_fn(temp_dir_path)?;
let root_contains_only_one_element = fs::read_dir(&temp_dir_path)?.count() == 1;
if root_contains_only_one_element {
// Only one file in the root directory, so we can just move it to the output directory
let file = fs::read_dir(&temp_dir_path)?.next().expect("item exists")?;
let file_path = file.path();
let file_name = file_path
.file_name()
.expect("Should be safe because paths in archives should not end with '..'");
let correct_path = output_dir.join(file_name);
// Before moving, need to check if a file with the same name already exists
if !utils::clear_path(&correct_path, question_policy)? {
return Ok(ControlFlow::Break(()));
}
fs::rename(&file_path, &correct_path)?;
info!(
accessible,
"Successfully moved {} to {}.",
nice_directory_display(&file_path),
nice_directory_display(&correct_path)
);
} else {
// Multiple files in the root directory, so:
// Rename the temporary directory to the archive name, which is output_file_path
// One case to handle tough is we need to check if a file with the same name already exists
if !utils::clear_path(output_file_path, question_policy)? {
return Ok(ControlFlow::Break(()));
}
fs::rename(&temp_dir_path, &output_file_path)?;
info!(
accessible,
"Successfully moved {} to {}.",
nice_directory_display(temp_dir_path),
nice_directory_display(output_file_path)
);
}
Ok(ControlFlow::Continue(files))
}

88
src/commands/list.rs Normal file
View File

@ -0,0 +1,88 @@
use std::{
io::{self, BufReader, Read},
path::Path,
};
use fs_err as fs;
use crate::{
commands::warn_user_about_loading_zip_in_memory,
extension::CompressionFormat::{self, *},
list::{self, FileInArchive, ListOptions},
utils::user_wants_to_continue,
QuestionAction, QuestionPolicy, BUFFER_CAPACITY,
};
// File at input_file_path is opened for reading, example: "archive.tar.gz"
// formats contains each format necessary for decompression, example: [Gz, Tar] (in decompression order)
pub fn list_archive_contents(
archive_path: &Path,
formats: Vec<CompressionFormat>,
list_options: ListOptions,
question_policy: QuestionPolicy,
) -> crate::Result<()> {
let reader = fs::File::open(&archive_path)?;
// Zip archives are special, because they require io::Seek, so it requires it's logic separated
// from decoder chaining.
//
// This is the only case where we can read and unpack it directly, without having to do
// in-memory decompression/copying first.
//
// Any other Zip decompression done can take up the whole RAM and freeze ouch.
if let &[Zip] = formats.as_slice() {
let zip_archive = zip::ZipArchive::new(reader)?;
let files = crate::archive::zip::list_archive(zip_archive);
list::list_files(archive_path, files, list_options)?;
return Ok(());
}
// Will be used in decoder chaining
let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader);
let mut reader: Box<dyn Read + Send> = Box::new(reader);
// Grab previous decoder and wrap it inside of a new one
let chain_reader_decoder =
|format: &CompressionFormat, decoder: Box<dyn Read + Send>| -> crate::Result<Box<dyn Read + Send>> {
let decoder: Box<dyn Read + Send> = match format {
Gzip => Box::new(flate2::read::GzDecoder::new(decoder)),
Bzip => Box::new(bzip2::read::BzDecoder::new(decoder)),
Lz4 => Box::new(lzzzz::lz4f::ReadDecompressor::new(decoder)?),
Lzma => Box::new(xz2::read::XzDecoder::new(decoder)),
Snappy => Box::new(snap::read::FrameDecoder::new(decoder)),
Zstd => Box::new(zstd::stream::Decoder::new(decoder)?),
Tar | Zip => unreachable!(),
};
Ok(decoder)
};
for format in formats.iter().skip(1).rev() {
reader = chain_reader_decoder(format, reader)?;
}
let files: Box<dyn Iterator<Item = crate::Result<FileInArchive>>> = match formats[0] {
Tar => Box::new(crate::archive::tar::list_archive(tar::Archive::new(reader))),
Zip => {
if formats.len() > 1 {
warn_user_about_loading_zip_in_memory();
// give user the option to continue decompressing after warning is shown
if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? {
return Ok(());
}
}
let mut vec = vec![];
io::copy(&mut reader, &mut vec)?;
let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?;
Box::new(crate::archive::zip::list_archive(zip_archive))
}
Gzip | Bzip | Lz4 | Lzma | Snappy | Zstd => {
panic!("Not an archive! This should never happen, if it does, something is wrong with `CompressionFormat::is_archive()`. Please report this error!");
}
};
list::list_files(archive_path, files, list_options)?;
Ok(())
}

388
src/commands/mod.rs Normal file
View File

@ -0,0 +1,388 @@
//! Receive command from the cli and call the respective function for that command.
mod compress;
mod decompress;
mod list;
use std::{
io::Write,
ops::ControlFlow,
path::{Path, PathBuf},
};
use fs_err as fs;
use utils::colors;
use crate::{
commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents},
error::FinalError,
extension::{self, flatten_compression_formats, Extension},
info,
list::ListOptions,
utils::{
self, dir_is_empty, pretty_format_list_of_paths, to_utf, try_infer_extension, user_wants_to_continue,
FileVisibilityPolicy,
},
warning, Opts, QuestionAction, QuestionPolicy, Subcommand,
};
/// Warn the user that (de)compressing this .zip archive might freeze their system.
fn warn_user_about_loading_zip_in_memory() {
const ZIP_IN_MEMORY_LIMITATION_WARNING: &str = "\n\
\tThe format '.zip' is limited and cannot be (de)compressed using encoding streams.\n\
\tWhen using '.zip' with other formats, (de)compression must be done in-memory\n\
\tCareful, you might run out of RAM if the archive is too large!";
warning!("{}", ZIP_IN_MEMORY_LIMITATION_WARNING);
}
fn represents_several_files(files: &[PathBuf]) -> bool {
let is_non_empty_dir = |path: &PathBuf| {
let is_non_empty = || !dir_is_empty(path);
path.is_dir().then(is_non_empty).unwrap_or_default()
};
files.iter().any(is_non_empty_dir) || files.len() > 1
}
/// This function checks what command needs to be run and performs A LOT of ahead-of-time checks
/// to assume everything is OK.
///
/// There are a lot of custom errors to give enough error description and explanation.
pub fn run(
args: Opts,
question_policy: QuestionPolicy,
file_visibility_policy: FileVisibilityPolicy,
) -> crate::Result<()> {
match args.cmd {
Subcommand::Compress {
mut files,
output: output_path,
} => {
// If the output_path file exists and is the same as some of the input files, warn the user and skip those inputs (in order to avoid compression recursion)
if output_path.exists() {
deduplicate_input_files(&mut files, &fs::canonicalize(&output_path)?);
}
// After cleaning, if there are no input files left, exit
if files.is_empty() {
return Err(FinalError::with_title("No files to compress").into());
}
// Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma]
let mut formats = extension::extensions_from_path(&output_path);
if formats.is_empty() {
let error = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path)))
.detail("You shall supply the compression format")
.hint("Try adding supported extensions (see --help):")
.hint(format!(" ouch compress <FILES>... {}.tar.gz", to_utf(&output_path)))
.hint(format!(" ouch compress <FILES>... {}.zip", to_utf(&output_path)))
.hint("")
.hint("Alternatively, you can overwrite this option by using the '--format' flag:")
.hint(format!(
" ouch compress <FILES>... {} --format tar.gz",
to_utf(&output_path)
));
return Err(error.into());
}
if !formats.get(0).map(Extension::is_archive).unwrap_or(false) && represents_several_files(&files) {
// This piece of code creates a suggestion for compressing multiple files
// It says:
// Change from file.bz.xz
// To file.tar.bz.xz
let extensions_text: String = formats.iter().map(|format| format.to_string()).collect();
let output_path = to_utf(&output_path).to_string();
// Breaks if Lzma is .lz or .lzma and not .xz
// Or if Bzip is .bz2 and not .bz
let extensions_start_position = output_path.rfind(&extensions_text).unwrap();
let pos = extensions_start_position - 1;
let mut suggested_output_path = output_path.to_string();
suggested_output_path.insert_str(pos, ".tar");
let error = FinalError::with_title(format!("Cannot compress to '{}'.", output_path))
.detail("You are trying to compress multiple files.")
.detail(format!(
"The compression format '{}' cannot receive multiple files.",
&formats[0]
))
.detail("The only supported formats that archive files into an archive are .tar and .zip.")
.hint(format!("Try inserting '.tar' or '.zip' before '{}'.", &formats[0]))
.hint(format!("From: {}", output_path))
.hint(format!("To: {}", suggested_output_path));
return Err(error.into());
}
if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive()) {
let error = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path)))
.detail(format!("Found the format '{}' in an incorrect position.", format))
.detail(format!(
"'{}' can only be used at the start of the file extension.",
format
))
.hint(format!(
"If you wish to compress multiple files, start the extension with '{}'.",
format
))
.hint(format!(
"Otherwise, remove the last '{}' from '{}'.",
format,
to_utf(&output_path)
));
return Err(error.into());
}
if output_path.exists() && !utils::user_wants_to_overwrite(&output_path, question_policy)? {
// User does not want to overwrite this file, skip and return without any errors
return Ok(());
}
let output_file = fs::File::create(&output_path)?;
if !represents_several_files(&files) {
// It is possible the file is already partially compressed so we don't want to compress it again
// `ouch compress file.tar.gz file.tar.gz.xz` should produce `file.tar.gz.xz` and not `file.tar.gz.tar.gz.xz`
let input_extensions = extension::extensions_from_path(&files[0]);
// We calculate the formats that are left if we filter out a sublist at the start of what we have that's the same as the input formats
let mut new_formats = Vec::with_capacity(formats.len());
for (inp_ext, out_ext) in input_extensions.iter().zip(&formats) {
if inp_ext.compression_formats == out_ext.compression_formats {
new_formats.push(out_ext.clone());
} else if inp_ext
.compression_formats
.iter()
.zip(out_ext.compression_formats.iter())
.all(|(inp, out)| inp == out)
{
let new_ext = Extension::new(
&out_ext.compression_formats[..inp_ext.compression_formats.len()],
&out_ext.display_text,
);
new_formats.push(new_ext);
break;
}
}
// If the input is a sublist at the start of `formats` then remove the extensions
// Note: If input_extensions is empty then it will make `formats` empty too, which we don't want
if !input_extensions.is_empty() && new_formats != formats {
// Safety:
// We checked above that input_extensions isn't empty, so files[0] has an extension.
//
// Path::extension says: "if there is no file_name, then there is no extension".
// Contrapositive statement: "if there is extension, then there is file_name".
info!(
accessible, // important information
"Partial compression detected. Compressing {} into {}",
to_utf(files[0].as_path().file_name().unwrap().as_ref()),
to_utf(&output_path)
);
formats = new_formats;
}
}
let compress_result = compress_files(
files,
formats,
output_file,
&output_path,
question_policy,
file_visibility_policy,
);
if let Ok(true) = compress_result {
// this is only printed once, so it doesn't result in much text. On the other hand,
// 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, "Successfully compressed '{}'.", to_utf(&output_path));
} else {
// If Ok(false) or Err() occurred, delete incomplete file
// Print an extra alert message pointing out that we left a possibly
// CORRUPTED FILE at `output_path`
if let Err(err) = fs::remove_file(&output_path) {
eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
eprintln!(" Please manually delete '{}'.", to_utf(&output_path));
eprintln!(
" Compression failed and we could not delete '{}'.",
to_utf(&output_path),
);
eprintln!(
" Error:{reset} {}{red}.{reset}\n",
err,
reset = *colors::RESET,
red = *colors::RED
);
}
}
compress_result?;
}
Subcommand::Decompress { files, output_dir } => {
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 ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
return Ok(());
}
let files_missing_format: Vec<PathBuf> = files
.iter()
.zip(&formats)
.filter(|(_, formats)| formats.is_empty())
.map(|(input_path, _)| PathBuf::from(input_path))
.collect();
if !files_missing_format.is_empty() {
let error = FinalError::with_title("Cannot decompress files without extensions")
.detail(format!(
"Files without supported extensions: {}",
pretty_format_list_of_paths(&files_missing_format)
))
.detail("Decompression formats are detected automatically by the file extension")
.hint("Provide a file with a supported extension:")
.hint(" ouch decompress example.tar.gz")
.hint("")
.hint("Or overwrite this option with the '--format' flag:")
.hint(format!(
" ouch decompress {} --format tar.gz",
to_utf(&files_missing_format[0])
));
return Err(error.into());
}
// The directory that will contain the output files
// We default to the current directory if the user didn't specify an output directory with --dir
let output_dir = if let Some(dir) = output_dir {
if !utils::clear_path(&dir, question_policy)? {
// User doesn't want to overwrite
return Ok(());
}
utils::create_dir_if_non_existent(&dir)?;
dir
} else {
PathBuf::from(".")
};
for ((input_path, formats), file_name) in files.iter().zip(formats).zip(output_paths) {
let output_file_path = output_dir.join(file_name); // Path used by single file format archives
decompress_file(input_path, formats, &output_dir, output_file_path, question_policy)?;
}
}
Subcommand::List { archives: files, tree } => {
let mut formats = vec![];
for path in files.iter() {
let (_, file_formats) = extension::separate_known_extensions_from_name(path);
formats.push(file_formats);
}
if let ControlFlow::Break(_) = check_mime_type(&files, &mut formats, question_policy)? {
return Ok(());
}
let not_archives: Vec<PathBuf> = files
.iter()
.zip(&formats)
.filter(|(_, formats)| !formats.get(0).map(Extension::is_archive).unwrap_or(false))
.map(|(path, _)| path.clone())
.collect();
if !not_archives.is_empty() {
let error = FinalError::with_title("Cannot list archive contents")
.detail("Only archives can have their contents listed")
.detail(format!(
"Files are not archives: {}",
pretty_format_list_of_paths(&not_archives)
));
return Err(error.into());
}
let list_options = ListOptions { tree };
for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
if i > 0 {
println!();
}
let formats = flatten_compression_formats(&formats);
list_archive_contents(archive_path, formats, list_options, question_policy)?;
}
}
}
Ok(())
}
fn check_mime_type(
files: &[PathBuf],
formats: &mut [Vec<Extension>],
question_policy: QuestionPolicy,
) -> crate::Result<ControlFlow<()>> {
for (path, format) in files.iter().zip(formats.iter_mut()) {
if format.is_empty() {
// File with no extension
// Try to detect it automatically and prompt the user about it
if let Some(detected_format) = try_infer_extension(path) {
// Infering the file extension can have unpredicted consequences (e.g. the user just
// mistyped, ...) which we should always inform the user about.
info!(
accessible,
"Detected file: `{}` extension as `{}`",
path.display(),
detected_format
);
if user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
format.push(detected_format);
} else {
return Ok(ControlFlow::Break(()));
}
}
} else if let Some(detected_format) = try_infer_extension(path) {
// File ending with extension
// Try to detect the extension and warn the user if it differs from the written one
let outer_ext = format.iter().next_back().unwrap();
if outer_ext != &detected_format {
warning!(
"The file extension: `{}` differ from the detected extension: `{}`",
outer_ext,
detected_format
);
if !user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? {
return Ok(ControlFlow::Break(()));
}
}
} else {
// NOTE: If this actually produces no false positives, we can upgrade it in the future
// to a warning and ask the user if he wants to continue decompressing.
info!(accessible, "Could not detect the extension of `{}`", path.display());
}
}
Ok(ControlFlow::Continue(()))
}
fn deduplicate_input_files(files: &mut Vec<PathBuf>, output_path: &Path) {
let mut idx = 0;
while idx < files.len() {
if files[idx] == output_path {
warning!(
"The output file and the input file are the same: `{}`, skipping...",
output_path.display()
);
files.remove(idx);
} else {
idx += 1;
}
}
}

View File

@ -7,11 +7,10 @@ use std::{
fmt::{self, Display},
};
use crate::utils::colors::*;
use crate::{accessible::is_running_in_accessible_mode, utils::colors::*};
#[allow(missing_docs)]
/// All errors that can be generated by `ouch`
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// Not every IoError, some of them get filtered by `From<io::Error>` into other variants
IoError { reason: String },
@ -42,7 +41,7 @@ pub type Result<T> = std::result::Result<T, Error>;
pub type CowStr = Cow<'static, str>;
/// Pretty final error message for end users, crashing the program after display.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FinalError {
/// Should be made of just one line, appears after the "\[ERROR\]" part
title: CowStr,
@ -53,11 +52,11 @@ pub struct FinalError {
}
impl Display for FinalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// Title
//
// When in ACCESSIBLE mode, the square brackets are suppressed
if *crate::cli::ACCESSIBLE.get().unwrap_or(&false) {
if is_running_in_accessible_mode() {
write!(f, "{}ERROR{}: {}", *RED, *RESET, self.title)?;
} else {
write!(f, "{}[ERROR]{} {}", *RED, *RESET, self.title)?;
@ -74,7 +73,7 @@ impl Display for FinalError {
writeln!(f)?;
// to reduce redundant output for text-to-speach systems, braille
// displays and so on, only print "hints" once in ACCESSIBLE mode
if *crate::cli::ACCESSIBLE.get().unwrap_or(&false) {
if is_running_in_accessible_mode() {
write!(f, "\n{}hints:{}", *GREEN, *RESET)?;
for hint in &self.hints {
write!(f, "\n{}", hint)?;
@ -94,7 +93,11 @@ impl FinalError {
/// Only constructor
#[must_use]
pub fn with_title(title: impl Into<CowStr>) -> Self {
Self { title: title.into(), details: vec![], hints: vec![] }
Self {
title: title.into(),
details: vec![],
hints: vec![],
}
}
/// Add one detail line, can have multiple
@ -142,17 +145,35 @@ impl fmt::Display for Error {
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
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() },
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(),
}
}
}
}
}
impl From<lzzzz::lz4f::Error> for Error {
fn from(err: lzzzz::lz4f::Error) -> Self {
Self::Lz4Error { reason: err.to_string() }
Self::Lz4Error {
reason: err.to_string(),
}
}
}
@ -174,7 +195,9 @@ impl From<zip::result::ZipError> for Error {
impl From<ignore::Error> for Error {
fn from(err: ignore::Error) -> Self {
Self::WalkdirError { reason: err.to_string() }
Self::WalkdirError {
reason: err.to_string(),
}
}
}

View File

@ -23,9 +23,12 @@ impl PartialEq for Extension {
impl Extension {
/// # Panics:
/// Will panic if `formats` is empty
pub fn new(formats: &'static [CompressionFormat], text: impl Into<String>) -> Self {
pub fn new(formats: &'static [CompressionFormat], text: impl ToString) -> Self {
assert!(!formats.is_empty());
Self { compression_formats: formats, display_text: text.into() }
Self {
compression_formats: formats,
display_text: text.to_string(),
}
}
/// Checks if the first format in `compression_formats` is an archive
@ -33,15 +36,10 @@ impl Extension {
// Safety: we check that `compression_formats` is not empty in `Self::new`
self.compression_formats[0].is_archive_format()
}
/// Iteration to inner compression formats, useful for flat_mapping
pub fn iter(&self) -> impl Iterator<Item = &CompressionFormat> {
self.compression_formats.iter()
}
}
impl fmt::Display for Extension {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.display_text.fmt(f)
}
}
@ -85,20 +83,18 @@ impl CompressionFormat {
impl fmt::Display for CompressionFormat {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}",
match self {
Gzip => ".gz",
Bzip => ".bz",
Zstd => ".zst",
Lz4 => ".lz4",
Lzma => ".lz",
Snappy => ".sz",
Tar => ".tar",
Zip => ".zip",
}
)
let text = match self {
Gzip => ".gz",
Bzip => ".bz",
Zstd => ".zst",
Lz4 => ".lz4",
Lzma => ".lz",
Snappy => ".sz",
Tar => ".tar",
Zip => ".zip",
};
write!(f, "{text}")
}
}
@ -142,7 +138,11 @@ pub fn separate_known_extensions_from_name(mut path: &Path) -> (&Path, Vec<Exten
extensions.push(extension);
// Update for the next iteration
path = if let Some(stem) = path.file_stem() { Path::new(stem) } else { Path::new("") };
path = if let Some(stem) = path.file_stem() {
Path::new(stem)
} else {
Path::new("")
};
}
// Put the extensions in the correct order: left to right
extensions.reverse();
@ -166,8 +166,23 @@ mod tests {
let path = Path::new("bolovo.tar.gz");
let extensions: Vec<Extension> = extensions_from_path(path);
let formats: Vec<&CompressionFormat> = extensions.iter().flat_map(Extension::iter).collect::<Vec<_>>();
let formats: Vec<CompressionFormat> = flatten_compression_formats(&extensions);
assert_eq!(formats, vec![&Tar, &Gzip]);
assert_eq!(formats, vec![Tar, Gzip]);
}
}
// Panics if formats has an empty list of compression formats
pub fn split_first_compression_format(formats: &[Extension]) -> (CompressionFormat, Vec<CompressionFormat>) {
let mut extensions: Vec<CompressionFormat> = flatten_compression_formats(formats);
let first_extension = extensions.remove(0);
(first_extension, extensions)
}
pub fn flatten_compression_formats(extensions: &[Extension]) -> Vec<CompressionFormat> {
extensions
.iter()
.flat_map(|extension| extension.compression_formats.iter())
.copied()
.collect()
}

View File

@ -1,10 +1,11 @@
//! Implementation of the 'list' command, print list of files in an archive
//! Some implementation helpers related to the 'list' command.
use std::path::{Path, PathBuf};
use indicatif::{ProgressBar, ProgressStyle};
use self::tree::Tree;
use crate::accessible::is_running_in_accessible_mode;
/// Options controlling how archive contents should be listed
#[derive(Debug, Clone, Copy)]
@ -33,7 +34,7 @@ pub fn list_files(
println!("Archive: {}", archive.display());
if list_options.tree {
let pb = if !crate::cli::ACCESSIBLE.get().unwrap() {
let pb = if !is_running_in_accessible_mode() {
let template = "{wide_msg} [{elapsed_precise}] {spinner:.green}";
let pb = ProgressBar::new_spinner();
pb.set_style(ProgressStyle::default_bar().template(template));
@ -46,8 +47,10 @@ pub fn list_files(
.into_iter()
.map(|file| {
let file = file?;
if !crate::cli::ACCESSIBLE.get().unwrap() {
pb.as_ref().expect("exists").set_message(format!("Processing: {}", file.path.display()));
if !is_running_in_accessible_mode() {
pb.as_ref()
.expect("exists")
.set_message(format!("Processing: {}", file.path.display()));
}
Ok(file)
})
@ -75,7 +78,7 @@ fn print_entry(name: impl std::fmt::Display, is_dir: bool) {
// if in ACCESSIBLE mode, use colors but print final / in case colors
// aren't read out aloud with a screen reader or aren't printed on a
// braille reader
} else if *crate::cli::ACCESSIBLE.get().unwrap() {
} else if is_running_in_accessible_mode() {
println!("{}{}{}/{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET);
} else {
println!("{}{}{}{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET);
@ -103,6 +106,7 @@ mod tree {
file: Option<FileInArchive>,
children: LinkedHashMap<OsString, Tree>,
}
impl Tree {
/// Insert a file into the tree
pub fn insert(&mut self, file: FileInArchive) {

View File

@ -1,5 +1,7 @@
//! Macros used on ouch.
use crate::accessible::is_running_in_accessible_mode;
/// Macro that prints \[INFO\] messages, wraps [`println`].
///
/// There are essentially two different versions of the `info!()` macro:
@ -28,7 +30,7 @@ macro_rules! info {
(@$display_handle: expr, accessible, $($arg:tt)*) => {
let display_handle = &mut $display_handle;
// if in ACCESSIBLE mode, suppress the "[INFO]" and just print the message
if !(*$crate::cli::ACCESSIBLE.get().unwrap()) {
if !$crate::accessible::is_running_in_accessible_mode() {
$crate::macros::_info_helper(display_handle);
}
writeln!(display_handle, $($arg)*).unwrap();
@ -39,8 +41,7 @@ macro_rules! info {
info!(@::std::io::stdout(), inaccessible, $($arg)*);
};
(@$display_handle: expr, inaccessible, $($arg:tt)*) => {
if (!$crate::cli::ACCESSIBLE.get().unwrap())
{
if !$crate::accessible::is_running_in_accessible_mode() {
let display_handle = &mut $display_handle;
$crate::macros::_info_helper(display_handle);
writeln!(display_handle, $($arg)*).unwrap();
@ -68,9 +69,9 @@ macro_rules! warning {
pub fn _warning_helper() {
use crate::utils::colors::{ORANGE, RESET};
if !crate::cli::ACCESSIBLE.get().unwrap() {
print!("{}Warning:{} ", *ORANGE, *RESET);
if is_running_in_accessible_mode() {
eprint!("{}Warning:{} ", *ORANGE, *RESET);
} else {
print!("{}[WARNING]{} ", *ORANGE, *RESET);
eprint!("{}[WARNING]{} ", *ORANGE, *RESET);
}
}

View File

@ -1,6 +1,7 @@
// Macros should be declared first
pub mod macros;
pub mod accessible;
pub mod archive;
pub mod cli;
pub mod commands;
@ -17,6 +18,9 @@ use error::{Error, Result};
use opts::{Opts, Subcommand};
use utils::{QuestionAction, QuestionPolicy};
// Used in BufReader and BufWriter to perform less syscalls
const BUFFER_CAPACITY: usize = 1024 * 32;
/// The status code returned from `ouch` on error
pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE;

View File

@ -8,6 +8,8 @@ use std::{
use indicatif::{ProgressBar, ProgressStyle};
use crate::accessible::is_running_in_accessible_mode;
/// Draw a ProgressBar using a function that checks periodically for the progress
pub struct Progress {
draw_stop: Sender<()>,
@ -36,7 +38,9 @@ impl io::Write for DisplayHandle {
fn io_error<X>(_: X) -> io::Error {
io::Error::new(io::ErrorKind::Other, "failed to flush buffer")
}
self.sender.send(String::from_utf8(self.buf.drain(..).collect()).map_err(io_error)?).map_err(io_error)
self.sender
.send(String::from_utf8(self.buf.drain(..).collect()).map_err(io_error)?)
.map_err(io_error)
}
}
@ -49,7 +53,7 @@ impl Progress {
precise: bool,
current_position_fn: Option<Box<dyn Fn() -> u64 + Send>>,
) -> Option<Self> {
if *crate::cli::ACCESSIBLE.get().unwrap() {
if is_running_in_accessible_mode() {
return None;
}
Some(Self::new(total_input_size, precise, current_position_fn))
@ -78,28 +82,31 @@ impl Progress {
t += "({bytes_per_sec}, {eta}) {path}";
t
};
let pb = ProgressBar::new(total_input_size);
pb.set_style(ProgressStyle::default_bar().template(&template).progress_chars("#>-"));
let bar = ProgressBar::new(total_input_size);
bar.set_style(ProgressStyle::default_bar().template(&template).progress_chars("#>-"));
while draw_rx.try_recv().is_err() {
if let Some(ref pos_fn) = current_position_fn {
pb.set_position(pos_fn());
bar.set_position(pos_fn());
} else {
pb.tick();
bar.tick();
}
if let Ok(msg) = msg_rx.try_recv() {
pb.set_message(msg);
bar.set_message(msg);
}
thread::sleep(Duration::from_millis(100));
}
pb.finish();
bar.finish();
let _ = clean_tx.send(());
});
Progress {
draw_stop: draw_tx,
clean_done: clean_rx,
display_handle: DisplayHandle { buf: Vec::new(), sender: msg_tx },
display_handle: DisplayHandle {
buf: Vec::new(),
sender: msg_tx,
},
}
}

View File

@ -23,7 +23,12 @@ pub struct FileVisibilityPolicy {
impl Default for FileVisibilityPolicy {
fn default() -> Self {
Self { read_ignore: false, read_hidden: true, read_git_ignore: false, read_git_exclude: false }
Self {
read_ignore: false,
read_hidden: true,
read_git_ignore: false,
read_git_exclude: false,
}
}
}
@ -41,13 +46,19 @@ impl FileVisibilityPolicy {
#[must_use]
/// Enables reading .gitignore files.
pub fn read_git_ignore(self, read_git_ignore: bool) -> Self {
Self { read_git_ignore, ..self }
Self {
read_git_ignore,
..self
}
}
#[must_use]
/// Enables reading `.git/info/exclude` files.
pub fn read_git_exclude(self, read_git_exclude: bool) -> Self {
Self { read_git_exclude, ..self }
Self {
read_git_exclude,
..self
}
}
#[must_use]

View File

@ -1,14 +1,11 @@
use std::{borrow::Cow, cmp, path::Path};
use std::{borrow::Cow, cmp, env, path::Path};
// The lifetimes here could elided but I think they help
// comprehension in this case
#[allow(clippy::needless_lifetimes)]
/// Converts an OsStr to utf8 with custom formatting.
///
/// This is different from [`Path::display`].
///
/// See <https://gist.github.com/marcospb19/ebce5572be26397cf08bbd0fd3b65ac1> for a comparison.
pub fn to_utf<'a>(os_str: &'a Path) -> Cow<'a, str> {
pub fn to_utf(os_str: &Path) -> Cow<str> {
let format = || {
let text = format!("{:?}", os_str);
Cow::Owned(text.trim_matches('"').to_string())
@ -17,34 +14,37 @@ pub fn to_utf<'a>(os_str: &'a Path) -> Cow<'a, str> {
os_str.to_str().map_or_else(format, Cow::Borrowed)
}
/// Removes the current dir from the beginning of a path
/// normally used for presentation sake.
/// If this function fails, it will return source path as a PathBuf.
/// Removes the current dir from the beginning of a path as it's redundant information,
/// useful for presentation sake.
pub fn strip_cur_dir(source_path: &Path) -> &Path {
let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
source_path.strip_prefix(cwd).unwrap_or(source_path)
let current_dir = env::current_dir();
let current_dir = match &current_dir {
Ok(inner) => inner.as_path(),
Err(_) => Path::new(""),
};
source_path.strip_prefix(current_dir).unwrap_or(source_path)
}
/// Converts a slice of AsRef<OsStr> to comma separated String
///
/// Panics if the slice is empty.
pub fn concatenate_os_str_list(os_strs: &[impl AsRef<Path>]) -> String {
pub fn pretty_format_list_of_paths(os_strs: &[impl AsRef<Path>]) -> String {
let mut iter = os_strs.iter().map(AsRef::as_ref);
let mut string = to_utf(iter.next().unwrap()).to_string(); // May panic
let first_element = iter.next().unwrap();
let mut string = to_utf(first_element).into_owned();
for os_str in iter {
string += ", ";
string += &*to_utf(os_str);
string += &to_utf(os_str);
}
string
}
// The lifetimes here could elided but I think they help
// comprehension in this case
#[allow(clippy::needless_lifetimes)]
/// Display the directory name, but change to "current directory" when necessary.
pub fn nice_directory_display<'a>(path: &'a Path) -> Cow<'a, str> {
/// Display the directory name, but use "current directory" when necessary.
pub fn nice_directory_display(path: &Path) -> Cow<str> {
if path == Path::new(".") {
Cow::Borrowed("current directory")
} else {
@ -67,7 +67,7 @@ impl Bytes {
}
impl std::fmt::Display for Bytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let num = self.bytes;
debug_assert!(num >= 0.0);
if num < 1_f64 {

View File

@ -129,5 +129,7 @@ pub fn try_infer_extension(path: &Path) -> Option<Extension> {
/// This is the same as the nightly https://doc.rust-lang.org/std/path/struct.Path.html#method.is_symlink
// Useful to detect broken symlinks when compressing. (So we can safely ignore them)
pub fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path).map(|m| m.file_type().is_symlink()).unwrap_or(false)
fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}

View File

@ -10,7 +10,7 @@ mod fs;
mod question;
pub use file_visibility::FileVisibilityPolicy;
pub use formatting::{concatenate_os_str_list, nice_directory_display, strip_cur_dir, to_utf, Bytes};
pub use formatting::{nice_directory_display, pretty_format_list_of_paths, strip_cur_dir, to_utf, Bytes};
pub use fs::{
cd_into_same_dir_as, clear_path, create_dir_if_non_existent, dir_is_empty, is_symlink, try_infer_extension,
};
@ -29,6 +29,9 @@ mod utf8 {
/// Filter out list of paths that are not utf8 valid
pub fn get_invalid_utf8_paths(paths: &[PathBuf]) -> Vec<&PathBuf> {
paths.iter().filter_map(|path| is_invalid_utf8(path).then(|| path)).collect()
paths
.iter()
.filter_map(|path| is_invalid_utf8(path).then(|| path))
.collect()
}
}

View File

@ -13,11 +13,12 @@ use fs_err as fs;
use super::{strip_cur_dir, to_utf};
use crate::{
accessible::is_running_in_accessible_mode,
error::{Error, Result},
utils::colors,
};
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
/// Determines if overwrite questions should be skipped or asked to the user
pub enum QuestionPolicy {
/// Ask the user every time
@ -28,7 +29,7 @@ pub enum QuestionPolicy {
AlwaysNo,
}
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
/// Determines which action is being questioned
pub enum QuestionAction {
/// question called from a compression function
@ -83,13 +84,13 @@ pub fn user_wants_to_continue(
QuestionPolicy::AlwaysNo => Ok(false),
QuestionPolicy::Ask => {
let action = match question_action {
QuestionAction::Compression => "compressing",
QuestionAction::Decompression => "decompressing",
QuestionAction::Compression => "compress",
QuestionAction::Decompression => "decompress",
};
let path = to_utf(strip_cur_dir(path));
let path = Some(&*path);
let placeholder = Some("FILE");
Confirmation::new(&format!("Do you want to continue {} 'FILE'?", action), placeholder).ask(path)
Confirmation::new(&format!("Do you want to {} 'FILE'?", action), placeholder).ask(path)
}
}
}
@ -110,7 +111,10 @@ pub struct Confirmation<'a> {
impl<'a> Confirmation<'a> {
/// Creates a new Confirmation.
pub const fn new(prompt: &'a str, pattern: Option<&'a str>) -> Self {
Self { prompt, placeholder: pattern }
Self {
prompt,
placeholder: pattern,
}
}
/// Creates user message and receives a boolean input to be used on the program
@ -123,10 +127,24 @@ impl<'a> Confirmation<'a> {
// Ask the same question to end while no valid answers are given
loop {
if *crate::cli::ACCESSIBLE.get().unwrap() {
print!("{} {}yes{}/{}no{}: ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET);
if is_running_in_accessible_mode() {
print!(
"{} {}yes{}/{}no{}: ",
message,
*colors::GREEN,
*colors::RESET,
*colors::RED,
*colors::RESET
);
} else {
print!("{} [{}Y{}/{}n{}] ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET);
print!(
"{} [{}Y{}/{}n{}] ",
message,
*colors::GREEN,
*colors::RESET,
*colors::RED,
*colors::RESET
);
}
io::stdout().flush()?;

View File

@ -51,7 +51,10 @@ enum Extension {
// converts a list of extension structs to string
fn merge_extensions(ext: impl ToString, exts: Vec<FileExtension>) -> String {
once(ext.to_string()).chain(exts.into_iter().map(|x| x.to_string())).collect::<Vec<_>>().join(".")
once(ext.to_string())
.chain(exts.into_iter().map(|x| x.to_string()))
.collect::<Vec<_>>()
.join(".")
}
// create random nested directories and files under the specified directory
@ -64,7 +67,10 @@ fn create_random_files(dir: impl Into<PathBuf>, depth: u8, rng: &mut SmallRng) {
// create 0 to 7 random files
for _ in 0..rng.gen_range(0..8u32) {
write_random_content(&mut tempfile::Builder::new().tempfile_in(dir).unwrap().keep().unwrap().0, rng);
write_random_content(
&mut tempfile::Builder::new().tempfile_in(dir).unwrap().keep().unwrap().0,
rng,
);
}
// create more random files in 0 to 3 new directories
@ -83,7 +89,10 @@ fn single_empty_file(ext: Extension, #[any(size_range(0..8).lift())] exts: Vec<F
let before_file = &before.join("file");
let archive = &dir.join(format!("file.{}", merge_extensions(ext, exts)));
let after = &dir.join("after");
write_random_content(&mut fs::File::create(before_file).unwrap(), &mut SmallRng::from_entropy());
write_random_content(
&mut fs::File::create(before_file).unwrap(),
&mut SmallRng::from_entropy(),
);
ouch!("-A", "c", before_file, archive);
ouch!("-A", "d", archive, "-d", after);
assert_same_directory(before, after, false);

View File

@ -46,8 +46,9 @@ fn sanity_check_through_mime() {
let compressed_file_path = &format!("{}.{}", path_to_compress.display(), format);
ouch!("c", path_to_compress, compressed_file_path);
let sniffed =
infer::get_from_path(compressed_file_path).expect("the file to be read").expect("the MIME to be found");
let sniffed = infer::get_from_path(compressed_file_path)
.expect("the file to be read")
.expect("the MIME to be found");
assert_eq!(&sniffed.mime_type(), expected_mime);
}