From 4cd36914c863733ddd1427a7c5dfffe496ee5f80 Mon Sep 17 00:00:00 2001 From: overtrue Date: Thu, 30 Apr 2026 05:06:38 +0800 Subject: [PATCH] fix(ls): include version summaries in JSON output --- crates/cli/src/commands/ls.rs | 89 +++++++++++++++++++++----- crates/cli/tests/integration.rs | 109 ++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/commands/ls.rs b/crates/cli/src/commands/ls.rs index 0b55820..8975f8d 100644 --- a/crates/cli/src/commands/ls.rs +++ b/crates/cli/src/commands/ls.rs @@ -63,6 +63,8 @@ struct LsVersionOutput { continuation_token: Option, #[serde(skip_serializing_if = "Option::is_none")] version_id_marker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, } #[derive(Debug, Serialize)] @@ -79,6 +81,13 @@ struct LsVersionInfo { size_human: Option, } +#[derive(Debug, Serialize)] +struct VersionSummary { + total_versions: usize, + total_size_bytes: i64, + total_size_human: String, +} + /// Execute the ls command pub async fn execute(args: LsArgs, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); @@ -151,7 +160,7 @@ async fn list_object_versions( let total_size: i64 = versions.iter().filter_map(|v| v.size_bytes).sum(); if formatter.is_json() { - formatter.json(&ls_version_output(result)); + formatter.json(&ls_version_output(result, summarize)); } else { for version in &versions { let marker = if version.is_delete_marker { @@ -193,7 +202,9 @@ async fn list_object_versions( } } -fn ls_version_output(result: ObjectVersionListResult) -> LsVersionOutput { +fn ls_version_output(result: ObjectVersionListResult, summarize: bool) -> LsVersionOutput { + let total_versions = result.items.len(); + let total_size_bytes: i64 = result.items.iter().filter_map(|v| v.size_bytes).sum(); let items = result .items .into_iter() @@ -215,6 +226,11 @@ fn ls_version_output(result: ObjectVersionListResult) -> LsVersionOutput { truncated: result.truncated, continuation_token: result.continuation_token, version_id_marker: result.version_id_marker, + summary: summarize.then(|| VersionSummary { + total_versions, + total_size_bytes, + total_size_human: humansize::format_size(total_size_bytes as u64, humansize::BINARY), + }), } } @@ -621,26 +637,67 @@ mod tests { #[test] fn test_ls_version_output_preserves_pagination_metadata() { - let output = ls_version_output(ObjectVersionListResult { - items: vec![ObjectVersion { - key: "logs/a.txt".to_string(), - version_id: "v1".to_string(), - is_latest: true, - is_delete_marker: false, - last_modified: None, - size_bytes: Some(12), - etag: None, - }], - truncated: true, - continuation_token: Some("logs/b.txt".to_string()), - version_id_marker: Some("v2".to_string()), - }); + let output = ls_version_output( + ObjectVersionListResult { + items: vec![ObjectVersion { + key: "logs/a.txt".to_string(), + version_id: "v1".to_string(), + is_latest: true, + is_delete_marker: false, + last_modified: None, + size_bytes: Some(12), + etag: None, + }], + truncated: true, + continuation_token: Some("logs/b.txt".to_string()), + version_id_marker: Some("v2".to_string()), + }, + false, + ); let json = serde_json::to_value(output).unwrap(); assert_eq!(json["truncated"], true); assert_eq!(json["continuation_token"], "logs/b.txt"); assert_eq!(json["version_id_marker"], "v2"); assert_eq!(json["items"][0]["key"], "logs/a.txt"); + assert!(json.get("summary").is_none()); + } + + #[test] + fn test_ls_version_output_adds_summary_when_requested() { + let output = ls_version_output( + ObjectVersionListResult { + items: vec![ + ObjectVersion { + key: "logs/a.txt".to_string(), + version_id: "v1".to_string(), + is_latest: false, + is_delete_marker: false, + last_modified: None, + size_bytes: Some(12), + etag: None, + }, + ObjectVersion { + key: "logs/a.txt".to_string(), + version_id: "v2".to_string(), + is_latest: true, + is_delete_marker: true, + last_modified: None, + size_bytes: None, + etag: None, + }, + ], + truncated: false, + continuation_token: None, + version_id_marker: None, + }, + true, + ); + + let json = serde_json::to_value(output).unwrap(); + assert_eq!(json["summary"]["total_versions"], 2); + assert_eq!(json["summary"]["total_size_bytes"], 12); + assert_eq!(json["summary"]["total_size_human"], "12 B"); } #[test] diff --git a/crates/cli/tests/integration.rs b/crates/cli/tests/integration.rs index fa40f60..a456ccd 100644 --- a/crates/cli/tests/integration.rs +++ b/crates/cli/tests/integration.rs @@ -2857,6 +2857,115 @@ mod version_operations { cleanup_bucket(config_dir.path(), &bucket_name); } + #[test] + fn test_ls_versions_json_summary_reports_totals() { + let (config_dir, bucket_name) = match setup_with_alias("lsversionssummary") { + Some(v) => v, + None => { + eprintln!("Skipping: S3 test config not available"); + return; + } + }; + + let enable_output = run_rc( + &[ + "version", + "enable", + &format!("test/{}", bucket_name), + "--json", + ], + config_dir.path(), + ); + if !enable_output.status.success() { + eprintln!( + "Enable versioning not supported: {}", + String::from_utf8_lossy(&enable_output.stderr) + ); + cleanup_bucket(config_dir.path(), &bucket_name); + return; + } + + let temp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file"); + let object_key = "summary-version.txt"; + + std::fs::write(temp_file.path(), "first version").expect("Failed to write first version"); + let first_upload_output = run_rc( + &[ + "cp", + temp_file + .path() + .to_str() + .expect("Temp file path should be UTF-8"), + &format!("test/{}/{}", bucket_name, object_key), + ], + config_dir.path(), + ); + assert!( + first_upload_output.status.success(), + "Failed to upload first version: {}", + String::from_utf8_lossy(&first_upload_output.stderr) + ); + + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(temp_file.path(), "second version with more bytes") + .expect("Failed to write second version"); + let second_upload_output = run_rc( + &[ + "cp", + temp_file + .path() + .to_str() + .expect("Temp file path should be UTF-8"), + &format!("test/{}/{}", bucket_name, object_key), + ], + config_dir.path(), + ); + assert!( + second_upload_output.status.success(), + "Failed to upload second version: {}", + String::from_utf8_lossy(&second_upload_output.stderr) + ); + + let list_output = run_rc( + &[ + "ls", + &format!("test/{}/", bucket_name), + "--versions", + "--summarize", + "--json", + ], + config_dir.path(), + ); + assert!( + list_output.status.success(), + "Failed to list versions through ls --summarize: {}", + String::from_utf8_lossy(&list_output.stderr) + ); + + let stdout = String::from_utf8_lossy(&list_output.stdout); + let payload: serde_json::Value = + serde_json::from_str(&stdout).expect("Invalid JSON ls version output"); + let items = payload["items"] + .as_array() + .expect("ls version output should expose an items array"); + let total_size_bytes: i64 = items + .iter() + .filter_map(|entry| entry["size_bytes"].as_i64()) + .sum(); + + assert_eq!(payload["summary"]["total_versions"], 2); + assert_eq!(payload["summary"]["total_size_bytes"], total_size_bytes); + assert_eq!( + payload["summary"]["total_size_human"], + serde_json::Value::String(humansize::format_size( + total_size_bytes as u64, + humansize::BINARY + )) + ); + + cleanup_bucket(config_dir.path(), &bucket_name); + } + #[test] fn test_rm_recursive_purge_permanently_deletes_versioned_prefix() { let (config_dir, bucket_name) = match setup_with_alias("rmpurgeprefix") {