diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..47232f9 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,87 @@ +name: build-and-test + +on: [push, pull_request] + +jobs: + build: + name: build + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + # - target: x86_64-pc-windows-gnu + # os: windows-latest + # ext: .exe + - target: x86_64-pc-windows-msvc + os: windows-latest + ext: .exe + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install dependencies (musl) + if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }} + run: | + sudo apt-get update + sudo apt-get install help2man musl-tools + + - name: Build and test on stable + run: | + rustup toolchain install stable --profile minimal -t ${{ matrix.target }} + cargo +stable build --target ${{ matrix.target }} + cargo +stable test --target ${{ matrix.target }} + + - name: Release on nightly + run: | + rustup toolchain install nightly --profile minimal -t ${{ matrix.target }} + cargo +nightly build --release --target ${{ matrix.target }} + env: + GEN_COMPLETIONS: 1 + RUSTFLAGS: -Z strip=symbols + + - name: Upload bianry + uses: actions/upload-artifact@v2 + with: + name: ouch-${{ matrix.target }}${{ matrix.ext }} + path: target/${{ matrix.target }}/release/ouch${{ matrix.ext }} + + - name: Build man page and find completions (musl) + if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }} + run: | + help2man target/${{ matrix.target }}/release/ouch > ouch.1 + cp -r target/${{ matrix.target }}/release/build/ouch-*/out/completions . + + - name: Upload completions (musl) + if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }} + uses: actions/upload-artifact@v2 + with: + name: completions + path: completions + + - name: Upload man page (musl) + if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }} + uses: actions/upload-artifact@v2 + with: + name: ouch.1 + path: ouch.1 + + clippy-rustfmt: + name: clippy-rustfmt + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: "Cargo: clippy, fmt" + run: | + rustup toolchain install stable --profile minimal -c clippy + rustup toolchain install nightly --profile minimal -c rustfmt + cargo +stable clippy -- -D warnings + cargo +nightly fmt -- --check diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 4f6592f..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,276 +0,0 @@ -on: [push, pull_request] - -name: build-and-test - - -jobs: - # aarch64-glibc: - # name: Ubuntu 18.04 (for ARMv8 - glibc) - # runs-on: ubuntu-18.04 - # steps: - # - uses: actions/checkout@v2 - # - uses: actions-rs/toolchain@v1 - # with: - # toolchain: stable - # target: aarch64-unknown-linux-gnu - # override: true - - # - name: Install binutils-arm-none-eabi - # run: | - # sudo apt-get update - # sudo apt-get install binutils-aarch64-linux-gnu - - # - uses: actions-rs/cargo@v1 - # with: - # use-cross: true - # command: build - # args: --target=aarch64-unknown-linux-gnu - - # - name: Run cargo test - # uses: actions-rs/cargo@v1 - # with: - # use-cross: true - # command: test - # args: --target=aarch64-unknown-linux-gnu - - # - name: Strip binary - # run: aarch64-linux-gnu-strip target/aarch64-unknown-linux-gnu/release/ouch - - # - name: Upload binary - # uses: actions/upload-artifact@v2 - # with: - # name: 'ouch-aarch64-linux-gnu' - # path: target/aarch64-unknown-linux-gnu/release/ouch - - - # armv7-glibc: - # name: Ubuntu 18.04 (for ARMv7 - glibc) - # continue-on-error: true - # runs-on: ubuntu-18.04 - # steps: - # - uses: actions/checkout@v2 - # - uses: actions-rs/toolchain@v1 - # with: - # toolchain: stable - # target: armv7-unknown-linux-gnueabihf - # override: true - - # - name: Install binutils-arm-none-eabi - # run: | - # sudo apt-get update - # sudo apt-get install binutils-arm-none-eabi - - # - uses: actions-rs/cargo@v1 - # with: - # use-cross: true - # command: build - # args: --target=armv7-unknown-linux-gnueabihf - - # - name: Run cargo test - # uses: actions-rs/cargo@v1 - # with: - # use-cross: true - # command: test - # args: --target=armv7-unknown-linux-gnueabihf - - # - name: Strip binary - # run: arm-none-eabi-strip target/armv7-unknown-linux-gnueabihf/release/ouch - - # - name: Upload binary - # uses: actions/upload-artifact@v2 - # with: - # name: 'ouch-armv7-linux-gnueabihf' - # path: target/armv7-unknown-linux-gnueabihf/release/ouch - - - x86_64_musl: - name: Ubuntu 20.04 (musl) - runs-on: ubuntu-20.04 - strategy: - matrix: - rust: - - stable - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - target: x86_64-unknown-linux-musl - override: true - - - name: Install dependencies for musl libc - run: | - sudo apt-get update - sudo apt-get install musl-tools - - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release --target x86_64-unknown-linux-musl - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --target x86_64-unknown-linux-musl - - - name: Strip binary - run: strip target/x86_64-unknown-linux-musl/release/ouch - - - name: Upload binary - uses: actions/upload-artifact@v2 - with: - name: 'ouch-x86_64-linux-musl' - path: target/x86_64-unknown-linux-musl/release/ouch - - - x86_64_glibc: - name: Ubuntu 20.04 (glibc) - runs-on: ubuntu-20.04 - strategy: - matrix: - rust: - - stable - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - - # - name: Strip binary - # run: strip target/release/ouch - - # - name: Upload binary - # uses: actions/upload-artifact@v2 - # with: - # name: 'ouch-x86_64-linux-gnu' - # path: target/release/ouch - - - x86_64_macos: - name: macOS (x86_64) - runs-on: macos-latest - strategy: - matrix: - rust: - - stable - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - target: x86_64-apple-darwin - override: true - - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - - - name: Strip binary - run: strip target/release/ouch - - - name: Upload binary - uses: actions/upload-artifact@v2 - with: - name: 'ouch-x86_64-apple-darwin' - path: target/release/ouch - - - windows-msvc: - name: Windows Server (MSVC) - runs-on: windows-latest - strategy: - matrix: - rust: - - stable - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.rust }} - override: true - - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - - - name: Upload binary - uses: actions/upload-artifact@v2 - with: - name: 'ouch-x86_64-pc-windows-msvc' - path: target\release\ouch.exe - - - # windows-mingw: - # name: Windows Server (MinGW) - # runs-on: windows-2019 - # strategy: - # matrix: - # rust: - # - stable - # steps: - # - name: Checkout sources - # uses: actions/checkout@v2 - - # - name: Install toolchain - # uses: actions-rs/toolchain@v1 - # with: - # toolchain: stable - # target: x86_64-pc-windows-gnu - # override: true - - # - name: Run cargo build - # uses: actions-rs/cargo@v1 - # with: - # command: build - # args: --target x86_64-pc-windows-gnu - - # - name: Run cargo test - # uses: actions-rs/cargo@v1 - # with: - # command: test - # args: --target x86_64-pc-windows-gnu - - # - name: Upload binary - # uses: actions/upload-artifact@v2 - # with: - # name: 'ouch-x86_64-pc-windows-gnu' - # path: target\x86_64-pc-windows-gnu\release\ouch.exe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67be18c..1bf3f44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,12 @@ Feel free to open an issue anytime you wish to ask a question, suggest a feature # Requirements -1. Be kind, considerate and respectfull. -2. If editing .rs files, run `rustfmt` on them before commiting. +1. Be nice to other people. +2. If editing the Rust source code, remember to run `rustfmt` (otherwise, CI will warn you the code was not properly formatted). -Note that we are using `unstable` features of `rustfmt`, so you will need to change your toolchain to nightly. +Note: we are using `unstable` features of `rustfmt`! Nightly toolchain is required (will likely be installed automatically, cause the toolchain was specified in the project root). # Suggestions -1. Ask for some guidance before solving an error if you feel like it. -2. If editing Rust code, run `clippy` before commiting. +1. If you wish to, you can ask for some guidance before solving an issue. +2. Run `cargo clippy` too. diff --git a/Cargo.lock b/Cargo.lock index 68b82c9..75733f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,15 @@ dependencies = [ "syn", ] +[[package]] +name = "clap_generate" +version = "3.0.0-beta.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "097ab5db1c3417442270cd57c8dd39f6c3114d3ce09d595f9efddbb1fcfaa799" +dependencies = [ + "clap", +] + [[package]] name = "crc32fast" version = "1.2.1" @@ -148,6 +157,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fs-err" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ebd3504ad6116843b8375ad70df74e7bfe83cac77a1f3fe73200c844d43bfe0" + [[package]] name = "getrandom" version = "0.2.3" @@ -283,7 +298,9 @@ dependencies = [ "atty", "bzip2", "clap", + "clap_generate", "flate2", + "fs-err", "infer", "libc", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index c42ac8b..a5627f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.2.0" authors = ["Vinícius Rodrigues Miguel ", "João M. Bezerra "] edition = "2018" readme = "README.md" -repository = "https://github.com/vrmiguel/ouch" +repository = "https://github.com/ouch-org/ouch" license = "MIT" keywords = ["decompression", "compression", "zip", "tar", "gzip"] categories = ["command-line-utilities", "compression", "encoding"] @@ -15,6 +15,7 @@ description = "A command-line utility for easily compressing and decompressing f [dependencies] clap = "=3.0.0-beta.5" # Keep it pinned while in beta! atty = "0.2.14" +fs-err = "2.6.0" once_cell = "1.8.0" walkdir = "2.3.2" bzip2 = "0.4.3" @@ -25,6 +26,10 @@ zip = { version = "0.5.13", default-features = false, features = ["defl flate2 = { version = "1.0.22", default-features = false, features = ["zlib"] } zstd = { version = "0.9.0", default-features = false, features = ["thin"] } +[build-dependencies] +clap = "=3.0.0-beta.5" +clap_generate = "=3.0.0-beta.5" + [dev-dependencies] tempfile = "3.2.0" infer = "0.5.0" diff --git a/README.md b/README.md index 4ab33f7..1d706cc 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ ouch decompress a.zip b.tar.gz c.tar ouch d a.zip ``` -You can redirect the decompression results to another folder with the `-o/--output` flag. +You can redirect the decompression results to another folder with the `-d/--dir` flag. ```sh # Decompress 'summer_vacation.zip' inside of new folder 'pictures' -ouch decompress summer_vacation.zip -o pictures +ouch decompress summer_vacation.zip -d pictures ``` ### Compressing diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..5372154 --- /dev/null +++ b/build.rs @@ -0,0 +1,22 @@ +use clap::{ArgEnum, IntoApp}; +use clap_generate::{generate_to, Shell}; + +use std::{env, fs::create_dir_all, path::Path}; + +include!("src/opts.rs"); + +fn main() { + println!("cargo:rerun-if-env-changed=GEN_COMPLETIONS"); + + if env::var_os("GEN_COMPLETIONS") != Some("1".into()) { + return; + } + + let out = &Path::new(&env::var_os("OUT_DIR").unwrap()).join("completions"); + create_dir_all(out).unwrap(); + let app = &mut Opts::into_app(); + + for shell in Shell::value_variants() { + generate_to(*shell, app, "ouch", out).unwrap(); + } +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs index be3f336..e02b432 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -1,2 +1,4 @@ +//! Archive compression algorithms + pub mod tar; pub mod zip; diff --git a/src/archive/tar.rs b/src/archive/tar.rs index 96f93e6..5aadb46 100644 --- a/src/archive/tar.rs +++ b/src/archive/tar.rs @@ -1,23 +1,27 @@ //! Contains Tar-specific building and unpacking functions use std::{ - env, fs, + env, io::prelude::*, path::{Path, PathBuf}, }; +use fs_err as fs; use tar; use walkdir::WalkDir; use crate::{ + error::FinalError, info, utils::{self, Bytes}, + QuestionPolicy, }; +/// Unpacks the archive given by `archive` into the folder given by `into`. pub fn unpack_archive( reader: Box, output_folder: &Path, - skip_questions_positively: Option, + question_policy: QuestionPolicy, ) -> crate::Result> { let mut archive = tar::Archive::new(reader); @@ -26,7 +30,7 @@ pub fn unpack_archive( let mut file = file?; let file_path = output_folder.join(file.path()?); - if file_path.exists() && !utils::user_wants_to_overwrite(&file_path, skip_questions_positively)? { + if file_path.exists() && !utils::user_wants_to_overwrite(&file_path, question_policy)? { continue; } @@ -40,6 +44,7 @@ pub fn unpack_archive( Ok(files_unpacked) } +/// Compresses the archives given by `input_filenames` into the file given previously to `writer`. pub fn build_archive_from_paths(input_filenames: &[PathBuf], writer: W) -> crate::Result where W: Write, @@ -62,7 +67,12 @@ where builder.append_dir(path, path)?; } else { let mut file = fs::File::open(path)?; - builder.append_file(path, &mut file)?; + builder.append_file(path, file.file_mut()).map_err(|err| { + FinalError::with_title("Could not create archive") + .detail("Unexpected error while trying to read file") + .detail(format!("Error: {}.", err)) + .into_owned() + })?; } } env::set_current_dir(previous_location)?; diff --git a/src/archive/zip.rs b/src/archive/zip.rs index f15e650..b62afd7 100644 --- a/src/archive/zip.rs +++ b/src/archive/zip.rs @@ -1,17 +1,20 @@ //! Contains Zip-specific building and unpacking functions use std::{ - env, fs, + env, io::{self, prelude::*}, path::{Path, PathBuf}, }; +use fs_err as fs; + use walkdir::WalkDir; use zip::{self, read::ZipFile, ZipArchive}; use crate::{ info, utils::{self, dir_is_empty, strip_cur_dir, Bytes}, + QuestionPolicy, }; use self::utf8::get_invalid_utf8_paths; @@ -20,7 +23,7 @@ use self::utf8::get_invalid_utf8_paths; pub fn unpack_archive( mut archive: ZipArchive, into: &Path, - skip_questions_positively: Option, + question_policy: QuestionPolicy, ) -> crate::Result> where R: Read + Seek, @@ -34,7 +37,7 @@ where }; let file_path = into.join(file_path); - if file_path.exists() && !utils::user_wants_to_overwrite(&file_path, skip_questions_positively)? { + if file_path.exists() && !utils::user_wants_to_overwrite(&file_path, question_policy)? { continue; } @@ -70,6 +73,7 @@ where Ok(unpacked_files) } +/// Compresses the archives given by `input_filenames` into the file given previously to `writer`. pub fn build_archive_from_paths(input_filenames: &[PathBuf], writer: W) -> crate::Result where W: Write + Seek, @@ -126,10 +130,11 @@ fn check_for_comments(file: &ZipFile) { #[cfg(unix)] fn __unix_set_permissions(file_path: &Path, file: &ZipFile) -> crate::Result<()> { + use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; if let Some(mode) = file.unix_mode() { - fs::set_permissions(file_path, fs::Permissions::from_mode(mode))?; + fs::set_permissions(file_path, Permissions::from_mode(mode))?; } Ok(()) diff --git a/src/cli.rs b/src/cli.rs index 7a13aab..cd853de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,70 +1,32 @@ -//! CLI arg parser configuration, command detection and input treatment. +//! CLI configuration step, uses definitions from `opts.rs`. +//! +//! Also used to treat some inputs. use std::{ path::{Path, PathBuf}, vec::Vec, }; -use clap::{Parser, ValueHint}; +use clap::Parser; +use fs_err as fs; -use crate::Error; - -#[derive(Parser, Debug)] -#[clap(version, about)] -pub struct Opts { - /// Skip overwrite questions positively. - #[clap(short, long, conflicts_with = "no")] - pub yes: bool, - - /// Skip overwrite questions negatively. - #[clap(short, long)] - pub no: bool, - - #[clap(subcommand)] - pub cmd: Subcommand, -} - -#[derive(Parser, PartialEq, Eq, Debug)] -pub enum Subcommand { - /// Compress files. Alias: c - #[clap(alias = "c")] - Compress { - /// Files to be compressed - #[clap(required = true, min_values = 1)] - files: Vec, - - /// The resulting file. Its extensions specify how the files will be compressed and they need to be supported - #[clap(required = true, value_hint = ValueHint::FilePath)] - output: PathBuf, - }, - /// Compress files. Alias: d - #[clap(alias = "d")] - Decompress { - /// Files to be decompressed - #[clap(required = true, min_values = 1)] - files: Vec, - - /// Decompress files in a directory other than the current - #[clap(short, long, value_hint = ValueHint::DirPath)] - output: Option, - }, -} +use crate::{Error, Opts, QuestionPolicy, Subcommand}; impl Opts { /// A helper method that calls `clap::Parser::parse` and then translates relative paths to absolute. /// Also determines if the user wants to skip questions or not - pub fn parse_args() -> crate::Result<(Self, Option)> { + pub fn parse_args() -> crate::Result<(Self, QuestionPolicy)> { let mut opts: Self = Self::parse(); let (Subcommand::Compress { files, .. } | Subcommand::Decompress { files, .. }) = &mut opts.cmd; *files = canonicalize_files(files)?; let skip_questions_positively = if opts.yes { - Some(true) + QuestionPolicy::AlwaysYes } else if opts.no { - Some(false) + QuestionPolicy::AlwaysNo } else { - None + QuestionPolicy::Ask }; Ok((opts, skip_questions_positively)) @@ -72,7 +34,7 @@ impl Opts { } fn canonicalize(path: impl AsRef) -> crate::Result { - match std::fs::canonicalize(&path.as_ref()) { + match fs::canonicalize(&path.as_ref()) { Ok(abs_path) => Ok(abs_path), Err(io_err) => { if !path.as_ref().exists() { diff --git a/src/commands.rs b/src/commands.rs index 28872cf..7467772 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,16 +3,15 @@ //! Also, where correctly call functions based on the detected `Command`. use std::{ - fs, io::{self, BufReader, BufWriter, Read, Write}, path::{Path, PathBuf}, }; +use fs_err as fs; use utils::colors; use crate::{ archive, - cli::{Opts, Subcommand}, error::FinalError, extension::{ self, @@ -20,10 +19,8 @@ use crate::{ Extension, }, info, - utils::nice_directory_display, - utils::to_utf, - utils::{self, dir_is_empty}, - Error, + utils::{self, dir_is_empty, nice_directory_display, to_utf}, + Error, Opts, QuestionPolicy, Subcommand, }; // Used in BufReader and BufWriter to perform less syscalls @@ -39,7 +36,9 @@ fn represents_several_files(files: &[PathBuf]) -> bool { files.iter().any(is_non_empty_dir) || files.len() > 1 } -pub fn run(args: Opts, skip_questions_positively: Option) -> crate::Result<()> { +/// Entrypoint of ouch, receives cli options and matches Subcommand +/// to decide current operation +pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { match args.cmd { Subcommand::Compress { files, output: output_path } => { // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma] @@ -95,7 +94,7 @@ pub fn run(args: Opts, skip_questions_positively: Option) -> crate::Result return Err(Error::with_reason(reason)); } - if output_path.exists() && !utils::user_wants_to_overwrite(&output_path, skip_questions_positively)? { + if output_path.exists() && !utils::user_wants_to_overwrite(&output_path, question_policy)? { // User does not want to overwrite this file return Ok(()); } @@ -160,7 +159,7 @@ pub fn run(args: Opts, skip_questions_positively: Option) -> crate::Result compress_result?; } - Subcommand::Decompress { files, output: output_folder } => { + Subcommand::Decompress { files, output_dir } => { let mut output_paths = vec![]; let mut formats = vec![]; @@ -189,16 +188,21 @@ pub fn run(args: Opts, skip_questions_positively: Option) -> crate::Result } // From Option to Option<&Path> - let output_folder = output_folder.as_ref().map(|path| path.as_ref()); + let output_dir = output_dir.as_ref().map(|path| path.as_ref()); for ((input_path, formats), file_name) in files.iter().zip(formats).zip(output_paths) { - decompress_file(input_path, formats, output_folder, file_name, skip_questions_positively)?; + decompress_file(input_path, formats, output_dir, file_name, 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" fn compress_files(files: Vec, formats: Vec, output_file: fs::File) -> crate::Result<()> { let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file); @@ -256,26 +260,28 @@ fn compress_files(files: Vec, formats: Vec, output_file: fs: Ok(()) } +// 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_folder it's where the file will be decompressed to +// output_dir it's where the file will be decompressed to // file_name is only used when extracting single file formats, no archive formats like .tar or .zip fn decompress_file( input_file_path: &Path, formats: Vec, - output_folder: Option<&Path>, + output_dir: Option<&Path>, file_name: &Path, - skip_questions_positively: Option, + question_policy: QuestionPolicy, ) -> crate::Result<()> { // TODO: improve error message let reader = fs::File::open(&input_file_path)?; // Output path is used by single file formats let output_path = - if let Some(output_folder) = output_folder { output_folder.join(file_name) } else { file_name.to_path_buf() }; + if let Some(output_dir) = output_dir { output_dir.join(file_name) } else { file_name.to_path_buf() }; // Output folder is used by archive file formats (zip and tar) - let output_folder = output_folder.unwrap_or_else(|| Path::new(".")); + let output_dir = output_dir.unwrap_or_else(|| Path::new(".")); // Zip archives are special, because they require io::Seek, so it requires it's logic separated // from decoder chaining. @@ -285,10 +291,10 @@ fn decompress_file( // // Any other Zip decompression done can take up the whole RAM and freeze ouch. if formats.len() == 1 && *formats[0].compression_formats.as_slice() == [Zip] { - utils::create_dir_if_non_existent(output_folder)?; + utils::create_dir_if_non_existent(output_dir)?; let zip_archive = zip::ZipArchive::new(reader)?; - let _files = crate::archive::zip::unpack_archive(zip_archive, output_folder, skip_questions_positively)?; - info!("Successfully decompressed archive in {}.", nice_directory_display(output_folder)); + let _files = crate::archive::zip::unpack_archive(zip_archive, output_dir, question_policy)?; + info!("Successfully decompressed archive in {}.", nice_directory_display(output_dir)); return Ok(()); } @@ -312,7 +318,9 @@ fn decompress_file( reader = chain_reader_decoder(format, reader)?; } - utils::create_dir_if_non_existent(output_folder)?; + utils::create_dir_if_non_existent(output_dir)?; + + let files_unpacked; match formats[0].compression_formats[0] { Gzip | Bzip | Lzma | Zstd => { @@ -322,11 +330,10 @@ fn decompress_file( let mut writer = fs::File::create(&output_path)?; io::copy(&mut reader, &mut writer)?; - info!("Successfully decompressed archive in {}.", nice_directory_display(output_path)); + files_unpacked = vec![output_path]; } Tar => { - let _ = crate::archive::tar::unpack_archive(reader, output_folder, skip_questions_positively)?; - info!("Successfully decompressed archive in {}.", nice_directory_display(output_folder)); + files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; } Zip => { eprintln!("Compressing first into .zip."); @@ -340,11 +347,12 @@ fn decompress_file( io::copy(&mut reader, &mut vec)?; let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?; - let _ = crate::archive::zip::unpack_archive(zip_archive, output_folder, skip_questions_positively)?; - - info!("Successfully decompressed archive in {}.", nice_directory_display(output_folder)); + files_unpacked = crate::archive::zip::unpack_archive(zip_archive, output_dir, question_policy)?; } } + info!("Successfully decompressed archive in {}.", nice_directory_display(output_dir)); + info!("Files unpacked: {}", files_unpacked.len()); + Ok(()) } diff --git a/src/dialogs.rs b/src/dialogs.rs index 4f4d7b2..ce18102 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -12,15 +12,22 @@ use crate::utils::colors; /// Represents a confirmation dialog pub struct Confirmation<'a> { + /// Represents the message to the displayed + /// e.g.: "Do you want to overwrite 'FILE'?" pub prompt: &'a str, + + /// Represents a placeholder to be changed at runtime + /// e.g.: Some("FILE") pub placeholder: Option<&'a str>, } impl<'a> Confirmation<'a> { + /// New Confirmation pub const fn new(prompt: &'a str, pattern: Option<&'a str>) -> Self { Self { prompt, placeholder: pattern } } + /// Creates user message and receives a boolean input to be used on the program pub fn ask(&self, substitute: Option<&'a str>) -> crate::Result { let message = match (self.placeholder, substitute) { (None, _) => Cow::Borrowed(self.prompt), diff --git a/src/error.rs b/src/error.rs index a8dd654..f395e7a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,8 @@ //! TODO: wrap `FinalError` in a variant to keep all `FinalError::display_and_crash()` function //! calls inside of this module. +#![allow(missing_docs)] + use std::{ fmt::{self, Display}, path::{Path, PathBuf}, @@ -13,6 +15,7 @@ use std::{ use crate::utils::colors::*; +/// Custom Ouch Errors #[derive(Debug, PartialEq)] pub enum Error { UnknownExtensionError(String), @@ -21,7 +24,7 @@ pub enum Error { FileNotFound(PathBuf), AlreadyExists, InvalidZipArchive(&'static str), - PermissionDenied, + PermissionDenied { error_title: String }, UnsupportedZipArchive(&'static str), InternalError, CompressingRootFolder, @@ -78,6 +81,10 @@ impl FinalError { self.hints.push(hint.to_string()); self } + + pub fn into_owned(&mut self) -> Self { + std::mem::take(self) + } } impl fmt::Display for Error { @@ -87,7 +94,7 @@ impl fmt::Display for Error { FinalError::with_title(format!("Cannot compress to {:?}", filename)) .detail("Ouch could not detect the compression format") .hint("Use a supported format extension, like '.zip' or '.tar.gz'") - .hint("Check https://github.com/vrmiguel/ouch for a full list of supported formats") + .hint("Check https://github.com/ouch-org/ouch for a full list of supported formats") } Error::WalkdirError { reason } => FinalError::with_title(reason), Error::FileNotFound(file) => { @@ -124,7 +131,7 @@ impl fmt::Display for Error { .detail("This should not have happened") .detail("It's probably our fault") .detail("Please help us improve by reporting the issue at:") - .detail(format!(" {}https://github.com/vrmiguel/ouch/issues ", *CYAN)) + .detail(format!(" {}https://github.com/ouch-org/ouch/issues ", *CYAN)) } Error::IoError { reason } => FinalError::with_title(reason), Error::CompressionTypo => { @@ -134,7 +141,7 @@ impl fmt::Display for Error { Error::UnknownExtensionError(_) => todo!(), Error::AlreadyExists => todo!(), Error::InvalidZipArchive(_) => todo!(), - Error::PermissionDenied => todo!(), + Error::PermissionDenied { error_title } => FinalError::with_title(error_title).detail("Permission denied"), Error::UnsupportedZipArchive(_) => todo!(), Error::Custom { reason } => reason.clone(), }; @@ -152,8 +159,8 @@ impl Error { impl From for Error { fn from(err: std::io::Error) -> Self { match err.kind() { - std::io::ErrorKind::NotFound => panic!("{}", err), - std::io::ErrorKind::PermissionDenied => Self::PermissionDenied, + std::io::ErrorKind::NotFound => todo!(), + std::io::ErrorKind::PermissionDenied => Self::PermissionDenied { error_title: err.to_string() }, std::io::ErrorKind::AlreadyExists => Self::AlreadyExists, _other => Self::IoError { reason: err.to_string() }, } @@ -177,3 +184,9 @@ impl From for Error { Self::WalkdirError { reason: err.to_string() } } } + +impl From for Error { + fn from(err: FinalError) -> Self { + Self::Custom { reason: err } + } +} diff --git a/src/extension.rs b/src/extension.rs index 0ac1ec1..d0ae41b 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -37,6 +37,7 @@ impl fmt::Display for Extension { } } +#[allow(missing_docs)] #[derive(Copy, Clone, PartialEq, Eq, Debug)] /// Accepted extensions for input and output pub enum CompressionFormat { @@ -49,6 +50,7 @@ pub enum CompressionFormat { } impl CompressionFormat { + /// Currently supported archive formats are .tar (and aliases to it) and .zip pub fn is_archive_format(&self) -> bool { // Keep this match like that without a wildcard `_` so we don't forget to update it match self { @@ -78,6 +80,19 @@ impl fmt::Display for CompressionFormat { } } +// use crate::extension::CompressionFormat::*; +// + +/// Extracts extensions from a path, +/// return both the remaining path and the list of extension objects +/// +/// ```rust +/// use ouch::extension::{separate_known_extensions_from_name, CompressionFormat}; +/// use std::path::Path; +/// +/// let mut path = Path::new("bolovo.tar.gz"); +/// assert_eq!(separate_known_extensions_from_name(&path), (Path::new("bolovo"), vec![CompressionFormat::Tar, CompressionFormat::Gzip])); +/// ``` pub fn separate_known_extensions_from_name(mut path: &Path) -> (&Path, Vec) { // // TODO: check for file names with the name of an extension // // TODO2: warn the user that currently .tar.gz is a .gz file named .tar @@ -114,6 +129,15 @@ pub fn separate_known_extensions_from_name(mut path: &Path) -> (&Path, Vec Vec { let (_, extensions) = separate_known_extensions_from_name(path); extensions diff --git a/src/lib.rs b/src/lib.rs index 9a1a795..40384a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,19 +4,25 @@ //! 1. It's required by `main.rs`, or //! 2. It's required by some integration tests at tests/ folder. -// Public modules +#![warn(missing_docs)] + +// Macros should be declared before +pub mod macros; + +pub mod archive; pub mod cli; pub mod commands; +pub mod dialogs; +pub mod error; +pub mod extension; +pub mod utils; -// Private modules -pub mod archive; -mod dialogs; -mod error; -mod extension; -mod macros; -mod utils; +/// CLI configuration step, uses definitions from `opts.rs`, also used to treat some inputs. +pub mod opts; pub use error::{Error, Result}; +pub use opts::{Opts, Subcommand}; +pub use utils::QuestionPolicy; /// The status code ouch has when an error is encountered pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE; diff --git a/src/macros.rs b/src/macros.rs index 76877fa..5bf7205 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,3 +1,6 @@ +//! Macros used on ouch. + +/// Macro that prints message in INFO mode #[macro_export] macro_rules! info { ($($arg:tt)*) => { @@ -6,6 +9,7 @@ macro_rules! info { }; } +/// Prints the `[Info]` tag pub fn _info_helper() { use crate::utils::colors::{RESET, YELLOW}; diff --git a/src/main.rs b/src/main.rs index 9fc88ec..eb886f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use ouch::{cli::Opts, commands, Result}; +use ouch::{commands, Opts, Result}; fn main() { if let Err(err) = run() { diff --git a/src/opts.rs b/src/opts.rs new file mode 100644 index 0000000..243a5a0 --- /dev/null +++ b/src/opts.rs @@ -0,0 +1,47 @@ +use clap::{Parser, ValueHint}; + +use std::path::PathBuf; + +/// Command line options +#[derive(Parser, Debug)] +#[clap(version, about)] +pub struct Opts { + /// Skip overwrite questions positively. + #[clap(short, long, conflicts_with = "no")] + pub yes: bool, + + /// Skip overwrite questions negatively. + #[clap(short, long)] + pub no: bool, + + /// Action to take + #[clap(subcommand)] + pub cmd: Subcommand, +} + +/// Actions to take +#[derive(Parser, PartialEq, Eq, Debug)] +pub enum Subcommand { + /// Compress files. Alias: c + #[clap(alias = "c")] + Compress { + /// Files to be compressed + #[clap(required = true, min_values = 1)] + files: Vec, + + /// The resulting file. Its extensions specify how the files will be compressed and they need to be supported + #[clap(required = true, value_hint = ValueHint::FilePath)] + output: PathBuf, + }, + /// Compress files. Alias: d + #[clap(alias = "d")] + Decompress { + /// Files to be decompressed + #[clap(required = true, min_values = 1)] + files: Vec, + + /// Decompress files in a directory other than the current + #[clap(short, long = "dir", value_hint = ValueHint::DirPath)] + output_dir: Option, + }, +} diff --git a/src/utils.rs b/src/utils.rs index 4e69ab0..e8aaf52 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,20 +1,24 @@ +//! Utils used on ouch. + use std::{ cmp, env, ffi::OsStr, - fs::{self, ReadDir}, path::Component, path::{Path, PathBuf}, }; +use fs_err as fs; + use crate::{dialogs::Confirmation, info}; /// Checks if the given path represents an empty directory. pub fn dir_is_empty(dir_path: &Path) -> bool { - let is_empty = |mut rd: ReadDir| rd.next().is_none(); + let is_empty = |mut rd: std::fs::ReadDir| rd.next().is_none(); dir_path.read_dir().map(is_empty).unwrap_or_default() } +/// Creates the dir if non existent. pub fn create_dir_if_non_existent(path: &Path) -> crate::Result<()> { if !path.exists() { fs::create_dir_all(path)?; @@ -23,6 +27,9 @@ pub fn create_dir_if_non_existent(path: &Path) -> crate::Result<()> { Ok(()) } +/// 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. pub fn strip_cur_dir(source_path: &Path) -> PathBuf { source_path .strip_prefix(Component::CurDir) @@ -43,11 +50,13 @@ pub fn cd_into_same_dir_as(filename: &Path) -> crate::Result { Ok(previous_location) } -pub fn user_wants_to_overwrite(path: &Path, skip_questions_positively: Option) -> crate::Result { - match skip_questions_positively { - Some(true) => Ok(true), - Some(false) => Ok(false), - None => { +/// Centralizes the decision of overwriting a file or not, +/// whether the user has already passed a question_policy or not. +pub fn user_wants_to_overwrite(path: &Path, question_policy: QuestionPolicy) -> crate::Result { + match question_policy { + QuestionPolicy::AlwaysYes => Ok(true), + QuestionPolicy::AlwaysNo => Ok(false), + QuestionPolicy::Ask => { let path = to_utf(strip_cur_dir(path)); let path = Some(path.as_str()); let placeholder = Some("FILE"); @@ -56,11 +65,13 @@ pub fn user_wants_to_overwrite(path: &Path, skip_questions_positively: Option) -> String { let text = format!("{:?}", os_str.as_ref()); text.trim_matches('"').to_string() } +/// Treats weird paths for better user messages. pub fn nice_directory_display(os_str: impl AsRef) -> String { let text = to_utf(os_str); if text == "." { @@ -70,6 +81,7 @@ pub fn nice_directory_display(os_str: impl AsRef) -> String { } } +/// Struct used to overload functionality onto Byte presentation. pub struct Bytes { bytes: f64, } @@ -86,6 +98,7 @@ pub mod colors { macro_rules! color { ($name:ident = $value:literal) => { #[cfg(target_family = "unix")] + /// Inserts color onto text based on configuration pub static $name: Lazy<&str> = Lazy::new(|| if *DISABLE_COLORED_TEXT { "" } else { $value }); #[cfg(not(target_family = "unix"))] pub static $name: &&str = &""; @@ -106,6 +119,7 @@ pub mod colors { impl Bytes { const UNIT_PREFIXES: [&'static str; 6] = ["", "k", "M", "G", "T", "P"]; + /// New Byte structure pub fn new(bytes: u64) -> Self { Self { bytes: bytes as f64 } } @@ -126,6 +140,17 @@ impl std::fmt::Display for Bytes { } } +#[derive(Debug, PartialEq, Clone, Copy)] +/// How overwrite questions should be handled +pub enum QuestionPolicy { + /// Ask everytime + Ask, + /// Skip overwrite questions positively + AlwaysYes, + /// Skip overwrite questions negatively + AlwaysNo, +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/compress_and_decompress.rs b/tests/compress_and_decompress.rs index 77d929d..3f06690 100644 --- a/tests/compress_and_decompress.rs +++ b/tests/compress_and_decompress.rs @@ -1,16 +1,15 @@ mod utils; use std::{ - env, fs, + env, io::prelude::*, path::{Path, PathBuf}, time::Duration, }; -use ouch::{ - cli::{Opts, Subcommand}, - commands::run, -}; +use ouch::{commands::run, Opts, QuestionPolicy, Subcommand}; + +use fs_err as fs; use rand::{rngs::SmallRng, RngCore, SeedableRng}; use tempfile::NamedTempFile; use utils::*; @@ -180,10 +179,10 @@ fn extract_files(archive_path: &Path) -> Vec { no: false, cmd: Subcommand::Decompress { files: vec![archive_path.to_owned()], - output: Some(extraction_output_folder.clone()), + output_dir: Some(extraction_output_folder.clone()), }, }; - run(command, None).expect("Failed to extract"); + run(command, QuestionPolicy::Ask).expect("Failed to extract"); fs::read_dir(extraction_output_folder).unwrap().map(Result::unwrap).map(|entry| entry.path()).collect() } diff --git a/tests/utils.rs b/tests/utils.rs index 10cc749..538c802 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -2,15 +2,11 @@ #![allow(dead_code)] -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; -use ouch::{ - cli::{Opts, Subcommand}, - commands::run, -}; +use fs_err as fs; + +use ouch::{commands::run, Opts, QuestionPolicy, Subcommand}; pub fn create_empty_dir(at: &Path, filename: &str) -> PathBuf { let dirname = Path::new(filename); @@ -30,7 +26,7 @@ pub fn compress_files(at: &Path, paths_to_compress: &[PathBuf], format: &str) -> no: false, cmd: Subcommand::Compress { files: paths_to_compress.to_vec(), output: archive_path.clone() }, }; - run(command, None).expect("Failed to compress test dummy files"); + run(command, QuestionPolicy::Ask).expect("Failed to compress test dummy files"); archive_path } @@ -52,10 +48,10 @@ pub fn extract_files(archive_path: &Path) -> Vec { no: false, cmd: Subcommand::Decompress { files: vec![archive_path.to_owned()], - output: Some(extraction_output_folder.clone()), + output_dir: Some(extraction_output_folder.clone()), }, }; - run(command, None).expect("Failed to extract"); + run(command, QuestionPolicy::Ask).expect("Failed to extract"); fs::read_dir(extraction_output_folder).unwrap().map(Result::unwrap).map(|entry| entry.path()).collect() }