use clap::Args;
use rc_core::{AliasManager, ListOptions, ObjectStore as _, ParsedPath, RemotePath, parse_path};
use rc_s3::S3Client;
use serde::Serialize;
use std::path::{Path, PathBuf};
use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig};
#[derive(Args, Debug)]
pub struct MvArgs {
pub source: String,
pub target: String,
#[arg(short, long)]
pub recursive: bool,
#[arg(long)]
pub continue_on_error: bool,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Serialize)]
struct MvOutput {
status: &'static str,
source: String,
target: String,
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<i64>,
}
pub async fn execute(args: MvArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);
let alias_manager = AliasManager::new().ok();
let source = match parse_mv_path(&args.source, alias_manager.as_ref()) {
Ok(p) => p,
Err(e) => {
formatter.error(&format!("Invalid source path: {e}"));
return ExitCode::UsageError;
}
};
let target = match parse_mv_path(&args.target, alias_manager.as_ref()) {
Ok(p) => p,
Err(e) => {
formatter.error(&format!("Invalid target path: {e}"));
return ExitCode::UsageError;
}
};
match (&source, &target) {
(ParsedPath::Local(src), ParsedPath::Remote(dst)) => {
move_local_to_s3(src, dst, &args, &formatter).await
}
(ParsedPath::Remote(src), ParsedPath::Local(dst)) => {
move_s3_to_local(src, dst, &args, &formatter).await
}
(ParsedPath::Remote(src), ParsedPath::Remote(dst)) => {
move_s3_to_s3(src, dst, &args, &formatter).await
}
(ParsedPath::Local(_), ParsedPath::Local(_)) => {
formatter.error("Cannot move between two local paths. Use system mv command.");
ExitCode::UsageError
}
}
}
fn parse_mv_path(path: &str, alias_manager: Option<&AliasManager>) -> rc_core::Result<ParsedPath> {
let parsed = parse_path(path)?;
let ParsedPath::Remote(remote) = &parsed else {
return Ok(parsed);
};
if let Some(manager) = alias_manager
&& matches!(manager.exists(&remote.alias), Ok(true))
{
return Ok(parsed);
}
if Path::new(path).exists() {
return Ok(ParsedPath::Local(PathBuf::from(path)));
}
Ok(parsed)
}
async fn move_local_to_s3(
src: &std::path::Path,
dst: &RemotePath,
args: &MvArgs,
formatter: &Formatter,
) -> ExitCode {
use crate::commands::cp;
let cp_args = cp::CpArgs {
source: src.to_string_lossy().to_string(),
target: format!("{}/{}/{}", dst.alias, dst.bucket, dst.key),
recursive: args.recursive,
preserve: false,
continue_on_error: args.continue_on_error,
overwrite: true,
dry_run: args.dry_run,
storage_class: None,
content_type: None,
};
let cp_result = cp::execute(
cp_args,
OutputConfig {
json: formatter.is_json(),
quiet: formatter.is_quiet(),
..Default::default()
},
)
.await;
if cp_result != ExitCode::Success {
return cp_result;
}
if !args.dry_run {
if src.is_file()
&& let Err(e) = std::fs::remove_file(src)
{
formatter.error(&format!("Failed to delete local file: {e}"));
return ExitCode::GeneralError;
} else if src.is_dir()
&& args.recursive
&& let Err(e) = std::fs::remove_dir_all(src)
{
formatter.error(&format!("Failed to delete local directory: {e}"));
return ExitCode::GeneralError;
}
}
ExitCode::Success
}
async fn move_s3_to_local(
src: &RemotePath,
dst: &std::path::Path,
args: &MvArgs,
formatter: &Formatter,
) -> ExitCode {
use crate::commands::cp;
let cp_args = cp::CpArgs {
source: format!("{}/{}/{}", src.alias, src.bucket, src.key),
target: dst.to_string_lossy().to_string(),
recursive: args.recursive,
preserve: false,
continue_on_error: args.continue_on_error,
overwrite: true,
dry_run: args.dry_run,
storage_class: None,
content_type: None,
};
let cp_result = cp::execute(
cp_args,
OutputConfig {
json: formatter.is_json(),
quiet: formatter.is_quiet(),
..Default::default()
},
)
.await;
if cp_result != ExitCode::Success {
return cp_result;
}
if !args.dry_run {
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(&src.alias) {
Ok(a) => a,
Err(_) => {
formatter.error(&format!("Alias '{}' not found", src.alias));
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 Err(e) = client.delete_object(src).await {
formatter.error(&format!("Failed to delete source: {e}"));
return ExitCode::NetworkError;
}
}
ExitCode::Success
}
async fn move_s3_to_s3(
src: &RemotePath,
dst: &RemotePath,
args: &MvArgs,
formatter: &Formatter,
) -> ExitCode {
if src.alias != dst.alias {
formatter.error("Cross-alias S3-to-S3 move not yet supported.");
return ExitCode::UnsupportedFeature;
}
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(&src.alias) {
Ok(a) => a,
Err(_) => {
formatter.error(&format!("Alias '{}' not found", src.alias));
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;
}
};
let src_display = format!("{}/{}/{}", src.alias, src.bucket, src.key);
let dst_display = format!("{}/{}/{}", dst.alias, dst.bucket, dst.key);
if args.dry_run {
formatter.println(&format!("Would move: {src_display} -> {dst_display}"));
return ExitCode::Success;
}
if args.recursive {
let mut continuation_token: Option<String> = None;
let mut moved_count = 0usize;
let mut error_count = 0usize;
let src_prefix = src.key.clone();
loop {
let list_opts = ListOptions {
recursive: true,
continuation_token: continuation_token.clone(),
..Default::default()
};
let list_result = match client.list_objects(src, list_opts).await {
Ok(result) => result,
Err(e) => {
formatter.error(&format!("Failed to list source objects: {e}"));
return ExitCode::NetworkError;
}
};
for item in &list_result.items {
if item.is_dir {
continue;
}
let relative = if src_prefix.is_empty() {
item.key.clone()
} else if let Some(rest) = item.key.strip_prefix(&src_prefix) {
rest.trim_start_matches('/').to_string()
} else {
item.key.clone()
};
let target_key = if dst.key.is_empty() {
relative.clone()
} else if dst.key.ends_with('/') {
format!("{}{}", dst.key, relative)
} else {
format!("{}/{}", dst.key, relative)
};
let src_obj = RemotePath::new(&src.alias, &src.bucket, &item.key);
let dst_obj = RemotePath::new(&dst.alias, &dst.bucket, &target_key);
let src_obj_display = src_obj.to_string();
let dst_obj_display = dst_obj.to_string();
match client.copy_object(&src_obj, &dst_obj).await {
Ok(_) => match client.delete_object(&src_obj).await {
Ok(()) => {
moved_count += 1;
if !formatter.is_json() {
formatter
.println(&format!("{src_obj_display} -> {dst_obj_display}"));
}
}
Err(e) => {
error_count += 1;
formatter.error(&format!(
"Copied but failed to delete source '{src_obj_display}': {e}"
));
if !args.continue_on_error {
return ExitCode::GeneralError;
}
}
},
Err(e) => {
error_count += 1;
formatter.error(&format!(
"Failed to move '{src_obj_display}' -> '{dst_obj_display}': {e}"
));
if !args.continue_on_error {
return ExitCode::NetworkError;
}
}
}
}
if !list_result.truncated {
break;
}
continuation_token = match list_result.continuation_token.clone() {
Some(token) => Some(token),
None => {
formatter.error(
"Backend indicated truncated results but did not provide a continuation token; stopping to avoid an infinite loop.",
);
return ExitCode::GeneralError;
}
};
}
if formatter.is_json() {
#[derive(Serialize)]
struct MvRecursiveOutput {
status: &'static str,
source: String,
target: String,
moved: usize,
errors: usize,
}
formatter.json(&MvRecursiveOutput {
status: if error_count == 0 {
"success"
} else {
"partial"
},
source: src_display,
target: dst_display,
moved: moved_count,
errors: error_count,
});
} else if error_count == 0 {
formatter.println(&format!("Moved {moved_count} object(s)."));
} else {
formatter.println(&format!(
"Moved {moved_count} object(s), {error_count} failed."
));
}
if error_count == 0 {
ExitCode::Success
} else {
ExitCode::GeneralError
}
} else {
match client.copy_object(src, dst).await {
Ok(info) => {
if let Err(e) = client.delete_object(src).await {
formatter.error(&format!("Copied but failed to delete source: {e}"));
return ExitCode::GeneralError;
}
if formatter.is_json() {
let output = MvOutput {
status: "success",
source: src_display,
target: dst_display,
size_bytes: info.size_bytes,
};
formatter.json(&output);
} else {
formatter.println(&format!(
"{src_display} -> {dst_display} ({})",
info.size_human.unwrap_or_default()
));
}
ExitCode::Success
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
formatter.error(&format!("Source not found: {src_display}"));
ExitCode::NotFound
} else {
formatter.error(&format!("Failed to move: {e}"));
ExitCode::NetworkError
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rc_core::{Alias, ConfigManager};
use tempfile::TempDir;
fn temp_alias_manager() -> (AliasManager, TempDir) {
let temp_dir = TempDir::new().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let config_manager = ConfigManager::with_path(config_path);
let alias_manager = AliasManager::with_config_manager(config_manager);
(alias_manager, temp_dir)
}
#[test]
fn test_parse_paths() {
let local = parse_path("./file.txt").unwrap();
assert!(matches!(local, ParsedPath::Local(_)));
let remote = parse_path("myalias/bucket/file.txt").unwrap();
assert!(matches!(remote, ParsedPath::Remote(_)));
}
#[test]
fn test_parse_local_absolute_path() {
#[cfg(unix)]
let path = "/tmp/file.txt";
#[cfg(windows)]
let path = "C:\\temp\\file.txt";
let result = parse_path(path).unwrap();
assert!(matches!(result, ParsedPath::Local(_)));
if let ParsedPath::Local(p) = result {
assert!(p.is_absolute());
}
}
#[test]
fn test_parse_remote_path_components() {
let result = parse_path("s3/mybucket/path/to/file.txt").unwrap();
if let ParsedPath::Remote(r) = result {
assert_eq!(r.alias, "s3");
assert_eq!(r.bucket, "mybucket");
assert_eq!(r.key, "path/to/file.txt");
} else {
panic!("Expected Remote path");
}
}
#[test]
fn test_parse_mv_path_prefers_existing_local_path_when_alias_missing() {
let (alias_manager, temp_dir) = temp_alias_manager();
let full = temp_dir.path().join("issue-2094-mv-local").join("file.txt");
let full_str = full.to_string_lossy().to_string();
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&full, b"test").expect("write local file");
let parsed = parse_mv_path(&full_str, Some(&alias_manager)).expect("parse path");
assert!(matches!(parsed, ParsedPath::Local(_)));
}
#[test]
fn test_parse_mv_path_keeps_remote_when_alias_exists() {
let (alias_manager, _temp_dir) = temp_alias_manager();
alias_manager
.set(Alias::new("target", "http://localhost:9000", "a", "b"))
.expect("set alias");
let parsed = parse_mv_path("target/bucket/file.txt", Some(&alias_manager))
.expect("parse remote path");
assert!(matches!(parsed, ParsedPath::Remote(_)));
}
#[test]
fn test_parse_mv_path_keeps_remote_when_local_missing() {
let (alias_manager, _temp_dir) = temp_alias_manager();
let parsed = parse_mv_path("missing/bucket/file.txt", Some(&alias_manager))
.expect("parse remote path");
assert!(matches!(parsed, ParsedPath::Remote(_)));
}
#[test]
fn test_mv_args_defaults() {
let args = MvArgs {
source: "src".to_string(),
target: "dst".to_string(),
recursive: false,
continue_on_error: false,
dry_run: false,
};
assert!(!args.recursive);
assert!(!args.dry_run);
assert!(!args.continue_on_error);
}
#[test]
fn test_mv_output_serialization() {
let output = MvOutput {
status: "success",
source: "src/file.txt".to_string(),
target: "dst/file.txt".to_string(),
size_bytes: Some(2048),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"status\":\"success\""));
assert!(json.contains("\"size_bytes\":2048"));
}
#[test]
fn test_mv_output_skips_none_size() {
let output = MvOutput {
status: "success",
source: "src".to_string(),
target: "dst".to_string(),
size_bytes: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(!json.contains("size_bytes"));
}
}