Repository: ouch-org/ouch (language: rust)

Issue:
Support decompressing stdin
With `tar`, I can run something like `curl .../file.tar.gz | tar xvz`. It would be nice if `ouch` supported that for formats where streaming is possible.


Discussion / hints:
Nice suggestion, this has been a suggestion for a couple years now and it's definitely something I'd merge in.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c6957762..13ff21f0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ Categories Used:
 ### Improvements
 
 - Fix logging IO bottleneck [\#642](https://github.com/ouch-org/ouch/pull/642) ([AntoniosBarotsis](https://github.com/AntoniosBarotsis))
+- Support decompression  over stdin [\#692](https://github.com/ouch-org/ouch/pull/692) ([rcorre](https://github.com/rcorre))
 
 ## [0.5.1](https://github.com/ouch-org/ouch/compare/0.5.0...0.5.1)
 
diff --git a/src/cli/args.rs b/src/cli/args.rs
index 85712a48d..9826c78f7 100644
--- a/src/cli/args.rs
+++ b/src/cli/args.rs
@@ -77,7 +77,7 @@ pub enum Subcommand {
     /// Decompresses one or more files, optionally into another folder
     #[command(visible_alias = "d")]
     Decompress {
-        /// Files to be decompressed
+        /// Files to be decompressed, or "-" for stdin
         #[arg(required = true, num_args = 1.., value_hint = ValueHint::FilePath)]
         files: Vec<PathBuf>,
 
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 69bf2139b..ce6b5d591 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -11,7 +11,11 @@ use clap::Parser;
 use fs_err as fs;
 
 pub use self::args::{CliArgs, Subcommand};
-use crate::{accessible::set_accessible, utils::FileVisibilityPolicy, QuestionPolicy};
+use crate::{
+    accessible::set_accessible,
+    utils::{is_path_stdin, FileVisibilityPolicy},
+    QuestionPolicy,
+};
 
 impl CliArgs {
     /// A helper method that calls `clap::Parser::parse`.
@@ -47,5 +51,14 @@ impl CliArgs {
 }
 
 fn canonicalize_files(files: &[impl AsRef<Path>]) -> io::Result<Vec<PathBuf>> {
-    files.iter().map(fs::canonicalize).collect()
+    files
+        .iter()
+        .map(|f| {
+            if is_path_stdin(f.as_ref()) {
+                Ok(f.as_ref().to_path_buf())
+            } else {
+                fs::canonicalize(f)
+            }
+        })
+        .collect()
 }
diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs
index 57ed9693e..dcb8f8549 100644
--- a/src/commands/decompress.rs
+++ b/src/commands/decompress.rs
@@ -14,11 +14,15 @@ use crate::{
         Extension,
     },
     utils::{
-        self, io::lock_and_flush_output_stdio, logger::info_accessible, nice_directory_display, user_wants_to_continue,
+        self, io::lock_and_flush_output_stdio, is_path_stdin, logger::info_accessible, nice_directory_display,
+        user_wants_to_continue,
     },
     QuestionAction, QuestionPolicy, BUFFER_CAPACITY,
 };
 
+trait ReadSeek: Read + io::Seek {}
+impl<T: Read + io::Seek> ReadSeek for T {}
+
 /// Decompress a file
 ///
 /// File at input_file_path is opened for reading, example: "archive.tar.gz"
@@ -34,7 +38,7 @@ pub fn decompress_file(
     quiet: bool,
 ) -> crate::Result<()> {
     assert!(output_dir.exists());
-    let reader = fs::File::open(input_file_path)?;
+    let input_is_stdin = is_path_stdin(input_file_path);
 
     // Zip archives are special, because they require io::Seek, so it requires it's logic separated
     // from decoder chaining.
@@ -48,6 +52,14 @@ pub fn decompress_file(
         ..
     }] = formats.as_slice()
     {
+        let mut vec = vec![];
+        let reader: Box<dyn ReadSeek> = if input_is_stdin {
+            warn_user_about_loading_zip_in_memory();
+            io::copy(&mut io::stdin(), &mut vec)?;
+            Box::new(io::Cursor::new(vec))
+        } else {
+            Box::new(fs::File::open(input_file_path)?)
+        };
         let zip_archive = zip::ZipArchive::new(reader)?;
         let files_unpacked = if let ControlFlow::Continue(files) = smart_unpack(
             |output_dir| crate::archive::zip::unpack_archive(zip_archive, output_dir, quiet),
@@ -74,6 +86,11 @@ pub fn decompress_file(
     }
 
     // Will be used in decoder chaining
+    let reader: Box<dyn Read> = if input_is_stdin {
+        Box::new(io::stdin())
+    } else {
+        Box::new(fs::File::open(input_file_path)?)
+    };
     let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader);
     let mut reader: Box<dyn Read> = Box::new(reader);
 
@@ -152,7 +169,7 @@ pub fn decompress_file(
         #[cfg(feature = "unrar")]
         Rar => {
             type UnpackResult = crate::Result<usize>;
-            let unpack_fn: Box<dyn FnOnce(&Path) -> UnpackResult> = if formats.len() > 1 {
+            let unpack_fn: Box<dyn FnOnce(&Path) -> UnpackResult> = if formats.len() > 1 || input_is_stdin {
                 let mut temp_file = tempfile::NamedTempFile::new()?;
                 io::copy(&mut reader, &mut temp_file)?;
                 Box::new(move |output_dir| crate::archive::rar::unpack_archive(temp_file.path(), output_dir, quiet))
@@ -217,6 +234,7 @@ pub fn decompress_file(
 /// - If the archive contains only one file, it will be extracted to the `output_dir`
 /// - If the archive contains multiple files, it will be extracted to a subdirectory of the
 ///   output_dir named after the archive (given by `output_file_path`)
+///
 /// Note: This functions assumes that `output_dir` exists
 fn smart_unpack(
     unpack_fn: impl FnOnce(&Path) -> crate::Result<usize>,
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index a868f4d92..6e5e9dbe5 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -16,7 +16,9 @@ use crate::{
     error::{Error, FinalError},
     extension::{self, parse_format},
     list::ListOptions,
-    utils::{self, colors::*, logger::info_accessible, to_utf, EscapedPathDisplay, FileVisibilityPolicy},
+    utils::{
+        self, colors::*, is_path_stdin, logger::info_accessible, to_utf, EscapedPathDisplay, FileVisibilityPolicy,
+    },
     CliArgs, QuestionPolicy,
 };
 
@@ -173,7 +175,12 @@ pub fn run(
                 .zip(formats)
                 .zip(output_paths)
                 .try_for_each(|((input_path, formats), file_name)| {
-                    let output_file_path = output_dir.join(file_name); // Path used by single file format archives
+                    // Path used by single file format archives
+                    let output_file_path = if is_path_stdin(file_name) {
+                        output_dir.join("stdin-output")
+                    } else {
+                        output_dir.join(file_name)
+                    };
                     decompress_file(
                         input_path,
                         formats,
diff --git a/src/utils/fs.rs b/src/utils/fs.rs
index c0eb385a1..2e4f7666c 100644
--- a/src/utils/fs.rs
+++ b/src/utils/fs.rs
@@ -15,6 +15,10 @@ use crate::{
     QuestionPolicy,
 };
 
+pub fn is_path_stdin(path: &Path) -> bool {
+    path.as_os_str() == "-"
+}
+
 /// Remove `path` asking the user to overwrite if necessary.
 ///
 /// * `Ok(true)` means the path is clear,
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
index dce133dff..e2726f72f 100644
--- a/src/utils/mod.rs
+++ b/src/utils/mod.rs
@@ -16,7 +16,8 @@ pub use formatting::{
     nice_directory_display, pretty_format_list_of_paths, strip_cur_dir, to_utf, Bytes, EscapedPathDisplay,
 };
 pub use fs::{
-    cd_into_same_dir_as, clear_path, create_dir_if_non_existent, is_symlink, remove_file_or_dir, try_infer_extension,
+    cd_into_same_dir_as, clear_path, create_dir_if_non_existent, is_path_stdin, is_symlink, remove_file_or_dir,
+    try_infer_extension,
 };
 pub use question::{
     ask_to_create_file, user_wants_to_continue, user_wants_to_overwrite, QuestionAction, QuestionPolicy,
diff --git a/src/utils/question.rs b/src/utils/question.rs
index f828ee83f..07122032b 100644
--- a/src/utils/question.rs
+++ b/src/utils/question.rs
@@ -5,7 +5,7 @@
 
 use std::{
     borrow::Cow,
-    io::{stdin, BufRead},
+    io::{stdin, BufRead, IsTerminal},
     path::Path,
 };
 
@@ -121,6 +121,12 @@ impl<'a> Confirmation<'a> {
             (Some(placeholder), Some(subs)) => Cow::Owned(self.prompt.replace(placeholder, subs)),
         };
 
+        if !stdin().is_terminal() {
+            eprintln!("{}", message);
+            eprintln!("Pass --yes to proceed");
+            return Ok(false);
+        }
+
         let _locks = lock_and_flush_output_stdio()?;
         let mut stdin_lock = stdin().lock();
 


Test patch:
diff --git a/tests/integration.rs b/tests/integration.rs
index 233e2fbd9..3801465d2 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -6,7 +6,7 @@ use std::{iter::once, path::PathBuf};
 use fs_err as fs;
 use parse_display::Display;
 use proptest::sample::size_range;
-use rand::{rngs::SmallRng, Rng, SeedableRng};
+use rand::{rngs::SmallRng, Rng, RngCore as _, SeedableRng};
 use tempfile::tempdir;
 use test_strategy::{proptest, Arbitrary};
 
@@ -128,6 +128,51 @@ fn single_file(
     assert_same_directory(before, after, false);
 }
 
+/// Compress and decompress a single file over stdin.
+#[proptest(cases = 200)]
+fn single_file_stdin(
+    ext: Extension,
+    #[any(size_range(0..8).lift())] exts: Vec<FileExtension>,
+    #[cfg_attr(not(target_arch = "arm"), strategy(proptest::option::of(0i16..12)))]
+    // Decrease the value of --level flag for `arm` systems, because our GitHub
+    // Actions CI runs QEMU which makes the memory consumption higher.
+    #[cfg_attr(target_arch = "arm", strategy(proptest::option::of(0i16..8)))]
+    level: Option<i16>,
+) {
+    let dir = tempdir().unwrap();
+    let dir = dir.path();
+    let before = &dir.join("before");
+    fs::create_dir(before).unwrap();
+    let before_file = &before.join("file");
+    let format = merge_extensions(&ext, exts);
+    let archive = &dir.join(format!("file.{}", format));
+    let after = &dir.join("after");
+    write_random_content(
+        &mut fs::File::create(before_file).unwrap(),
+        &mut SmallRng::from_entropy(),
+    );
+    if let Some(level) = level {
+        ouch!("-A", "c", "-l", level.to_string(), before_file, archive);
+    } else {
+        ouch!("-A", "c", before_file, archive);
+    }
+    crate::utils::cargo_bin()
+        .args(["-A", "-y", "d", "-", "-d", after.to_str().unwrap(), "--format", &format])
+        .pipe_stdin(archive)
+        .unwrap()
+        .assert()
+        .success();
+
+    match ext {
+        Extension::Directory(_) => {}
+        // We don't know the original filename, so we create a file named stdin-output
+        // Change the top-level "before" directory to match
+        Extension::File(_) => fs::rename(before_file, before_file.with_file_name("stdin-output")).unwrap(),
+    };
+
+    assert_same_directory(before, after, false);
+}
+
 /// Compress and decompress a directory with random content generated with create_random_files
 ///
 /// This one runs only 50 times because there are only `.zip` and `.tar` to be tested, and
@@ -173,3 +218,40 @@ fn unpack_rar() -> Result<(), Box<dyn std::error::Error>> {
 
     Ok(())
 }
+
+#[cfg(feature = "unrar")]
+#[test]
+fn unpack_rar_stdin() -> Result<(), Box<dyn std::error::Error>> {
+    fn test_unpack_rar_single(input: &std::path::Path, format: &str) -> Result<(), Box<dyn std::error::Error>> {
+        let dir = tempdir()?;
+        let dirpath = dir.path();
+        let unpacked_path = &dirpath.join("testfile.txt");
+        crate::utils::cargo_bin()
+            .args([
+                "-A",
+                "-y",
+                "d",
+                "-",
+                "-d",
+                dirpath.to_str().unwrap(),
+                "--format",
+                format,
+            ])
+            .pipe_stdin(input)
+            .unwrap()
+            .assert()
+            .success();
+        let content = fs::read_to_string(unpacked_path)?;
+        assert_eq!(content, "Testing 123\n");
+
+        Ok(())
+    }
+
+    let mut datadir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
+    datadir.push("tests/data");
+    [("testfile.rar3.rar.gz", "rar.gz"), ("testfile.rar5.rar", "rar")]
+        .iter()
+        .try_for_each(|(path, format)| test_unpack_rar_single(&datadir.join(path), format))?;
+
+    Ok(())
+}