From 82884e550437b5cec3affbea1f68a1716bccaa6c Mon Sep 17 00:00:00 2001 From: overtrue Date: Tue, 10 Mar 2026 01:07:51 +0800 Subject: [PATCH] fix(stat): include content type in metadata output --- crates/cli/src/commands/stat.rs | 87 ++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/crates/cli/src/commands/stat.rs b/crates/cli/src/commands/stat.rs index 0ff9c48..4f84beb 100644 --- a/crates/cli/src/commands/stat.rs +++ b/crates/cli/src/commands/stat.rs @@ -6,7 +6,7 @@ use clap::Args; use rc_core::{AliasManager, ObjectStore as _, RemotePath}; use rc_s3::S3Client; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use crate::exit_code::ExitCode; use crate::output::{Formatter, OutputConfig}; @@ -55,6 +55,31 @@ fn metadata_is_none_or_empty(metadata: &Option>) -> boo } } +fn normalize_metadata( + content_type: Option<&str>, + metadata: Option<&HashMap>, +) -> Option> { + let mut out = BTreeMap::new(); + + if let Some(ct) = content_type + && !ct.is_empty() + { + out.insert("Content-Type".to_string(), ct.to_string()); + } + + if let Some(meta) = metadata { + let mut sorted: BTreeMap<_, _> = meta.iter().collect(); + for (key, value) in &mut sorted { + out.insert( + format!("X-Amz-Meta-{}", capitalize_meta_key(key)), + (*value).clone(), + ); + } + } + + if out.is_empty() { None } else { Some(out) } +} + /// Execute the stat command pub async fn execute(args: StatArgs, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); @@ -109,15 +134,10 @@ pub async fn execute(args: StatArgs, output_config: OutputConfig) -> ExitCode { content_type: info.content_type.clone(), storage_class: info.storage_class.clone(), version_id: args.version_id, - metadata: info - .metadata - .as_ref() - .map(|m| { - m.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>() - }) - .filter(|m| !m.is_empty()), + metadata: normalize_metadata( + info.content_type.as_deref(), + info.metadata.as_ref(), + ), }; formatter.json(&output); } else { @@ -153,13 +173,12 @@ pub async fn execute(args: StatArgs, output_config: OutputConfig) -> ExitCode { if let Some(sc) = &info.storage_class { formatter.println(&format_kv("Class", sc)); } - if let Some(metadata) = &info.metadata { - let sorted: BTreeMap<_, _> = metadata.iter().collect(); - for (key, value) in &sorted { - formatter.println(&format_kv( - &format!("X-Amz-Meta-{}", capitalize_meta_key(key)), - value, - )); + if let Some(metadata) = + normalize_metadata(info.content_type.as_deref(), info.metadata.as_ref()) + { + formatter.println(&format_kv("Metadata", "")); + for (key, value) in &metadata { + formatter.println(&format_kv(key, value)); } } } @@ -327,4 +346,38 @@ mod tests { // Empty BTreeMap is treated as None via skip_serializing_if helper assert!(!json.contains("metadata")); } + + #[test] + fn test_normalize_metadata_includes_content_type() { + let metadata = normalize_metadata(Some("text/plain"), None).expect("metadata present"); + assert_eq!( + metadata.get("Content-Type").map(String::as_str), + Some("text/plain") + ); + } + + #[test] + fn test_normalize_metadata_includes_custom_metadata() { + let mut custom = HashMap::new(); + custom.insert("content-disposition".to_string(), "attachment".to_string()); + custom.insert("x-custom-key".to_string(), "value".to_string()); + + let metadata = + normalize_metadata(Some("text/plain"), Some(&custom)).expect("metadata present"); + + assert_eq!( + metadata.get("Content-Type").map(String::as_str), + Some("text/plain") + ); + assert_eq!( + metadata + .get("X-Amz-Meta-Content-Disposition") + .map(String::as_str), + Some("attachment") + ); + assert_eq!( + metadata.get("X-Amz-Meta-X-Custom-Key").map(String::as_str), + Some("value") + ); + } }