ouch 0.7.1

A command-line utility for easily compressing and decompressing files and directories.
//! Receive command from the cli and call the respective function for that command.

mod compress;
mod decompress;
mod list;

use bstr::ByteSlice;
use decompress::DecompressOptions;
use rayon::prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
use utils::colors;

use crate::{
    CliArgs, INITIAL_CURRENT_DIR, QuestionPolicy, Result,
    check::{self, CheckFileSignatureControlFlow},
    cli::Subcommand,
    commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents},
    error::{Error, FinalError},
    extension::{self, parse_format_flag},
    info_accessible,
    list::ListOptions,
    utils::{
        self, BytesFmt, FileVisibilityPolicy, NoQuotePathFmt, PathFmt, QuestionAction, canonicalize, colors::*,
        file_size, is_path_stdin,
    },
};

/// 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  \
        The format '.zip' is limited by design and cannot be (de)compressed with encoding streams.\n  \
        When chaining '.zip' with other formats, all (de)compression needs to be done in-memory\n  \
        Careful, you might run out of RAM if the archive is too large!";

    eprintln!("{}[WARNING]{}: {ZIP_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET);
}

/// Warn the user that (de)compressing this .7z archive might freeze their system.
fn warn_user_about_loading_sevenz_in_memory() {
    const SEVENZ_IN_MEMORY_LIMITATION_WARNING: &str = "\n  \
        The format '.7z' is limited by design and cannot be (de)compressed with encoding streams.\n  \
        When chaining '.7z' with other formats, all (de)compression needs to be done in-memory\n  \
        Careful, you might run out of RAM if the archive is too large!";

    eprintln!("{}[WARNING]{}: {SEVENZ_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET);
}

/// 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: CliArgs, question_policy: QuestionPolicy, file_visibility_policy: FileVisibilityPolicy) -> Result<()> {
    if let Some(threads) = args.threads {
        rayon::ThreadPoolBuilder::new()
            .num_threads(threads)
            .build_global()
            .unwrap();
    }

    match args.cmd {
        Subcommand::Compress {
            files,
            output: output_path,
            level,
            fast,
            slow,
            follow_symlinks,
        } => {
            // 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 (formats_from_flag, formats) = match args.format {
                Some(formats) => {
                    let parsed_formats = parse_format_flag(&formats)?;
                    (Some(formats), parsed_formats)
                }
                None => (None, extension::extensions_from_path(&output_path)?),
            };

            check::check_invalid_compression_with_non_archive_format(
                &formats,
                &output_path,
                &files,
                formats_from_flag.as_deref(),
            )?;
            check::check_archive_formats_position(&formats, &output_path)?;

            let (output_file, output_path) = match utils::create_file_or_prompt_on_conflict(
                &output_path,
                question_policy,
                QuestionAction::Compression,
            )? {
                Some(writer) => writer,
                None => return Ok(()),
            };

            let level = if fast {
                Some(1) // Lowest level of compression
            } else if slow {
                Some(i16::MAX) // Highest level of compression
            } else {
                level
            };

            let compress_result = compress_files(
                files,
                formats,
                output_file,
                &output_path,
                follow_symlinks,
                question_policy,
                file_visibility_policy,
                level,
            );

            if let Ok(true) = compress_result {
                info_accessible!("Output file size: {}", BytesFmt(file_size(&output_path)?));
                info_accessible!("Successfully compressed to {}", PathFmt(&output_path));
            } else {
                // If Ok(false) or Err() occurred, delete incomplete file at `output_path`
                //
                // if deleting fails, print an extra alert message pointing
                // out that we left a possibly CORRUPTED file at `output_path`
                if utils::remove_file_or_dir(&output_path).is_err() {
                    eprintln!("{red}FATAL ERROR:\n", red = *colors::RED);
                    eprintln!("  Ouch failed to delete the file {}", PathFmt(&output_path));
                    eprintln!("  Please delete it manually.");
                    eprintln!("  This file is corrupted if compression didn't finished.");

                    if compress_result.is_err() {
                        eprintln!("  Compression failed for reasons below.");
                    }
                }
            }

            compress_result.map(|_| ())
        }
        Subcommand::Decompress {
            files,
            output_dir,
            remove,
        } => {
            let mut files_output_paths: Vec<_> = vec![];
            let mut files_extensions: Vec<Vec<_>> = vec![];

            if let Some(format) = args.format {
                let format = parse_format_flag(&format)?;
                for path in files.iter() {
                    let file_name = path.file_name().ok_or_else(|| Error::Custom {
                        reason: FinalError::with_title(format!("{} does not have a file name", PathFmt(path))),
                    })?;
                    files_output_paths.push(file_name.into());
                    files_extensions.push(format.clone());
                }
            } else {
                for path in files.iter() {
                    let (output_path, mut extensions) = extension::separate_known_extensions_from_name(path)?;
                    let mut output_path = output_path.to_owned();

                    match check::check_file_signature(path, &extensions, question_policy)? {
                        CheckFileSignatureControlFlow::HaltProgram => return Ok(()),
                        CheckFileSignatureControlFlow::Continue => {}
                        CheckFileSignatureControlFlow::ChangeToDetectedExtension {
                            new_extension,
                            new_path_filename,
                        } => {
                            extensions = vec![new_extension];
                            output_path = output_path.with_file_name(new_path_filename);
                        }
                    }

                    files_output_paths.push(output_path);
                    files_extensions.push(extensions);
                }
            }

            check::check_missing_formats_when_decompressing(&files, &files_extensions)?;

            // 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 {
                utils::create_dir_if_non_existent(&dir)?;
                // If not canonicalized, strip_prefix won't work and logs will break
                // Led to bugs when output_dir was a symlink
                canonicalize(&dir)?
            } else {
                INITIAL_CURRENT_DIR.clone()
            };

            files
                .par_iter()
                .zip(files_extensions)
                .zip(files_output_paths)
                .try_for_each(|((input_path, formats), file_name)| {
                    // Path used by single file format archives
                    let output_file_path = if is_path_stdin(&file_name) {
                        output_dir.join("ouch-output")
                    } else {
                        output_dir.join(file_name)
                    };

                    decompress_file(DecompressOptions {
                        input_file_path: input_path,
                        formats,
                        output_dir: &output_dir,
                        output_file_path,
                        question_policy,
                        password: args.password.as_deref().map(|str| {
                            <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed")
                        }),
                        remove,
                    })
                    .map_err(|err| match err {
                        Error::IoError { reason } => Error::Custom {
                            reason: FinalError::with_title(format!(
                                "Failed to decompress {}",
                                NoQuotePathFmt(input_path)
                            ))
                            .detail(reason),
                        },
                        other => other,
                    })
                })
        }
        Subcommand::List { archives: files, tree } => {
            let mut formats = vec![];

            if let Some(format) = args.format {
                let format = parse_format_flag(&format)?;
                for _ in 0..files.len() {
                    formats.push(format.clone());
                }
            } else {
                for path in files.iter() {
                    let mut extensions = extension::extensions_from_path(path)?;

                    match check::check_file_signature(path, &extensions, question_policy)? {
                        CheckFileSignatureControlFlow::HaltProgram => return Ok(()),
                        CheckFileSignatureControlFlow::Continue => {}
                        CheckFileSignatureControlFlow::ChangeToDetectedExtension { new_extension, .. } => {
                            extensions = vec![new_extension]
                        }
                    }

                    formats.push(extensions);
                }
            }

            // Ensure we were not told to list the content of a non-archive compressed file
            check::check_for_non_archive_formats(&files, &formats)?;

            let list_options = ListOptions { tree };

            for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() {
                if i > 0 {
                    println!();
                }
                let formats = extension::flatten_compression_formats(&formats);
                list_archive_contents(
                    archive_path,
                    formats,
                    list_options,
                    question_policy,
                    args.password
                        .as_deref()
                        .map(|str| <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed")),
                )?;
            }

            Ok(())
        }
    }
}