Enhance file copy operations with new copy method options and CLI support

- Updated `CopyMethod` enum to include `Atomic` and `Streaming` variants, replacing the previous `AtomicMove` and `StreamingCopy` options for clarity.
- Refactored the `select_strategy` method to respect user preferences for copy methods, improving the logic for same-volume operations.
- Added CLI support for the new copy methods in `args.rs`, allowing users to specify their preferred method during file copy operations.
- Updated relevant tests to reflect changes in copy method naming and functionality.
- Enhanced documentation to include new copy method options and their usage.

Co-authored-by: ijamespine <ijamespine@me.com>
This commit is contained in:
Jamie Pine 2025-09-19 13:05:18 -07:00
parent ecdcf04066
commit aa1a8d8c00
14 changed files with 922 additions and 708 deletions

View File

@ -11,7 +11,7 @@ clap = { version = "4", features = ["derive"] }
crossterm = "0.27"
indicatif = "0.17"
ratatui = "0.26"
sd-core = { path = "../../core" }
sd-core = { path = "../../core", features = ["cli"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

View File

@ -30,6 +30,10 @@ pub struct FileCopyArgs {
/// Delete source files after copy (move)
#[arg(long, default_value_t = false)]
pub move_files: bool,
/// Copy method to use
#[arg(long, default_value_t = CopyMethod::Auto)]
pub method: CopyMethod,
}
impl From<FileCopyArgs> for FileCopyInput {
@ -47,8 +51,7 @@ impl From<FileCopyArgs> for FileCopyInput {
verify_checksum: args.verify_checksum,
preserve_timestamps: args.preserve_timestamps,
move_files: args.move_files,
copy_method: CopyMethod::Auto,
copy_method: args.method,
}
}
}

View File

@ -58,3 +58,4 @@ pub const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '
pub fn spinner_char(frame: usize) -> char {
SPINNER_CHARS[frame % SPINNER_CHARS.len()]
}

View File

@ -2,25 +2,25 @@
/// Display the Spacedrive logo
pub fn print_logo() {
println!(r#" [48;2;240;230;255m [48;2;235;220;255m [48;2;240;230;255m [0m
[48;2;240;230;255m [48;2;220;180;255m [48;2;240;230;255m [0m
[48;2;235;220;255m [48;2;180;120;255m [48;2;160;80;255m [48;2;235;220;255m [0m
[48;2;235;220;255m [48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [48;2;235;220;255m [0m
[48;2;240;230;255m [48;2;140;40;255m [48;2;120;20;255m [48;2;140;40;255m [48;2;240;230;255m [0m
[48;2;220;180;255m [48;2;120;20;255m [48;2;100;0;255m [48;2;120;20;255m [48;2;220;180;255m [0m
[48;2;180;120;255m [48;2;100;0;255m [48;2;120;20;255m [48;2;100;0;255m [48;2;180;120;255m [0m
[48;2;160;80;255m [48;2;120;20;255m [48;2;140;40;255m [48;2;120;20;255m [48;2;160;80;255m [0m
[48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [0m
[48;2;180;120;255m [48;2;160;80;255m [48;2;180;120;255m [48;2;160;80;255m [0m
[48;2;220;180;255m [48;2;180;120;255m [48;2;220;180;255m [0m
[48;2;235;220;255m [48;2;220;180;255m [48;2;235;220;255m [0m"#);
println!();
println!(" 🚀 Spacedrive CLI v2");
println!(" Cross-platform file management");
println!();
// println!(r#" [48;2;240;230;255m [48;2;235;220;255m [48;2;240;230;255m [0m
// [48;2;240;230;255m [48;2;220;180;255m [48;2;240;230;255m [0m
// [48;2;235;220;255m [48;2;180;120;255m [48;2;160;80;255m [48;2;235;220;255m [0m
// [48;2;235;220;255m [48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [48;2;235;220;255m [0m
// [48;2;240;230;255m [48;2;140;40;255m [48;2;120;20;255m [48;2;140;40;255m [48;2;240;230;255m [0m
// [48;2;220;180;255m [48;2;120;20;255m [48;2;100;0;255m [48;2;120;20;255m [48;2;220;180;255m [0m
// [48;2;180;120;255m [48;2;100;0;255m [48;2;120;20;255m [48;2;100;0;255m [48;2;180;120;255m [0m
// [48;2;160;80;255m [48;2;120;20;255m [48;2;140;40;255m [48;2;120;20;255m [48;2;160;80;255m [0m
// [48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [48;2;140;40;255m [48;2;160;80;255m [0m
// [48;2;180;120;255m [48;2;160;80;255m [48;2;180;120;255m [48;2;160;80;255m [0m
// [48;2;220;180;255m [48;2;180;120;255m [48;2;220;180;255m [0m
// [48;2;235;220;255m [48;2;220;180;255m [48;2;235;220;255m [0m"#);
println!();
println!(" 🚀 Spacedrive CLI v2");
println!(" Cross-platform file management");
println!();
}
/// Display a compact version of the logo
pub fn print_compact_logo() {
println!("🚀 Spacedrive CLI v2");
println!("🚀 Spacedrive CLI v2");
}

View File

@ -464,3 +464,4 @@ where
Ok(())
}

View File

@ -97,3 +97,4 @@ crate::register_core_action!(LibraryCreateAction, "libraries.create");
## Debug Instructions
- You can view the logs of a job in the job_logs directory in the root of the data folder
- When testing the CLI, after compiling you must `stop` then `start` the Spacedrive daemon.

View File

@ -13,6 +13,8 @@ ai = []
heif = []
# Mobile platform support
mobile = []
# CLI support
cli = ["dep:clap"]
[dependencies]
@ -45,6 +47,7 @@ serde_json = "1.0"
strum = { version = "0.26", features = ["derive"] }
toml = "0.8"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
@ -139,7 +142,7 @@ whoami = "1.5"
keyring = "3.6"
# CLI dependencies
clap = { version = "4.5", features = ["derive", "env"] }
clap = { version = "4.5", features = ["derive", "env"], optional = true }
colored = "2.1"
comfy-table = "7.1"
console = "0.15"

View File

@ -8,13 +8,14 @@ use std::path::PathBuf;
/// Copy method preference for file operations
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum CopyMethod {
/// Automatically select the best method based on source and destination
Auto,
/// Use atomic move (rename) for same-volume operations
AtomicMove,
/// Use streaming copy for cross-volume operations
StreamingCopy,
/// Use atomic operations (rename for moves, APFS clone for copies, etc.)
Atomic,
/// Use streaming copy/move (works across all scenarios)
Streaming,
}
impl Default for CopyMethod {
@ -23,6 +24,17 @@ impl Default for CopyMethod {
}
}
#[cfg(feature = "cli")]
impl std::fmt::Display for CopyMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CopyMethod::Auto => write!(f, "auto"),
CopyMethod::Atomic => write!(f, "atomic"),
CopyMethod::Streaming => write!(f, "streaming"),
}
}
}
/// Core input structure for file copy operations
/// This is the canonical interface that all external APIs (CLI, GraphQL, REST) convert to
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -1,8 +1,11 @@
//! Strategy router for selecting the optimal copy method
use super::{
input::CopyMethod,
strategy::{CopyStrategy, LocalMoveStrategy, LocalStreamCopyStrategy, RemoteTransferStrategy},
input::CopyMethod,
strategy::{
CopyStrategy, FastCopyStrategy, LocalMoveStrategy, LocalStreamCopyStrategy,
RemoteTransferStrategy,
},
};
use crate::{domain::addressing::SdPath, volume::VolumeManager};
use std::sync::Arc;
@ -10,276 +13,309 @@ use std::sync::Arc;
pub struct CopyStrategyRouter;
impl CopyStrategyRouter {
/// Selects the optimal copy strategy based on source, destination, and volume info
pub async fn select_strategy(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> Box<dyn CopyStrategy> {
// Cross-device transfer - always use network strategy
if source.device_id() != destination.device_id() {
return Box::new(RemoteTransferStrategy);
}
/// Selects the optimal copy strategy based on source, destination, and volume info
pub async fn select_strategy(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> Box<dyn CopyStrategy> {
// Cross-device transfer - always use network strategy
if source.device_id() != destination.device_id() {
return Box::new(RemoteTransferStrategy);
}
// For same-device operations, respect user's method preference
match copy_method {
CopyMethod::AtomicMove => {
// User explicitly wants atomic move - validate it's possible
if is_move {
return Box::new(LocalMoveStrategy);
} else {
// Cannot do atomic move for copy operations, fall back to streaming
return Box::new(LocalStreamCopyStrategy);
}
}
CopyMethod::StreamingCopy => {
// User explicitly wants streaming copy
return Box::new(LocalStreamCopyStrategy);
}
CopyMethod::Auto => {
// Auto-select based on optimal strategy (original logic)
// Same-device operation - get local paths for volume analysis
let (source_path, dest_path) = match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
// Fallback to streaming copy if paths aren't local
return Box::new(LocalStreamCopyStrategy);
}
};
// For same-device operations, respect user's method preference
match copy_method {
CopyMethod::Atomic => {
// User explicitly wants atomic operations
if is_move {
return Box::new(LocalMoveStrategy);
} else {
// For atomic copy, use fast copy strategy (std::fs::copy handles optimizations)
return Box::new(FastCopyStrategy);
}
}
CopyMethod::Streaming => {
// User explicitly wants streaming copy
return Box::new(LocalStreamCopyStrategy);
}
CopyMethod::Auto => {
// Auto-select based on optimal strategy (original logic)
// Same-device operation - get local paths for volume analysis
let (source_path, dest_path) =
match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
// Fallback to streaming copy if paths aren't local
return Box::new(LocalStreamCopyStrategy);
}
};
// Check if paths are on the same volume
if let Some(vm) = volume_manager {
if vm.same_volume(source_path, dest_path).await {
// Same volume
if is_move {
// Use atomic move for same-volume moves
return Box::new(LocalMoveStrategy);
}
// For same-volume copies, we could add optimized copy strategies here
// (e.g., reflink on filesystems that support it)
// For now, fall through to streaming copy
}
} else {
// No volume manager available - make best guess
// If it's a move operation on the same device, try atomic move
if is_move {
return Box::new(LocalMoveStrategy);
}
}
// Check if paths are on the same volume
let same_volume = if let Some(vm) = volume_manager {
vm.same_volume(source_path, dest_path).await
} else {
// Fallback: if no volume manager, assume same-device local paths are same-volume
Self::paths_likely_same_volume(source_path, dest_path)
};
// Default to streaming copy for cross-volume or non-move same-volume
Box::new(LocalStreamCopyStrategy)
}
}
}
if same_volume {
// Same volume
if is_move {
// Use atomic move for same-volume moves
return Box::new(LocalMoveStrategy);
} else {
// Same-volume copy - use fast copy strategy (std::fs::copy handles optimizations)
return Box::new(FastCopyStrategy);
}
} else {
// Cross-volume operation - use streaming copy
return Box::new(LocalStreamCopyStrategy);
}
/// Provides a human-readable description of the selected strategy
pub async fn describe_strategy(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> String {
if source.device_id() != destination.device_id() {
return if is_move {
"Cross-device move".to_string()
} else {
"Cross-device transfer".to_string()
};
}
// Default to streaming copy for same-volume non-move operations
Box::new(LocalStreamCopyStrategy)
}
}
}
// For same-device operations, include user preference info
let method_prefix = match copy_method {
CopyMethod::Auto => "",
CopyMethod::AtomicMove => "User-requested atomic ",
CopyMethod::StreamingCopy => "User-requested streaming ",
};
/// Heuristic to determine if two local paths are likely on the same volume
/// Used as fallback when VolumeManager is unavailable or incomplete
fn paths_likely_same_volume(path1: &std::path::Path, path2: &std::path::Path) -> bool {
// On macOS, paths under the same root are typically same volume
#[cfg(target_os = "macos")]
{
// Both under /Users, /Applications, /System, etc. are likely same volume
let common_roots = ["/Users", "/Applications", "/System", "/Library", "/private"];
for root in &common_roots {
if path1.starts_with(root) && path2.starts_with(root) {
return true;
}
}
// Both directly under / (like /tmp, /var) are likely same volume
if path1.parent() == Some(std::path::Path::new("/"))
&& path2.parent() == Some(std::path::Path::new("/"))
{
return true;
}
}
match copy_method {
CopyMethod::AtomicMove => {
if is_move {
format!("{}move", method_prefix)
} else {
format!("{}copy (fallback to streaming)", method_prefix)
}
}
CopyMethod::StreamingCopy => {
if is_move {
format!("{}move", method_prefix)
} else {
format!("{}copy", method_prefix)
}
}
CopyMethod::Auto => {
// Auto-select - use original logic for description
let (source_path, dest_path) = match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
return "Streaming copy".to_string();
}
};
// On Linux, similar heuristics
#[cfg(target_os = "linux")]
{
let common_roots = ["/home", "/usr", "/var", "/opt", "/tmp"];
for root in &common_roots {
if path1.starts_with(root) && path2.starts_with(root) {
return true;
}
}
}
if let Some(vm) = volume_manager {
if vm.same_volume(source_path, dest_path).await {
if is_move {
return "Atomic move".to_string();
} else {
return "Same-volume copy".to_string();
}
} else {
return if is_move {
"Cross-volume move".to_string()
} else {
"Cross-volume streaming copy".to_string()
};
}
}
// On Windows, same drive letter
#[cfg(target_os = "windows")]
{
if let (Some(s1), Some(s2)) = (path1.to_str(), path2.to_str()) {
if s1.len() >= 2 && s2.len() >= 2 {
return s1.chars().nth(0) == s2.chars().nth(0)
&& s1.chars().nth(1) == Some(':')
&& s2.chars().nth(1) == Some(':');
}
}
}
// Fallback description
if is_move {
"Local move".to_string()
} else {
"Local copy".to_string()
}
}
}
}
false
}
/// Estimates the performance characteristics of the selected strategy
pub async fn estimate_performance(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> PerformanceEstimate {
// Cross-device transfers always use network
if source.device_id() != destination.device_id() {
return PerformanceEstimate {
speed_category: SpeedCategory::Network,
supports_resume: true,
requires_network: true,
is_atomic: false,
};
}
/// Provides a human-readable description of the selected strategy
pub async fn describe_strategy(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> String {
if source.device_id() != destination.device_id() {
return if is_move {
"Cross-device move".to_string()
} else {
"Cross-device transfer".to_string()
};
}
// For same-device operations, consider user's method preference
match copy_method {
CopyMethod::AtomicMove => {
if is_move {
PerformanceEstimate {
speed_category: SpeedCategory::Instant,
supports_resume: false,
requires_network: false,
is_atomic: true,
}
} else {
// Fallback to streaming for copy operations
PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: false,
requires_network: false,
is_atomic: false,
}
}
}
CopyMethod::StreamingCopy => {
PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: false,
requires_network: false,
is_atomic: false,
}
}
CopyMethod::Auto => {
// Auto-select - use original performance estimation logic
let (source_path, dest_path) = match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
return PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: false,
requires_network: false,
is_atomic: false,
};
}
};
// For same-device operations, include user preference info
let method_prefix = match copy_method {
CopyMethod::Auto => "",
CopyMethod::Atomic => "User-requested atomic ",
CopyMethod::Streaming => "User-requested streaming ",
};
if let Some(vm) = volume_manager {
if vm.same_volume(source_path, dest_path).await {
if is_move {
return PerformanceEstimate {
speed_category: SpeedCategory::Instant,
supports_resume: false,
requires_network: false,
is_atomic: true,
};
} else {
// Could be optimized with filesystem features
let source_vol = vm.volume_for_path(source_path).await;
let supports_fast_copy = source_vol
.map(|v| v.supports_fast_copy())
.unwrap_or(false);
match copy_method {
CopyMethod::Atomic => {
if is_move {
format!("{}move", method_prefix)
} else {
format!("{}fast copy", method_prefix)
}
}
CopyMethod::Streaming => {
if is_move {
format!("{}move", method_prefix)
} else {
format!("{}copy", method_prefix)
}
}
CopyMethod::Auto => {
// Auto-select - use same logic as strategy selection
let (source_path, dest_path) =
match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
return "Streaming copy".to_string();
}
};
return PerformanceEstimate {
speed_category: if supports_fast_copy {
SpeedCategory::FastLocal
} else {
SpeedCategory::LocalDisk
},
supports_resume: false,
requires_network: false,
is_atomic: supports_fast_copy,
};
}
} else {
// Cross-volume on same device
return PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: true,
requires_network: false,
is_atomic: false,
};
}
}
// Check if paths are on the same volume (same logic as select_strategy)
let same_volume = if let Some(vm) = volume_manager {
vm.same_volume(source_path, dest_path).await
} else {
Self::paths_likely_same_volume(source_path, dest_path)
};
// Fallback estimate
PerformanceEstimate {
speed_category: if is_move {
SpeedCategory::FastLocal
} else {
SpeedCategory::LocalDisk
},
supports_resume: false,
requires_network: false,
is_atomic: is_move,
}
}
}
}
if same_volume {
if is_move {
"Atomic move".to_string()
} else {
// Same-volume copy - use fast copy
"Fast copy".to_string()
}
} else {
if is_move {
"Cross-volume move".to_string()
} else {
"Cross-volume streaming copy".to_string()
}
}
}
}
}
/// Estimates the performance characteristics of the selected strategy
pub async fn estimate_performance(
source: &SdPath,
destination: &SdPath,
is_move: bool,
copy_method: &CopyMethod,
volume_manager: Option<&VolumeManager>,
) -> PerformanceEstimate {
// Cross-device transfers always use network
if source.device_id() != destination.device_id() {
return PerformanceEstimate {
speed_category: SpeedCategory::Network,
supports_resume: true,
requires_network: true,
is_atomic: false,
};
}
// For same-device operations, consider user's method preference
match copy_method {
CopyMethod::Atomic => {
if is_move {
PerformanceEstimate {
speed_category: SpeedCategory::Instant,
supports_resume: false,
requires_network: false,
is_atomic: true,
}
} else {
// Fast copy operations (std::fs::copy with filesystem optimizations)
PerformanceEstimate {
speed_category: SpeedCategory::FastLocal,
supports_resume: false,
requires_network: false,
is_atomic: true,
}
}
}
CopyMethod::Streaming => PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: false,
requires_network: false,
is_atomic: false,
},
CopyMethod::Auto => {
// Auto-select - use same logic as strategy selection
let (source_path, dest_path) =
match (source.as_local_path(), destination.as_local_path()) {
(Some(s), Some(d)) => (s, d),
_ => {
return PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: false,
requires_network: false,
is_atomic: false,
};
}
};
// Check if paths are on the same volume (same logic as select_strategy)
let same_volume = if let Some(vm) = volume_manager {
vm.same_volume(source_path, dest_path).await
} else {
Self::paths_likely_same_volume(source_path, dest_path)
};
if same_volume {
if is_move {
PerformanceEstimate {
speed_category: SpeedCategory::Instant,
supports_resume: false,
requires_network: false,
is_atomic: true,
}
} else {
// Same-volume copy - use fast copy
PerformanceEstimate {
speed_category: SpeedCategory::FastLocal,
supports_resume: false,
requires_network: false,
is_atomic: true,
}
}
} else {
// Cross-volume on same device
PerformanceEstimate {
speed_category: SpeedCategory::LocalDisk,
supports_resume: true,
requires_network: false,
is_atomic: false,
}
}
}
}
}
}
/// Performance characteristics of a copy strategy
#[derive(Debug, Clone)]
pub struct PerformanceEstimate {
pub speed_category: SpeedCategory,
pub supports_resume: bool,
pub requires_network: bool,
pub is_atomic: bool,
pub speed_category: SpeedCategory,
pub supports_resume: bool,
pub requires_network: bool,
pub is_atomic: bool,
}
/// Categories of copy operation speed
#[derive(Debug, Clone, PartialEq)]
pub enum SpeedCategory {
/// Instant operations (like atomic moves)
Instant,
/// Fast local operations (reflinks, same-volume copies)
FastLocal,
/// Regular disk-to-disk operations
LocalDisk,
/// Network transfers
Network,
}
/// Instant operations (like atomic moves)
Instant,
/// Fast local operations (reflinks, same-volume copies)
FastLocal,
/// Regular disk-to-disk operations
LocalDisk,
/// Network transfers
Network,
}

File diff suppressed because it is too large Load Diff

View File

@ -5,3 +5,4 @@ pub mod cancel;
pub use pause::*;
pub use resume::*;
pub use cancel::*;

View File

@ -122,7 +122,7 @@ async fn test_copy_progress_monitoring_large_file() {
preserve_timestamps: true, // --preserve-timestamps
delete_after_copy: false,
move_mode: None,
copy_method: CopyMethod::StreamingCopy, // --method streaming
copy_method: CopyMethod::Streaming, // --method streaming
},
};
@ -418,7 +418,7 @@ async fn test_copy_progress_multiple_files() {
preserve_timestamps: true,
delete_after_copy: false,
move_mode: None,
copy_method: CopyMethod::StreamingCopy,
copy_method: CopyMethod::Streaming,
},
};

BIN
debug_paths Executable file

Binary file not shown.

View File

@ -315,3 +315,4 @@ nc -U ~/.local/share/spacedrive/daemon/daemon.sock
- **Structured logging**: Use `tracing` fields for filtering
- **Log levels**: DEBUG for development, INFO for production
- **Event correlation**: Track operations across client-daemon boundary