|
3 | 3 | //! Moves objects between locations (copy + delete). |
4 | 4 |
|
5 | 5 | use clap::Args; |
6 | | -use rc_core::{AliasManager, ObjectStore as _, ParsedPath, RemotePath, parse_path}; |
| 6 | +use rc_core::{AliasManager, ListOptions, ObjectStore as _, ParsedPath, RemotePath, parse_path}; |
7 | 7 | use rc_s3::S3Client; |
8 | 8 | use serde::Serialize; |
9 | 9 | use std::path::{Path, PathBuf}; |
@@ -273,39 +273,168 @@ async fn move_s3_to_s3( |
273 | 273 | return ExitCode::Success; |
274 | 274 | } |
275 | 275 |
|
276 | | - // Copy |
277 | | - match client.copy_object(src, dst).await { |
278 | | - Ok(info) => { |
279 | | - // Delete source |
280 | | - if let Err(e) = client.delete_object(src).await { |
281 | | - formatter.error(&format!("Copied but failed to delete source: {e}")); |
282 | | - return ExitCode::GeneralError; |
283 | | - } |
| 276 | + // Recursive move for prefix/directory semantics. |
| 277 | + if args.recursive { |
| 278 | + let mut continuation_token: Option<String> = None; |
| 279 | + let mut moved_count = 0usize; |
| 280 | + let mut error_count = 0usize; |
| 281 | + let src_prefix = src.key.clone(); |
| 282 | + |
| 283 | + loop { |
| 284 | + let list_opts = ListOptions { |
| 285 | + recursive: true, |
| 286 | + continuation_token: continuation_token.clone(), |
| 287 | + ..Default::default() |
| 288 | + }; |
| 289 | + |
| 290 | + let list_result = match client.list_objects(src, list_opts).await { |
| 291 | + Ok(result) => result, |
| 292 | + Err(e) => { |
| 293 | + formatter.error(&format!("Failed to list source objects: {e}")); |
| 294 | + return ExitCode::NetworkError; |
| 295 | + } |
| 296 | + }; |
| 297 | + |
| 298 | + for item in &list_result.items { |
| 299 | + if item.is_dir { |
| 300 | + continue; |
| 301 | + } |
| 302 | + |
| 303 | + let relative = if src_prefix.is_empty() { |
| 304 | + item.key.clone() |
| 305 | + } else if let Some(rest) = item.key.strip_prefix(&src_prefix) { |
| 306 | + rest.trim_start_matches('/').to_string() |
| 307 | + } else { |
| 308 | + item.key.clone() |
| 309 | + }; |
284 | 310 |
|
285 | | - if formatter.is_json() { |
286 | | - let output = MvOutput { |
287 | | - status: "success", |
288 | | - source: src_display, |
289 | | - target: dst_display, |
290 | | - size_bytes: info.size_bytes, |
| 311 | + let target_key = if dst.key.is_empty() { |
| 312 | + relative.clone() |
| 313 | + } else if dst.key.ends_with('/') { |
| 314 | + format!("{}{}", dst.key, relative) |
| 315 | + } else { |
| 316 | + format!("{}/{}", dst.key, relative) |
291 | 317 | }; |
292 | | - formatter.json(&output); |
293 | | - } else { |
294 | | - formatter.println(&format!( |
295 | | - "{src_display} -> {dst_display} ({})", |
296 | | - info.size_human.unwrap_or_default() |
297 | | - )); |
| 318 | + |
| 319 | + let src_obj = RemotePath::new(&src.alias, &src.bucket, &item.key); |
| 320 | + let dst_obj = RemotePath::new(&dst.alias, &dst.bucket, &target_key); |
| 321 | + let src_obj_display = src_obj.to_string(); |
| 322 | + let dst_obj_display = dst_obj.to_string(); |
| 323 | + |
| 324 | + match client.copy_object(&src_obj, &dst_obj).await { |
| 325 | + Ok(_) => match client.delete_object(&src_obj).await { |
| 326 | + Ok(()) => { |
| 327 | + moved_count += 1; |
| 328 | + if !formatter.is_json() { |
| 329 | + formatter |
| 330 | + .println(&format!("{src_obj_display} -> {dst_obj_display}")); |
| 331 | + } |
| 332 | + } |
| 333 | + Err(e) => { |
| 334 | + error_count += 1; |
| 335 | + formatter.error(&format!( |
| 336 | + "Copied but failed to delete source '{src_obj_display}': {e}" |
| 337 | + )); |
| 338 | + if !args.continue_on_error { |
| 339 | + return ExitCode::GeneralError; |
| 340 | + } |
| 341 | + } |
| 342 | + }, |
| 343 | + Err(e) => { |
| 344 | + error_count += 1; |
| 345 | + formatter.error(&format!( |
| 346 | + "Failed to move '{src_obj_display}' -> '{dst_obj_display}': {e}" |
| 347 | + )); |
| 348 | + if !args.continue_on_error { |
| 349 | + return ExitCode::NetworkError; |
| 350 | + } |
| 351 | + } |
| 352 | + } |
| 353 | + } |
| 354 | + |
| 355 | + if !list_result.truncated { |
| 356 | + break; |
| 357 | + } |
| 358 | + continuation_token = match list_result.continuation_token.clone() { |
| 359 | + Some(token) => Some(token), |
| 360 | + None => { |
| 361 | + formatter.error( |
| 362 | + "Backend indicated truncated results but did not provide a continuation token; stopping to avoid an infinite loop.", |
| 363 | + ); |
| 364 | + return ExitCode::GeneralError; |
| 365 | + } |
| 366 | + }; |
| 367 | + } |
| 368 | + |
| 369 | + if formatter.is_json() { |
| 370 | + #[derive(Serialize)] |
| 371 | + struct MvRecursiveOutput { |
| 372 | + status: &'static str, |
| 373 | + source: String, |
| 374 | + target: String, |
| 375 | + moved: usize, |
| 376 | + errors: usize, |
298 | 377 | } |
| 378 | + |
| 379 | + formatter.json(&MvRecursiveOutput { |
| 380 | + status: if error_count == 0 { |
| 381 | + "success" |
| 382 | + } else { |
| 383 | + "partial" |
| 384 | + }, |
| 385 | + source: src_display, |
| 386 | + target: dst_display, |
| 387 | + moved: moved_count, |
| 388 | + errors: error_count, |
| 389 | + }); |
| 390 | + } else if error_count == 0 { |
| 391 | + formatter.println(&format!("Moved {moved_count} object(s).")); |
| 392 | + } else { |
| 393 | + formatter.println(&format!( |
| 394 | + "Moved {moved_count} object(s), {error_count} failed." |
| 395 | + )); |
| 396 | + } |
| 397 | + |
| 398 | + if error_count == 0 { |
299 | 399 | ExitCode::Success |
| 400 | + } else { |
| 401 | + ExitCode::GeneralError |
300 | 402 | } |
301 | | - Err(e) => { |
302 | | - let err_str = e.to_string(); |
303 | | - if err_str.contains("NotFound") || err_str.contains("NoSuchKey") { |
304 | | - formatter.error(&format!("Source not found: {src_display}")); |
305 | | - ExitCode::NotFound |
306 | | - } else { |
307 | | - formatter.error(&format!("Failed to move: {e}")); |
308 | | - ExitCode::NetworkError |
| 403 | + } else { |
| 404 | + // Copy |
| 405 | + match client.copy_object(src, dst).await { |
| 406 | + Ok(info) => { |
| 407 | + // Delete source |
| 408 | + if let Err(e) = client.delete_object(src).await { |
| 409 | + formatter.error(&format!("Copied but failed to delete source: {e}")); |
| 410 | + return ExitCode::GeneralError; |
| 411 | + } |
| 412 | + |
| 413 | + if formatter.is_json() { |
| 414 | + let output = MvOutput { |
| 415 | + status: "success", |
| 416 | + source: src_display, |
| 417 | + target: dst_display, |
| 418 | + size_bytes: info.size_bytes, |
| 419 | + }; |
| 420 | + formatter.json(&output); |
| 421 | + } else { |
| 422 | + formatter.println(&format!( |
| 423 | + "{src_display} -> {dst_display} ({})", |
| 424 | + info.size_human.unwrap_or_default() |
| 425 | + )); |
| 426 | + } |
| 427 | + ExitCode::Success |
| 428 | + } |
| 429 | + Err(e) => { |
| 430 | + let err_str = e.to_string(); |
| 431 | + if err_str.contains("NotFound") || err_str.contains("NoSuchKey") { |
| 432 | + formatter.error(&format!("Source not found: {src_display}")); |
| 433 | + ExitCode::NotFound |
| 434 | + } else { |
| 435 | + formatter.error(&format!("Failed to move: {e}")); |
| 436 | + ExitCode::NetworkError |
| 437 | + } |
309 | 438 | } |
310 | 439 | } |
311 | 440 | } |
|
0 commit comments