diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 000000000..47232f95d --- /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 2c5276607..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,317 +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 help2man musl-tools - - - name: Run cargo build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release --target x86_64-unknown-linux-musl - env: - GEN_COMPLETIONS: 1 - - - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --target x86_64-unknown-linux-musl - - - name: Build man page and find completions - run: | - help2man target/x86_64-unknown-linux-musl/release/ouch > ouch.1 - cp -r target/x86_64-unknown-linux-musl/release/build/ouch-*/out/completions . - - - 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 - - - name: Upload completions - uses: actions/upload-artifact@v2 - with: - name: completions - path: completions - - - name: Upload man page - uses: actions/upload-artifact@v2 - with: - name: ouch.1 - path: ouch.1 - - 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 - - fmt: - name: Check sourcecode format - runs-on: ubuntu-latest - - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: nightly - target: x86_64-unknown-linux-musl - components: rustfmt - override: true - - - name: Check format with cargo fmt - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check diff --git a/Cargo.lock b/Cargo.lock index 8bcd69d52..e66158897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,9 +234,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.103" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" +checksum = "a60553f9a9e039a333b4e9b20573b9e9b9c0bb3a11e201ccc48ef4283456d673" [[package]] name = "libz-sys" @@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "ouch" -version = "0.2.0" +version = "0.3.0" dependencies = [ "atty", "bzip2", @@ -322,15 +322,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9b1041b4387893b91ee6746cddfc28516aff326a3519fb2adf820932c5e6cb" +checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" [[package]] name = "ppv-lite86" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ca011bd0129ff4ae15cd04c4eef202cadf6c51c21e47aba319b4e0501db741" +checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba" [[package]] name = "proc-macro-error" @@ -358,9 +358,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.30" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc3358ebc67bc8b7fa0c007f945b0b18226f78437d61bec735a9eb96b61ee70" +checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" dependencies = [ "unicode-xid", ] @@ -449,9 +449,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "1.0.80" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d010a1623fbd906d51d650a9916aaefc05ffa0e4053ff7fe601167f3e715d194" +checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 325eee954..ea7c98af0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ouch" -version = "0.2.0" +version = "0.3.0" authors = ["Vinícius Rodrigues Miguel ", "João M. Bezerra "] edition = "2018" readme = "README.md" diff --git a/README.md b/README.md index 1d706cc9e..4453fd577 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ 3. Same syntax, various formats. 4. Encoding and decoding streams, it's fast. 5. No runtime dependencies (for _Linux x86_64_). +6. Listing archive contents with tree formatting (in next release!). ## Usage diff --git a/install.sh b/install.sh index a05924fdb..9a3c06da3 100644 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #! /usr/bin/sh # Needs to be updated each version bump -VERSION="0.2.0" +VERSION="0.3.0" DOWNLOAD_LOCATION="/tmp/ouch-binary" INSTALLATION_LOCATION="/usr/local/bin/ouch" diff --git a/src/archive/tar.rs b/src/archive/tar.rs index a5c68b0cb..e0265fda7 100644 --- a/src/archive/tar.rs +++ b/src/archive/tar.rs @@ -35,6 +35,13 @@ pub fn unpack_archive( continue; } + if file_path.is_dir() { + // ToDo: Maybe we should emphasise that `file_path` is a directory and everything inside it will be gone? + fs::remove_dir_all(&file_path)?; + } else if file_path.is_file() { + fs::remove_file(&file_path)?; + } + file.unpack_in(output_folder)?; info!("{:?} extracted. ({})", output_folder.join(file.path()?), Bytes::new(file.size())); @@ -89,7 +96,6 @@ where FinalError::with_title("Could not create archive") .detail("Unexpected error while trying to read file") .detail(format!("Error: {}.", err)) - .into_owned() })?; } } diff --git a/src/archive/zip.rs b/src/archive/zip.rs index 58b3f7bb4..50ec85328 100644 --- a/src/archive/zip.rs +++ b/src/archive/zip.rs @@ -42,6 +42,13 @@ where continue; } + if file_path.is_dir() { + // ToDo: Maybe we should emphasise that `file_path` is a directory and everything inside it will be gone? + fs::remove_dir_all(&file_path)?; + } else if file_path.is_file() { + fs::remove_file(&file_path)?; + } + check_for_comments(&file); match (&*file.name()).ends_with('/') { diff --git a/src/cli.rs b/src/cli.rs index cca1f7192..677471f55 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,8 +1,7 @@ -//! CLI configuration step, uses definitions from `opts.rs`. -//! -//! Also used to treat some inputs. +//! CLI related functions, uses the clap argparsing definitions from `opts.rs`. use std::{ + io, path::{Path, PathBuf}, vec::Vec, }; @@ -10,13 +9,16 @@ use std::{ use clap::Parser; use fs_err as fs; -use crate::{Error, Opts, QuestionPolicy, Subcommand}; +use crate::{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 + /// A helper method that calls `clap::Parser::parse`. + /// + /// And: + /// 1. Make paths absolute. + /// 2. Checks the QuestionPolicy. pub fn parse_args() -> crate::Result<(Self, QuestionPolicy)> { - let mut opts: Self = Self::parse(); + let mut opts = Self::parse(); let (Subcommand::Compress { files, .. } | Subcommand::Decompress { files, .. } @@ -35,19 +37,6 @@ impl Opts { } } -fn canonicalize(path: impl AsRef) -> crate::Result { - match fs::canonicalize(&path.as_ref()) { - Ok(abs_path) => Ok(abs_path), - Err(io_err) => { - if !path.as_ref().exists() { - Err(Error::FileNotFound(path.as_ref().into())) - } else { - Err(io_err.into()) - } - } - } -} - -fn canonicalize_files(files: &[impl AsRef]) -> crate::Result> { - files.iter().map(canonicalize).collect() +fn canonicalize_files(files: &[impl AsRef]) -> io::Result> { + files.iter().map(fs::canonicalize).collect() } diff --git a/src/commands.rs b/src/commands.rs index 0cc07756f..c4faa2ee5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,11 +16,12 @@ use crate::{ extension::{ self, CompressionFormat::{self, *}, + Extension, }, info, list::{self, ListOptions}, - utils::{self, dir_is_empty, nice_directory_display, to_utf}, - Error, Opts, QuestionPolicy, Subcommand, + utils::{self, concatenate_list_of_os_str, dir_is_empty, nice_directory_display, to_utf}, + Opts, QuestionPolicy, Subcommand, }; // Used in BufReader and BufWriter to perform less syscalls @@ -36,8 +37,7 @@ fn represents_several_files(files: &[PathBuf]) -> bool { files.iter().any(is_non_empty_dir) || files.len() > 1 } -/// Entrypoint of ouch, receives cli options and matches Subcommand -/// to decide current operation +/// Entrypoint of ouch, receives cli options and matches Subcommand to decide what to do pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { match args.cmd { Subcommand::Compress { files, output: output_path } => { @@ -45,20 +45,19 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { let mut formats = extension::extensions_from_path(&output_path); if formats.is_empty() { - let reason = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path))) - .detail("You shall supply the compression format via the extension.") - .hint("Try adding something like .tar.gz or .zip to the output file.") + 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 ... {}.tar.gz", to_utf(&output_path))) + .hint(format!(" ouch compress ... {}.zip", to_utf(&output_path))) .hint("") - .hint("Examples:") - .hint(format!(" ouch compress ... {}.tar.gz", to_utf(&output_path))) - .hint(format!(" ouch compress ... {}.zip", to_utf(&output_path))); + .hint("Alternatively, you can overwrite this option by using the '--format' flag:") + .hint(format!(" ouch compress ... {} --format tar.gz", to_utf(&output_path))); - return Err(Error::with_reason(reason)); + return Err(error.into()); } - if !formats.get(0).map(CompressionFormat::is_archive_format).unwrap_or(false) - && represents_several_files(&files) - { + 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 @@ -75,29 +74,29 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { let mut suggested_output_path = output_path.clone(); suggested_output_path.replace_range(empty_range, ".tar"); - let reason = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path))) + let error = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&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)); + .hint(format!("To: {}", suggested_output_path)); - return Err(Error::with_reason(reason)); + return Err(error.into()); } - if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive_format()) { - let reason = FinalError::with_title(format!("Cannot compress to '{}'.", to_utf(&output_path))) + 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::with_reason(reason)); + 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 + // User does not want to overwrite this file, skip and return without any errors return Ok(()); } @@ -108,12 +107,28 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { // `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) + .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 this counts as true - if !input_extensions.is_empty() - && input_extensions.len() < formats.len() - && input_extensions.iter().zip(&formats).all(|(inp, out)| inp == out) - { + // 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 a extension. // @@ -124,8 +139,7 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { to_utf(files[0].as_path().file_name().unwrap()), to_utf(&output_path) ); - let drain_iter = formats.drain(..input_extensions.len()); - drop(drain_iter); // Remove the extensions from `formats` + formats = new_formats; } } let compress_result = compress_files(files, formats, output_file); @@ -163,15 +177,20 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { .map(|(input_path, _)| PathBuf::from(input_path)) .collect(); - // Error if !files_missing_format.is_empty() { - eprintln!("Some file you asked ouch to decompress lacks a supported extension."); - eprintln!("Could not decompress {}.", to_utf(&files_missing_format[0])); - todo!( - "Dev note: add this error variant and pass the Vec to it, all the files \ - lacking extension shall be shown: {:#?}.", - files_missing_format - ); + let error = FinalError::with_title("Cannot decompress files without extensions") + .detail(format!( + "Files without supported extensions: {}", + concatenate_list_of_os_str(&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()); } // From Option to Option<&Path> @@ -227,7 +246,7 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { // 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<()> { +fn compress_files(files: Vec, formats: Vec, output_file: fs::File) -> crate::Result<()> { let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file); let mut writer: Box = Box::new(file_writer); @@ -250,13 +269,13 @@ fn compress_files(files: Vec, formats: Vec, output_f encoder }; - for format in formats.iter().skip(1).rev() { + for format in formats.iter().flat_map(Extension::iter).skip(1).collect::>().iter().rev() { writer = chain_writer_encoder(format, writer); } - match formats[0] { + match formats[0].compression_formats[0] { Gzip | Bzip | Lzma | Zstd => { - writer = chain_writer_encoder(&formats[0], writer); + 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)?; } @@ -264,26 +283,6 @@ fn compress_files(files: Vec, formats: Vec, output_f let mut writer = archive::tar::build_archive_from_paths(&files, writer)?; writer.flush()?; } - Tgz => { - let encoder = flate2::write::GzEncoder::new(writer, Default::default()); - let writer = archive::tar::build_archive_from_paths(&files, encoder)?; - writer.finish()?.flush()?; - } - Tbz => { - let encoder = bzip2::write::BzEncoder::new(writer, Default::default()); - let writer = archive::tar::build_archive_from_paths(&files, encoder)?; - writer.finish()?.flush()?; - } - Tlzma => { - let encoder = xz2::write::XzEncoder::new(writer, 6); - let writer = archive::tar::build_archive_from_paths(&files, encoder)?; - writer.finish()?.flush()?; - } - Tzst => { - let encoder = zstd::stream::write::Encoder::new(writer, Default::default())?; - let writer = archive::tar::build_archive_from_paths(&files, encoder)?; - writer.finish()?.flush()?; - } Zip => { eprintln!("{yellow}Warning:{reset}", yellow = *colors::YELLOW, reset = *colors::RESET); eprintln!("\tCompressing .zip entirely in memory."); @@ -312,7 +311,7 @@ fn compress_files(files: Vec, formats: Vec, output_f // 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, + formats: Vec, output_dir: Option<&Path>, file_name: &Path, question_policy: QuestionPolicy, @@ -334,7 +333,7 @@ fn decompress_file( // 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() { + if formats.len() == 1 && *formats[0].compression_formats.as_slice() == [Zip] { 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_dir, question_policy)?; @@ -358,7 +357,7 @@ fn decompress_file( Ok(decoder) }; - for format in formats.iter().skip(1).rev() { + for format in formats.iter().flat_map(Extension::iter).skip(1).collect::>().iter().rev() { reader = chain_reader_decoder(format, reader)?; } @@ -366,12 +365,16 @@ fn decompress_file( let files_unpacked; - match formats[0] { + match formats[0].compression_formats[0] { Gzip | Bzip | Lzma | Zstd => { - reader = chain_reader_decoder(&formats[0], reader)?; + reader = chain_reader_decoder(&formats[0].compression_formats[0], reader)?; - // TODO: improve error treatment - let mut writer = fs::File::create(&output_path)?; + let writer = utils::create_or_ask_overwrite(&output_path, question_policy)?; + if writer.is_none() { + // Means that the user doesn't want to overwrite + return Ok(()); + } + let mut writer = writer.unwrap(); io::copy(&mut reader, &mut writer)?; files_unpacked = vec![output_path]; @@ -379,22 +382,6 @@ fn decompress_file( Tar => { files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; } - Tgz => { - let reader = chain_reader_decoder(&Gzip, reader)?; - files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; - } - Tbz => { - let reader = chain_reader_decoder(&Bzip, reader)?; - files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; - } - Tlzma => { - let reader = chain_reader_decoder(&Lzma, reader)?; - files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; - } - Tzst => { - let reader = chain_reader_decoder(&Zstd, reader)?; - files_unpacked = crate::archive::tar::unpack_archive(reader, output_dir, question_policy)?; - } Zip => { eprintln!("Compressing first into .zip."); eprintln!("Warning: .zip archives with extra extensions have a downside."); diff --git a/src/dialogs.rs b/src/dialogs.rs index ce1810271..dbf6a73a1 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -1,7 +1,7 @@ //! Pretty (and colored) dialog for asking [Y/n] for the end user. //! //! Example: -//! "Do you want to overwrite 'archive.targz'? [Y/n]" +//! "Do you want to overwrite 'archive.tar.gz'? [Y/n]" use std::{ borrow::Cow, @@ -10,19 +10,21 @@ use std::{ use crate::utils::colors; -/// Represents a confirmation dialog +/// Confirmation dialog for end user with [Y/n] question. +/// +/// If the placeholder is found in the prompt text, it will be replaced to form the final message. pub struct Confirmation<'a> { - /// Represents the message to the displayed + /// The message to be displayed with the placeholder text in it. /// e.g.: "Do you want to overwrite 'FILE'?" pub prompt: &'a str, - /// Represents a placeholder to be changed at runtime + /// The placeholder text that will be replaced in the `ask` function: /// e.g.: Some("FILE") pub placeholder: Option<&'a str>, } impl<'a> Confirmation<'a> { - /// New Confirmation + /// Creates a new Confirmation. pub const fn new(prompt: &'a str, pattern: Option<&'a str>) -> Self { Self { prompt, placeholder: pattern } } @@ -31,24 +33,21 @@ impl<'a> Confirmation<'a> { pub fn ask(&self, substitute: Option<&'a str>) -> crate::Result { let message = match (self.placeholder, substitute) { (None, _) => Cow::Borrowed(self.prompt), - (Some(_), None) => return Err(crate::Error::InternalError), + (Some(_), None) => unreachable!("dev error, should be reported, we checked this won't happen"), (Some(placeholder), Some(subs)) => Cow::Owned(self.prompt.replace(placeholder, subs)), }; + // Ask the same question to end while no valid answers are given loop { print!("{} [{}Y{}/{}n{}] ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET); io::stdout().flush()?; let mut answer = String::new(); io::stdin().read_line(&mut answer)?; - let trimmed_answer = answer.trim(); - if trimmed_answer.is_empty() { - return Ok(true); - } - - match trimmed_answer.to_ascii_lowercase().as_ref() { - "y" | "yes" => return Ok(true), + answer.make_ascii_lowercase(); + match answer.trim() { + "" | "y" | "yes" => return Ok(true), "n" | "no" => return Ok(false), _ => continue, // Try again } diff --git a/src/error.rs b/src/error.rs index f395e7ac4..81c5eef46 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,12 +1,6 @@ -//! Error type definitions. +//! Error types definitions. //! -//! All the unexpected user-side errors should be treated in this file, that does not include -//! errors made by devs in our implementation. -//! -//! TODO: wrap `FinalError` in a variant to keep all `FinalError::display_and_crash()` function -//! calls inside of this module. - -#![allow(missing_docs)] +//! All usage errors will pass throught the Error enum, a lot of them in the Error::Custom. use std::{ fmt::{self, Display}, @@ -15,32 +9,41 @@ use std::{ use crate::utils::colors::*; -/// Custom Ouch Errors +#[allow(missing_docs)] +/// All errors that can be generated by `ouch` #[derive(Debug, PartialEq)] pub enum Error { - UnknownExtensionError(String), - MissingExtensionError(PathBuf), + /// Not every IoError, some of them get filtered by `From` into other variants IoError { reason: String }, + /// Detected from io::Error if .kind() is io::ErrorKind::NotFound FileNotFound(PathBuf), + /// NEEDS MORE CONTEXT AlreadyExists, + /// From zip::result::ZipError::InvalidArchive InvalidZipArchive(&'static str), + /// Detected from io::Error if .kind() is io::ErrorKind::PermissionDenied PermissionDenied { error_title: String }, + /// From zip::result::ZipError::UnsupportedArchive UnsupportedZipArchive(&'static str), - InternalError, + /// TO BE REMOVED CompressingRootFolder, - MissingArgumentsForCompression, - MissingArgumentsForDecompression, - CompressionTypo, + /// Specialized walkdir's io::Error wrapper with additional information on the error WalkdirError { reason: String }, + /// Custom and unique errors are reported in this variant Custom { reason: FinalError }, } +/// Alias to std's Result with ouch's Error pub type Result = std::result::Result; +/// Pretty final error message for end users, crashing the program after display. #[derive(Clone, Debug, Default, PartialEq)] pub struct FinalError { + /// Should be made of just one line, appears after the "[ERROR]" part title: String, + /// Shown as a unnumbered list in yellow details: Vec, + /// Shown as green at the end to give hints on how to work around this error, if it's fixable hints: Vec, } @@ -68,34 +71,27 @@ impl Display for FinalError { } impl FinalError { + /// Only constructor pub fn with_title(title: impl ToString) -> Self { Self { title: title.to_string(), details: vec![], hints: vec![] } } + /// Add one detail line, can have multiple pub fn detail(mut self, detail: impl ToString) -> Self { self.details.push(detail.to_string()); self } + /// Add one hint line, can have multiple pub fn hint(mut self, hint: impl ToString) -> Self { self.hints.push(hint.to_string()); self } - - pub fn into_owned(&mut self) -> Self { - std::mem::take(self) - } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let err = match self { - Error::MissingExtensionError(filename) => { - 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/ouch-org/ouch for a full list of supported formats") - } Error::WalkdirError { reason } => FinalError::with_title(reason), Error::FileNotFound(file) => { if file == Path::new("") { @@ -109,36 +105,7 @@ impl fmt::Display for Error { .detail("This is unadvisable since ouch does compressions in-memory.") .hint("Use a more appropriate tool for this, such as rsync.") } - Error::MissingArgumentsForCompression => { - FinalError::with_title("Could not compress") - .detail("The compress command requires at least 2 arguments") - .hint("You must provide:") - .hint(" - At least one input argument.") - .hint(" - The output argument.") - .hint("") - .hint("Example: `ouch compress image.png img.zip`") - } - Error::MissingArgumentsForDecompression => { - FinalError::with_title("Could not decompress") - .detail("The compress command requires at least one argument") - .hint("You must provide:") - .hint(" - At least one input argument.") - .hint("") - .hint("Example: `ouch decompress imgs.tar.gz`") - } - Error::InternalError => { - FinalError::with_title("InternalError :(") - .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/ouch-org/ouch/issues ", *CYAN)) - } Error::IoError { reason } => FinalError::with_title(reason), - Error::CompressionTypo => { - FinalError::with_title("Possible typo detected") - .hint(format!("Did you mean '{}ouch compress{}'?", *MAGENTA, *RESET)) - } - Error::UnknownExtensionError(_) => todo!(), Error::AlreadyExists => todo!(), Error::InvalidZipArchive(_) => todo!(), Error::PermissionDenied { error_title } => FinalError::with_title(error_title).detail("Permission denied"), @@ -150,12 +117,6 @@ impl fmt::Display for Error { } } -impl Error { - pub fn with_reason(reason: FinalError) -> Self { - Self::Custom { reason } - } -} - impl From for Error { fn from(err: std::io::Error) -> Self { match err.kind() { diff --git a/src/extension.rs b/src/extension.rs index 3cd17d608..c9dc05565 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -4,26 +4,70 @@ use std::{ffi::OsStr, fmt, path::Path}; use self::CompressionFormat::*; -#[allow(missing_docs)] -#[derive(Clone, PartialEq, Eq, Debug)] +/// A wrapper around `CompressionFormat` that allows combinations like `tgz` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Extension { + /// One extension like "tgz" can be made of multiple CompressionFormats ([Tar, Gz]) + pub compression_formats: Vec, + /// The input text for this extension, like "tgz", "tar" or "xz" + pub display_text: String, +} + +impl Extension { + /// # Panics: + /// Will panic if `formats` is empty + pub fn new(formats: impl Into>, text: impl Into) -> Self { + let formats = formats.into(); + assert!(!formats.is_empty()); + Self { compression_formats: formats, display_text: text.into() } + } + + /// Checks if the first format in `compression_formats` is an archive + pub fn is_archive(&self) -> bool { + // 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 { + self.compression_formats.iter() + } +} + +impl fmt::Display for Extension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.display_text) + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] /// Accepted extensions for input and output pub enum CompressionFormat { - Gzip, // .gz - Bzip, // .bz - Lzma, // .lzma - Tar, // .tar (technically not a compression extension, but will do for now) - Tgz, // .tgz - Tbz, // .tbz - Tlzma, // .tlzma - Tzst, // .tzst - Zstd, // .zst - Zip, // .zip + /// .gz + Gzip, + /// .bz .bz2 + Bzip, + /// .xz .lzma .lz + Lzma, + /// tar, tgz, tbz, tbz2, txz, tlz, tlzma, tzst + Tar, + /// .zst + Zstd, + /// .zip + Zip, } impl CompressionFormat { /// Currently supported archive formats are .tar (and aliases to it) and .zip pub fn is_archive_format(&self) -> bool { - matches!(self, Tar | Tgz | Tbz | Tlzma | Tzst | Zip) + // Keep this match like that without a wildcard `_` so we don't forget to update it + match self { + Tar | Zip => true, + Gzip => false, + Bzip => false, + Lzma => false, + Zstd => false, + } } } @@ -38,10 +82,6 @@ impl fmt::Display for CompressionFormat { Zstd => ".zst", Lzma => ".lz", Tar => ".tar", - Tgz => ".tgz", - Tbz => ".tbz", - Tlzma => ".tlz", - Tzst => ".tzst", Zip => ".zip", } ) @@ -53,15 +93,7 @@ impl fmt::Display for 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) { +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 // @@ -75,16 +107,16 @@ pub fn separate_known_extensions_from_name(mut path: &Path) -> (&Path, Vec Tar, - "tgz" => Tgz, - "tbz" | "tbz2" => Tbz, - "txz" | "tlz" | "tlzma" => Tlzma, - "tzst" => Tzst, - "zip" => Zip, - "bz" | "bz2" => Bzip, - "gz" => Gzip, - "xz" | "lzma" | "lz" => Lzma, - "zst" => Zstd, + "tar" => Extension::new([Tar], extension), + "tgz" => Extension::new([Tar, Gzip], extension), + "tbz" | "tbz2" => Extension::new([Tar, Bzip], extension), + "txz" | "tlz" | "tlzma" => Extension::new([Tar, Lzma], extension), + "tzst" => Extension::new([Tar, Zstd], ".tzst"), + "zip" => Extension::new([Zip], extension), + "bz" | "bz2" => Extension::new([Bzip], extension), + "gz" => Extension::new([Gzip], extension), + "xz" | "lzma" | "lz" => Extension::new([Lzma], extension), + "zst" => Extension::new([Zstd], extension), _ => break, }); @@ -98,15 +130,23 @@ pub fn separate_known_extensions_from_name(mut path: &Path) -> (&Path, Vec Vec { +pub fn extensions_from_path(path: &Path) -> Vec { let (_, extensions) = separate_known_extensions_from_name(path); extensions } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extensions_from_path() { + use CompressionFormat::*; + let path = Path::new("bolovo.tar.gz"); + + let extensions: Vec = extensions_from_path(&path); + let formats: Vec<&CompressionFormat> = extensions.iter().flat_map(Extension::iter).collect::>(); + + assert_eq!(formats, vec![&Tar, &Gzip]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 96e710ee4..137775ebf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,4 @@ -//! This library is meant to be published, just used internally by our binary crate at `main.rs`. -//! -//! A module shall be public only if: -//! 1. It's required by `main.rs`, or -//! 2. It's required by some integration tests at tests/ folder. +//! This library isn't meant to be published, but used internally by our binary crate `main.rs`. #![warn(missing_docs)] @@ -18,12 +14,12 @@ pub mod extension; pub mod list; pub mod utils; -/// CLI configuration step, uses definitions from `opts.rs`, also used to treat some inputs. +/// CLI argparsing definitions, using `clap`. 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 +/// The status code returned from `ouch` on error pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE; diff --git a/src/macros.rs b/src/macros.rs index 5bf72055f..1057120ab 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,6 +1,6 @@ //! Macros used on ouch. -/// Macro that prints message in INFO mode +/// Macro that prints [INFO] messages, wraps [`println`]. #[macro_export] macro_rules! info { ($($arg:tt)*) => { @@ -9,7 +9,7 @@ macro_rules! info { }; } -/// Prints the `[Info]` tag +/// Helper to display "[INFO]", colored yellow pub fn _info_helper() { use crate::utils::colors::{RESET, YELLOW}; diff --git a/src/opts.rs b/src/opts.rs index c9b31e663..553d03304 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -6,41 +6,51 @@ use std::path::PathBuf; #[derive(Parser, Debug)] #[clap(version, about)] pub struct Opts { - /// Skip overwrite questions positively. + /// Skip [Y/n] questions positively. #[clap(short, long, conflicts_with = "no")] pub yes: bool, - /// Skip overwrite questions negatively. + /// Skip [Y/n] questions negatively. #[clap(short, long)] pub no: bool, - /// Action to take + /// Ouch and claps subcommands #[clap(subcommand)] pub cmd: Subcommand, } -/// Actions to take +// CAREFUL: this docs can accidentally become part of the --help message if they get too long +// this was tested in clap 3.0.0-beta5. +/// Repository: https://github.com/ouch-org/ouch +// +// Ouch commands: +// - `compress` +// - `decompress` +// - `list` +// +// Clap commands: +// - `help` #[derive(Parser, PartialEq, Eq, Debug)] pub enum Subcommand { - /// Compress files. Alias: c + /// Compress one or more files into one output file. #[clap(alias = "c")] Compress { - /// Files to be compressed + /// 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 + /// The resulting file. It's extensions can be used to specify the compression formats. #[clap(required = true, value_hint = ValueHint::FilePath)] output: PathBuf, }, - /// Compress files. Alias: d + /// Decompresses one or more files, optionally into another folder. #[clap(alias = "d")] Decompress { - /// Files to be decompressed + /// Files to be decompressed. #[clap(required = true, min_values = 1)] files: Vec, - /// Decompress files in a directory other than the current + /// Choose to 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 c47e76331..17b47fb84 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,17 +1,39 @@ -//! Utils used on ouch. +//! Random stuff used on ouch. use std::{ cmp, env, ffi::OsStr, + io, path::Component, path::{Path, PathBuf}, }; use fs_err as fs; -use crate::{dialogs::Confirmation, info}; +use crate::{dialogs::Confirmation, info, Error}; + +/// Create the file if it doesn't exist and if it does then ask to overwrite it. +/// If the user doesn't want to overwrite then we return [`Ok(None)`] +pub fn create_or_ask_overwrite(path: &Path, question_policy: QuestionPolicy) -> Result, Error> { + match fs::OpenOptions::new().write(true).create_new(true).open(path) { + Ok(w) => Ok(Some(w)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + if user_wants_to_overwrite(path, question_policy)? { + if path.is_dir() { + // We can't just use `fs::File::create(&path)` because it would return io::ErrorKind::IsADirectory + // ToDo: Maybe we should emphasise that `path` is a directory and everything inside it will be gone? + fs::remove_dir_all(path)?; + } + Ok(Some(fs::File::create(path)?)) + } else { + Ok(None) + } + } + Err(e) => Err(Error::from(e)), + } +} -/// Checks if the given path represents an empty directory. +/// Checks given path points to an empty directory. pub fn dir_is_empty(dir_path: &Path) -> bool { let is_empty = |mut rd: std::fs::ReadDir| rd.next().is_none(); @@ -37,21 +59,18 @@ pub fn strip_cur_dir(source_path: &Path) -> PathBuf { .unwrap_or_else(|_| source_path.to_path_buf()) } -/// Changes the process' current directory to the directory that contains the -/// file pointed to by `filename` and returns the directory that the process -/// was in before this function was called. +/// Returns current directory, but before change the process' directory to the +/// one that contains the file pointed to by `filename`. pub fn cd_into_same_dir_as(filename: &Path) -> crate::Result { let previous_location = env::current_dir()?; let parent = filename.parent().ok_or(crate::Error::CompressingRootFolder)?; - env::set_current_dir(parent)?; Ok(previous_location) } -/// Centralizes the decision of overwriting a file or not, -/// whether the user has already passed a question_policy or not. +/// Check if QuestionPolicy flags were set, otherwise, ask user if they want to overwrite. pub fn user_wants_to_overwrite(path: &Path, question_policy: QuestionPolicy) -> crate::Result { match question_policy { QuestionPolicy::AlwaysYes => Ok(true), @@ -65,13 +84,32 @@ pub fn user_wants_to_overwrite(path: &Path, question_policy: QuestionPolicy) -> } } -/// Converts an OsStr to utf8. +/// 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(os_str: impl AsRef) -> String { let text = format!("{:?}", os_str.as_ref()); text.trim_matches('"').to_string() } -/// Treats weird paths for better user messages. +/// Converts a slice of AsRef to comma separated String +/// +/// Panics if the slice is empty. +pub fn concatenate_list_of_os_str(os_strs: &[impl AsRef]) -> String { + let mut iter = os_strs.iter().map(AsRef::as_ref); + + let mut string = to_utf(iter.next().unwrap()); // May panic + + for os_str in iter { + string += ", "; + string += &to_utf(os_str); + } + string +} + +/// Display the directory name, but change to "current directory" when necessary. pub fn nice_directory_display(os_str: impl AsRef) -> String { let text = to_utf(os_str); if text == "." { @@ -81,11 +119,6 @@ pub fn nice_directory_display(os_str: impl AsRef) -> String { } } -/// Struct used to overload functionality onto Byte presentation. -pub struct Bytes { - bytes: f64, -} - /// Module with a list of bright colors. #[allow(dead_code)] pub mod colors { @@ -119,10 +152,15 @@ pub mod colors { color!(ALL_RESET = "\u{1b}[0;39m"); } +/// Struct useful to printing bytes as kB, MB, GB, etc. +pub struct Bytes { + bytes: f64, +} + impl Bytes { const UNIT_PREFIXES: [&'static str; 6] = ["", "k", "M", "G", "T", "P"]; - /// New Byte structure + /// Create a new Bytes. pub fn new(bytes: u64) -> Self { Self { bytes: bytes as f64 } } @@ -144,13 +182,13 @@ impl std::fmt::Display for Bytes { } #[derive(Debug, PartialEq, Clone, Copy)] -/// How overwrite questions should be handled +/// Determines if overwrite questions should be skipped or asked to the user pub enum QuestionPolicy { - /// Ask everytime + /// Ask the user every time Ask, - /// Skip overwrite questions positively + /// Set by `--yes`, will say 'Y' to all overwrite questions AlwaysYes, - /// Skip overwrite questions negatively + /// Set by `--no`, will say 'N' to all overwrite questions AlwaysNo, }