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(¤t_dst_path) - .await? - .into_futures_async_write(); + let mut writer_builder = dst_op.writer_with(¤t_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 -----
