From eebd702f7d2581d4769f8a6de57862bdf7dd8ea4 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 26 Jul 2025 14:51:21 -0700 Subject: [PATCH] feat(volume): Enhance volume management with device ID integration - Updated VolumeManager to accept a device ID during initialization, allowing for better tracking of volumes associated with specific devices. - Modified volume detection logic to utilize the device ID, improving the accuracy of volume management across different devices. - Introduced new volume commands in the CLI for managing volumes, including tracking, untracking, and refreshing volume lists. - Enhanced database schema to include device ID in the volumes table, ensuring proper relationships between volumes and their respective devices. - Added functionality to save speed test results for tracked volumes, improving performance monitoring capabilities. This update significantly improves the volume management system, enabling more robust tracking and operations across multiple devices. --- .../src/infrastructure/cli/commands/daemon.rs | 628 ++++++------- .../src/infrastructure/cli/commands/volume.rs | 423 ++++++++- .../cli/daemon/handlers/volume.rs | 300 +++--- core-new/src/infrastructure/cli/daemon/mod.rs | 48 + .../cli/daemon/types/responses.rs | 14 +- core-new/src/infrastructure/cli/mod.rs | 15 +- .../database/entities/volume.rs | 99 +- .../m20240103_000001_create_volumes.rs | 226 ++--- .../infrastructure/database/migration/mod.rs | 22 +- core-new/src/lib.rs | 3 +- .../operations/volumes/speed_test/action.rs | 57 +- .../operations/volumes/speed_test/handler.rs | 123 ++- core-new/src/services/file_sharing.rs | 17 +- core-new/src/volume/manager.rs | 264 ++++-- core-new/src/volume/os_detection.rs | 863 ++++++++++-------- core-new/src/volume/speed.rs | 531 ++++++----- core-new/src/volume/types.rs | 11 +- 17 files changed, 2219 insertions(+), 1425 deletions(-) diff --git a/core-new/src/infrastructure/cli/commands/daemon.rs b/core-new/src/infrastructure/cli/commands/daemon.rs index 96221ee3b..2aa03be62 100644 --- a/core-new/src/infrastructure/cli/commands/daemon.rs +++ b/core-new/src/infrastructure/cli/commands/daemon.rs @@ -5,375 +5,379 @@ //! - Checking daemon status //! - Managing multiple daemon instances -use crate::infrastructure::cli::daemon::{Daemon, DaemonClient, DaemonCommand, DaemonConfig, DaemonResponse}; +use crate::infrastructure::cli::daemon::{ + Daemon, DaemonClient, DaemonCommand, DaemonConfig, DaemonResponse, +}; +use crate::infrastructure::cli::output::messages::LibraryInfo as OutputLibraryInfo; use crate::infrastructure::cli::output::{CliOutput, Message}; -use crate::infrastructure::cli::output::messages::{LibraryInfo as OutputLibraryInfo}; use clap::Subcommand; use comfy_table::Table; use std::path::PathBuf; #[derive(Subcommand, Clone, Debug)] pub enum DaemonCommands { - /// Start the Spacedrive daemon in the background - Start { - /// Run in foreground instead of daemonizing - #[arg(short, long)] - foreground: bool, - /// Enable networking on startup - #[arg(long)] - enable_networking: bool, - }, + /// Start the Spacedrive daemon in the background + Start { + /// Run in foreground instead of daemonizing + #[arg(long)] + foreground: bool, + /// Enable networking on startup + #[arg(long)] + enable_networking: bool, + }, - /// Stop the Spacedrive daemon - Stop, + /// Stop the Spacedrive daemon + Stop, - /// Check if the daemon is running and show status - Status, + /// Check if the daemon is running and show status + Status, - /// Manage daemon instances - #[command(subcommand)] - Instance(InstanceCommands), + /// Manage daemon instances + #[command(subcommand)] + Instance(InstanceCommands), } #[derive(Subcommand, Clone, Debug)] pub enum InstanceCommands { - /// List all daemon instances - List, - /// Stop a specific daemon instance - Stop { - /// Instance name to stop - name: String, - }, - /// Show currently targeted instance - Current, + /// List all daemon instances + List, + /// Stop a specific daemon instance + Stop { + /// Instance name to stop + name: String, + }, + /// Show currently targeted instance + Current, } pub async fn handle_daemon_command( - cmd: DaemonCommands, - data_dir: PathBuf, - instance_name: Option, - mut output: CliOutput, + cmd: DaemonCommands, + data_dir: PathBuf, + instance_name: Option, + mut output: CliOutput, ) -> Result<(), Box> { - match cmd { - DaemonCommands::Start { - foreground, - enable_networking, - } => { - handle_start_daemon(data_dir, foreground, enable_networking, instance_name, output).await - } - DaemonCommands::Stop => { - handle_stop_daemon(instance_name, output).await - } - DaemonCommands::Status => { - handle_daemon_status(instance_name, output).await - } - DaemonCommands::Instance(instance_cmd) => { - handle_instance_command(instance_cmd, output).await - } - } + match cmd { + DaemonCommands::Start { + foreground, + enable_networking, + } => { + handle_start_daemon( + data_dir, + foreground, + enable_networking, + instance_name, + output, + ) + .await + } + DaemonCommands::Stop => handle_stop_daemon(instance_name, output).await, + DaemonCommands::Status => handle_daemon_status(instance_name, output).await, + DaemonCommands::Instance(instance_cmd) => { + handle_instance_command(instance_cmd, output).await + } + } } async fn handle_start_daemon( - data_dir: PathBuf, - foreground: bool, - enable_networking: bool, - instance_name: Option, - mut output: CliOutput, + data_dir: PathBuf, + foreground: bool, + enable_networking: bool, + instance_name: Option, + mut output: CliOutput, ) -> Result<(), Box> { - if Daemon::is_running_instance(instance_name.clone()) { - let instance_display = instance_name.as_deref().unwrap_or("default"); - output.warning(&format!( - "Spacedrive daemon instance '{}' is already running", - instance_display - ))?; - return Ok(()); - } + if Daemon::is_running_instance(instance_name.clone()) { + let instance_display = instance_name.as_deref().unwrap_or("default"); + output.warning(&format!( + "Spacedrive daemon instance '{}' is already running", + instance_display + ))?; + return Ok(()); + } - output.print(Message::DaemonStarting { - instance: instance_name.as_deref().unwrap_or("default").to_string(), - })?; + output.print(Message::DaemonStarting { + instance: instance_name.as_deref().unwrap_or("default").to_string(), + })?; - if foreground { - // Run in foreground - if enable_networking { - // For networking enabled startup, we need a default password - output.info("Starting daemon with networking enabled...")?; - output.info("Using master key for secure device authentication.")?; + if foreground { + // Run in foreground + if enable_networking { + // For networking enabled startup, we need a default password + output.info("Starting daemon with networking enabled...")?; + output.info("Using master key for secure device authentication.")?; - match Daemon::new_with_networking_and_instance( - data_dir.clone(), - instance_name.clone(), - ) - .await - { - Ok(daemon) => daemon.start().await?, - Err(e) => { - output.error(Message::Error(format!("Failed to start daemon with networking: {}", e)))?; - output.info("Falling back to daemon without networking...")?; - let daemon = - Daemon::new_with_instance(data_dir, instance_name.clone()).await?; - daemon.start().await?; - } - } - } else { - let daemon = Daemon::new_with_instance(data_dir, instance_name.clone()).await?; - daemon.start().await?; - } - } else { - // Daemonize (simplified version - in production use proper daemonization) - use std::process::Command; + match Daemon::new_with_networking_and_instance(data_dir.clone(), instance_name.clone()) + .await + { + Ok(daemon) => daemon.start().await?, + Err(e) => { + output.error(Message::Error(format!( + "Failed to start daemon with networking: {}", + e + )))?; + output.info("Falling back to daemon without networking...")?; + let daemon = Daemon::new_with_instance(data_dir, instance_name.clone()).await?; + daemon.start().await?; + } + } + } else { + let daemon = Daemon::new_with_instance(data_dir, instance_name.clone()).await?; + daemon.start().await?; + } + } else { + // Daemonize (simplified version - in production use proper daemonization) + use std::process::Command; - let exe = std::env::current_exe()?; - let mut cmd = Command::new(exe); - cmd.arg("daemon") - .arg("start") - .arg("--foreground") - .arg("--data-dir") - .arg(data_dir); + let exe = std::env::current_exe()?; + let mut cmd = Command::new(exe); + cmd.arg("daemon") + .arg("start") + .arg("--foreground") + .arg("--data-dir") + .arg(data_dir); - if let Some(ref instance) = instance_name { - cmd.arg("--instance").arg(instance); - } + if let Some(ref instance) = instance_name { + cmd.arg("--instance").arg(instance); + } - if enable_networking { - cmd.arg("--enable-networking"); - } + if enable_networking { + cmd.arg("--enable-networking"); + } - // Detach from terminal - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); + // Detach from terminal + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); - unsafe { - cmd.pre_exec(|| { - // Create new session - libc::setsid(); - Ok(()) - }); - } - } + unsafe { + cmd.pre_exec(|| { + // Create new session + libc::setsid(); + Ok(()) + }); + } + } - cmd.spawn()?; + cmd.spawn()?; - // Wait a bit to see if it started - tokio::time::sleep(std::time::Duration::from_secs(2)).await; + // Wait for daemon to be ready (try connecting to socket) + output.info("Waiting for daemon to initialize...")?; - if Daemon::is_running_instance(instance_name.clone()) { - let instance_display = instance_name.as_deref().unwrap_or("default"); - output.success(&format!( - "Spacedrive daemon instance '{}' started successfully", - instance_display - ))?; - } else { - let instance_display = instance_name.as_deref().unwrap_or("default"); - output.error(Message::Error(format!( - "Failed to start Spacedrive daemon instance '{}'", - instance_display - )))?; - } - } + if Daemon::wait_for_ready(instance_name.clone(), 10).await? { + let instance_display = instance_name.as_deref().unwrap_or("default"); + output.success(&format!( + "Spacedrive daemon instance '{}' started successfully", + instance_display + ))?; + } else { + let instance_display = instance_name.as_deref().unwrap_or("default"); + output.error(Message::Error(format!( + "Failed to start Spacedrive daemon instance '{}' (timed out waiting for readiness)", + instance_display + )))?; + } + } - Ok(()) + Ok(()) } async fn handle_stop_daemon( - instance_name: Option, - mut output: CliOutput, + instance_name: Option, + mut output: CliOutput, ) -> Result<(), Box> { - if !Daemon::is_running_instance(instance_name.clone()) { - let instance_display = instance_name.as_deref().unwrap_or("default"); - output.warning(&format!( - "Spacedrive daemon instance '{}' is not running", - instance_display - ))?; - return Ok(()); - } + if !Daemon::is_running_instance(instance_name.clone()) { + let instance_display = instance_name.as_deref().unwrap_or("default"); + output.warning(&format!( + "Spacedrive daemon instance '{}' is not running", + instance_display + ))?; + return Ok(()); + } - let instance_display = instance_name.as_deref().unwrap_or("default"); - output.print(Message::DaemonStopping { - instance: instance_display.to_string(), - })?; - Daemon::stop_instance(instance_name.clone()).await?; + let instance_display = instance_name.as_deref().unwrap_or("default"); + output.print(Message::DaemonStopping { + instance: instance_display.to_string(), + })?; + Daemon::stop_instance(instance_name.clone()).await?; - // Wait a bit to ensure it's stopped - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Wait a bit to ensure it's stopped + tokio::time::sleep(std::time::Duration::from_secs(1)).await; - if !Daemon::is_running_instance(instance_name.clone()) { - output.print(Message::DaemonStopped { - instance: instance_display.to_string(), - })?; - } else { - output.error(Message::Error(format!( - "Failed to stop Spacedrive daemon instance '{}'", - instance_display - )))?; - } + if !Daemon::is_running_instance(instance_name.clone()) { + output.print(Message::DaemonStopped { + instance: instance_display.to_string(), + })?; + } else { + output.error(Message::Error(format!( + "Failed to stop Spacedrive daemon instance '{}'", + instance_display + )))?; + } - Ok(()) + Ok(()) } async fn handle_daemon_status( - instance_name: Option, - mut output: CliOutput, + instance_name: Option, + mut output: CliOutput, ) -> Result<(), Box> { - let instance_display = instance_name.as_deref().unwrap_or("default"); + let instance_display = instance_name.as_deref().unwrap_or("default"); - if Daemon::is_running_instance(instance_name.clone()) { - output.success(&format!( - "Spacedrive daemon instance '{}' is running", - instance_display - ))?; + if Daemon::is_running_instance(instance_name.clone()) { + output.success(&format!( + "Spacedrive daemon instance '{}' is running", + instance_display + ))?; - // Try to get more info from daemon - let client = DaemonClient::new_with_instance(instance_name); + // Try to get more info from daemon + let client = DaemonClient::new_with_instance(instance_name); - // Get status - match client.send_command(DaemonCommand::GetStatus).await { - Ok(DaemonResponse::Status(status)) => { - output.section() - .title("Status") - .status("Version", &status.version) - .status("Uptime", &format!("{} seconds", status.uptime_secs)) - .status("Active Jobs", &status.active_jobs.to_string()) - .status("Total Locations", &status.total_locations.to_string()) - .render()?; - } - Err(e) => { - output.warning(&format!("Could not get status: {}", e))?; - } - _ => {} - } + // Get status + match client.send_command(DaemonCommand::GetStatus).await { + Ok(DaemonResponse::Status(status)) => { + output + .section() + .title("Status") + .status("Version", &status.version) + .status("Uptime", &format!("{} seconds", status.uptime_secs)) + .status("Active Jobs", &status.active_jobs.to_string()) + .status("Total Locations", &status.total_locations.to_string()) + .render()?; + } + Err(e) => { + output.warning(&format!("Could not get status: {}", e))?; + } + _ => {} + } - // Get libraries - match client - .send_command(DaemonCommand::ListLibraries) - .await - { - Ok(DaemonResponse::Libraries(libraries)) => { - if !libraries.is_empty() { - output.section() - .empty_line() - .title("Libraries") - .render()?; - - for lib in &libraries { - output.section() - .text(&format!(" • {} ({})", lib.name, lib.id)) - .render()?; - } - } - } - Err(e) => { - output.warning(&format!("Could not get libraries: {}", e))?; - } - _ => {} - } + // Get libraries + match client.send_command(DaemonCommand::ListLibraries).await { + Ok(DaemonResponse::Libraries(libraries)) => { + if !libraries.is_empty() { + output.section().empty_line().title("Libraries").render()?; - // Get current library - match client - .send_command(DaemonCommand::GetCurrentLibrary) - .await - { - Ok(DaemonResponse::CurrentLibrary(lib_opt)) => { - if let Some(lib) = lib_opt { - output.section() - .empty_line() - .title("Current Library") - .item("Name", &lib.name) - .item("ID", &lib.id.to_string()) - .item("Path", &lib.path.display().to_string()) - .render()?; - } else { - output.section() - .empty_line() - .title("Current Library") - .text("None selected") - .render()?; - } - } - Err(e) => { - output.warning(&format!("Could not get current library: {}", e))?; - } - _ => {} - } - } else { - output.error(Message::DaemonNotRunning { - instance: instance_display.to_string(), - })?; - - let start_cmd = if instance_name.is_some() { - format!("spacedrive --instance {} start", instance_display) - } else { - "spacedrive start".to_string() - }; - - output.section() - .help() - .item(&format!("Start it with: {}", start_cmd)) - .render()?; - } + for lib in &libraries { + output + .section() + .text(&format!(" • {} ({})", lib.name, lib.id)) + .render()?; + } + } + } + Err(e) => { + output.warning(&format!("Could not get libraries: {}", e))?; + } + _ => {} + } - Ok(()) + // Get current library + match client.send_command(DaemonCommand::GetCurrentLibrary).await { + Ok(DaemonResponse::CurrentLibrary(lib_opt)) => { + if let Some(lib) = lib_opt { + output + .section() + .empty_line() + .title("Current Library") + .item("Name", &lib.name) + .item("ID", &lib.id.to_string()) + .item("Path", &lib.path.display().to_string()) + .render()?; + } else { + output + .section() + .empty_line() + .title("Current Library") + .text("None selected") + .render()?; + } + } + Err(e) => { + output.warning(&format!("Could not get current library: {}", e))?; + } + _ => {} + } + } else { + output.error(Message::DaemonNotRunning { + instance: instance_display.to_string(), + })?; + + let start_cmd = if instance_name.is_some() { + format!("spacedrive --instance {} start", instance_display) + } else { + "spacedrive start".to_string() + }; + + output + .section() + .help() + .item(&format!("Start it with: {}", start_cmd)) + .render()?; + } + + Ok(()) } -async fn handle_instance_command(cmd: InstanceCommands, mut output: CliOutput) -> Result<(), Box> { - match cmd { - InstanceCommands::List => match Daemon::list_instances() { - Ok(instances) => { - if instances.is_empty() { - output.info("No daemon instances found")?; - } else { - let mut table = Table::new(); - table.set_header(vec!["Instance", "Status", "Socket Path"]); +async fn handle_instance_command( + cmd: InstanceCommands, + mut output: CliOutput, +) -> Result<(), Box> { + match cmd { + InstanceCommands::List => match Daemon::list_instances() { + Ok(instances) => { + if instances.is_empty() { + output.info("No daemon instances found")?; + } else { + let mut table = Table::new(); + table.set_header(vec!["Instance", "Status", "Socket Path"]); - for instance in instances { - let status = if instance.is_running { - "Running" - } else { - "Stopped" - }; + for instance in instances { + let status = if instance.is_running { + "Running" + } else { + "Stopped" + }; - table.add_row(vec![ - instance.display_name().to_string(), - status.to_string(), - instance.socket_path.display().to_string(), - ]); - } + table.add_row(vec![ + instance.display_name().to_string(), + status.to_string(), + instance.socket_path.display().to_string(), + ]); + } - output.section() - .table(table) - .render()?; - } - } - Err(e) => { - output.error(Message::Error(format!("Failed to list instances: {}", e)))?; - } - }, + output.section().table(table).render()?; + } + } + Err(e) => { + output.error(Message::Error(format!("Failed to list instances: {}", e)))?; + } + }, - InstanceCommands::Stop { name } => { - let instance_name = if name == "default" { - None - } else { - Some(name.clone()) - }; - match Daemon::stop_instance(instance_name).await { - Ok(_) => { - output.success(&format!("Daemon instance '{}' stopped", name))?; - } - Err(e) => { - output.error(Message::Error(format!("Failed to stop instance '{}': {}", name, e)))?; - } - } - } + InstanceCommands::Stop { name } => { + let instance_name = if name == "default" { + None + } else { + Some(name.clone()) + }; + match Daemon::stop_instance(instance_name).await { + Ok(_) => { + output.success(&format!("Daemon instance '{}' stopped", name))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to stop instance '{}': {}", + name, e + )))?; + } + } + } - InstanceCommands::Current => { - output.info("Current instance functionality not yet implemented")?; - output.info("Use --instance flag to target specific instances")?; - } - } + InstanceCommands::Current => { + output.info("Current instance functionality not yet implemented")?; + output.info("Use --instance flag to target specific instances")?; + } + } - Ok(()) -} \ No newline at end of file + Ok(()) +} diff --git a/core-new/src/infrastructure/cli/commands/volume.rs b/core-new/src/infrastructure/cli/commands/volume.rs index 3b23c7193..7637e4403 100644 --- a/core-new/src/infrastructure/cli/commands/volume.rs +++ b/core-new/src/infrastructure/cli/commands/volume.rs @@ -1,49 +1,390 @@ //! Volume CLI commands +use crate::infrastructure::cli::daemon::{DaemonClient, DaemonCommand, DaemonResponse}; +use crate::infrastructure::cli::output::{CliOutput, Message}; use clap::Subcommand; +use comfy_table::Table; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Volume-related commands +/// Volume management commands #[derive(Debug, Clone, Subcommand, Serialize, Deserialize)] pub enum VolumeCommands { - /// List all volumes - List, - - /// Show details for a specific volume - Get { - /// Volume fingerprint - fingerprint: String, - }, - - /// Track a volume in a library - Track { - /// Library ID - library_id: Uuid, - - /// Volume fingerprint - fingerprint: String, - - /// Optional name for the tracked volume - #[arg(short, long)] - name: Option, - }, - - /// Untrack a volume from a library - Untrack { - /// Library ID - library_id: Uuid, - - /// Volume fingerprint - fingerprint: String, - }, - - /// Run speed test on a volume - SpeedTest { - /// Volume fingerprint - fingerprint: String, - }, - - /// Refresh volume list - Refresh, -} \ No newline at end of file + /// List all volumes + List, + /// Show details for a specific volume + Get { + /// Volume fingerprint + fingerprint: String, + }, + /// Track a volume in a library + Track { + /// Volume fingerprint + fingerprint: String, + /// Optional name for the tracked volume + #[arg(short, long)] + name: Option, + }, + /// Untrack a volume from a library + Untrack { + /// Volume fingerprint + fingerprint: String, + }, + /// Run speed test on a volume + SpeedTest { + /// Volume fingerprint + fingerprint: String, + }, + /// Refresh volume list + Refresh, + /// Fix empty display names for tracked volumes + FixNames, +} + +pub async fn handle_volume_command( + cmd: VolumeCommands, + instance_name: Option, + mut output: CliOutput, +) -> Result<(), Box> { + let mut client = DaemonClient::new_with_instance(instance_name.clone()); + + match cmd { + VolumeCommands::List => { + output.info("Fetching volumes...")?; + + match client + .send_command(DaemonCommand::Volume(VolumeCommands::List)) + .await + { + Ok(DaemonResponse::VolumeListWithTracking(volume_infos)) => { + if volume_infos.is_empty() { + output.info("No volumes found")?; + } else { + output.info(&format!("Found {} volume(s):", volume_infos.len()))?; + + let mut table = Table::new(); + table.set_header(vec![ + "Name", + "Mount Point", + "File System", + "Capacity", + "Available", + "Status", + "Tracked", + ]); + + for volume_info in volume_infos { + let volume = volume_info["volume"].as_object().unwrap(); + let is_tracked = volume_info["is_tracked"].as_bool().unwrap_or(false); + let tracked_name = volume_info["tracked_name"].as_str(); + + let name = volume["name"].as_str().unwrap_or("Unknown"); + let mount_point = volume["mount_point"].as_str().unwrap_or("Unknown"); + let file_system = volume["file_system"].as_str().unwrap_or("Unknown"); + let total_capacity = + volume["total_bytes_capacity"].as_u64().unwrap_or(0); + let available_space = + volume["total_bytes_available"].as_u64().unwrap_or(0); + let is_mounted = volume["is_mounted"].as_bool().unwrap_or(false); + + // Format capacity in a human-readable way + let capacity_str = if total_capacity > 0 { + format_bytes(total_capacity) + } else { + "Unknown".to_string() + }; + + let available_str = if available_space > 0 { + format_bytes(available_space) + } else { + "Unknown".to_string() + }; + + let status = if is_mounted { "Mounted" } else { "Unmounted" }; + + let tracked_status = if is_tracked { + if let Some(custom_name) = tracked_name { + format!("Yes ({})", custom_name) + } else { + "Yes".to_string() + } + } else { + "No".to_string() + }; + + table.add_row(vec![ + name.to_string(), + mount_point.to_string(), + file_system.to_string(), + capacity_str, + available_str, + status.to_string(), + tracked_status, + ]); + } + + output.section().table(table).render()?; + } + } + Ok(DaemonResponse::VolumeList(volumes)) => { + // Fallback for when no current library is set + if volumes.is_empty() { + output.info("No volumes found")?; + } else { + output.info(&format!("Found {} volume(s):", volumes.len()))?; + output.info("💡 Set a current library to see tracking status: spacedrive library switch ")?; + + let mut table = Table::new(); + table.set_header(vec![ + "Name", + "Mount Point", + "File System", + "Capacity", + "Available", + "Status", + ]); + + for volume in volumes { + let capacity_str = if volume.total_bytes_capacity > 0 { + format_bytes(volume.total_bytes_capacity) + } else { + "Unknown".to_string() + }; + + let available_str = if volume.total_bytes_available > 0 { + format_bytes(volume.total_bytes_available) + } else { + "Unknown".to_string() + }; + + let status = if volume.is_mounted { + "Mounted" + } else { + "Unmounted" + }; + + table.add_row(vec![ + volume.name, + volume.mount_point.display().to_string(), + volume.file_system.to_string(), + capacity_str, + available_str, + status.to_string(), + ]); + } + + output.section().table(table).render()?; + } + } + Ok(DaemonResponse::Error(msg)) => { + output.error(Message::Error(msg))?; + } + Ok(_) => { + output.error(Message::Error("Unexpected response".to_string()))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to communicate with daemon: {}", + e + )))?; + } + } + } + + VolumeCommands::Get { fingerprint } => { + output.info(&format!("Fetching volume {}...", fingerprint))?; + + match client + .send_command(DaemonCommand::Volume(VolumeCommands::Get { + fingerprint: fingerprint.clone(), + })) + .await + { + Ok(DaemonResponse::Volume(volume)) => { + output.info(&format!("Volume: {}", volume.name))?; + output.info(&format!(" Fingerprint: {}", volume.fingerprint.0))?; + output.info(&format!(" Mount Point: {}", volume.mount_point.display()))?; + output.info(&format!(" File System: {}", volume.file_system))?; + output.info(&format!( + " Total Capacity: {} bytes", + volume.total_bytes_capacity + ))?; + output.info(&format!( + " Available Space: {} bytes", + volume.total_bytes_available + ))?; + output.info(&format!( + " Status: {}", + if volume.is_mounted { + "mounted" + } else { + "unmounted" + } + ))?; + } + Ok(DaemonResponse::Error(msg)) => { + output.error(Message::Error(msg))?; + } + Ok(_) => { + output.error(Message::Error("Unexpected response".to_string()))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to communicate with daemon: {}", + e + )))?; + } + } + } + + VolumeCommands::Track { fingerprint, name } => { + let name_display = name.as_deref().unwrap_or("(no custom name)"); + output.info(&format!( + "Tracking volume {} as '{}'...", + fingerprint, name_display + ))?; + + match client + .send_command(DaemonCommand::Volume(VolumeCommands::Track { + fingerprint: fingerprint.clone(), + name: name.clone(), + })) + .await + { + Ok(DaemonResponse::ActionOutput(action_output)) => { + output.info(&format!("Successfully tracked volume {}", fingerprint))?; + output.info(&format!("Action completed: {}", action_output))?; + } + Ok(DaemonResponse::Error(msg)) => { + output.error(Message::Error(msg))?; + } + Ok(_) => { + output.error(Message::Error("Unexpected response".to_string()))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to communicate with daemon: {}", + e + )))?; + } + } + } + + VolumeCommands::Untrack { fingerprint } => { + output.info(&format!("Untracking volume {}...", fingerprint))?; + + match client + .send_command(DaemonCommand::Volume(VolumeCommands::Untrack { + fingerprint: fingerprint.clone(), + })) + .await + { + Ok(DaemonResponse::ActionOutput(action_output)) => { + output.info(&format!("Successfully untracked volume {}", fingerprint))?; + output.info(&format!("Action completed: {}", action_output))?; + } + Ok(DaemonResponse::Error(msg)) => { + output.error(Message::Error(msg))?; + } + Ok(_) => { + output.error(Message::Error("Unexpected response".to_string()))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to communicate with daemon: {}", + e + )))?; + } + } + } + + VolumeCommands::SpeedTest { fingerprint } => { + output.info(&format!("Running speed test on volume {}...", fingerprint))?; + output.info("This may take a moment...")?; + + match client + .send_command(DaemonCommand::Volume(VolumeCommands::SpeedTest { + fingerprint: fingerprint.clone(), + })) + .await + { + Ok(DaemonResponse::ActionOutput(action_output)) => { + output.info(&format!("Speed test completed for volume {}", fingerprint))?; + output.info(&format!("Action completed: {}", action_output))?; + } + Ok(DaemonResponse::Error(msg)) => { + output.error(Message::Error(msg))?; + } + Ok(_) => { + output.error(Message::Error("Unexpected response".to_string()))?; + } + Err(e) => { + output.error(Message::Error(format!( + "Failed to communicate with daemon: {}", + e + )))?; + } + } + } + + VolumeCommands::Refresh => { + let daemon_cmd = DaemonCommand::Volume(VolumeCommands::Refresh); + let response = client.send_command(daemon_cmd).await?; + + match response { + DaemonResponse::VolumeList(volumes) => { + println!("ℹ️ Refreshed {} volume(s)", volumes.len()); + } + DaemonResponse::Error(err) => { + return Err(format!("Failed to refresh volumes: {}", err).into()); + } + _ => { + return Err("Unexpected response from daemon".into()); + } + } + } + + VolumeCommands::FixNames => { + let daemon_cmd = DaemonCommand::Volume(VolumeCommands::FixNames); + let response = client.send_command(daemon_cmd).await?; + + match response { + DaemonResponse::Ok => { + println!("✅ Fixed display names for tracked volumes"); + } + DaemonResponse::Error(err) => { + return Err(format!("Failed to fix display names: {}", err).into()); + } + _ => { + return Err("Unexpected response from daemon".into()); + } + } + } + } + + Ok(()) +} + +/// Format bytes in a human-readable way +fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"]; + const THRESHOLD: u64 = 1024; + + if bytes == 0 { + return "0 B".to_string(); + } + + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= THRESHOLD as f64 && unit_index < UNITS.len() - 1 { + size /= THRESHOLD as f64; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } +} diff --git a/core-new/src/infrastructure/cli/daemon/handlers/volume.rs b/core-new/src/infrastructure/cli/daemon/handlers/volume.rs index 458f6a503..7cf35a3c1 100644 --- a/core-new/src/infrastructure/cli/daemon/handlers/volume.rs +++ b/core-new/src/infrastructure/cli/daemon/handlers/volume.rs @@ -4,23 +4,22 @@ use async_trait::async_trait; use std::sync::Arc; use crate::{ - Core, - infrastructure::{ - actions::{Action, manager::ActionManager}, - cli::{ - commands::VolumeCommands, - daemon::{ - services::StateService, - types::{DaemonCommand, DaemonResponse}, - }, - }, - }, - operations::volumes::{ - track::action::VolumeTrackAction, - untrack::action::VolumeUntrackAction, - speed_test::action::VolumeSpeedTestAction, - }, - volume::VolumeFingerprint, + infrastructure::{ + actions::{manager::ActionManager, Action}, + cli::{ + commands::VolumeCommands, + daemon::{ + services::StateService, + types::{DaemonCommand, DaemonResponse}, + }, + }, + }, + operations::volumes::{ + speed_test::action::VolumeSpeedTestAction, track::action::VolumeTrackAction, + untrack::action::VolumeUntrackAction, + }, + volume::VolumeFingerprint, + Core, }; use super::CommandHandler; @@ -30,100 +29,175 @@ pub struct VolumeHandler; #[async_trait] impl CommandHandler for VolumeHandler { - async fn handle( - &self, - cmd: DaemonCommand, - core: &Arc, - _state_service: &Arc, - ) -> DaemonResponse { - match cmd { - DaemonCommand::Volume(volume_cmd) => match volume_cmd { - VolumeCommands::List => { - // Get all volumes - let volumes = core.volumes.get_all_volumes().await; - DaemonResponse::VolumeList(volumes) - } - - VolumeCommands::Get { fingerprint } => { - let fingerprint = VolumeFingerprint(fingerprint); - match core.volumes.get_volume(&fingerprint).await { - Some(volume) => DaemonResponse::Volume(volume), - None => DaemonResponse::Error("Volume not found".to_string()), - } - } - - VolumeCommands::Track { library_id, fingerprint, name } => { - let action = Action::VolumeTrack { - action: VolumeTrackAction { - fingerprint: VolumeFingerprint(fingerprint), - library_id, - name, - }, - }; - - match core.context.get_action_manager().await { - Some(action_manager) => { - match action_manager.dispatch(action).await { - Ok(output) => DaemonResponse::ActionOutput(output), - Err(e) => DaemonResponse::Error(format!("Failed to track volume: {}", e)), - } - } - None => DaemonResponse::Error("Action manager not initialized".to_string()), - } - } - - VolumeCommands::Untrack { library_id, fingerprint } => { - let action = Action::VolumeUntrack { - action: VolumeUntrackAction { - fingerprint: VolumeFingerprint(fingerprint), - library_id, - }, - }; - - match core.context.get_action_manager().await { - Some(action_manager) => { - match action_manager.dispatch(action).await { - Ok(output) => DaemonResponse::ActionOutput(output), - Err(e) => DaemonResponse::Error(format!("Failed to untrack volume: {}", e)), - } - } - None => DaemonResponse::Error("Action manager not initialized".to_string()), - } - } - - VolumeCommands::SpeedTest { fingerprint } => { - let action = Action::VolumeSpeedTest { - action: VolumeSpeedTestAction { - fingerprint: VolumeFingerprint(fingerprint), - }, - }; - - match core.context.get_action_manager().await { - Some(action_manager) => { - match action_manager.dispatch(action).await { - Ok(output) => DaemonResponse::ActionOutput(output), - Err(e) => DaemonResponse::Error(format!("Failed to run speed test: {}", e)), - } - } - None => DaemonResponse::Error("Action manager not initialized".to_string()), - } - } - - VolumeCommands::Refresh => { - match core.volumes.refresh_volumes().await { - Ok(_) => { - let volumes = core.volumes.get_all_volumes().await; - DaemonResponse::VolumeList(volumes) - } - Err(e) => DaemonResponse::Error(format!("Failed to refresh volumes: {}", e)), - } - } - }, - _ => DaemonResponse::Error("Invalid command for volume handler".to_string()), - } - } + async fn handle( + &self, + cmd: DaemonCommand, + core: &Arc, + state_service: &Arc, + ) -> DaemonResponse { + match cmd { + DaemonCommand::Volume(volume_cmd) => match volume_cmd { + VolumeCommands::List => { + // Get all volumes + let volumes = core.volumes.get_all_volumes().await; - fn can_handle(&self, cmd: &DaemonCommand) -> bool { - matches!(cmd, DaemonCommand::Volume(_)) - } -} \ No newline at end of file + // Get current library to check track status + if let Some(library) = state_service.get_current_library(core).await { + // Get tracked volumes for this library + let tracked_volumes = match core.volumes.get_tracked_volumes(&library).await + { + Ok(tracked) => tracked, + Err(_) => Vec::new(), + }; + + // Create a map of fingerprint -> tracked info for quick lookup + let tracked_map: std::collections::HashMap<_, _> = tracked_volumes + .into_iter() + .map(|tv| (tv.fingerprint.clone(), tv)) + .collect(); + + // Combine volume info with track status + let volume_info_list: Vec<_> = volumes + .into_iter() + .map(|volume| { + let is_tracked = tracked_map.contains_key(&volume.fingerprint); + let tracked_name = tracked_map + .get(&volume.fingerprint) + .and_then(|tv| tv.display_name.clone()); + + serde_json::json!({ + "volume": volume, + "is_tracked": is_tracked, + "tracked_name": tracked_name + }) + }) + .collect(); + + DaemonResponse::VolumeListWithTracking(volume_info_list) + } else { + // No current library, just return basic volume list + DaemonResponse::VolumeList(volumes) + } + } + + VolumeCommands::Get { fingerprint } => { + let fingerprint = VolumeFingerprint(fingerprint); + match core.volumes.get_volume(&fingerprint).await { + Some(volume) => DaemonResponse::Volume(volume), + None => DaemonResponse::Error("Volume not found".to_string()), + } + } + + VolumeCommands::Track { fingerprint, name } => { + // Get current library from CLI state + if let Some(library) = state_service.get_current_library(core).await { + let library_id = library.id(); + + let action = Action::VolumeTrack { + action: VolumeTrackAction { + fingerprint: VolumeFingerprint(fingerprint), + library_id, + name, + }, + }; + + match core.context.get_action_manager().await { + Some(action_manager) => match action_manager.dispatch(action).await { + Ok(output) => DaemonResponse::ActionOutput(output), + Err(e) => { + DaemonResponse::Error(format!("Failed to track volume: {}", e)) + } + }, + None => { + DaemonResponse::Error("Action manager not initialized".to_string()) + } + } + } else { + DaemonResponse::Error("No current library set. Use 'spacedrive library switch ' to select a library.".to_string()) + } + } + + VolumeCommands::Untrack { fingerprint } => { + // Get current library from CLI state + if let Some(library) = state_service.get_current_library(core).await { + let library_id = library.id(); + + let action = Action::VolumeUntrack { + action: VolumeUntrackAction { + fingerprint: VolumeFingerprint(fingerprint), + library_id, + }, + }; + + match core.context.get_action_manager().await { + Some(action_manager) => match action_manager.dispatch(action).await { + Ok(output) => DaemonResponse::ActionOutput(output), + Err(e) => DaemonResponse::Error(format!( + "Failed to untrack volume: {}", + e + )), + }, + None => { + DaemonResponse::Error("Action manager not initialized".to_string()) + } + } + } else { + DaemonResponse::Error("No current library set. Use 'spacedrive library switch ' to select a library.".to_string()) + } + } + + VolumeCommands::SpeedTest { fingerprint } => { + let action = Action::VolumeSpeedTest { + action: VolumeSpeedTestAction { + fingerprint: VolumeFingerprint(fingerprint), + }, + }; + + match core.context.get_action_manager().await { + Some(action_manager) => match action_manager.dispatch(action).await { + Ok(output) => DaemonResponse::ActionOutput(output), + Err(e) => { + DaemonResponse::Error(format!("Failed to run speed test: {}", e)) + } + }, + None => DaemonResponse::Error("Action manager not initialized".to_string()), + } + } + + VolumeCommands::Refresh => match core.volumes.refresh_volumes().await { + Ok(_) => { + let volumes = core.volumes.get_all_volumes().await; + DaemonResponse::VolumeList(volumes) + } + Err(e) => DaemonResponse::Error(format!("Failed to refresh volumes: {}", e)), + }, + + VolumeCommands::FixNames => { + if let Some(library) = state_service.get_current_library(core).await { + match core.volumes.update_empty_display_names(&library).await { + Ok(count) => { + if count > 0 { + tracing::info!("Updated display names for {} volumes", count); + } else { + tracing::info!("No volumes with empty display names found"); + } + DaemonResponse::Ok + } + Err(e) => DaemonResponse::Error(format!( + "Failed to update display names: {}", + e + )), + } + } else { + DaemonResponse::Error("No library selected".to_string()) + } + } + }, + _ => DaemonResponse::Error("Invalid command for volume handler".to_string()), + } + } + + fn can_handle(&self, cmd: &DaemonCommand) -> bool { + matches!(cmd, DaemonCommand::Volume(_)) + } +} diff --git a/core-new/src/infrastructure/cli/daemon/mod.rs b/core-new/src/infrastructure/cli/daemon/mod.rs index 3be665fed..edfe231c0 100644 --- a/core-new/src/infrastructure/cli/daemon/mod.rs +++ b/core-new/src/infrastructure/cli/daemon/mod.rs @@ -156,6 +156,11 @@ impl Daemon { let listener = UnixListener::bind(&self.config.socket_path)?; info!("Daemon listening on {:?}", self.config.socket_path); + // Emit CoreStarted event to signal daemon is ready + self.core + .events + .emit(crate::infrastructure::events::Event::CoreStarted); + // Set up shutdown channel let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); *self.shutdown_tx.lock().await = Some(shutdown_tx); @@ -270,6 +275,49 @@ impl Daemon { Ok(()) } + /// Wait for daemon to be ready by attempting to connect + pub async fn wait_for_ready( + instance_name: Option, + timeout_secs: u64, + ) -> Result> { + let config = DaemonConfig::new(instance_name); + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + // Try to connect to the socket + if let Ok(mut stream) = UnixStream::connect(&config.socket_path).await { + // Try to send a simple ping command to verify it's responsive + let cmd = DaemonCommand::Ping; + let json = serde_json::to_string(&cmd)?; + + if stream + .write_all(format!("{}\n", json).as_bytes()) + .await + .is_ok() + { + if stream.flush().await.is_ok() { + // Try to read the response + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line).await.is_ok() { + if let Ok(response) = serde_json::from_str::(&line) { + if matches!(response, DaemonResponse::Pong) { + return Ok(true); + } + } + } + } + } + } + + // Wait a bit before retrying + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + Ok(false) + } + /// List all daemon instances pub fn list_instances() -> Result, Box> { let runtime_dir = dirs::runtime_dir() diff --git a/core-new/src/infrastructure/cli/daemon/types/responses.rs b/core-new/src/infrastructure/cli/daemon/types/responses.rs index ea498dc96..16f670c47 100644 --- a/core-new/src/infrastructure/cli/daemon/types/responses.rs +++ b/core-new/src/infrastructure/cli/daemon/types/responses.rs @@ -4,11 +4,10 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; -use super::common::{BrowseEntry, ConnectedDeviceInfo, JobInfo, LibraryInfo, LocationInfo, PairingRequestInfo}; -use crate::{ - infrastructure::actions::output::ActionOutput, - volume::Volume, +use super::common::{ + BrowseEntry, ConnectedDeviceInfo, JobInfo, LibraryInfo, LocationInfo, PairingRequestInfo, }; +use crate::{infrastructure::actions::output::ActionOutput, volume::Volume}; /// Responses from the daemon #[derive(Debug, Serialize, Deserialize)] @@ -63,11 +62,12 @@ pub enum DaemonResponse { remote_device: Option, }, PendingPairings(Vec), - + // Volume responses VolumeList(Vec), + VolumeListWithTracking(Vec), Volume(Volume), - + // Action output (generic for all action results) ActionOutput(ActionOutput), } @@ -79,4 +79,4 @@ pub struct DaemonStatus { pub current_library: Option, pub active_jobs: usize, pub total_locations: usize, -} \ No newline at end of file +} diff --git a/core-new/src/infrastructure/cli/mod.rs b/core-new/src/infrastructure/cli/mod.rs index e5c2985d5..54ab2511c 100644 --- a/core-new/src/infrastructure/cli/mod.rs +++ b/core-new/src/infrastructure/cli/mod.rs @@ -14,6 +14,7 @@ use crate::infrastructure::cli::commands::{ location::{handle_location_command, LocationCommands}, network::{handle_network_command, NetworkCommands}, system::{handle_system_command, SystemCommands}, + volume::{handle_volume_command, VolumeCommands}, }; use crate::infrastructure::cli::output::{CliOutput, Message}; use clap::{Parser, Subcommand}; @@ -62,7 +63,7 @@ pub enum Commands { /// Start the Spacedrive daemon in the background Start { /// Run in foreground instead of daemonizing - #[arg(short, long)] + #[arg(long)] foreground: bool, /// Enable networking on startup #[arg(long)] @@ -102,6 +103,10 @@ pub enum Commands { /// System monitoring and information #[command(subcommand)] System(SystemCommands), + + /// Volume management and operations + #[command(subcommand)] + Volume(VolumeCommands), } pub async fn run() -> Result<(), Box> { @@ -267,6 +272,14 @@ pub async fn run() -> Result<(), Box> { } } } + Commands::Volume(volume_cmd) => { + // Check if daemon is running + if !daemon::Daemon::is_running_instance(cli.instance.clone()) { + print_daemon_not_running(&cli.instance, &mut output)?; + return Ok(()); + } + handle_volume_command(volume_cmd.clone(), cli.instance.clone(), output).await + } } } diff --git a/core-new/src/infrastructure/database/entities/volume.rs b/core-new/src/infrastructure/database/entities/volume.rs index 257f506be..8aad7bdeb 100644 --- a/core-new/src/infrastructure/database/entities/volume.rs +++ b/core-new/src/infrastructure/database/entities/volume.rs @@ -7,52 +7,67 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "volumes")] pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub uuid: Uuid, - pub fingerprint: String, - pub display_name: Option, - pub tracked_at: DateTimeUtc, - pub last_seen_at: DateTimeUtc, - pub is_online: bool, - pub total_capacity: Option, - pub available_capacity: Option, - pub read_speed_mbps: Option, - pub write_speed_mbps: Option, - pub last_speed_test_at: Option, - pub file_system: Option, - pub mount_point: Option, - pub is_removable: Option, - pub is_network_drive: Option, - pub device_model: Option, + #[sea_orm(primary_key)] + pub id: i32, + pub uuid: Uuid, + pub device_id: Uuid, // Foreign key to devices table + pub fingerprint: String, + pub display_name: Option, + pub tracked_at: DateTimeUtc, + pub last_seen_at: DateTimeUtc, + pub is_online: bool, + pub total_capacity: Option, + pub available_capacity: Option, + pub read_speed_mbps: Option, + pub write_speed_mbps: Option, + pub last_speed_test_at: Option, + pub file_system: Option, + pub mount_point: Option, + pub is_removable: Option, + pub is_network_drive: Option, + pub device_model: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm( + belongs_to = "super::device::Entity", + from = "Column::DeviceId", + to = "super::device::Column::Uuid" + )] + Device, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Device.def() + } +} impl ActiveModelBehavior for ActiveModel {} impl Model { - /// Convert database model to tracked volume - pub fn to_tracked_volume(&self) -> TrackedVolume { - TrackedVolume { - id: self.id, - uuid: self.uuid, - fingerprint: crate::volume::VolumeFingerprint(self.fingerprint.clone()), - display_name: self.display_name.clone(), - tracked_at: self.tracked_at, - last_seen_at: self.last_seen_at, - is_online: self.is_online, - total_capacity: self.total_capacity.map(|c| c as u64), - available_capacity: self.available_capacity.map(|c| c as u64), - read_speed_mbps: self.read_speed_mbps.map(|s| s as u32), - write_speed_mbps: self.write_speed_mbps.map(|s| s as u32), - last_speed_test_at: self.last_speed_test_at, - file_system: self.file_system.clone(), - mount_point: self.mount_point.clone(), - is_removable: self.is_removable, - is_network_drive: self.is_network_drive, - device_model: self.device_model.clone(), - } - } -} \ No newline at end of file + /// Convert database model to tracked volume + pub fn to_tracked_volume(&self) -> TrackedVolume { + TrackedVolume { + id: self.id, + uuid: self.uuid, + device_id: self.device_id, + fingerprint: crate::volume::VolumeFingerprint(self.fingerprint.clone()), + display_name: self.display_name.clone(), + tracked_at: self.tracked_at, + last_seen_at: self.last_seen_at, + is_online: self.is_online, + total_capacity: self.total_capacity.map(|c| c as u64), + available_capacity: self.available_capacity.map(|c| c as u64), + read_speed_mbps: self.read_speed_mbps.map(|s| s as u32), + write_speed_mbps: self.write_speed_mbps.map(|s| s as u32), + last_speed_test_at: self.last_speed_test_at, + file_system: self.file_system.clone(), + mount_point: self.mount_point.clone(), + is_removable: self.is_removable, + is_network_drive: self.is_network_drive, + device_model: self.device_model.clone(), + } + } +} diff --git a/core-new/src/infrastructure/database/migration/m20240103_000001_create_volumes.rs b/core-new/src/infrastructure/database/migration/m20240103_000001_create_volumes.rs index 11f3a2df9..466c8049a 100644 --- a/core-new/src/infrastructure/database/migration/m20240103_000001_create_volumes.rs +++ b/core-new/src/infrastructure/database/migration/m20240103_000001_create_volumes.rs @@ -7,115 +7,135 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // Create volumes table - manager - .create_table( - Table::create() - .table(Volumes::Table) - .if_not_exists() - .col( - ColumnDef::new(Volumes::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(Volumes::Uuid).text().not_null().unique_key()) - .col(ColumnDef::new(Volumes::Fingerprint).text().not_null()) - .col(ColumnDef::new(Volumes::DisplayName).text()) - .col( - ColumnDef::new(Volumes::TrackedAt) - .timestamp() - .not_null(), - ) - .col( - ColumnDef::new(Volumes::LastSeenAt) - .timestamp() - .not_null(), - ) - .col( - ColumnDef::new(Volumes::IsOnline) - .boolean() - .not_null() - .default(true), - ) - .col(ColumnDef::new(Volumes::TotalCapacity).big_integer()) - .col(ColumnDef::new(Volumes::AvailableCapacity).big_integer()) - .col(ColumnDef::new(Volumes::ReadSpeedMbps).integer()) - .col(ColumnDef::new(Volumes::WriteSpeedMbps).integer()) - .col(ColumnDef::new(Volumes::LastSpeedTestAt).timestamp()) - .col(ColumnDef::new(Volumes::FileSystem).text()) - .col(ColumnDef::new(Volumes::MountPoint).text()) - .col(ColumnDef::new(Volumes::IsRemovable).boolean()) - .col(ColumnDef::new(Volumes::IsNetworkDrive).boolean()) - .col(ColumnDef::new(Volumes::DeviceModel).text()) - .to_owned(), - ) - .await?; + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create volumes table + manager + .create_table( + Table::create() + .table(Volumes::Table) + .if_not_exists() + .col( + ColumnDef::new(Volumes::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Volumes::Uuid).text().not_null().unique_key()) + .col(ColumnDef::new(Volumes::DeviceId).text().not_null()) // Device this volume belongs to + .col(ColumnDef::new(Volumes::Fingerprint).text().not_null()) + .col(ColumnDef::new(Volumes::DisplayName).text()) + .col(ColumnDef::new(Volumes::TrackedAt).timestamp().not_null()) + .col(ColumnDef::new(Volumes::LastSeenAt).timestamp().not_null()) + .col( + ColumnDef::new(Volumes::IsOnline) + .boolean() + .not_null() + .default(true), + ) + .col(ColumnDef::new(Volumes::TotalCapacity).big_integer()) + .col(ColumnDef::new(Volumes::AvailableCapacity).big_integer()) + .col(ColumnDef::new(Volumes::ReadSpeedMbps).integer()) + .col(ColumnDef::new(Volumes::WriteSpeedMbps).integer()) + .col(ColumnDef::new(Volumes::LastSpeedTestAt).timestamp()) + .col(ColumnDef::new(Volumes::FileSystem).text()) + .col(ColumnDef::new(Volumes::MountPoint).text()) + .col(ColumnDef::new(Volumes::IsRemovable).boolean()) + .col(ColumnDef::new(Volumes::IsNetworkDrive).boolean()) + .col(ColumnDef::new(Volumes::DeviceModel).text()) + .foreign_key( + ForeignKey::create() + .name("fk_volumes_device_id") + .from(Volumes::Table, Volumes::DeviceId) + .to(Devices::Table, Devices::Uuid) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; - // Create unique index on fingerprint (since each library tracks volumes independently) - manager - .create_index( - Index::create() - .name("idx_volume_fingerprint_unique") - .table(Volumes::Table) - .col(Volumes::Fingerprint) - .unique() - .to_owned(), - ) - .await?; + // Create unique index on device_id + fingerprint (volumes are unique per device) + manager + .create_index( + Index::create() + .name("idx_volume_device_fingerprint_unique") + .table(Volumes::Table) + .col(Volumes::DeviceId) + .col(Volumes::Fingerprint) + .unique() + .to_owned(), + ) + .await?; - // Create index on last_seen_at for efficient queries - manager - .create_index( - Index::create() - .name("idx_volume_last_seen_at") - .table(Volumes::Table) - .col(Volumes::LastSeenAt) - .to_owned(), - ) - .await?; + // Create index on device_id for efficient device queries + manager + .create_index( + Index::create() + .name("idx_volume_device_id") + .table(Volumes::Table) + .col(Volumes::DeviceId) + .to_owned(), + ) + .await?; - // Create index on is_online for filtering - manager - .create_index( - Index::create() - .name("idx_volume_is_online") - .table(Volumes::Table) - .col(Volumes::IsOnline) - .to_owned(), - ) - .await?; + // Create index on last_seen_at for efficient queries + manager + .create_index( + Index::create() + .name("idx_volume_last_seen_at") + .table(Volumes::Table) + .col(Volumes::LastSeenAt) + .to_owned(), + ) + .await?; - Ok(()) - } + // Create index on is_online for filtering + manager + .create_index( + Index::create() + .name("idx_volume_is_online") + .table(Volumes::Table) + .col(Volumes::IsOnline) + .to_owned(), + ) + .await?; - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Volumes::Table).to_owned()) - .await - } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Volumes::Table).to_owned()) + .await + } } #[derive(DeriveIden)] enum Volumes { - Table, - Id, - Uuid, - Fingerprint, - DisplayName, - TrackedAt, - LastSeenAt, - IsOnline, - TotalCapacity, - AvailableCapacity, - ReadSpeedMbps, - WriteSpeedMbps, - LastSpeedTestAt, - FileSystem, - MountPoint, - IsRemovable, - IsNetworkDrive, - DeviceModel, -} \ No newline at end of file + Table, + Id, + Uuid, + DeviceId, + Fingerprint, + DisplayName, + TrackedAt, + LastSeenAt, + IsOnline, + TotalCapacity, + AvailableCapacity, + ReadSpeedMbps, + WriteSpeedMbps, + LastSpeedTestAt, + FileSystem, + MountPoint, + IsRemovable, + IsNetworkDrive, + DeviceModel, +} + +#[derive(DeriveIden)] +enum Devices { + Table, + Uuid, +} diff --git a/core-new/src/infrastructure/database/migration/mod.rs b/core-new/src/infrastructure/database/migration/mod.rs index 21f5084f5..a38089b21 100644 --- a/core-new/src/infrastructure/database/migration/mod.rs +++ b/core-new/src/infrastructure/database/migration/mod.rs @@ -2,19 +2,19 @@ use sea_orm_migration::prelude::*; +mod m20240101_000001_create_initial_tables; +mod m20240102_000001_add_audit_log; +mod m20240103_000001_create_volumes; + pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { - fn migrations() -> Vec> { - vec![ - Box::new(m20240101_000001_create_initial_tables::Migration), - Box::new(m20240102_000001_add_audit_log::Migration), - Box::new(m20240103_000001_create_volumes::Migration), - ] - } + fn migrations() -> Vec> { + vec![ + Box::new(m20240101_000001_create_initial_tables::Migration), + Box::new(m20240102_000001_add_audit_log::Migration), + Box::new(m20240103_000001_create_volumes::Migration), + ] + } } - -mod m20240101_000001_create_initial_tables; -mod m20240102_000001_add_audit_log; -mod m20240103_000001_create_volumes; \ No newline at end of file diff --git a/core-new/src/lib.rs b/core-new/src/lib.rs index 9fe882451..d0821b481 100644 --- a/core-new/src/lib.rs +++ b/core-new/src/lib.rs @@ -158,7 +158,8 @@ impl Core { // 4. Initialize volume manager let volume_config = VolumeDetectionConfig::default(); - let volumes = Arc::new(VolumeManager::new(volume_config, events.clone())); + let device_id = device.device_id()?; + let volumes = Arc::new(VolumeManager::new(device_id, volume_config, events.clone())); // 5. Initialize volume detection info!("Initializing volume detection..."); diff --git a/core-new/src/operations/volumes/speed_test/action.rs b/core-new/src/operations/volumes/speed_test/action.rs index 300e3b001..7793f33c6 100644 --- a/core-new/src/operations/volumes/speed_test/action.rs +++ b/core-new/src/operations/volumes/speed_test/action.rs @@ -3,41 +3,40 @@ //! This action tests the read/write performance of a volume. use crate::{ - infrastructure::actions::{error::ActionError, output::ActionOutput}, - volume::VolumeFingerprint, + infrastructure::actions::{error::ActionError, output::ActionOutput}, + volume::VolumeFingerprint, }; use serde::{Deserialize, Serialize}; /// Input for volume speed testing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VolumeSpeedTestAction { - /// The fingerprint of the volume to test - pub fingerprint: VolumeFingerprint, + /// The fingerprint of the volume to test + pub fingerprint: VolumeFingerprint, } impl VolumeSpeedTestAction { - /// Execute the volume speed test action - pub async fn execute( - &self, - core: &crate::Core, - ) -> Result { - // Run the speed test through the volume manager - core.volumes - .run_speed_test(&self.fingerprint) - .await - .map_err(|e| ActionError::Internal(e.to_string()))?; - - // Get the updated volume with speed test results - let volume = core - .volumes - .get_volume(&self.fingerprint) - .await - .ok_or_else(|| ActionError::InvalidInput("Volume not found after speed test".to_string()))?; - - Ok(ActionOutput::VolumeSpeedTested { - fingerprint: self.fingerprint.clone(), - read_speed_mbps: volume.read_speed_mbps.map(|v| v as u32), - write_speed_mbps: volume.write_speed_mbps.map(|v| v as u32), - }) - } -} \ No newline at end of file + /// Execute the volume speed test action + pub async fn execute(&self, core: &crate::Core) -> Result { + // Run the speed test through the volume manager + core.volumes + .run_speed_test(&self.fingerprint) + .await + .map_err(|e| ActionError::Internal(e.to_string()))?; + + // Get the updated volume with speed test results + let volume = core + .volumes + .get_volume(&self.fingerprint) + .await + .ok_or_else(|| { + ActionError::InvalidInput("Volume not found after speed test".to_string()) + })?; + + Ok(ActionOutput::VolumeSpeedTested { + fingerprint: self.fingerprint.clone(), + read_speed_mbps: volume.read_speed_mbps.map(|v| v as u32), + write_speed_mbps: volume.write_speed_mbps.map(|v| v as u32), + }) + } +} diff --git a/core-new/src/operations/volumes/speed_test/handler.rs b/core-new/src/operations/volumes/speed_test/handler.rs index ee8325dc2..dee6f7c9c 100644 --- a/core-new/src/operations/volumes/speed_test/handler.rs +++ b/core-new/src/operations/volumes/speed_test/handler.rs @@ -1,10 +1,13 @@ //! Handler for volume speed test action use crate::{ - context::CoreContext, - infrastructure::actions::{ - Action, error::{ActionError, ActionResult}, handler::ActionHandler, output::ActionOutput, - }, + context::CoreContext, + infrastructure::actions::{ + error::{ActionError, ActionResult}, + handler::ActionHandler, + output::ActionOutput, + Action, + }, }; use async_trait::async_trait; use std::sync::Arc; @@ -12,55 +15,77 @@ use std::sync::Arc; pub struct VolumeSpeedTestHandler; impl VolumeSpeedTestHandler { - pub fn new() -> Self { - Self - } + pub fn new() -> Self { + Self + } } #[async_trait] impl ActionHandler for VolumeSpeedTestHandler { - async fn execute( - &self, - context: Arc, - action: Action, - ) -> ActionResult { - match action { - Action::VolumeSpeedTest { action } => { - // Run speed test through volume manager - context - .volume_manager - .run_speed_test(&action.fingerprint) - .await - .map_err(|e| ActionError::Internal(e.to_string()))?; - - // Get updated volume with results - let volume = context - .volume_manager - .get_volume(&action.fingerprint) - .await - .ok_or_else(|| ActionError::InvalidInput("Volume not found after speed test".to_string()))?; - - Ok(ActionOutput::VolumeSpeedTested { - fingerprint: action.fingerprint, - read_speed_mbps: volume.read_speed_mbps.map(|v| v as u32), - write_speed_mbps: volume.write_speed_mbps.map(|v| v as u32), - }) - } - _ => Err(ActionError::InvalidActionType), - } - } - - fn can_handle(&self, action: &Action) -> bool { - matches!(action, Action::VolumeSpeedTest { .. }) - } - - fn supported_actions() -> &'static [&'static str] - where - Self: Sized - { - &["volume.speed_test"] - } + async fn execute( + &self, + context: Arc, + action: Action, + ) -> ActionResult { + match action { + Action::VolumeSpeedTest { action } => { + // Run speed test through volume manager + context + .volume_manager + .run_speed_test(&action.fingerprint) + .await + .map_err(|e| ActionError::Internal(e.to_string()))?; + + // Get updated volume with results + let volume = context + .volume_manager + .get_volume(&action.fingerprint) + .await + .ok_or_else(|| { + ActionError::InvalidInput("Volume not found after speed test".to_string()) + })?; + + // Extract speed test results + let read_speed = volume.read_speed_mbps.unwrap_or(0); + let write_speed = volume.write_speed_mbps.unwrap_or(0); + + // Save results to database for all libraries where this volume is tracked + let libraries = context.library_manager.get_open_libraries().await; + if let Err(e) = context + .volume_manager + .save_speed_test_results( + &action.fingerprint, + read_speed, + write_speed, + &libraries, + ) + .await + { + // Log error but don't fail the action since the speed test itself succeeded + tracing::warn!("Failed to save speed test results to database: {}", e); + } + + Ok(ActionOutput::VolumeSpeedTested { + fingerprint: action.fingerprint, + read_speed_mbps: Some(read_speed as u32), + write_speed_mbps: Some(write_speed as u32), + }) + } + _ => Err(ActionError::InvalidActionType), + } + } + + fn can_handle(&self, action: &Action) -> bool { + matches!(action, Action::VolumeSpeedTest { .. }) + } + + fn supported_actions() -> &'static [&'static str] + where + Self: Sized, + { + &["volume.speed_test"] + } } // Register the handler -crate::register_action_handler!(VolumeSpeedTestHandler, "volume.speed_test"); \ No newline at end of file +crate::register_action_handler!(VolumeSpeedTestHandler, "volume.speed_test"); diff --git a/core-new/src/services/file_sharing.rs b/core-new/src/services/file_sharing.rs index 698622ffa..f0e0fee14 100644 --- a/core-new/src/services/file_sharing.rs +++ b/core-new/src/services/file_sharing.rs @@ -165,7 +165,6 @@ impl FileSharingService { "No active library for job dispatch".to_string(), ))?; - // Create and dispatch the FileCopyJob let job_manager = library.jobs(); let sources = files.into_iter().map(SdPath::local).collect(); @@ -323,9 +322,10 @@ impl FileSharingService { .library_manager .get_library(*library_id) .await - .ok_or(SharingError::JobError( - format!("Library {} not found", library_id), - ))?; + .ok_or(SharingError::JobError(format!( + "Library {} not found", + library_id + )))?; let job_manager = library.jobs(); @@ -400,9 +400,10 @@ impl FileSharingService { .library_manager .get_library(*library_id) .await - .ok_or(SharingError::JobError( - format!("Library {} not found", library_id), - ))?; + .ok_or(SharingError::JobError(format!( + "Library {} not found", + library_id + )))?; let job_manager = library.jobs(); @@ -523,6 +524,7 @@ mod tests { events.clone(), )); let volume_manager = Arc::new(crate::volume::VolumeManager::new( + uuid::Uuid::new_v4(), // Test device ID crate::volume::VolumeDetectionConfig::default(), events.clone(), )); @@ -556,6 +558,7 @@ mod tests { events.clone(), )); let volume_manager = Arc::new(crate::volume::VolumeManager::new( + uuid::Uuid::new_v4(), // Test device ID crate::volume::VolumeDetectionConfig::default(), events.clone(), )); diff --git a/core-new/src/volume/manager.rs b/core-new/src/volume/manager.rs index d7f07ca44..259417f0f 100644 --- a/core-new/src/volume/manager.rs +++ b/core-new/src/volume/manager.rs @@ -19,6 +19,9 @@ use uuid::Uuid; /// Central manager for volume detection, monitoring, and operations pub struct VolumeManager { + /// Device ID for this manager + device_id: uuid::Uuid, + /// Currently known volumes, indexed by fingerprint volumes: Arc>>, @@ -39,9 +42,14 @@ pub struct VolumeManager { } impl VolumeManager { - /// Create a new volume manager - pub fn new(config: VolumeDetectionConfig, events: Arc) -> Self { + /// Create a new VolumeManager instance + pub fn new( + device_id: uuid::Uuid, + config: VolumeDetectionConfig, + events: Arc, + ) -> Self { Self { + device_id, volumes: Arc::new(RwLock::new(HashMap::new())), path_cache: Arc::new(RwLock::new(HashMap::new())), config, @@ -91,6 +99,7 @@ impl VolumeManager { let events = self.events.clone(); let config = self.config.clone(); let is_monitoring = self.is_monitoring.clone(); + let device_id = self.device_id; tokio::spawn(async move { info!( @@ -104,8 +113,14 @@ impl VolumeManager { while *is_monitoring.read().await { interval.tick().await; - if let Err(e) = - Self::refresh_volumes_internal(&volumes, &path_cache, &events, &config).await + if let Err(e) = Self::refresh_volumes_internal( + device_id, + &volumes, + &path_cache, + &events, + &config, + ) + .await { error!("Error during volume refresh: {}", e); } @@ -124,21 +139,28 @@ impl VolumeManager { /// Refresh all volumes and detect changes #[instrument(skip(self))] pub async fn refresh_volumes(&self) -> VolumeResult<()> { - Self::refresh_volumes_internal(&self.volumes, &self.path_cache, &self.events, &self.config) - .await + Self::refresh_volumes_internal( + self.device_id, + &self.volumes, + &self.path_cache, + &self.events, + &self.config, + ) + .await } /// Internal implementation of volume refresh async fn refresh_volumes_internal( + device_id: uuid::Uuid, volumes: &Arc>>, path_cache: &Arc>>, events: &Arc, config: &VolumeDetectionConfig, ) -> VolumeResult<()> { - debug!("Refreshing volumes"); + debug!("Refreshing volumes for device {}", device_id); // Detect current volumes - let detected_volumes = os_detection::detect_volumes(config).await?; + let detected_volumes = os_detection::detect_volumes(device_id, config).await?; let mut current_volumes = volumes.write().await; let mut cache = path_cache.write().await; @@ -378,7 +400,7 @@ impl VolumeManager { display_name: Option, ) -> VolumeResult { let db = library.db().conn(); - + // Check if already tracked if let Some(existing) = entities::volume::Entity::find() .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) @@ -388,18 +410,22 @@ impl VolumeManager { { return Err(VolumeError::AlreadyTracked(fingerprint.to_string())); } - + // Get current volume info - let volume = self.get_volume(fingerprint).await + let volume = self + .get_volume(fingerprint) + .await .ok_or_else(|| VolumeError::NotFound(fingerprint.to_string()))?; - + // Determine removability and network status let is_removable = matches!(volume.mount_type, crate::volume::types::MountType::External); - let is_network_drive = matches!(volume.mount_type, crate::volume::types::MountType::Network); - + let is_network_drive = + matches!(volume.mount_type, crate::volume::types::MountType::Network); + // Create tracking record let active_model = entities::volume::ActiveModel { uuid: Set(Uuid::new_v4()), + device_id: Set(volume.device_id), // Use Uuid directly fingerprint: Set(fingerprint.0.clone()), display_name: Set(display_name.clone()), tracked_at: Set(chrono::Utc::now()), @@ -417,29 +443,28 @@ impl VolumeManager { device_model: Set(volume.hardware_id.clone()), ..Default::default() }; - + let model = active_model .insert(db) .await .map_err(|e| VolumeError::Database(e.to_string()))?; - + info!( "Tracked volume '{}' for library '{}'", display_name.as_ref().unwrap_or(&volume.name), library.name().await ); - + // Emit tracking event - self.events - .emit(Event::Custom { - event_type: "VolumeTracked".to_string(), - data: serde_json::json!({ - "library_id": library.id(), - "volume_fingerprint": fingerprint.to_string(), - "display_name": display_name, - }), - }); - + self.events.emit(Event::Custom { + event_type: "VolumeTracked".to_string(), + data: serde_json::json!({ + "library_id": library.id(), + "volume_fingerprint": fingerprint.to_string(), + "display_name": display_name, + }), + }); + Ok(model) } @@ -450,33 +475,32 @@ impl VolumeManager { fingerprint: &VolumeFingerprint, ) -> VolumeResult<()> { let db = library.db().conn(); - + let result = entities::volume::Entity::delete_many() .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) .exec(db) .await .map_err(|e| VolumeError::Database(e.to_string()))?; - + if result.rows_affected == 0 { return Err(VolumeError::NotTracked(fingerprint.to_string())); } - + info!( "Untracked volume '{}' from library '{}'", fingerprint.to_string(), library.name().await ); - + // Emit untracking event - self.events - .emit(Event::Custom { - event_type: "VolumeUntracked".to_string(), - data: serde_json::json!({ - "library_id": library.id(), - "volume_fingerprint": fingerprint.to_string(), - }), - }); - + self.events.emit(Event::Custom { + event_type: "VolumeUntracked".to_string(), + data: serde_json::json!({ + "library_id": library.id(), + "volume_fingerprint": fingerprint.to_string(), + }), + }); + Ok(()) } @@ -486,23 +510,23 @@ impl VolumeManager { library: &crate::library::Library, ) -> VolumeResult> { let db = library.db().conn(); - + let volumes = entities::volume::Entity::find() .all(db) .await .map_err(|e| VolumeError::Database(e.to_string()))?; - + let tracked_volumes: Vec = volumes .into_iter() .map(|model| model.to_tracked_volume()) .collect(); - + debug!( "Found {} tracked volumes for library '{}'", tracked_volumes.len(), library.name().await ); - + Ok(tracked_volumes) } @@ -513,16 +537,23 @@ impl VolumeManager { fingerprint: &VolumeFingerprint, ) -> VolumeResult { let db = library.db().conn(); - + + // Get the volume to find its device_id + let volume = self + .get_volume(fingerprint) + .await + .ok_or_else(|| VolumeError::NotFound(fingerprint.to_string()))?; + let count = entities::volume::Entity::find() + .filter(entities::volume::Column::DeviceId.eq(volume.device_id)) .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) .count(db) .await .map_err(|e| VolumeError::Database(e.to_string()))?; - + Ok(count > 0) } - + /// Update tracked volume state during refresh pub async fn update_tracked_volume_state( &self, @@ -531,28 +562,81 @@ impl VolumeManager { volume: &Volume, ) -> VolumeResult<()> { let db = library.db().conn(); - + let mut active_model: entities::volume::ActiveModel = entities::volume::Entity::find() + .filter(entities::volume::Column::DeviceId.eq(volume.device_id)) .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) .one(db) .await .map_err(|e| VolumeError::Database(e.to_string()))? .ok_or_else(|| VolumeError::NotTracked(fingerprint.to_string()))? .into(); - + active_model.last_seen_at = Set(chrono::Utc::now()); active_model.is_online = Set(volume.is_mounted); active_model.total_capacity = Set(Some(volume.total_bytes_capacity as i64)); active_model.available_capacity = Set(Some(volume.total_bytes_available as i64)); - + active_model .update(db) .await .map_err(|e| VolumeError::Database(e.to_string()))?; - + Ok(()) } - + + /// Update display names for tracked volumes that have empty display names + pub async fn update_empty_display_names( + &self, + library: &crate::library::Library, + ) -> VolumeResult { + let db = library.db().conn(); + + // Find tracked volumes with empty display names + let volumes_to_update = entities::volume::Entity::find() + .filter( + entities::volume::Column::DisplayName + .is_null() + .or(entities::volume::Column::DisplayName.eq("")), + ) + .all(db) + .await + .map_err(|e| VolumeError::Database(e.to_string()))?; + + let mut updated_count = 0; + + for tracked_volume in volumes_to_update { + let fingerprint = VolumeFingerprint(tracked_volume.fingerprint.clone()); + + // Get the current volume info to get the name + if let Some(volume) = self.get_volume(&fingerprint).await { + let volume_name = volume.name.clone(); + let mut active_model: entities::volume::ActiveModel = tracked_volume.into(); + active_model.display_name = Set(Some(volume.name)); + + match active_model.update(db).await { + Ok(_) => { + updated_count += 1; + info!("Updated display name for volume: {}", volume_name); + } + Err(e) => { + warn!( + "Failed to update display name for volume {}: {}", + fingerprint.0, e + ); + } + } + } + } + + info!( + "Updated display names for {} volumes in library '{}'", + updated_count, + library.name().await + ); + Ok(updated_count) + } + /// Get all system volumes (boot/OS volumes) pub async fn get_system_volumes(&self) -> Vec { self.volumes @@ -563,7 +647,7 @@ impl VolumeManager { .cloned() .collect() } - + /// Automatically track system volumes for a library pub async fn auto_track_system_volumes( &self, @@ -571,25 +655,25 @@ impl VolumeManager { ) -> VolumeResult> { let system_volumes = self.get_system_volumes().await; let mut tracked_volumes = Vec::new(); - + info!( "Auto-tracking {} system volumes for library '{}'", system_volumes.len(), library.name().await ); - + for volume in system_volumes { // Skip if already tracked if self.is_volume_tracked(library, &volume.fingerprint).await? { - debug!( - "System volume '{}' already tracked in library", - volume.name - ); + debug!("System volume '{}' already tracked in library", volume.name); continue; } - + // Track the system volume - match self.track_volume(library, &volume.fingerprint, None).await { + match self + .track_volume(library, &volume.fingerprint, Some(volume.name.clone())) + .await + { Ok(tracked) => { info!( "Auto-tracked system volume '{}' in library '{}'", @@ -606,9 +690,59 @@ impl VolumeManager { } } } - + Ok(tracked_volumes) } + + /// Save speed test results to all libraries where this volume is tracked + pub async fn save_speed_test_results( + &self, + fingerprint: &VolumeFingerprint, + read_speed_mbps: u64, + write_speed_mbps: u64, + libraries: &[Arc], + ) -> VolumeResult<()> { + for library in libraries { + // Check if this volume is tracked in this library + if self.is_volume_tracked(library, fingerprint).await? { + let db = library.db().conn(); + + // Get the volume to find its device_id + let volume = self + .get_volume(fingerprint) + .await + .ok_or_else(|| VolumeError::NotFound(fingerprint.to_string()))?; + + // Update the tracked volume record with speed test results + let now = chrono::Utc::now(); + + let update_result = entities::volume::Entity::update_many() + .filter(entities::volume::Column::DeviceId.eq(volume.device_id)) + .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) + .set(entities::volume::ActiveModel { + read_speed_mbps: Set(Some(read_speed_mbps as i32)), + write_speed_mbps: Set(Some(write_speed_mbps as i32)), + last_speed_test_at: Set(Some(now)), + ..Default::default() + }) + .exec(db) + .await + .map_err(|e| VolumeError::Database(e.to_string()))?; + + if update_result.rows_affected > 0 { + info!( + "Saved speed test results for volume {} in library {}: {}MB/s read, {}MB/s write", + fingerprint.0, + library.name().await, + read_speed_mbps, + write_speed_mbps + ); + } + } + } + + Ok(()) + } } /// Statistics about detected volumes @@ -644,7 +778,7 @@ mod tests { async fn test_volume_manager_creation() { let config = VolumeDetectionConfig::default(); let events = create_test_events(); - let manager = VolumeManager::new(config, events); + let manager = VolumeManager::new(Uuid::new_v4(), config, events); let stats = manager.get_statistics().await; assert_eq!(stats.total_volumes, 0); @@ -654,7 +788,7 @@ mod tests { async fn test_volume_path_lookup() { let config = VolumeDetectionConfig::default(); let events = create_test_events(); - let manager = VolumeManager::new(config, events); + let manager = VolumeManager::new(Uuid::new_v4(), config, events); // Initially no volumes let volume = manager @@ -667,7 +801,7 @@ mod tests { async fn test_same_volume_check() { let config = VolumeDetectionConfig::default(); let events = create_test_events(); - let manager = VolumeManager::new(config, events); + let manager = VolumeManager::new(Uuid::new_v4(), config, events); // Both paths don't exist, so should return false let same = manager diff --git a/core-new/src/volume/os_detection.rs b/core-new/src/volume/os_detection.rs index 5004c194a..c65c79480 100644 --- a/core-new/src/volume/os_detection.rs +++ b/core-new/src/volume/os_detection.rs @@ -1,330 +1,432 @@ //! Platform-specific volume detection use crate::volume::{ - error::{VolumeError, VolumeResult}, - types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig}, + error::{VolumeError, VolumeResult}, + types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig}, }; use std::path::PathBuf; use tokio::task; -use tracing::{debug, warn, instrument}; +use tracing::{debug, instrument, warn}; /// Detect all volumes on the current system #[instrument(skip(config))] -pub async fn detect_volumes(config: &VolumeDetectionConfig) -> VolumeResult> { - debug!("Starting volume detection"); - - #[cfg(target_os = "macos")] - let volumes = macos::detect_volumes(config).await?; - - #[cfg(target_os = "linux")] - let volumes = linux::detect_volumes(config).await?; - - #[cfg(target_os = "windows")] - let volumes = windows::detect_volumes(config).await?; - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - let volumes = unsupported::detect_volumes(config).await?; - - debug!("Detected {} volumes", volumes.len()); - Ok(volumes) +pub async fn detect_volumes( + device_id: uuid::Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + debug!("Starting volume detection for device {}", device_id); + + #[cfg(target_os = "macos")] + let volumes = macos::detect_volumes(device_id, config).await?; + + #[cfg(target_os = "linux")] + let volumes = linux::detect_volumes(device_id, config).await?; + + #[cfg(target_os = "windows")] + let volumes = windows::detect_volumes(device_id, config).await?; + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let volumes = unsupported::detect_volumes(device_id, config).await?; + + debug!( + "Detected {} volumes for device {}", + volumes.len(), + device_id + ); + Ok(volumes) } #[cfg(target_os = "macos")] mod macos { - use super::*; - use std::process::Command; - - pub async fn detect_volumes(config: &VolumeDetectionConfig) -> VolumeResult> { - // Clone config for move into task - let config = config.clone(); - - // Run in blocking task since Command is sync - task::spawn_blocking(move || { - let mut volumes = Vec::new(); - - // Use diskutil to get volume information - let output = Command::new("diskutil") - .args(["list", "-plist"]) - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run diskutil: {}", e)))?; - - if !output.status.success() { - return Err(VolumeError::platform("diskutil command failed")); - } - - // For now, use simple df command to get basic volume info - let df_output = Command::new("df") - .args(["-h"]) - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; - - let df_text = String::from_utf8_lossy(&df_output.stdout); - - for line in df_text.lines().skip(1) { // Skip header - if let Some(volume) = parse_df_line(line, &config)? { - volumes.push(volume); - } - } - - Ok(volumes) - }) - .await - .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } - - fn parse_df_line(line: &str, config: &VolumeDetectionConfig) -> VolumeResult> { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { - return Ok(None); - } - - let filesystem = parts[0]; - - // Handle special case where autofs filesystem has name and target split across columns - if filesystem == "map" && parts.len() > 1 && parts[1].contains("auto") { - debug!("Skipping autofs filesystem: map {}", parts[1]); - return Ok(None); - } - - let size_str = parts[1]; - let used_str = parts[2]; - let available_str = parts[3]; - let mount_point = parts[8]; - - // Skip autofs and other special filesystems - if filesystem.starts_with("map") || filesystem.contains("auto_") { - debug!("Skipping autofs filesystem: {}", filesystem); - return Ok(None); - } - - // Skip system filesystems unless requested - if !config.include_system && is_system_filesystem(filesystem) { - return Ok(None); - } - - // Skip virtual filesystems unless requested - if !config.include_virtual && is_virtual_filesystem(filesystem) { - return Ok(None); - } - - let mount_path = PathBuf::from(mount_point); - - // Parse sizes (df output like "931Gi", "465Gi", etc.) - let total_bytes = parse_size_string(size_str)?; - let available_bytes = parse_size_string(available_str)?; - - let name = if mount_point == "/" { - "Macintosh HD".to_string() - } else { - mount_path.file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }; - - let mount_type = if mount_point == "/" { - MountType::System - } else if mount_point.starts_with("/Volumes/") { - MountType::External - } else if filesystem.starts_with("//") { - MountType::Network - } else { - MountType::System - }; - - let disk_type = detect_disk_type(&mount_path)?; - let file_system = detect_filesystem(&mount_path)?; - - let volume = Volume::new( - name, - mount_type, - mount_path, - vec![], // Additional mount points would need diskutil parsing - disk_type, - file_system, - total_bytes, - available_bytes, - false, // Read-only detection would need additional checks - Some(filesystem.to_string()), // Use filesystem as hardware ID - ); - - Ok(Some(volume)) - } - - fn detect_disk_type(mount_point: &PathBuf) -> VolumeResult { - // Try to detect SSD vs HDD using system_profiler or diskutil - let output = Command::new("diskutil") - .args(["info", mount_point.to_str().unwrap_or("/")]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let info = String::from_utf8_lossy(&output.stdout); - if info.contains("Solid State") { - Ok(DiskType::SSD) - } else if info.contains("Rotational") { - Ok(DiskType::HDD) - } else { - Ok(DiskType::Unknown) - } - } - _ => Ok(DiskType::Unknown), - } - } - - fn detect_filesystem(mount_point: &PathBuf) -> VolumeResult { - let output = Command::new("diskutil") - .args(["info", mount_point.to_str().unwrap_or("/")]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let info = String::from_utf8_lossy(&output.stdout); - if info.contains("APFS") { - Ok(FileSystem::APFS) - } else if info.contains("HFS+") { - Ok(FileSystem::Other("HFS+".to_string())) - } else if info.contains("ExFAT") { - Ok(FileSystem::ExFAT) - } else if info.contains("FAT32") { - Ok(FileSystem::FAT32) - } else { - Ok(FileSystem::Other("Unknown".to_string())) - } - } - _ => Ok(FileSystem::Other("Unknown".to_string())), - } - } + use super::*; + use std::process::Command; + + pub async fn detect_volumes( + device_id: uuid::Uuid, + config: &VolumeDetectionConfig, + ) -> VolumeResult> { + // Clone config for move into task + let config = config.clone(); + + // Run in blocking task since Command is sync + task::spawn_blocking(move || { + let mut volumes = Vec::new(); + + // Use diskutil to get volume information + let output = Command::new("diskutil") + .args(["list", "-plist"]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run diskutil: {}", e)))?; + + if !output.status.success() { + return Err(VolumeError::platform(format!( + "diskutil failed with status: {}", + output.status + ))); + } + + // For now, use a simpler approach with df command to get mounted volumes + let df_output = Command::new("df") + .args(["-H"]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !df_output.status.success() { + return Err(VolumeError::platform( + "Failed to get volume information".to_string(), + )); + } + + let df_stdout = String::from_utf8_lossy(&df_output.stdout); + for line in df_stdout.lines().skip(1) { + // Skip header + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 9 { + let filesystem = fields[0]; + let total_str = fields[1]; + let used_str = fields[2]; + let available_str = fields[3]; + let mount_point = fields[8]; + + // Skip certain filesystems + if should_skip_filesystem(filesystem) { + debug!("Skipping {} filesystem: {}", filesystem, mount_point); + continue; + } + + // Parse sizes (in bytes) + let total_bytes = parse_size_string(total_str).unwrap_or(0); + let available_bytes = parse_size_string(available_str).unwrap_or(0); + + let mount_path = PathBuf::from(mount_point); + let name = extract_volume_name(&mount_path); + + let mount_type = if mount_point.starts_with("/Volumes/") { + MountType::External + } else if mount_point.starts_with("/System/") { + MountType::System + } else if filesystem.contains("://") { + MountType::Network + } else { + MountType::System + }; + + let disk_type = detect_disk_type(&mount_path)?; + let file_system = detect_filesystem(&mount_path)?; + + let volume = Volume::new( + device_id, + name, + mount_type, + mount_path, + vec![], // Additional mount points would need diskutil parsing + disk_type, + file_system, + total_bytes, + available_bytes, + false, // Read-only detection would need additional checks + Some(filesystem.to_string()), // Use filesystem as hardware ID + ); + volumes.push(volume); + } + } + Ok(volumes) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? + } + + // Helper function to check if filesystem should be skipped + fn should_skip_filesystem(filesystem: &str) -> bool { + matches!( + filesystem, + "devfs" | "tmpfs" | "proc" | "sysfs" | "fdescfs" | "kernfs" + ) + } + + // Helper function to extract volume name from mount path + fn extract_volume_name(mount_path: &PathBuf) -> String { + if let Some(name) = mount_path.file_name() { + name.to_string_lossy().to_string() + } else if mount_path.to_string_lossy() == "/" { + "Macintosh HD".to_string() + } else { + mount_path.to_string_lossy().to_string() + } + } + + fn parse_df_line( + line: &str, + device_id: uuid::Uuid, + config: &VolumeDetectionConfig, + ) -> VolumeResult> { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 9 { + return Ok(None); + } + + let filesystem = parts[0]; + + // Handle special case where autofs filesystem has name and target split across columns + if filesystem == "map" && parts.len() > 1 && parts[1].contains("auto") { + debug!("Skipping autofs filesystem: map {}", parts[1]); + return Ok(None); + } + + let size_str = parts[1]; + let used_str = parts[2]; + let available_str = parts[3]; + let mount_point = parts[8]; + + // Skip autofs and other special filesystems + if filesystem.starts_with("map") || filesystem.contains("auto_") { + debug!("Skipping autofs filesystem: {}", filesystem); + return Ok(None); + } + + // Skip system filesystems unless requested + if !config.include_system && is_system_filesystem(filesystem) { + return Ok(None); + } + + // Skip virtual filesystems unless requested + if !config.include_virtual && is_virtual_filesystem(filesystem) { + return Ok(None); + } + + let mount_path = PathBuf::from(mount_point); + + // Parse sizes (df output like "931Gi", "465Gi", etc.) + let total_bytes = parse_size_string(size_str)?; + let available_bytes = parse_size_string(available_str)?; + + let name = if mount_point == "/" { + "Macintosh HD".to_string() + } else { + mount_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }; + + let mount_type = if mount_point == "/" { + MountType::System + } else if mount_point.starts_with("/Volumes/") { + MountType::External + } else if filesystem.starts_with("//") { + MountType::Network + } else { + MountType::System + }; + + let disk_type = detect_disk_type(&mount_path)?; + let file_system = detect_filesystem(&mount_path)?; + + let volume = Volume::new( + device_id, + name, + mount_type, + mount_path, + vec![], // Additional mount points would need diskutil parsing + disk_type, + file_system, + total_bytes, + available_bytes, + false, // Read-only detection would need additional checks + Some(filesystem.to_string()), // Use filesystem as hardware ID + ); + + Ok(Some(volume)) + } + + fn detect_disk_type(mount_point: &PathBuf) -> VolumeResult { + // Try to detect SSD vs HDD using system_profiler or diskutil + let output = Command::new("diskutil") + .args(["info", mount_point.to_str().unwrap_or("/")]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let info = String::from_utf8_lossy(&output.stdout); + if info.contains("Solid State") { + Ok(DiskType::SSD) + } else if info.contains("Rotational") { + Ok(DiskType::HDD) + } else { + Ok(DiskType::Unknown) + } + } + _ => Ok(DiskType::Unknown), + } + } + + fn detect_filesystem(mount_point: &PathBuf) -> VolumeResult { + let output = Command::new("diskutil") + .args(["info", mount_point.to_str().unwrap_or("/")]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let info = String::from_utf8_lossy(&output.stdout); + if info.contains("APFS") { + Ok(FileSystem::APFS) + } else if info.contains("HFS+") { + Ok(FileSystem::Other("HFS+".to_string())) + } else if info.contains("ExFAT") { + Ok(FileSystem::ExFAT) + } else if info.contains("FAT32") { + Ok(FileSystem::FAT32) + } else { + Ok(FileSystem::Other("Unknown".to_string())) + } + } + _ => Ok(FileSystem::Other("Unknown".to_string())), + } + } } #[cfg(target_os = "linux")] mod linux { - use super::*; - use std::process::Command; - - pub async fn detect_volumes(config: &VolumeDetectionConfig) -> VolumeResult> { - task::spawn_blocking(move || { - let mut volumes = Vec::new(); - - // Use df to get mounted filesystems - let output = Command::new("df") - .args(["-h", "-T"]) // -T shows filesystem type - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; - - if !output.status.success() { - return Err(VolumeError::platform("df command failed")); - } - - let df_text = String::from_utf8_lossy(&output.stdout); - - for line in df_text.lines().skip(1) { // Skip header - if let Some(volume) = parse_df_line(line, &config)? { - volumes.push(volume); - } - } - - Ok(volumes) - }) - .await - .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } - - fn parse_df_line(line: &str, config: &VolumeDetectionConfig) -> VolumeResult> { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 7 { - return Ok(None); - } - - let filesystem_device = parts[0]; - let filesystem_type = parts[1]; - let size_str = parts[2]; - let used_str = parts[3]; - let available_str = parts[4]; - let mount_point = parts[6]; - - // Skip system filesystems unless requested - if !config.include_system && is_system_filesystem(filesystem_device) { - return Ok(None); - } - - // Skip virtual filesystems unless requested - if !config.include_virtual && is_virtual_filesystem(filesystem_type) { - return Ok(None); - } - - let mount_path = PathBuf::from(mount_point); - - let total_bytes = parse_size_string(size_str)?; - let available_bytes = parse_size_string(available_str)?; - - let name = if mount_point == "/" { - "Root".to_string() - } else { - mount_path.file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }; - - let mount_type = determine_mount_type(mount_point, filesystem_device); - let disk_type = detect_disk_type_linux(filesystem_device)?; - let file_system = FileSystem::from_string(filesystem_type); - - let volume = Volume::new( - name, - mount_type, - mount_path, - vec![], - disk_type, - file_system, - total_bytes, - available_bytes, - false, // Would need additional check for read-only - Some(filesystem_device.to_string()), - ); - - Ok(Some(volume)) - } - - fn detect_disk_type_linux(device: &str) -> VolumeResult { - // Try to detect using /sys/block/*/queue/rotational - if let Some(device_name) = device.strip_prefix("/dev/") { - let base_device = device_name.trim_end_matches(char::is_numeric); - let rotational_path = format!("/sys/block/{}/queue/rotational", base_device); - - if let Ok(contents) = std::fs::read_to_string(rotational_path) { - return match contents.trim() { - "0" => Ok(DiskType::SSD), - "1" => Ok(DiskType::HDD), - _ => Ok(DiskType::Unknown), - }; - } - } - - Ok(DiskType::Unknown) - } - - fn determine_mount_type(mount_point: &str, device: &str) -> MountType { - if mount_point == "/" || mount_point.starts_with("/boot") { - MountType::System - } else if device.starts_with("//") || device.contains("nfs") { - MountType::Network - } else if mount_point.starts_with("/media/") || mount_point.starts_with("/mnt/") { - MountType::External - } else { - MountType::System - } - } + use super::*; + use std::process::Command; + + pub async fn detect_volumes( + device_id: uuid::Uuid, + config: &VolumeDetectionConfig, + ) -> VolumeResult> { + task::spawn_blocking(move || { + let mut volumes = Vec::new(); + + // Use df to get mounted filesystems + let output = Command::new("df") + .args(["-h", "-T"]) // -T shows filesystem type + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !output.status.success() { + return Err(VolumeError::platform("df command failed")); + } + + let df_text = String::from_utf8_lossy(&output.stdout); + + for line in df_text.lines().skip(1) { + // Skip header + if let Some(volume) = parse_df_line(line, device_id, &config)? { + volumes.push(volume); + } + } + + Ok(volumes) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? + } + + fn parse_df_line( + line: &str, + device_id: uuid::Uuid, + config: &VolumeDetectionConfig, + ) -> VolumeResult> { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 7 { + return Ok(None); + } + + let filesystem_device = parts[0]; + let filesystem_type = parts[1]; + let size_str = parts[2]; + let used_str = parts[3]; + let available_str = parts[4]; + let mount_point = parts[6]; + + // Skip system filesystems unless requested + if !config.include_system && is_system_filesystem(filesystem_device) { + return Ok(None); + } + + // Skip virtual filesystems unless requested + if !config.include_virtual && is_virtual_filesystem(filesystem_type) { + return Ok(None); + } + + let mount_path = PathBuf::from(mount_point); + + let total_bytes = parse_size_string(size_str)?; + let available_bytes = parse_size_string(available_str)?; + + let name = if mount_point == "/" { + "Root".to_string() + } else { + mount_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }; + + let mount_type = determine_mount_type(mount_point, filesystem_device); + let disk_type = detect_disk_type_linux(filesystem_device)?; + let file_system = FileSystem::from_string(filesystem_type); + + let volume = Volume::new( + device_id, + name, + mount_type, + mount_path, + vec![], + disk_type, + file_system, + total_bytes, + available_bytes, + false, // Would need additional check for read-only + Some(filesystem_device.to_string()), + ); + + Ok(Some(volume)) + } + + fn detect_disk_type_linux(device: &str) -> VolumeResult { + // Try to detect using /sys/block/*/queue/rotational + if let Some(device_name) = device.strip_prefix("/dev/") { + let base_device = device_name.trim_end_matches(char::is_numeric); + let rotational_path = format!("/sys/block/{}/queue/rotational", base_device); + + if let Ok(contents) = std::fs::read_to_string(rotational_path) { + return match contents.trim() { + "0" => Ok(DiskType::SSD), + "1" => Ok(DiskType::HDD), + _ => Ok(DiskType::Unknown), + }; + } + } + + Ok(DiskType::Unknown) + } + + fn determine_mount_type(mount_point: &str, device: &str) -> MountType { + if mount_point == "/" || mount_point.starts_with("/boot") { + MountType::System + } else if device.starts_with("//") || device.contains("nfs") { + MountType::Network + } else if mount_point.starts_with("/media/") || mount_point.starts_with("/mnt/") { + MountType::External + } else { + MountType::System + } + } } #[cfg(target_os = "windows")] mod windows { - use super::*; - use std::process::Command; - - pub async fn detect_volumes(_config: &VolumeDetectionConfig) -> VolumeResult> { - task::spawn_blocking(|| { + use super::*; + use std::process::Command; + + pub async fn detect_volumes( + device_id: uuid::Uuid, + _config: &VolumeDetectionConfig, + ) -> VolumeResult> { + task::spawn_blocking(|| { // Use PowerShell to get volume information let output = Command::new("powershell") .args([ @@ -333,101 +435,112 @@ mod windows { ]) .output() .map_err(|e| VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; - + if !output.status.success() { return Err(VolumeError::platform("PowerShell command failed")); } - + // For now, return empty until we implement full Windows support warn!("Windows volume detection not fully implemented yet"); Ok(Vec::new()) }) .await .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } + } } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] mod unsupported { - use super::*; - - pub async fn detect_volumes(_config: &VolumeDetectionConfig) -> VolumeResult> { - warn!("Volume detection not supported on this platform"); - Ok(Vec::new()) - } + use super::*; + + pub async fn detect_volumes( + device_id: uuid::Uuid, + _config: &VolumeDetectionConfig, + ) -> VolumeResult> { + warn!("Volume detection not supported on this platform"); + Ok(Vec::new()) + } } // Common utility functions fn is_system_filesystem(filesystem: &str) -> bool { - matches!(filesystem, "/" | "/dev" | "/proc" | "/sys" | "/tmp" | "/var/tmp") + matches!( + filesystem, + "/" | "/dev" | "/proc" | "/sys" | "/tmp" | "/var/tmp" + ) } fn is_virtual_filesystem(filesystem: &str) -> bool { - let fs_lower = filesystem.to_lowercase(); - matches!( - fs_lower.as_str(), - "devfs" | "sysfs" | "proc" | "tmpfs" | "ramfs" | "devtmpfs" | "overlay" | "fuse" - ) || fs_lower.starts_with("map ") || fs_lower.contains("auto_") + let fs_lower = filesystem.to_lowercase(); + matches!( + fs_lower.as_str(), + "devfs" | "sysfs" | "proc" | "tmpfs" | "ramfs" | "devtmpfs" | "overlay" | "fuse" + ) || fs_lower.starts_with("map ") + || fs_lower.contains("auto_") } fn parse_size_string(size_str: &str) -> VolumeResult { - if size_str == "-" { - return Ok(0); - } - - // Skip invalid size strings that don't look like numbers - if size_str.is_empty() || size_str.chars().all(char::is_alphabetic) { - return Ok(0); - } - - let size_str = size_str.replace(",", ""); // Remove commas - let (number_part, unit) = if let Some(pos) = size_str.find(char::is_alphabetic) { - (&size_str[..pos], &size_str[pos..]) - } else { - (size_str.as_str(), "") - }; - - let number: f64 = number_part.parse() - .map_err(|_| VolumeError::InvalidData(format!("Invalid size: {}", size_str)))?; - - let multiplier = match unit.to_uppercase().as_str() { - "" | "B" => 1, - "K" | "KB" | "KI" => 1024, - "M" | "MB" | "MI" => 1024 * 1024, - "G" | "GB" | "GI" => 1024 * 1024 * 1024, - "T" | "TB" | "TI" => 1024u64.pow(4), - "P" | "PB" | "PI" => 1024u64.pow(5), - _ => { - warn!("Unknown size unit: {}", unit); - 1 - } - }; - - Ok((number * multiplier as f64) as u64) + if size_str == "-" { + return Ok(0); + } + + // Skip invalid size strings that don't look like numbers + if size_str.is_empty() || size_str.chars().all(char::is_alphabetic) { + return Ok(0); + } + + let size_str = size_str.replace(",", ""); // Remove commas + let (number_part, unit) = if let Some(pos) = size_str.find(char::is_alphabetic) { + (&size_str[..pos], &size_str[pos..]) + } else { + (size_str.as_str(), "") + }; + + let number: f64 = number_part + .parse() + .map_err(|_| VolumeError::InvalidData(format!("Invalid size: {}", size_str)))?; + + let multiplier = match unit.to_uppercase().as_str() { + "" | "B" => 1, + "K" | "KB" | "KI" => 1024, + "M" | "MB" | "MI" => 1024 * 1024, + "G" | "GB" | "GI" => 1024 * 1024 * 1024, + "T" | "TB" | "TI" => 1024u64.pow(4), + "P" | "PB" | "PI" => 1024u64.pow(5), + _ => { + warn!("Unknown size unit: {}", unit); + 1 + } + }; + + Ok((number * multiplier as f64) as u64) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_parse_size_string() { - assert_eq!(parse_size_string("1024").unwrap(), 1024); - assert_eq!(parse_size_string("1K").unwrap(), 1024); - assert_eq!(parse_size_string("1M").unwrap(), 1024 * 1024); - assert_eq!(parse_size_string("1G").unwrap(), 1024 * 1024 * 1024); - assert_eq!(parse_size_string("1.5G").unwrap(), (1.5 * 1024.0 * 1024.0 * 1024.0) as u64); - assert_eq!(parse_size_string("-").unwrap(), 0); - } + #[test] + fn test_parse_size_string() { + assert_eq!(parse_size_string("1024").unwrap(), 1024); + assert_eq!(parse_size_string("1K").unwrap(), 1024); + assert_eq!(parse_size_string("1M").unwrap(), 1024 * 1024); + assert_eq!(parse_size_string("1G").unwrap(), 1024 * 1024 * 1024); + assert_eq!( + parse_size_string("1.5G").unwrap(), + (1.5 * 1024.0 * 1024.0 * 1024.0) as u64 + ); + assert_eq!(parse_size_string("-").unwrap(), 0); + } - #[test] - fn test_filesystem_detection() { - assert!(is_virtual_filesystem("tmpfs")); - assert!(is_virtual_filesystem("proc")); - assert!(!is_virtual_filesystem("ext4")); - - assert!(is_system_filesystem("/")); - assert!(is_system_filesystem("/proc")); - assert!(!is_system_filesystem("/home")); - } -} \ No newline at end of file + #[test] + fn test_filesystem_detection() { + assert!(is_virtual_filesystem("tmpfs")); + assert!(is_virtual_filesystem("proc")); + assert!(!is_virtual_filesystem("ext4")); + + assert!(is_system_filesystem("/")); + assert!(is_system_filesystem("/proc")); + assert!(!is_system_filesystem("/home")); + } +} diff --git a/core-new/src/volume/speed.rs b/core-new/src/volume/speed.rs index 05e6f8141..82ff28ea3 100644 --- a/core-new/src/volume/speed.rs +++ b/core-new/src/volume/speed.rs @@ -1,365 +1,360 @@ //! Volume speed testing functionality use crate::volume::{ - error::{VolumeError, VolumeResult}, - types::{MountType, Volume}, + error::{VolumeError, VolumeResult}, + types::{MountType, Volume}, }; use std::time::Instant; use tokio::{ - fs::{File, OpenOptions}, - io::{AsyncReadExt, AsyncWriteExt}, - time::{timeout, Duration}, + fs::{File, OpenOptions}, + io::{AsyncReadExt, AsyncWriteExt}, + time::{timeout, Duration}, }; use tracing::{debug, instrument, warn}; /// Configuration for speed tests #[derive(Debug, Clone)] pub struct SpeedTestConfig { - /// Size of the test file in megabytes - pub file_size_mb: usize, - /// Timeout for the test in seconds - pub timeout_secs: u64, - /// Number of test iterations for averaging - pub iterations: usize, + /// Size of the test file in megabytes + pub file_size_mb: usize, + /// Timeout for the test in seconds + pub timeout_secs: u64, + /// Number of test iterations for averaging + pub iterations: usize, } impl Default for SpeedTestConfig { - fn default() -> Self { - Self { - file_size_mb: 10, - timeout_secs: 30, - iterations: 1, - } - } + fn default() -> Self { + Self { + file_size_mb: 10, + timeout_secs: 30, + iterations: 1, + } + } } /// Result of a speed test #[derive(Debug, Clone)] pub struct SpeedTestResult { - /// Write speed in MB/s - pub write_speed_mbps: f64, - /// Read speed in MB/s - pub read_speed_mbps: f64, - /// Total time taken for the test - pub duration_secs: f64, + /// Write speed in MB/s + pub write_speed_mbps: f64, + /// Read speed in MB/s + pub read_speed_mbps: f64, + /// Total time taken for the test + pub duration_secs: f64, } /// Run a speed test on the given volume #[instrument(skip(volume), fields(volume_name = %volume.name))] pub async fn run_speed_test(volume: &Volume) -> VolumeResult<(u64, u64)> { - run_speed_test_with_config(volume, SpeedTestConfig::default()).await + run_speed_test_with_config(volume, SpeedTestConfig::default()).await } /// Run a speed test with custom configuration #[instrument(skip(volume, config), fields(volume_name = %volume.name))] pub async fn run_speed_test_with_config( - volume: &Volume, - config: SpeedTestConfig, + volume: &Volume, + config: SpeedTestConfig, ) -> VolumeResult<(u64, u64)> { - if !volume.is_mounted { - return Err(VolumeError::NotMounted(volume.name.clone())); - } + if !volume.is_mounted { + return Err(VolumeError::NotMounted(volume.name.clone())); + } - if volume.read_only { - return Err(VolumeError::ReadOnly(volume.name.clone())); - } + if volume.read_only { + return Err(VolumeError::ReadOnly(volume.name.clone())); + } - debug!("Starting speed test with config: {:?}", config); + debug!("Starting speed test with config: {:?}", config); - let test_location = TestLocation::new(&volume.mount_point, &volume.mount_type).await?; - let result = perform_speed_test(&test_location, &config).await?; + let test_location = TestLocation::new(&volume.mount_point, &volume.mount_type).await?; + let result = perform_speed_test(&test_location, &config).await?; - // Cleanup - test_location.cleanup().await?; + // Cleanup + test_location.cleanup().await?; - debug!( - "Speed test completed: {:.2} MB/s write, {:.2} MB/s read", - result.write_speed_mbps, result.read_speed_mbps - ); + debug!( + "Speed test completed: {:.2} MB/s write, {:.2} MB/s read", + result.write_speed_mbps, result.read_speed_mbps + ); - Ok(( - result.read_speed_mbps as u64, - result.write_speed_mbps as u64, - )) + Ok(( + result.read_speed_mbps as u64, + result.write_speed_mbps as u64, + )) } /// Helper for managing test files and directories struct TestLocation { - test_file: std::path::PathBuf, - created_dir: Option, + test_file: std::path::PathBuf, + created_dir: Option, } impl TestLocation { - /// Create a new test location - async fn new( - volume_path: &std::path::Path, - mount_type: &MountType, - ) -> VolumeResult { - let (dir, created_dir) = get_writable_directory(volume_path, mount_type).await?; - let test_file = dir.join("spacedrive_speed_test.tmp"); + /// Create a new test location + async fn new(volume_path: &std::path::Path, mount_type: &MountType) -> VolumeResult { + let (dir, created_dir) = get_writable_directory(volume_path, mount_type).await?; + let test_file = dir.join("spacedrive_speed_test.tmp"); - Ok(Self { - test_file, - created_dir, - }) - } + Ok(Self { + test_file, + created_dir, + }) + } - /// Clean up test files and directories - async fn cleanup(&self) -> VolumeResult<()> { - // Remove test file - if self.test_file.exists() { - if let Err(e) = tokio::fs::remove_file(&self.test_file).await { - warn!("Failed to remove test file: {}", e); - } - } + /// Clean up test files and directories + async fn cleanup(&self) -> VolumeResult<()> { + // Remove test file + if self.test_file.exists() { + if let Err(e) = tokio::fs::remove_file(&self.test_file).await { + warn!("Failed to remove test file: {}", e); + } + } - // Remove created directory if we created it - if let Some(ref dir) = self.created_dir { - if let Err(e) = tokio::fs::remove_dir_all(dir).await { - warn!("Failed to remove test directory: {}", e); - } - } + // Remove created directory if we created it + if let Some(ref dir) = self.created_dir { + if let Err(e) = tokio::fs::remove_dir_all(dir).await { + warn!("Failed to remove test directory: {}", e); + } + } - Ok(()) - } + Ok(()) + } } /// Perform the actual speed test async fn perform_speed_test( - location: &TestLocation, - config: &SpeedTestConfig, + location: &TestLocation, + config: &SpeedTestConfig, ) -> VolumeResult { - let test_data = generate_test_data(config.file_size_mb); - let timeout_duration = Duration::from_secs(config.timeout_secs); + let test_data = generate_test_data(config.file_size_mb); + let timeout_duration = Duration::from_secs(config.timeout_secs); - let mut write_speeds = Vec::new(); - let mut read_speeds = Vec::new(); - let overall_start = Instant::now(); + let mut write_speeds = Vec::new(); + let mut read_speeds = Vec::new(); + let overall_start = Instant::now(); - for iteration in 0..config.iterations { - debug!("Speed test iteration {}/{}", iteration + 1, config.iterations); + for iteration in 0..config.iterations { + debug!( + "Speed test iteration {}/{}", + iteration + 1, + config.iterations + ); - // Write test - let write_speed = timeout( - timeout_duration, - perform_write_test(&location.test_file, &test_data), - ) - .await - .map_err(|_| VolumeError::Timeout)??; + // Write test + let write_speed = timeout( + timeout_duration, + perform_write_test(&location.test_file, &test_data), + ) + .await + .map_err(|_| VolumeError::Timeout)??; - write_speeds.push(write_speed); + write_speeds.push(write_speed); - // Read test - let read_speed = timeout( - timeout_duration, - perform_read_test(&location.test_file, test_data.len()), - ) - .await - .map_err(|_| VolumeError::Timeout)??; + // Read test + let read_speed = timeout( + timeout_duration, + perform_read_test(&location.test_file, test_data.len()), + ) + .await + .map_err(|_| VolumeError::Timeout)??; - read_speeds.push(read_speed); + read_speeds.push(read_speed); - // Clean up test file between iterations - if iteration < config.iterations - 1 { - let _ = tokio::fs::remove_file(&location.test_file).await; - } - } + // Clean up test file between iterations + if iteration < config.iterations - 1 { + let _ = tokio::fs::remove_file(&location.test_file).await; + } + } - let avg_write_speed = write_speeds.iter().sum::() / write_speeds.len() as f64; - let avg_read_speed = read_speeds.iter().sum::() / read_speeds.len() as f64; + let avg_write_speed = write_speeds.iter().sum::() / write_speeds.len() as f64; + let avg_read_speed = read_speeds.iter().sum::() / read_speeds.len() as f64; - Ok(SpeedTestResult { - write_speed_mbps: avg_write_speed, - read_speed_mbps: avg_read_speed, - duration_secs: overall_start.elapsed().as_secs_f64(), - }) + Ok(SpeedTestResult { + write_speed_mbps: avg_write_speed, + read_speed_mbps: avg_read_speed, + duration_secs: overall_start.elapsed().as_secs_f64(), + }) } /// Generate test data for speed testing fn generate_test_data(size_mb: usize) -> Vec { - let size_bytes = size_mb * 1024 * 1024; - - // Use a pattern instead of zeros to avoid compression optimizations - let pattern = b"SpacedriveSpeedTest0123456789ABCDEF"; - let mut data = Vec::with_capacity(size_bytes); - - for i in 0..size_bytes { - data.push(pattern[i % pattern.len()]); - } - - data + let size_bytes = size_mb * 1024 * 1024; + + // Use a pattern instead of zeros to avoid compression optimizations + let pattern = b"SpacedriveSpeedTest0123456789ABCDEF"; + let mut data = Vec::with_capacity(size_bytes); + + for i in 0..size_bytes { + data.push(pattern[i % pattern.len()]); + } + + data } /// Perform write speed test -async fn perform_write_test( - file_path: &std::path::Path, - data: &[u8], -) -> VolumeResult { - let start = Instant::now(); +async fn perform_write_test(file_path: &std::path::Path, data: &[u8]) -> VolumeResult { + let start = Instant::now(); - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(file_path) - .await?; + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(file_path) + .await?; - file.write_all(data).await?; - file.sync_all().await?; // Ensure data is written to disk + file.write_all(data).await?; + file.sync_all().await?; // Ensure data is written to disk - let duration = start.elapsed(); - let speed_mbps = (data.len() as f64 / 1024.0 / 1024.0) / duration.as_secs_f64(); + let duration = start.elapsed(); + let speed_mbps = (data.len() as f64 / 1024.0 / 1024.0) / duration.as_secs_f64(); - Ok(speed_mbps) + Ok(speed_mbps) } /// Perform read speed test -async fn perform_read_test( - file_path: &std::path::Path, - expected_size: usize, -) -> VolumeResult { - let start = Instant::now(); +async fn perform_read_test(file_path: &std::path::Path, expected_size: usize) -> VolumeResult { + let start = Instant::now(); - let mut file = File::open(file_path).await?; - let mut buffer = Vec::with_capacity(expected_size); - file.read_to_end(&mut buffer).await?; + let mut file = File::open(file_path).await?; + let mut buffer = Vec::with_capacity(expected_size); + file.read_to_end(&mut buffer).await?; - let duration = start.elapsed(); - let speed_mbps = (buffer.len() as f64 / 1024.0 / 1024.0) / duration.as_secs_f64(); + let duration = start.elapsed(); + let speed_mbps = (buffer.len() as f64 / 1024.0 / 1024.0) / duration.as_secs_f64(); - Ok(speed_mbps) + Ok(speed_mbps) } /// Get a writable directory within the volume async fn get_writable_directory( - volume_path: &std::path::Path, - mount_type: &MountType, + volume_path: &std::path::Path, + mount_type: &MountType, ) -> VolumeResult<(std::path::PathBuf, Option)> { - match mount_type { - MountType::System => { - // For system volumes, prefer using temp directory - let temp_dir = std::env::temp_dir(); - Ok((temp_dir, None)) - } - _ => { - // For external volumes, try to write in the root or create a temp directory - let candidates = [ - volume_path.join("tmp"), - volume_path.join(".spacedrive_temp"), - volume_path.to_path_buf(), - ]; + match mount_type { + MountType::System => { + // For system volumes, prefer using temp directory + let temp_dir = std::env::temp_dir(); + Ok((temp_dir, None)) + } + _ => { + // For external volumes, try to write in the root or create a temp directory + let candidates = [ + volume_path.join("tmp"), + volume_path.join(".spacedrive_temp"), + volume_path.to_path_buf(), + ]; - for candidate in &candidates { - // Try to create the directory - if let Ok(()) = tokio::fs::create_dir_all(candidate).await { - // Test if we can write to it - let test_file = candidate.join("test_write_permissions"); - if tokio::fs::write(&test_file, b"test").await.is_ok() { - let _ = tokio::fs::remove_file(&test_file).await; - - // If we created a directory specifically for this test, mark it for cleanup - let created_dir = if candidate.file_name() - .map_or(false, |name| name == "tmp" || name == ".spacedrive_temp") - { - Some(candidate.clone()) - } else { - None - }; - - return Ok((candidate.clone(), created_dir)); - } - } - } + for candidate in &candidates { + // Try to create the directory + if let Ok(()) = tokio::fs::create_dir_all(candidate).await { + // Test if we can write to it + let test_file = candidate.join("test_write_permissions"); + if tokio::fs::write(&test_file, b"test").await.is_ok() { + let _ = tokio::fs::remove_file(&test_file).await; - Err(VolumeError::PermissionDenied(format!( - "No writable directory found in volume: {}", - volume_path.display() - ))) - } - } + // If we created a directory specifically for this test, mark it for cleanup + let created_dir = if candidate + .file_name() + .map_or(false, |name| name == "tmp" || name == ".spacedrive_temp") + { + Some(candidate.clone()) + } else { + None + }; + + return Ok((candidate.clone(), created_dir)); + } + } + } + + Err(VolumeError::PermissionDenied(format!( + "No writable directory found in volume: {}", + volume_path.display() + ))) + } + } } #[cfg(test)] mod tests { - use super::*; - use crate::volume::types::{DiskType, FileSystem}; - use tempfile::TempDir; + use super::*; + use crate::volume::types::{DiskType, FileSystem}; + use tempfile::TempDir; - #[tokio::test] - async fn test_speed_test_config() { - let config = SpeedTestConfig::default(); - assert_eq!(config.file_size_mb, 10); - assert_eq!(config.timeout_secs, 30); - assert_eq!(config.iterations, 1); - } + #[tokio::test] + async fn test_speed_test_config() { + let config = SpeedTestConfig::default(); + assert_eq!(config.file_size_mb, 10); + assert_eq!(config.timeout_secs, 30); + assert_eq!(config.iterations, 1); + } - #[tokio::test] - async fn test_generate_test_data() { - let data = generate_test_data(1); // 1MB - assert_eq!(data.len(), 1024 * 1024); - - // Verify pattern is not all zeros - assert!(data.iter().any(|&b| b != 0)); - } + #[tokio::test] + async fn test_generate_test_data() { + let data = generate_test_data(1); // 1MB + assert_eq!(data.len(), 1024 * 1024); - #[tokio::test] - async fn test_writable_directory_external() { - let temp_dir = TempDir::new().unwrap(); - let volume_path = temp_dir.path(); - - let (writable_dir, created_dir) = get_writable_directory(volume_path, &MountType::External) - .await - .unwrap(); - - assert!(writable_dir.exists()); - - // Cleanup if we created a directory - if let Some(dir) = created_dir { - let _ = tokio::fs::remove_dir_all(dir).await; - } - } + // Verify pattern is not all zeros + assert!(data.iter().any(|&b| b != 0)); + } - #[tokio::test] - async fn test_writable_directory_system() { - let (writable_dir, created_dir) = get_writable_directory( - &std::path::PathBuf::from("/"), - &MountType::System - ) - .await - .unwrap(); - - assert!(writable_dir.exists()); - assert!(created_dir.is_none()); // Should use system temp, not create new dir - } + #[tokio::test] + async fn test_writable_directory_external() { + let temp_dir = TempDir::new().unwrap(); + let volume_path = temp_dir.path(); - #[tokio::test] - async fn test_full_speed_test() { - let temp_dir = TempDir::new().unwrap(); - - let volume = Volume::new( - "Test Volume".to_string(), - MountType::External, - temp_dir.path().to_path_buf(), - vec![], - DiskType::Unknown, - FileSystem::Other("test".to_string()), - 1000000000, // 1GB capacity - 500000000, // 500MB available - false, // Not read-only - None, - ); + let (writable_dir, created_dir) = get_writable_directory(volume_path, &MountType::External) + .await + .unwrap(); - let config = SpeedTestConfig { - file_size_mb: 1, // Small test file - timeout_secs: 10, - iterations: 1, - }; + assert!(writable_dir.exists()); - let result = run_speed_test_with_config(&volume, config).await; - assert!(result.is_ok()); - - let (read_speed, write_speed) = result.unwrap(); - assert!(read_speed > 0); - assert!(write_speed > 0); - } -} \ No newline at end of file + // Cleanup if we created a directory + if let Some(dir) = created_dir { + let _ = tokio::fs::remove_dir_all(dir).await; + } + } + + #[tokio::test] + async fn test_writable_directory_system() { + let (writable_dir, created_dir) = + get_writable_directory(&std::path::PathBuf::from("/"), &MountType::System) + .await + .unwrap(); + + assert!(writable_dir.exists()); + assert!(created_dir.is_none()); // Should use system temp, not create new dir + } + + #[tokio::test] + async fn test_full_speed_test() { + let temp_dir = TempDir::new().unwrap(); + + let volume = Volume::new( + uuid::Uuid::new_v4(), // Test device ID + "Test Volume".to_string(), + MountType::External, + temp_dir.path().to_path_buf(), + vec![], + DiskType::Unknown, + FileSystem::Other("test".to_string()), + 1000000000, // 1GB capacity + 500000000, // 500MB available + false, // Not read-only + None, + ); + + let config = SpeedTestConfig { + file_size_mb: 1, // Small test file + timeout_secs: 10, + iterations: 1, + }; + + let result = run_speed_test_with_config(&volume, config).await; + assert!(result.is_ok()); + + let (read_speed, write_speed) = result.unwrap(); + assert!(read_speed > 0); + assert!(write_speed > 0); + } +} diff --git a/core-new/src/volume/types.rs b/core-new/src/volume/types.rs index d8c70853c..25f2e981b 100644 --- a/core-new/src/volume/types.rs +++ b/core-new/src/volume/types.rs @@ -12,6 +12,7 @@ impl VolumeFingerprint { /// Create a new volume fingerprint from volume properties pub fn new(volume: &Volume) -> Self { let mut hasher = blake3::Hasher::new(); + hasher.update(volume.device_id.as_bytes()); hasher.update(volume.mount_point.to_string_lossy().as_bytes()); hasher.update(volume.name.as_bytes()); hasher.update(&volume.total_bytes_capacity.to_be_bytes()); @@ -29,7 +30,7 @@ impl VolumeFingerprint { pub fn from_hex(hex: impl Into) -> Self { Self(hex.into()) } - + /// Create fingerprint from string (alias for from_hex) pub fn from_string(s: &str) -> Result { Ok(Self(s.to_string())) @@ -79,6 +80,9 @@ pub struct Volume { /// Unique fingerprint for this volume pub fingerprint: VolumeFingerprint, + /// Device this volume belongs to + pub device_id: uuid::Uuid, + /// Human-readable volume name pub name: String, /// Type of mount (system, external, etc) @@ -133,6 +137,7 @@ pub struct VolumeInfo { pub struct TrackedVolume { pub id: i32, pub uuid: uuid::Uuid, + pub device_id: uuid::Uuid, pub fingerprint: VolumeFingerprint, pub display_name: Option, pub tracked_at: chrono::DateTime, @@ -165,6 +170,7 @@ impl From<&Volume> for VolumeInfo { impl Volume { /// Create a new Volume instance pub fn new( + device_id: uuid::Uuid, name: String, mount_type: MountType, mount_point: PathBuf, @@ -178,6 +184,7 @@ impl Volume { ) -> Self { let volume = Self { fingerprint: VolumeFingerprint::from_hex(""), // Will be set after creation + device_id, name, mount_type, mount_point, @@ -418,6 +425,7 @@ mod tests { #[test] fn test_volume_fingerprint() { let volume = Volume::new( + uuid::Uuid::new_v4(), "Test Volume".to_string(), MountType::External, PathBuf::from("/mnt/test"), @@ -441,6 +449,7 @@ mod tests { #[test] fn test_volume_contains_path() { let volume = Volume::new( + uuid::Uuid::new_v4(), "Test".to_string(), MountType::System, PathBuf::from("/home"),