Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 70 additions & 17 deletions crates/cli/src/commands/stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -55,6 +55,31 @@ fn metadata_is_none_or_empty(metadata: &Option<BTreeMap<String, String>>) -> boo
}
}

fn normalize_metadata(
content_type: Option<&str>,
metadata: Option<&HashMap<String, String>>,
) -> Option<BTreeMap<String, String>> {
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(),
Comment on lines +71 to +75
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_metadata builds an intermediate sorted BTreeMap (meta.iter().collect()) and iterates it mutably, but the final out is already a BTreeMap (deterministic sorting). Consider iterating meta directly and inserting into out (with an explicit owned conversion like to_owned() for values) and avoid &mut iteration to keep this helper simpler.

Suggested change
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(),
for (key, value) in meta {
out.insert(
format!("X-Amz-Meta-{}", capitalize_meta_key(key)),
value.to_owned(),

Copilot uses AI. Check for mistakes.
);
}
}

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);
Expand Down Expand Up @@ -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::<BTreeMap<_, _>>()
})
.filter(|m| !m.is_empty()),
metadata: normalize_metadata(
info.content_type.as_deref(),
info.metadata.as_ref(),
),
};
formatter.json(&output);
} else {
Expand Down Expand Up @@ -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())
{
Comment on lines +176 to +178
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_metadata(...) is recomputed in the human-output branch even though the JSON branch already computes it. Consider computing the normalized metadata once per object and reusing it for both output modes to avoid duplication and potential drift.

Copilot uses AI. Check for mistakes.
formatter.println(&format_kv("Metadata", ""));
for (key, value) in &metadata {
formatter.println(&format_kv(key, value));
}
}
}
Expand Down Expand Up @@ -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")
);
}
}