This is an automated email from the ASF dual-hosted git repository.

psiace pushed a commit to branch feat/mime-guess
in repository https://gitbox.apache.org/repos/asf/opendal-oli.git

commit 0d878d65d472422b7181b67d82c87c7c54efefd1
Author: Chojan Shang <[email protected]>
AuthorDate: Thu Nov 20 02:28:30 2025 +0800

    feat: add cp content-type flag and enable mime-guess layer
    
    Signed-off-by: Chojan Shang <[email protected]>
---
 Cargo.lock                | 23 ++++++++++++++++++++
 Cargo.toml                |  1 +
 README.md                 |  3 +++
 src/commands/cp.rs        | 54 +++++++++++++++++++++++++++++++++++------------
 src/config/mod.rs         | 30 ++++++++++++++++++++++++--
 tests/integration/stat.rs |  2 ++
 6 files changed, 97 insertions(+), 16 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1845488..ab7ce87 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1251,6 +1251,22 @@ version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.8"
@@ -1397,6 +1413,7 @@ dependencies = [
  "http-body",
  "log",
  "md-5",
+ "mime_guess",
  "percent-encoding",
  "prost",
  "quick-xml 0.38.0",
@@ -2583,6 +2600,12 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
 
+[[package]]
+name = "unicase"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.18"
diff --git a/Cargo.toml b/Cargo.toml
index b87d95b..52305b2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -54,6 +54,7 @@ opendal = { version = "0.54.0", features = [
   "services-webhdfs",
   "services-azfile",
   "services-dropbox",
+  "layers-mime-guess",
 ] }
 parse-size = { version = "1.1" }
 pollster = { version = "0.4" }
diff --git a/README.md b/README.md
index fd067eb..a0140bd 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,9 @@ oli cp ./update-ecs-loadbalancer.json 
s3:/update-ecs-loadbalancer.json
 oli ls s3:/
 # fleet.png
 # update-ecs-loadbalancer.json
+
+# Override the inferred MIME type when needed:
+oli cp --content-type application/json ./update-ecs-loadbalancer.json 
s3:/update-ecs-loadbalancer.json
 ```
 
 ### Example: use `oli` copy file from S3 to R2
diff --git a/src/commands/cp.rs b/src/commands/cp.rs
index 52d876d..a0bb2cf 100644
--- a/src/commands/cp.rs
+++ b/src/commands/cp.rs
@@ -62,6 +62,9 @@ pub struct CopyCmd {
     /// Copy objects recursively.
     #[arg(short = 'r', long)]
     pub recursive: bool,
+    /// Explicit content type for destination objects.
+    #[arg(long = "content-type")]
+    pub content_type: Option<String>,
 }
 
 impl CopyCmd {
@@ -70,14 +73,22 @@ impl CopyCmd {
     }
 
     async fn do_run(self) -> Result<()> {
-        let cfg = Config::load(&self.config_params.config)?;
+        let CopyCmd {
+            config_params,
+            source,
+            destination,
+            recursive,
+            content_type,
+        } = self;
 
-        let (src_op, src_path) = cfg.parse_location(&self.source)?;
-        let (dst_op, dst_path) = cfg.parse_location(&self.destination)?;
+        let cfg = Config::load(&config_params.config)?;
+
+        let (src_op, src_path) = cfg.parse_location(&source)?;
+        let (dst_op, dst_path) = cfg.parse_location(&destination)?;
 
         let final_dst_path = match dst_op.stat(&dst_path).await {
             Ok(dst_meta) if dst_meta.mode().is_dir() => {
-                if self.recursive {
+                if recursive {
                     dst_path.clone()
                 } else if let Some(filename) = 
Path::new(&src_path).file_name() {
                     Path::new(&dst_path)
@@ -95,7 +106,7 @@ impl CopyCmd {
             Ok(_) => {
                 // Destination exists but is a file. Overwrite it 
(non-recursive)
                 // or error (recursive, handled below).
-                if self.recursive {
+                if recursive {
                     bail!(
                         "Recursive copy destination '{}' exists but is not a 
directory.",
                         dst_path
@@ -109,12 +120,13 @@ impl CopyCmd {
             }
         };
 
-        if !self.recursive {
+        if !recursive {
             // Non-recursive copy: Use the final_dst_path directly.
-            let mut dst_w = dst_op
-                .writer(&final_dst_path)
-                .await?
-                .into_futures_async_write();
+            let mut dst_builder = dst_op.writer_with(&final_dst_path);
+            if let Some(ref ct) = content_type {
+                dst_builder = dst_builder.content_type(ct);
+            }
+            let mut dst_w = dst_builder.await?.into_futures_async_write();
             let src_meta = src_op.stat(&src_path).await?;
             let reader = src_op.reader_with(&src_path).chunk(8 * 1024 * 
1024).await?;
             let buf_reader = reader
@@ -204,10 +216,11 @@ impl CopyCmd {
                 .await?;
 
             let copy_progress = CopyProgress::new(&fresh_meta, 
depath.to_string());
-            let mut writer = dst_op
-                .writer(&current_dst_path)
-                .await?
-                .into_futures_async_write();
+            let mut writer_builder = dst_op.writer_with(&current_dst_path);
+            if let Some(ref ct) = content_type {
+                writer_builder = writer_builder.content_type(ct);
+            }
+            let mut writer = writer_builder.await?.into_futures_async_write();
 
             copy_progress.copy(buf_reader, &mut writer).await?;
             writer.close().await?;
@@ -297,6 +310,7 @@ fn relative_path_from_entry(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use clap::Parser;
 
     #[test]
     fn normalize_path_strips_root_and_current_components() {
@@ -323,4 +337,16 @@ mod tests {
             .expect_err("expected prefix mismatch");
         assert!(format!("{err}").contains("does not start with source path"));
     }
+
+    #[test]
+    fn parses_content_type_flag() {
+        let cmd = CopyCmd::parse_from([
+            "cp",
+            "--content-type",
+            "text/plain",
+            "src_profile:/foo",
+            "dst_profile:/bar",
+        ]);
+        assert_eq!(cmd.content_type.as_deref(), Some("text/plain"));
+    }
 }
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 97ed9fa..d6e467d 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -28,6 +28,7 @@ use anyhow::Result;
 use anyhow::anyhow;
 use opendal::Operator;
 use opendal::Scheme;
+use opendal::layers::MimeGuessLayer;
 use opendal::services;
 use serde::Deserialize;
 use url::Url;
@@ -142,7 +143,12 @@ impl Config {
                 }
             };
 
-            return Ok((Operator::new(fs_builder)?.finish(), filename.into()));
+            return Ok((
+                Operator::new(fs_builder)?
+                    .layer(MimeGuessLayer::default())
+                    .finish(),
+                filename.into(),
+            ));
         }
 
         let location = Url::parse(s)?;
@@ -166,12 +172,15 @@ impl Config {
             .get("type")
             .ok_or_else(|| anyhow!("missing 'type' in profile"))?;
         let scheme = Scheme::from_str(svc)?;
-        Ok(Operator::via_iter(scheme, profile.clone())?)
+        let op = Operator::via_iter(scheme, 
profile.clone())?.layer(MimeGuessLayer::default());
+        Ok(op)
     }
 }
 
 #[cfg(test)]
 mod tests {
+    use std::collections::HashMap;
+
     use opendal::Scheme;
 
     use super::*;
@@ -207,6 +216,23 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn operator_uses_mime_guess_layer() -> Result<()> {
+        let mut memory_profile = HashMap::new();
+        memory_profile.insert("type".to_string(), "memory".to_string());
+
+        let mut profiles = HashMap::new();
+        profiles.insert("mem".to_string(), memory_profile);
+
+        let cfg = Config { profiles };
+        let op = cfg.operator("mem")?;
+        op.write("demo.json", "{}").await?;
+
+        let meta = op.stat("demo.json").await?;
+        assert_eq!(meta.content_type(), Some("application/json"));
+        Ok(())
+    }
+
     #[test]
     fn test_load_from_toml() -> Result<()> {
         let dir = tempfile::tempdir()?;
diff --git a/tests/integration/stat.rs b/tests/integration/stat.rs
index 3cea0ac..0f6ae68 100644
--- a/tests/integration/stat.rs
+++ b/tests/integration/stat.rs
@@ -34,6 +34,7 @@ async fn test_basic_stat() -> Result<()> {
     path: [TEMP_DIR]/dst.txt
     size: 5
     type: file
+    content-type: text/plain
     last-modified: [TIMESTAMP]
 
     ----- stderr -----
@@ -56,6 +57,7 @@ async fn test_stat_for_path_in_current_dir() -> Result<()> {
     path: dst.txt
     size: 5
     type: file
+    content-type: text/plain
     last-modified: [TIMESTAMP]
 
     ----- stderr -----

Reply via email to