use clap::Args;
use comfy_table::{ContentArrangement, Table};
use rc_core::{
AliasManager, Error, ListOptions, MultipartUploadListOptions, ObjectInfo, ObjectStore as _,
ObjectVersionListResult, RemotePath,
};
use rc_s3::S3Client;
use serde::Serialize;
use std::collections::HashMap;
use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig};
use super::multipart::{collect_multipart_uploads, emit_multipart_error, output_multipart_listing};
#[derive(Args, Debug)]
pub struct LsArgs {
pub path: String,
#[arg(short, long)]
pub recursive: bool,
#[arg(long)]
pub versions: bool,
#[arg(long)]
pub incomplete: bool,
#[arg(long)]
pub summarize: bool,
}
#[derive(Debug, Serialize)]
struct LsOutput {
items: Vec<ObjectInfo>,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<Summary>,
}
#[derive(Debug, Serialize)]
struct Summary {
total_objects: usize,
total_size_bytes: i64,
total_size_human: String,
}
#[derive(Debug, Serialize)]
struct LsVersionOutput {
items: Vec<LsVersionInfo>,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version_id_marker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<VersionSummary>,
}
#[derive(Debug, Serialize)]
struct LsVersionInfo {
key: String,
version_id: String,
is_latest: bool,
is_delete_marker: bool,
#[serde(skip_serializing_if = "Option::is_none")]
last_modified: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
size_human: Option<String>,
}
#[derive(Debug, Serialize)]
struct VersionSummary {
total_versions: usize,
total_size_bytes: i64,
total_size_human: String,
}
pub async fn execute(args: LsArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);
if args.incomplete {
return execute_incomplete(&args, &formatter).await;
}
let (alias_name, bucket, prefix) = match parse_ls_path(&args.path) {
Ok(parsed) => parsed,
Err(e) => {
formatter.error(&e);
return ExitCode::UsageError;
}
};
let alias_manager = match AliasManager::new() {
Ok(am) => am,
Err(e) => {
formatter.error(&format!("Failed to load aliases: {e}"));
return ExitCode::GeneralError;
}
};
let alias = match alias_manager.get(&alias_name) {
Ok(a) => a,
Err(_) => {
formatter.error(&format!("Alias '{alias_name}' not found"));
return ExitCode::NotFound;
}
};
let client = match S3Client::new(alias).await {
Ok(c) => c,
Err(e) => {
formatter.error(&format!("Failed to create S3 client: {e}"));
return ExitCode::NetworkError;
}
};
if let Some(list_mode) = alias_listing_mode(bucket.as_ref(), args.recursive) {
return match list_mode {
AliasListingMode::Buckets => list_buckets(&client, &formatter, args.summarize).await,
AliasListingMode::AllObjects => {
list_all_objects(&client, alias_name, &formatter, args.summarize).await
}
};
}
let Some(bucket) = bucket else {
return formatter.fail(ExitCode::UsageError, "Bucket name is required");
};
let path = RemotePath::new(&alias_name, &bucket, prefix.unwrap_or_default());
if args.versions {
return list_object_versions(&client, &path, args.summarize, &formatter).await;
}
list_objects(&client, &path, &args, &formatter).await
}
async fn execute_incomplete(args: &LsArgs, formatter: &Formatter) -> ExitCode {
if args.versions {
return emit_multipart_error(
formatter,
ExitCode::UsageError,
"--incomplete and --versions cannot be used together",
"list_multipart_uploads",
);
}
let (alias_name, bucket, prefix) = match parse_incomplete_ls_path(&args.path) {
Ok(path) => path,
Err((code, message)) => {
return emit_multipart_error(formatter, code, message, "list_multipart_uploads");
}
};
let alias_manager = match AliasManager::new() {
Ok(manager) => manager,
Err(error) => {
return emit_multipart_error(
formatter,
ExitCode::GeneralError,
format!("Failed to load aliases: {error}"),
"list_multipart_uploads",
);
}
};
let alias = match alias_manager.get(&alias_name) {
Ok(alias) => alias,
Err(_) => {
return emit_multipart_error(
formatter,
ExitCode::NotFound,
format!("Alias '{alias_name}' not found"),
"list_multipart_uploads",
);
}
};
let client = match S3Client::new(alias).await {
Ok(client) => client,
Err(error) => {
return emit_multipart_error(
formatter,
ExitCode::NetworkError,
format!("Failed to create S3 client: {error}"),
"list_multipart_uploads",
);
}
};
list_incomplete_uploads(
&client,
&bucket,
&prefix,
args.recursive,
args.summarize,
formatter,
)
.await
}
async fn list_incomplete_uploads(
client: &S3Client,
bucket: &str,
prefix: &str,
recursive: bool,
summarize: bool,
formatter: &Formatter,
) -> ExitCode {
let options = MultipartUploadListOptions {
prefix: (!prefix.is_empty()).then(|| prefix.to_string()),
delimiter: (!recursive).then(|| "/".to_string()),
max_uploads: Some(1000),
..Default::default()
};
let uploads = match collect_multipart_uploads(options, |page_options| {
client.list_multipart_uploads(bucket, page_options)
})
.await
{
Ok(uploads) => uploads,
Err(error) => {
let exit_code = multipart_listing_exit_code(&error);
return emit_multipart_error(
formatter,
exit_code,
format!("Failed to list incomplete multipart uploads: {error}"),
"list_multipart_uploads",
);
}
};
if formatter.is_quiet() {
return ExitCode::Success;
}
if formatter.is_json() {
output_multipart_listing(formatter, &uploads, summarize);
return ExitCode::Success;
}
if !uploads.is_empty() {
let now = jiff::Timestamp::now();
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
"INITIATED",
"AGE",
"UPLOAD ID",
"INITIATOR",
"STORAGE CLASS",
"KEY",
]);
for upload in &uploads {
let initiated = upload
.initiated
.map(|value| value.strftime("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "-".to_string());
let age = upload
.initiated
.map(|value| format_upload_age(value, now))
.unwrap_or_else(|| "-".to_string());
let initiator = upload
.initiator
.as_ref()
.and_then(|identity| identity.display_name.as_deref().or(identity.id.as_deref()))
.unwrap_or("-");
table.add_row(vec![
formatter.sanitize_text(&initiated),
age,
formatter.sanitize_text(&upload.upload_id),
formatter.sanitize_text(initiator),
formatter.sanitize_text(upload.storage_class.as_deref().unwrap_or("-")),
formatter.sanitize_text(&upload.key),
]);
}
formatter.println(&table.to_string());
}
if summarize {
formatter.println(&format!("Total: {} incomplete upload(s)", uploads.len()));
}
ExitCode::Success
}
fn multipart_listing_exit_code(error: &Error) -> ExitCode {
match error {
Error::Auth(_) => ExitCode::AuthError,
Error::NotFound(_) => ExitCode::NotFound,
Error::Network(_) | Error::Io(_) => ExitCode::NetworkError,
Error::UnsupportedFeature(_) => ExitCode::UnsupportedFeature,
_ => ExitCode::GeneralError,
}
}
fn format_upload_age(initiated: jiff::Timestamp, now: jiff::Timestamp) -> String {
let seconds = now.as_second().saturating_sub(initiated.as_second()).max(0) as u64;
if seconds >= 86_400 {
format!("{}d{}h", seconds / 86_400, (seconds % 86_400) / 3_600)
} else if seconds >= 3_600 {
format!("{}h{}m", seconds / 3_600, (seconds % 3_600) / 60)
} else if seconds >= 60 {
format!("{}m{}s", seconds / 60, seconds % 60)
} else {
format!("{seconds}s")
}
}
async fn list_object_versions(
client: &S3Client,
path: &RemotePath,
summarize: bool,
formatter: &Formatter,
) -> ExitCode {
match list_all_object_versions(client, path).await {
Ok(result) => {
let versions = result.items.clone();
let total_size: i64 = versions.iter().filter_map(|v| v.size_bytes).sum();
if formatter.is_json() {
formatter.json(&ls_version_output(result, summarize));
} else {
for version in &versions {
let marker = if version.is_delete_marker {
" [DELETE]"
} else {
""
};
let latest = if version.is_latest { "*" } else { " " };
let size = version
.size_bytes
.map(|s| humansize::format_size(s as u64, humansize::BINARY))
.unwrap_or_default();
formatter.println(&format!(
"{latest} {:<40} {:>10} {:>12}{marker}",
formatter.sanitize_text(&version.key),
formatter.sanitize_text(
&version.version_id.chars().take(10).collect::<String>()
),
size
));
}
if summarize {
let total_size_human =
humansize::format_size(total_size as u64, humansize::BINARY);
formatter.println(&format!(
"\nTotal: {} version(s), {}",
formatter.style_size(&versions.len().to_string()),
formatter.style_size(&total_size_human)
));
}
}
ExitCode::Success
}
Err(e) => {
let exit_code = exit_code_from_version_listing_error(&e);
formatter.error_with_code(exit_code, &format!("Failed to list versions: {e}"));
exit_code
}
}
}
async fn list_all_object_versions(
client: &S3Client,
path: &RemotePath,
) -> Result<ObjectVersionListResult, Error> {
let mut items = Vec::new();
let mut key_marker: Option<String> = None;
let mut version_id_marker: Option<String> = None;
loop {
let page = client
.list_object_versions_page_with_markers(
path,
Some(1000),
key_marker.as_deref(),
version_id_marker.as_deref(),
)
.await?;
items.extend(page.items);
if !page.truncated {
return Ok(ObjectVersionListResult {
items,
truncated: false,
continuation_token: None,
version_id_marker: None,
});
}
let next_key_marker = page.continuation_token.ok_or_else(|| {
Error::Network(
"S3 returned a truncated version listing without a key marker".to_string(),
)
})?;
let next_version_id_marker = page.version_id_marker;
if key_marker.as_deref() == Some(next_key_marker.as_str())
&& version_id_marker == next_version_id_marker
{
return Err(Error::Network(
"S3 returned a truncated version listing without advancing its markers".to_string(),
));
}
key_marker = Some(next_key_marker);
version_id_marker = next_version_id_marker;
}
}
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()
.map(|v| LsVersionInfo {
key: v.key,
version_id: v.version_id,
is_latest: v.is_latest,
is_delete_marker: v.is_delete_marker,
last_modified: v.last_modified.map(|t| t.to_string()),
size_bytes: v.size_bytes,
size_human: v
.size_bytes
.map(|s| humansize::format_size(s as u64, humansize::BINARY)),
})
.collect();
LsVersionOutput {
items,
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),
}),
}
}
fn exit_code_from_version_listing_error(error: &Error) -> ExitCode {
match error {
Error::NotFound(_) => ExitCode::NotFound,
Error::Network(_) => ExitCode::NetworkError,
_ => {
let error_text = error.to_string();
if error_text.contains("NotFound") || error_text.contains("NoSuchBucket") {
ExitCode::NotFound
} else {
ExitCode::GeneralError
}
}
}
}
async fn list_buckets(client: &S3Client, formatter: &Formatter, summarize: bool) -> ExitCode {
match client.list_buckets().await {
Ok(buckets) => {
if formatter.is_json() {
let output = LsOutput {
items: buckets.clone(),
truncated: false,
continuation_token: None,
summary: if summarize {
Some(Summary {
total_objects: buckets.len(),
total_size_bytes: 0,
total_size_human: "0 B".to_string(),
})
} else {
None
},
};
formatter.json(&output);
} else {
for bucket in &buckets {
let date = bucket
.last_modified
.map(|d| d.strftime("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| " ".to_string());
let styled_date = formatter.style_date(&format!("[{date}]"));
let styled_size = formatter.style_size(&format!("{:>10}", "0B"));
let styled_name = formatter.style_dir(&format!("{}/", bucket.key));
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
}
if summarize {
formatter.println(&format!(
"\nTotal: {} buckets",
formatter.style_size(&buckets.len().to_string())
));
}
}
ExitCode::Success
}
Err(e) => {
formatter.error(&format!("Failed to list buckets: {e}"));
match e {
Error::Auth(_) => ExitCode::AuthError,
Error::NotFound(_) => ExitCode::NotFound,
_ => ExitCode::NetworkError,
}
}
}
}
async fn list_objects(
client: &S3Client,
path: &RemotePath,
args: &LsArgs,
formatter: &Formatter,
) -> ExitCode {
let options = ListOptions {
recursive: args.recursive,
max_keys: Some(1000),
..Default::default()
};
let (all_items, is_truncated, continuation_token) =
match list_objects_with_paging(client, path, &options).await {
Ok(r) => r,
Err((message, exit_code)) => {
formatter.error(&message);
return exit_code;
}
};
let total_objects = all_items.iter().filter(|i| !i.is_dir).count();
let total_size: i64 = all_items.iter().filter_map(|i| i.size_bytes).sum();
if formatter.is_json() {
let output = LsOutput {
items: all_items,
truncated: is_truncated,
continuation_token,
summary: if args.summarize {
Some(Summary {
total_objects,
total_size_bytes: total_size,
total_size_human: humansize::format_size(total_size as u64, humansize::BINARY),
})
} else {
None
},
};
formatter.json(&output);
} else {
for item in &all_items {
let date = item
.last_modified
.map(|d| d.strftime("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| " ".to_string());
let styled_date = formatter.style_date(&format!("[{date}]"));
if item.is_dir {
let styled_size = formatter.style_size(&format!("{:>10}", "0B"));
let styled_name = formatter.style_dir(&item.key);
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
} else {
let size = item.size_human.clone().unwrap_or_else(|| "0 B".to_string());
let styled_size = formatter.style_size(&format!("{:>10}", size));
let styled_name = formatter.style_file(&item.key);
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
}
}
if args.summarize {
let total_size_human = humansize::format_size(total_size as u64, humansize::BINARY);
formatter.println(&format!(
"\nTotal: {} objects, {}",
formatter.style_size(&total_objects.to_string()),
formatter.style_size(&total_size_human)
));
}
}
ExitCode::Success
}
async fn list_all_objects(
client: &S3Client,
alias: String,
formatter: &Formatter,
summarize: bool,
) -> ExitCode {
let buckets = match client.list_buckets().await {
Ok(buckets) => buckets,
Err(e) => {
formatter.error(&format!("Failed to list buckets: {e}"));
return ExitCode::NetworkError;
}
};
let options = ListOptions {
recursive: true,
max_keys: Some(1000),
..Default::default()
};
let mut all_items: HashMap<&str, Vec<ObjectInfo>> = HashMap::new();
let mut is_truncated = false;
let mut continuation_token: Option<String> = None;
for bucket in &buckets {
let path = &RemotePath::new(&alias, &bucket.key, "");
let new_items: Vec<ObjectInfo>;
(new_items, is_truncated, continuation_token) =
match list_objects_with_paging(client, path, &options).await {
Ok(r) => r,
Err((message, exit_code)) => {
formatter.error(&message);
return exit_code;
}
};
all_items.entry(&bucket.key).or_default().extend(new_items);
}
let total_objects = all_items.values().flatten().filter(|i| !i.is_dir).count();
let total_size = all_items
.values()
.flatten()
.filter_map(|i| i.size_bytes)
.sum();
if formatter.is_json() {
let output = LsOutput {
items: all_items
.into_iter()
.flat_map(|(bucket, objects)| {
objects.into_iter().map(move |mut obj| {
obj.key = format!("{}/{}", bucket, obj.key);
obj
})
})
.collect(),
truncated: is_truncated,
continuation_token,
summary: if summarize {
Some(Summary {
total_objects,
total_size_bytes: total_size,
total_size_human: humansize::format_size(total_size as u64, humansize::BINARY),
})
} else {
None
},
};
formatter.json(&output);
} else {
let mut bucket_names: Vec<&str> = all_items.keys().copied().collect();
bucket_names.sort_unstable();
for bucket_name in bucket_names {
if let Some(objects) = all_items.get(bucket_name) {
for item in objects {
let date = item
.last_modified
.map(|d| d.strftime("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| " ".to_string());
let styled_date = formatter.style_date(&format!("[{date}]"));
if item.is_dir {
let styled_size = formatter.style_size(&format!("{:>10}", "0B"));
let styled_name =
formatter.style_dir(&format!("{}/{}", bucket_name, item.key));
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
} else {
let size = item.size_human.clone().unwrap_or_else(|| "0 B".to_string());
let styled_size = formatter.style_size(&format!("{:>10}", size));
let styled_name =
formatter.style_file(&format!("{}/{}", bucket_name, item.key));
formatter.println(&format!("{styled_date} {styled_size} {styled_name}"));
}
}
}
}
if summarize {
let total_size_human = humansize::format_size(total_size as u64, humansize::BINARY);
formatter.println(&format!(
"\nTotal: {} objects, {}",
formatter.style_size(&total_objects.to_string()),
formatter.style_size(&total_size_human)
));
}
}
ExitCode::Success
}
async fn list_objects_with_paging(
client: &S3Client,
path: &RemotePath,
options: &ListOptions,
) -> Result<(Vec<ObjectInfo>, bool, Option<String>), (String, ExitCode)> {
let mut all_items = Vec::new();
let mut is_truncated;
let mut continuation_token = None;
loop {
let opts = ListOptions {
continuation_token: continuation_token.clone(),
..options.clone()
};
match client.list_objects(path, opts).await {
Ok(result) => {
all_items.extend(result.items);
is_truncated = result.truncated;
continuation_token = result.continuation_token.clone();
if !result.truncated {
break;
}
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("NotFound") || err_str.contains("NoSuchBucket") {
return Err((
format!("Bucket not found: {}", path.bucket),
ExitCode::NotFound,
));
}
return Err((
format!("Failed to list objects: {e}"),
ExitCode::NetworkError,
));
}
}
}
Ok((all_items, is_truncated, continuation_token))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AliasListingMode {
Buckets,
AllObjects,
}
fn alias_listing_mode(bucket: Option<&String>, recursive: bool) -> Option<AliasListingMode> {
if bucket.is_some() {
return None;
}
if recursive {
Some(AliasListingMode::AllObjects)
} else {
Some(AliasListingMode::Buckets)
}
}
fn parse_incomplete_ls_path(path: &str) -> Result<(String, String, String), (ExitCode, String)> {
if path.is_empty() {
return Err((ExitCode::UsageError, "Path cannot be empty".to_string()));
}
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err((
ExitCode::UsageError,
"--incomplete requires a bucket path in the form alias/bucket[/prefix]".to_string(),
));
}
Ok((
parts[0].to_string(),
parts[1].to_string(),
parts.get(2).copied().unwrap_or_default().to_string(),
))
}
fn parse_ls_path(path: &str) -> Result<(String, Option<String>, Option<String>), String> {
let path = path.trim_end_matches('/');
if path.is_empty() {
return Err("Path cannot be empty".to_string());
}
let parts: Vec<&str> = path.splitn(3, '/').collect();
match parts.len() {
1 => Ok((parts[0].to_string(), None, None)),
2 => Ok((parts[0].to_string(), Some(parts[1].to_string()), None)),
3 => Ok((
parts[0].to_string(),
Some(parts[1].to_string()),
Some(format!("{}/", parts[2])),
)),
_ => Err(format!("Invalid path format: {path}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rc_core::{MultipartUpload, MultipartUploadListResult, ObjectVersion};
use std::collections::VecDeque;
fn incomplete_upload(key: &str, upload_id: &str) -> MultipartUpload {
MultipartUpload {
bucket: "bucket".to_string(),
key: key.to_string(),
upload_id: upload_id.to_string(),
initiated: None,
size_bytes: None,
storage_class: Some("STANDARD".to_string()),
initiator: None,
owner: None,
checksum_algorithm: None,
checksum_type: None,
}
}
#[test]
fn test_parse_ls_path_alias_only() {
let (alias, bucket, prefix) = parse_ls_path("myalias").unwrap();
assert_eq!(alias, "myalias");
assert!(bucket.is_none());
assert!(prefix.is_none());
}
#[test]
fn test_parse_ls_path_alias_bucket() {
let (alias, bucket, prefix) = parse_ls_path("myalias/mybucket").unwrap();
assert_eq!(alias, "myalias");
assert_eq!(bucket, Some("mybucket".to_string()));
assert!(prefix.is_none());
}
#[test]
fn test_parse_ls_path_with_prefix() {
let (alias, bucket, prefix) = parse_ls_path("myalias/mybucket/path/to").unwrap();
assert_eq!(alias, "myalias");
assert_eq!(bucket, Some("mybucket".to_string()));
assert_eq!(prefix, Some("path/to/".to_string()));
}
#[test]
fn test_parse_ls_path_trailing_slash() {
let (alias, bucket, prefix) = parse_ls_path("myalias/mybucket/").unwrap();
assert_eq!(alias, "myalias");
assert_eq!(bucket, Some("mybucket".to_string()));
assert!(prefix.is_none());
}
#[test]
fn test_parse_ls_path_empty() {
assert!(parse_ls_path("").is_err());
}
#[tokio::test]
async fn incomplete_listing_follows_both_markers_and_sorts_results() {
let mut pages = VecDeque::from([
MultipartUploadListResult {
uploads: vec![incomplete_upload("z.bin", "upload-2")],
common_prefixes: Vec::new(),
truncated: true,
next_key_marker: Some("z.bin".to_string()),
next_upload_id_marker: Some("upload-2".to_string()),
},
MultipartUploadListResult {
uploads: vec![
incomplete_upload("a.bin", "upload-3"),
incomplete_upload("a.bin", "upload-1"),
],
common_prefixes: Vec::new(),
truncated: false,
next_key_marker: None,
next_upload_id_marker: None,
},
]);
let mut requested = Vec::new();
let uploads = collect_multipart_uploads(
MultipartUploadListOptions {
prefix: Some("logs/".to_string()),
max_uploads: Some(1),
..Default::default()
},
|options| {
requested.push(options);
std::future::ready(Ok(pages.pop_front().expect("test page should exist")))
},
)
.await
.expect("all pages should be collected");
assert_eq!(requested.len(), 2);
assert_eq!(requested[1].key_marker.as_deref(), Some("z.bin"));
assert_eq!(requested[1].upload_id_marker.as_deref(), Some("upload-2"));
let identities: Vec<(&str, &str)> = uploads
.iter()
.map(|upload| (upload.key.as_str(), upload.upload_id.as_str()))
.collect();
assert_eq!(
identities,
vec![
("a.bin", "upload-1"),
("a.bin", "upload-3"),
("z.bin", "upload-2")
]
);
}
#[tokio::test]
async fn incomplete_listing_rejects_non_advancing_pagination() {
let result = collect_multipart_uploads(
MultipartUploadListOptions {
key_marker: Some("same-key".to_string()),
upload_id_marker: Some("same-upload".to_string()),
..Default::default()
},
|_| {
std::future::ready(Ok(MultipartUploadListResult {
uploads: Vec::new(),
common_prefixes: Vec::new(),
truncated: true,
next_key_marker: Some("same-key".to_string()),
next_upload_id_marker: Some("same-upload".to_string()),
}))
},
)
.await;
assert!(matches!(result, Err(Error::Network(_))));
}
#[test]
fn incomplete_path_preserves_a_nested_prefix() {
let (alias, bucket, key) = parse_incomplete_ls_path("local/archive/2026/backup.tar")
.expect("nested prefix should parse");
assert_eq!(alias, "local");
assert_eq!(bucket, "archive");
assert_eq!(key, "2026/backup.tar");
}
#[test]
fn incomplete_path_accepts_bucket_and_prefix_modes() {
assert_eq!(
parse_incomplete_ls_path("local/archive")
.expect("bucket path should parse")
.2,
""
);
assert_eq!(
parse_incomplete_ls_path("local/archive/logs/")
.expect("prefix path should parse")
.2,
"logs/"
);
}
#[test]
fn incomplete_listing_maps_typed_errors_to_specific_exit_codes() {
assert_eq!(
multipart_listing_exit_code(&Error::Auth("denied".to_string())),
ExitCode::AuthError
);
assert_eq!(
multipart_listing_exit_code(&Error::NotFound("bucket".to_string())),
ExitCode::NotFound
);
assert_eq!(
multipart_listing_exit_code(&Error::Network("timeout".to_string())),
ExitCode::NetworkError
);
}
#[test]
fn incomplete_age_is_human_readable_and_never_negative() {
let initiated = "2026-07-20T00:00:00Z"
.parse()
.expect("test timestamp should parse");
let now = "2026-07-21T02:30:00Z"
.parse()
.expect("test timestamp should parse");
assert_eq!(format_upload_age(initiated, now), "1d2h");
assert_eq!(format_upload_age(now, initiated), "0s");
}
#[test]
fn test_alias_listing_mode_lists_all_objects_for_recursive_alias_path() {
assert_eq!(
alias_listing_mode(None, true),
Some(AliasListingMode::AllObjects)
);
}
#[test]
fn test_alias_listing_mode_lists_buckets_without_recursive_flag() {
assert_eq!(
alias_listing_mode(None, false),
Some(AliasListingMode::Buckets)
);
}
#[test]
fn test_alias_listing_mode_ignores_alias_only_logic_when_bucket_is_present() {
let bucket = "demo".to_string();
assert_eq!(alias_listing_mode(Some(&bucket), false), None);
assert_eq!(alias_listing_mode(Some(&bucket), true), None);
}
#[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()),
},
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]
fn test_version_listing_not_found_errors_use_not_found_exit_code() {
assert_eq!(
exit_code_from_version_listing_error(&Error::NotFound("missing".to_string())),
ExitCode::NotFound
);
assert_eq!(
exit_code_from_version_listing_error(&Error::General(
"list_object_versions: Service error: NoSuchBucket".to_string()
)),
ExitCode::NotFound
);
}
#[test]
fn test_version_listing_other_errors_use_general_exit_code() {
assert_eq!(
exit_code_from_version_listing_error(&Error::General(
"list_object_versions: timeout".to_string()
)),
ExitCode::GeneralError
);
}
#[test]
fn test_version_listing_network_errors_use_network_exit_code() {
assert_eq!(
exit_code_from_version_listing_error(&Error::Network("timeout".to_string())),
ExitCode::NetworkError
);
}
}