Skip to content
Merged
Show file tree
Hide file tree
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
46 changes: 31 additions & 15 deletions crates/cli/src/commands/cat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
//! Outputs the entire content of an object to stdout.

use clap::Args;
use rc_core::{AliasManager, RemotePath};
use rc_core::{AliasManager, ObjectReadOptions, ObjectStore, RemotePath};
use rc_s3::S3Client;

use crate::commands::{exit_code_for_core_error, validate_version_selector};
use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig};

Expand All @@ -32,12 +33,27 @@ pub struct CatArgs {
pub async fn execute(args: CatArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);

if args.enc_key.is_some() || args.rewind.is_some() || args.version_id.is_some() {
if let Err(error) =
validate_version_selector(args.version_id.as_deref(), args.rewind.as_deref())
{
return formatter.fail(ExitCode::UsageError, &error);
}
if args.enc_key.is_some() {
return formatter.fail(
ExitCode::UnsupportedFeature,
"--enc-key is not implemented for cat",
);
}
if args.rewind.is_some() {
return formatter.fail(
ExitCode::UnsupportedFeature,
"--enc-key, --rewind, and --version-id are not implemented for cat",
"--rewind is not implemented for cat",
);
}
let read_options = match ObjectReadOptions::for_version(args.version_id.clone()) {
Ok(options) => options,
Err(error) => return formatter.fail(ExitCode::UsageError, &error.to_string()),
};

// Parse the path
let (alias_name, bucket, key) = match parse_cat_path(&args.path) {
Expand Down Expand Up @@ -78,20 +94,20 @@ pub async fn execute(args: CatArgs, output_config: OutputConfig) -> ExitCode {

// Get object content
let mut stdout = tokio::io::stdout();
match client.write_object_to(&path, &mut stdout, None).await {
match ObjectStore::write_object_to_with_options(
&client,
&path,
&read_options,
&mut stdout,
None,
)
.await
{
Ok(_) => ExitCode::Success,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
formatter.error(&format!("Object not found: {}", args.path));
ExitCode::NotFound
} else if err_str.contains("AccessDenied") {
formatter.error(&format!("Access denied: {}", args.path));
ExitCode::AuthError
} else {
formatter.error(&format!("Failed to get object: {e}"));
ExitCode::NetworkError
}
let exit_code = exit_code_for_core_error(&e);
formatter.error_with_code(exit_code, &format!("Failed to read {}: {e}", args.path));
exit_code
}
}
}
Expand Down
90 changes: 73 additions & 17 deletions crates/cli/src/commands/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig, ProgressBar};
use crate::output::{Formatter, OutputConfig, ProgressBar, V3SuccessEnvelope};

const CP_AFTER_HELP: &str = "\
Examples:
Expand Down Expand Up @@ -168,6 +168,21 @@ struct CpOutput {
size_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
size_human: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_version_id: Option<String>,
}

#[derive(Debug, Serialize)]
struct VersionCopyData {
operation: &'static str,
source: String,
target: String,
source_version_id: Option<String>,
version_id: Option<String>,
size_bytes: Option<i64>,
size_human: Option<String>,
}

/// Execute the cp command
Expand Down Expand Up @@ -652,6 +667,8 @@ fn print_planned_success(
target: item.target.clone(),
size_bytes,
size_human,
version_id: None,
source_version_id: None,
});
} else {
formatter.println(&format!(
Expand Down Expand Up @@ -1284,14 +1301,7 @@ fn print_upload_success(
dst_display: &str,
) {
if formatter.is_json() {
let output = CpOutput {
status: "success",
source: src_display.to_string(),
target: dst_display.to_string(),
size_bytes: info.size_bytes,
size_human: info.size_human.clone(),
};
formatter.json(&output);
print_copy_json(formatter, info, src_display, dst_display);
} else {
let styled_src = formatter.style_file(src_display);
let styled_dst = formatter.style_file(dst_display);
Expand Down Expand Up @@ -1606,6 +1616,8 @@ pub(super) async fn download_file(
target: dst_display,
size_bytes: Some(size),
size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
version_id: None,
source_version_id: None,
};
formatter.json(&output);
} else {
Expand Down Expand Up @@ -1892,14 +1904,7 @@ async fn copy_s3_to_s3_prepared(
match client.copy_object(src, dst, encryption).await {
Ok(info) => {
if formatter.is_json() {
let output = CpOutput {
status: "success",
source: src_display,
target: dst_display,
size_bytes: info.size_bytes,
size_human: info.size_human,
};
formatter.json(&output);
print_copy_json(formatter, &info, &src_display, &dst_display);
} else {
let styled_src = formatter.style_file(&src_display);
let styled_dst = formatter.style_file(&dst_display);
Expand All @@ -1923,6 +1928,30 @@ async fn copy_s3_to_s3_prepared(
}
}

fn print_copy_json(formatter: &Formatter, info: &rc_core::ObjectInfo, source: &str, target: &str) {
if info.version_id.is_some() || info.source_version_id.is_some() {
formatter.json(&V3SuccessEnvelope::versioned_objects(VersionCopyData {
operation: "copy",
source: source.to_string(),
target: target.to_string(),
source_version_id: info.source_version_id.clone(),
version_id: info.version_id.clone(),
size_bytes: info.size_bytes,
size_human: info.size_human.clone(),
}));
} else {
formatter.json(&CpOutput {
status: "success",
source: source.to_string(),
target: target.to_string(),
size_bytes: info.size_bytes,
size_human: info.size_human.clone(),
version_id: None,
source_version_id: None,
});
}
}

fn parse_kms_target(value: &str) -> Result<(String, String), String> {
let (target, key_id) = value
.split_once('=')
Expand Down Expand Up @@ -2492,10 +2521,14 @@ mod tests {
target: "dst/file.txt".to_string(),
size_bytes: Some(1024),
size_human: Some("1 KiB".to_string()),
version_id: Some("destination-v2".to_string()),
source_version_id: Some("source-v1".to_string()),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"status\":\"success\""));
assert!(json.contains("\"size_bytes\":1024"));
assert!(json.contains("\"version_id\":\"destination-v2\""));
assert!(json.contains("\"source_version_id\":\"source-v1\""));
}

#[test]
Expand All @@ -2506,9 +2539,32 @@ mod tests {
target: "dst".to_string(),
size_bytes: None,
size_human: None,
version_id: None,
source_version_id: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(!json.contains("size_bytes"));
assert!(!json.contains("size_human"));
assert!(!json.contains("version_id"));
}

#[test]
fn versioned_copy_output_uses_v3_and_preserves_both_version_ids() {
let envelope = V3SuccessEnvelope::versioned_objects(VersionCopyData {
operation: "copy",
source: "src/object.txt".to_string(),
target: "dst/object.txt".to_string(),
source_version_id: Some("source-v1".to_string()),
version_id: Some("destination-v2".to_string()),
size_bytes: Some(1024),
size_human: Some("1 KiB".to_string()),
});

let json = serde_json::to_value(envelope).expect("serialize versioned copy output");
assert_eq!(json["schema_version"], 3);
assert_eq!(json["type"], "versioned_objects");
assert_eq!(json["data"]["operation"], "copy");
assert_eq!(json["data"]["source_version_id"], "source-v1");
assert_eq!(json["data"]["version_id"], "destination-v2");
}
}
41 changes: 20 additions & 21 deletions crates/cli/src/commands/head.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
//! Outputs the first N lines (or bytes) of an object to stdout.

use clap::Args;
use rc_core::{AliasManager, ObjectStore as _, RemotePath};
use rc_core::{AliasManager, ObjectReadOptions, ObjectStore, RemotePath};
use rc_s3::S3Client;
use std::io::{self, Write};

use crate::commands::{exit_code_for_core_error, validate_version_selector};
use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig};

Expand All @@ -33,12 +34,13 @@ pub struct HeadArgs {
pub async fn execute(args: HeadArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);

if args.version_id.is_some() {
return formatter.fail(
ExitCode::UnsupportedFeature,
"--version-id is not implemented for head",
);
if let Err(error) = validate_version_selector(args.version_id.as_deref(), None) {
return formatter.fail(ExitCode::UsageError, &error);
}
let read_options = match ObjectReadOptions::for_version(args.version_id.clone()) {
Ok(options) => options,
Err(error) => return formatter.fail(ExitCode::UsageError, &error.to_string()),
};

// Parse the path
let (alias_name, bucket, key) = match parse_head_path(&args.path) {
Expand Down Expand Up @@ -79,17 +81,22 @@ pub async fn execute(args: HeadArgs, output_config: OutputConfig) -> ExitCode {

if let Some(num_bytes) = args.bytes {
let mut stdout = tokio::io::stdout();
return match client
.write_object_to(&path, &mut stdout, Some(num_bytes as u64))
.await
return match ObjectStore::write_object_to_with_options(
&client,
&path,
&read_options,
&mut stdout,
Some(num_bytes as u64),
)
.await
{
Ok(_) => ExitCode::Success,
Err(e) => output_get_error(&formatter, &args.path, e),
};
}

// Get object content
match client.get_object(&path).await {
match ObjectStore::get_object_with_options(&client, &path, &read_options).await {
Ok(data) => {
let content = String::from_utf8_lossy(&data);
let lines: Vec<&str> = content.lines().take(args.lines).collect();
Expand All @@ -105,17 +112,9 @@ pub async fn execute(args: HeadArgs, output_config: OutputConfig) -> ExitCode {
}

fn output_get_error(formatter: &Formatter, path: &str, error: rc_core::Error) -> ExitCode {
let message = error.to_string();
if message.contains("NotFound") || message.contains("NoSuchKey") {
formatter.error(&format!("Object not found: {path}"));
ExitCode::NotFound
} else if message.contains("AccessDenied") {
formatter.error(&format!("Access denied: {path}"));
ExitCode::AuthError
} else {
formatter.error(&format!("Failed to get object: {error}"));
ExitCode::NetworkError
}
let exit_code = exit_code_for_core_error(&error);
formatter.error_with_code(exit_code, &format!("Failed to read {path}: {error}"));
exit_code
}

/// Parse head path into (alias, bucket, key)
Expand Down
Loading
Loading