diff --git a/core-new/src/infrastructure/database/entities/mod.rs b/core-new/src/infrastructure/database/entities/mod.rs index be17f37af..71d3e1eeb 100644 --- a/core-new/src/infrastructure/database/entities/mod.rs +++ b/core-new/src/infrastructure/database/entities/mod.rs @@ -15,6 +15,7 @@ pub mod metadata_tag; pub use metadata_tag as user_metadata_tag; // Alias for hierarchical metadata operations pub mod metadata_label; pub mod audit_log; +pub mod volume; // Re-export all entities pub use device::Entity as Device; @@ -26,6 +27,7 @@ pub use tag::Entity as Tag; pub use label::Entity as Label; pub use metadata_tag::Entity as UserMetadataTag; pub use audit_log::Entity as AuditLog; +pub use volume::Entity as Volume; // Re-export active models for easy access pub use device::ActiveModel as DeviceActive; @@ -36,4 +38,5 @@ pub use content_identity::ActiveModel as ContentIdentityActive; pub use tag::ActiveModel as TagActive; pub use label::ActiveModel as LabelActive; pub use metadata_tag::ActiveModel as UserMetadataTagActive; -pub use audit_log::ActiveModel as AuditLogActive; \ No newline at end of file +pub use audit_log::ActiveModel as AuditLogActive; +pub use volume::ActiveModel as VolumeActive; \ No newline at end of file diff --git a/core-new/src/infrastructure/database/entities/volume.rs b/core-new/src/infrastructure/database/entities/volume.rs new file mode 100644 index 000000000..257f506be --- /dev/null +++ b/core-new/src/infrastructure/database/entities/volume.rs @@ -0,0 +1,58 @@ +//! Volume entity + +use crate::volume::types::TrackedVolume; +use sea_orm::entity::prelude::*; +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, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +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 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 new file mode 100644 index 000000000..5586881b8 --- /dev/null +++ b/core-new/src/infrastructure/database/migration/m20240103_000001_create_volumes.rs @@ -0,0 +1,121 @@ +//! Create volumes table for tracking mounted volumes in each library + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +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(Volume::Table) + .if_not_exists() + .col( + ColumnDef::new(Volume::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Volume::Uuid).text().not_null().unique_key()) + .col(ColumnDef::new(Volume::Fingerprint).text().not_null()) + .col(ColumnDef::new(Volume::DisplayName).text()) + .col( + ColumnDef::new(Volume::TrackedAt) + .timestamp() + .not_null(), + ) + .col( + ColumnDef::new(Volume::LastSeenAt) + .timestamp() + .not_null(), + ) + .col( + ColumnDef::new(Volume::IsOnline) + .boolean() + .not_null() + .default(true), + ) + .col(ColumnDef::new(Volume::TotalCapacity).big_integer()) + .col(ColumnDef::new(Volume::AvailableCapacity).big_integer()) + .col(ColumnDef::new(Volume::ReadSpeedMbps).integer()) + .col(ColumnDef::new(Volume::WriteSpeedMbps).integer()) + .col(ColumnDef::new(Volume::LastSpeedTestAt).timestamp()) + .col(ColumnDef::new(Volume::FileSystem).text()) + .col(ColumnDef::new(Volume::MountPoint).text()) + .col(ColumnDef::new(Volume::IsRemovable).boolean()) + .col(ColumnDef::new(Volume::IsNetworkDrive).boolean()) + .col(ColumnDef::new(Volume::DeviceModel).text()) + .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(Volume::Table) + .col(Volume::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(Volume::Table) + .col(Volume::LastSeenAt) + .to_owned(), + ) + .await?; + + // Create index on is_online for filtering + manager + .create_index( + Index::create() + .name("idx_volume_is_online") + .table(Volume::Table) + .col(Volume::IsOnline) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Volume::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Volume { + Table, + Id, + Uuid, + Fingerprint, + DisplayName, + TrackedAt, + LastSeenAt, + IsOnline, + TotalCapacity, + AvailableCapacity, + ReadSpeedMbps, + WriteSpeedMbps, + LastSpeedTestAt, + FileSystem, + MountPoint, + IsRemovable, + IsNetworkDrive, + DeviceModel, +} \ No newline at end of file diff --git a/core-new/src/infrastructure/database/migration/mod.rs b/core-new/src/infrastructure/database/migration/mod.rs index 24daefbec..21f5084f5 100644 --- a/core-new/src/infrastructure/database/migration/mod.rs +++ b/core-new/src/infrastructure/database/migration/mod.rs @@ -10,9 +10,11 @@ impl MigratorTrait for Migrator { 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; \ No newline at end of file +mod m20240102_000001_add_audit_log; +mod m20240103_000001_create_volumes; \ No newline at end of file diff --git a/core-new/src/library/config.rs b/core-new/src/library/config.rs index 066f7139e..cd13de4d5 100644 --- a/core-new/src/library/config.rs +++ b/core-new/src/library/config.rs @@ -58,6 +58,12 @@ pub struct LibrarySettings { /// Maximum file size to index (in bytes) pub max_file_size: Option, + + /// Whether to automatically track system volumes + pub auto_track_system_volumes: bool, + + /// Whether to automatically track external volumes when connected + pub auto_track_external_volumes: bool, } impl LibraryConfig { @@ -87,6 +93,8 @@ impl Default for LibrarySettings { ".part".to_string(), ], max_file_size: Some(100 * 1024 * 1024 * 1024), // 100GB + auto_track_system_volumes: true, // Default to true for user convenience + auto_track_external_volumes: false, // Default to false for privacy } } } diff --git a/core-new/src/library/manager.rs b/core-new/src/library/manager.rs index c78f2ff0c..6fc12bda8 100644 --- a/core-new/src/library/manager.rs +++ b/core-new/src/library/manager.rs @@ -245,6 +245,17 @@ impl LibraryManager { let mut libraries = self.libraries.write().await; libraries.insert(config.id, library.clone()); } + + // Auto-track system volumes if enabled + if config.settings.auto_track_system_volumes { + info!("Auto-tracking system volumes for library {}", config.name); + if let Err(e) = context.volume_manager + .auto_track_system_volumes(&library) + .await + { + warn!("Failed to auto-track system volumes: {}", e); + } + } // Emit event self.event_bus.emit(Event::LibraryOpened { diff --git a/core-new/src/operations/volumes/track/handler.rs b/core-new/src/operations/volumes/track/handler.rs index a6d17c5e2..ce7626694 100644 --- a/core-new/src/operations/volumes/track/handler.rs +++ b/core-new/src/operations/volumes/track/handler.rs @@ -45,12 +45,28 @@ impl ActionHandler for VolumeTrackHandler { )); } - // TODO: Implement actual volume tracking in library + // Track the volume in the database + let tracked = context + .volume_manager + .track_volume(&library, &action.fingerprint, action.name.clone()) + .await + .map_err(|e| match e { + crate::volume::VolumeError::AlreadyTracked(_) => { + ActionError::InvalidInput("Volume is already tracked in this library".to_string()) + } + crate::volume::VolumeError::NotFound(_) => { + ActionError::InvalidInput("Volume not found".to_string()) + } + crate::volume::VolumeError::Database(msg) => { + ActionError::Internal(format!("Database error: {}", msg)) + } + _ => ActionError::Internal(e.to_string()), + })?; Ok(ActionOutput::VolumeTracked { fingerprint: action.fingerprint, library_id: action.library_id, - volume_name: volume.name, + volume_name: tracked.display_name.unwrap_or(volume.name), }) } _ => Err(ActionError::InvalidActionType), diff --git a/core-new/src/operations/volumes/untrack/handler.rs b/core-new/src/operations/volumes/untrack/handler.rs index 51e8d4066..c17f55dbd 100644 --- a/core-new/src/operations/volumes/untrack/handler.rs +++ b/core-new/src/operations/volumes/untrack/handler.rs @@ -27,13 +27,26 @@ impl ActionHandler for VolumeUntrackHandler { match action { Action::VolumeUntrack { action } => { // Verify library exists - let _library = context + let library = context .library_manager .get_library(action.library_id) .await .ok_or_else(|| ActionError::InvalidInput("Library not found".to_string()))?; - // TODO: Implement actual volume untracking from library + // Untrack the volume from the database + context + .volume_manager + .untrack_volume(&library, &action.fingerprint) + .await + .map_err(|e| match e { + crate::volume::VolumeError::NotTracked(_) => { + ActionError::InvalidInput("Volume is not tracked in this library".to_string()) + } + crate::volume::VolumeError::Database(msg) => { + ActionError::Internal(format!("Database error: {}", msg)) + } + _ => ActionError::Internal(e.to_string()), + })?; Ok(ActionOutput::VolumeUntracked { fingerprint: action.fingerprint, diff --git a/core-new/src/services/mod.rs b/core-new/src/services/mod.rs index 9b1c6d1c9..7c2642965 100644 --- a/core-new/src/services/mod.rs +++ b/core-new/src/services/mod.rs @@ -13,11 +13,13 @@ pub mod device; pub mod file_sharing; pub mod location_watcher; pub mod networking; +pub mod volume_monitor; use device::DeviceService; use file_sharing::FileSharingService; use location_watcher::{LocationWatcher, LocationWatcherConfig}; use networking::NetworkingService; +use volume_monitor::{VolumeMonitorService, VolumeMonitorConfig}; /// Container for all background services pub struct Services { @@ -29,6 +31,8 @@ pub struct Services { pub device: Arc, /// Networking service for device connections pub networking: Option>, + /// Volume monitoring service + pub volume_monitor: Option>, /// Library key manager pub library_key_manager: Arc, /// Shared context for all services @@ -54,6 +58,7 @@ impl Services { file_sharing, device, networking: None, // Initialized separately when needed + volume_monitor: None, // Initialized after library manager is available library_key_manager, context, } @@ -69,6 +74,11 @@ impl Services { info!("Starting all background services"); self.location_watcher.start().await?; + + // Start volume monitor if initialized + if let Some(monitor) = &self.volume_monitor { + monitor.start().await?; + } // Networking service is already started during initialization @@ -84,6 +94,11 @@ impl Services { info!("Stopping all background services"); self.location_watcher.stop().await?; + + // Stop volume monitor if initialized + if let Some(monitor) = &self.volume_monitor { + monitor.stop().await?; + } // Stop networking service if initialized if let Some(networking) = &self.networking { @@ -137,6 +152,40 @@ impl Services { pub fn networking(&self) -> Option> { self.networking.clone() } + + /// Initialize volume monitor service + pub fn init_volume_monitor( + &mut self, + volume_manager: Arc, + library_manager: std::sync::Weak, + ) { + info!("Initializing volume monitor service"); + + let config = VolumeMonitorConfig::default(); + let volume_monitor = Arc::new(VolumeMonitorService::new( + volume_manager, + library_manager, + config, + )); + + self.volume_monitor = Some(volume_monitor); + } + + /// Start volume monitor service + pub async fn start_volume_monitor(&self) -> Result<()> { + if let Some(monitor) = &self.volume_monitor { + monitor.start().await?; + } + Ok(()) + } + + /// Stop volume monitor service + pub async fn stop_volume_monitor(&self) -> Result<()> { + if let Some(monitor) = &self.volume_monitor { + monitor.stop().await?; + } + Ok(()) + } } /// Trait for background services diff --git a/core-new/src/services/volume_monitor.rs b/core-new/src/services/volume_monitor.rs new file mode 100644 index 000000000..3452f3ab2 --- /dev/null +++ b/core-new/src/services/volume_monitor.rs @@ -0,0 +1,244 @@ +//! Volume monitoring service +//! +//! Periodically refreshes volume information and updates tracked volumes in the database. + +use crate::{ + context::CoreContext, + infrastructure::events::EventBus, + library::LibraryManager, + services::Service, + volume::VolumeManager, +}; +use anyhow::Result; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +/// Configuration for volume monitoring +#[derive(Debug, Clone)] +pub struct VolumeMonitorConfig { + /// How often to refresh volume information (in seconds) + pub refresh_interval_secs: u64, + /// Whether to update tracked volumes in the database + pub update_tracked_volumes: bool, +} + +impl Default for VolumeMonitorConfig { + fn default() -> Self { + Self { + refresh_interval_secs: 30, + update_tracked_volumes: true, + } + } +} + +/// Background service that monitors volume state changes +pub struct VolumeMonitorService { + volume_manager: Arc, + library_manager: Weak, + config: VolumeMonitorConfig, + running: RwLock, + handle: RwLock>>, +} + +impl VolumeMonitorService { + /// Create a new volume monitor service + pub fn new( + volume_manager: Arc, + library_manager: Weak, + config: VolumeMonitorConfig, + ) -> Self { + Self { + volume_manager, + library_manager, + config, + running: RwLock::new(false), + handle: RwLock::new(None), + } + } + + /// Monitor volumes and update tracked volumes in libraries + async fn monitor_loop( + volume_manager: Arc, + library_manager: Weak, + config: VolumeMonitorConfig, + running: Arc>, + ) { + let mut interval = tokio::time::interval(Duration::from_secs(config.refresh_interval_secs)); + + while *running.read().await { + interval.tick().await; + + // Refresh all volumes + if let Err(e) = volume_manager.refresh_volumes().await { + error!("Failed to refresh volumes: {}", e); + continue; + } + + // Update tracked volumes if enabled and library manager is available + if config.update_tracked_volumes { + if let Some(lib_manager) = library_manager.upgrade() { + debug!("Updating tracked volumes across libraries"); + + // Get all open libraries + let libraries = lib_manager.get_open_libraries().await; + + for library in &libraries { + // Get tracked volumes for this library + match volume_manager.get_tracked_volumes(&library).await { + Ok(tracked_volumes) => { + for tracked in tracked_volumes { + // Check if volume is still present + if let Some(current_volume) = volume_manager + .get_volume(&tracked.fingerprint) + .await + { + // Update volume state if changed + if tracked.is_online != current_volume.is_mounted { + if let Err(e) = volume_manager + .update_tracked_volume_state( + &library, + &tracked.fingerprint, + ¤t_volume, + ) + .await + { + error!( + "Failed to update tracked volume {} in library {}: {}", + tracked.fingerprint, + library.id(), + e + ); + } else { + debug!( + "Updated tracked volume {} in library {} (online: {} -> {})", + tracked.fingerprint, + library.id(), + tracked.is_online, + current_volume.is_mounted + ); + } + } + } else { + // Volume no longer detected but still tracked + debug!( + "Tracked volume {} not detected in library {}", + tracked.fingerprint, + library.id() + ); + } + } + } + Err(e) => { + error!( + "Failed to get tracked volumes for library {}: {}", + library.id(), + e + ); + } + } + } + + // Check for new external volumes to auto-track + let all_volumes = volume_manager.get_all_volumes().await; + for volume in all_volumes { + // Only consider external volumes + if matches!(volume.mount_type, crate::volume::types::MountType::External) { + for library in &libraries { + // Check if auto-tracking is enabled + let config = library.config().await; + if config.settings.auto_track_external_volumes { + // Check if not already tracked + if !volume_manager + .is_volume_tracked(&library, &volume.fingerprint) + .await + .unwrap_or(false) + { + // Auto-track the external volume + match volume_manager + .track_volume(&library, &volume.fingerprint, None) + .await + { + Ok(_) => { + info!( + "Auto-tracked external volume '{}' in library '{}'", + volume.name, + library.name().await + ); + } + Err(e) => { + debug!( + "Failed to auto-track external volume '{}': {}", + volume.name, e + ); + } + } + } + } + } + } + } + } else { + debug!("Library manager not available, skipping tracked volume updates"); + } + } + } + + info!("Volume monitoring stopped"); + } +} + +#[async_trait::async_trait] +impl Service for VolumeMonitorService { + async fn start(&self) -> Result<()> { + let mut running = self.running.write().await; + if *running { + warn!("Volume monitor service already running"); + return Ok(()); + } + + *running = true; + + let volume_manager = self.volume_manager.clone(); + let library_manager = self.library_manager.clone(); + let config = self.config.clone(); + let running_flag = Arc::new(RwLock::new(*running)); + + let handle = tokio::spawn(Self::monitor_loop( + volume_manager, + library_manager, + config, + running_flag, + )); + + *self.handle.write().await = Some(handle); + + info!( + "Volume monitor service started (refresh every {}s)", + self.config.refresh_interval_secs + ); + + Ok(()) + } + + async fn stop(&self) -> Result<()> { + *self.running.write().await = false; + + if let Some(handle) = self.handle.write().await.take() { + handle.abort(); + } + + info!("Volume monitor service stopped"); + Ok(()) + } + + fn is_running(&self) -> bool { + // Use blocking read since this is a sync method + *self.running.blocking_read() + } + + fn name(&self) -> &'static str { + "volume_monitor" + } +} \ No newline at end of file diff --git a/core-new/src/volume/error.rs b/core-new/src/volume/error.rs index eb4133e4a..cc2564ff0 100644 --- a/core-new/src/volume/error.rs +++ b/core-new/src/volume/error.rs @@ -48,6 +48,18 @@ pub enum VolumeError { /// Invalid volume data #[error("Invalid volume data: {0}")] InvalidData(String), + + /// Database operation failed + #[error("Database error: {0}")] + Database(String), + + /// Volume is already tracked + #[error("Volume is already tracked: {0}")] + AlreadyTracked(String), + + /// Volume is not tracked + #[error("Volume is not tracked: {0}")] + NotTracked(String), } impl VolumeError { diff --git a/core-new/src/volume/manager.rs b/core-new/src/volume/manager.rs index cac66185f..d7f07ca44 100644 --- a/core-new/src/volume/manager.rs +++ b/core-new/src/volume/manager.rs @@ -1,17 +1,21 @@ //! Volume Manager - Central management for all volume operations +use crate::infrastructure::database::entities; use crate::infrastructure::events::{Event, EventBus}; +use crate::library::LibraryManager; use crate::volume::{ error::{VolumeError, VolumeResult}, os_detection, - types::{Volume, VolumeDetectionConfig, VolumeFingerprint, VolumeInfo}, + types::{TrackedVolume, Volume, VolumeDetectionConfig, VolumeFingerprint, VolumeInfo}, }; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::RwLock; use tracing::{debug, error, info, instrument, warn}; +use uuid::Uuid; /// Central manager for volume detection, monitoring, and operations pub struct VolumeManager { @@ -29,6 +33,9 @@ pub struct VolumeManager { /// Whether the manager is currently running monitoring is_monitoring: Arc>, + + /// Weak reference to library manager for database operations + library_manager: RwLock>>, } impl VolumeManager { @@ -40,9 +47,15 @@ impl VolumeManager { config, events, is_monitoring: Arc::new(RwLock::new(false)), + library_manager: RwLock::new(None), } } + /// Set the library manager reference + pub async fn set_library_manager(&self, library_manager: Weak) { + *self.library_manager.write().await = Some(library_manager); + } + /// Initialize the volume manager and perform initial detection #[instrument(skip(self))] pub async fn initialize(&self) -> VolumeResult<()> { @@ -360,77 +373,110 @@ impl VolumeManager { /// Track a volume in the database pub async fn track_volume( &self, - fingerprint: &VolumeFingerprint, library: &crate::library::Library, + fingerprint: &VolumeFingerprint, display_name: Option, - ) -> VolumeResult<()> { - let volumes = self.volumes.read().await; - - if let Some(runtime_volume) = volumes.get(fingerprint) { - // Convert runtime volume to domain volume - let device_id = crate::shared::types::get_current_device_id(); - let mut domain_volume = - crate::domain::volume::Volume::from_runtime_volume(runtime_volume, device_id); - - // Track the volume for this library - domain_volume.track(Some(library.id())); - - // Set custom display name if provided - if let Some(name) = display_name { - domain_volume.set_display_preferences(Some(name), None, None); - } - - // TODO: Save to database via library context - // library_ctx.db.volume().create(domain_volume).await?; - - info!( - "Tracked volume '{}' for library '{}'", - domain_volume.display_name(), - library.name().await - ); - - // Emit tracking event - self.events - .emit(crate::infrastructure::events::Event::Custom { - event_type: "VolumeTracked".to_string(), - data: serde_json::json!({ - "fingerprint": fingerprint.to_string(), - "library_id": library.id(), - "volume_name": domain_volume.display_name(), - }), - }); - - Ok(()) - } else { - Err(VolumeError::NotFound(fingerprint.to_string())) + ) -> 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())) + .one(db) + .await + .map_err(|e| VolumeError::Database(e.to_string()))? + { + return Err(VolumeError::AlreadyTracked(fingerprint.to_string())); } + + // Get current volume info + 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); + + // Create tracking record + let active_model = entities::volume::ActiveModel { + uuid: Set(Uuid::new_v4()), + fingerprint: Set(fingerprint.0.clone()), + display_name: Set(display_name.clone()), + tracked_at: Set(chrono::Utc::now()), + last_seen_at: Set(chrono::Utc::now()), + is_online: Set(volume.is_mounted), + total_capacity: Set(Some(volume.total_bytes_capacity as i64)), + available_capacity: Set(Some(volume.total_bytes_available as i64)), + read_speed_mbps: Set(volume.read_speed_mbps.map(|s| s as i32)), + write_speed_mbps: Set(volume.write_speed_mbps.map(|s| s as i32)), + last_speed_test_at: Set(None), + file_system: Set(Some(volume.file_system.to_string())), + mount_point: Set(Some(volume.mount_point.to_string_lossy().to_string())), + is_removable: Set(Some(is_removable)), + is_network_drive: Set(Some(is_network_drive)), + 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, + }), + }); + + Ok(model) } /// Untrack a volume from the database pub async fn untrack_volume( &self, - fingerprint: &VolumeFingerprint, library: &crate::library::Library, + fingerprint: &VolumeFingerprint, ) -> VolumeResult<()> { - // TODO: Update database to mark as untracked - // library_ctx.db.volume().untrack(fingerprint).await?; - + 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(crate::infrastructure::events::Event::Custom { + .emit(Event::Custom { event_type: "VolumeUntracked".to_string(), data: serde_json::json!({ - "fingerprint": fingerprint.to_string(), "library_id": library.id(), + "volume_fingerprint": fingerprint.to_string(), }), }); - + Ok(()) } @@ -438,26 +484,130 @@ impl VolumeManager { pub async fn get_tracked_volumes( &self, library: &crate::library::Library, - ) -> VolumeResult> { - // TODO: Query database for tracked volumes - // library_ctx.db.volume().find_by_library(library.id()).await - + ) -> 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!( - "Getting tracked volumes for library '{}'", + "Found {} tracked volumes for library '{}'", + tracked_volumes.len(), library.name().await ); - Ok(Vec::new()) + + Ok(tracked_volumes) } - /// Check if a volume is tracked in any library - pub async fn is_volume_tracked(&self, fingerprint: &VolumeFingerprint) -> VolumeResult { - // TODO: Query database to check if volume is tracked - // This would check across all libraries on this device - debug!( - "Checking if volume '{}' is tracked", - fingerprint.to_string() + /// Check if a volume is tracked in a specific library + pub async fn is_volume_tracked( + &self, + library: &crate::library::Library, + fingerprint: &VolumeFingerprint, + ) -> VolumeResult { + let db = library.db().conn(); + + let count = entities::volume::Entity::find() + .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, + library: &crate::library::Library, + fingerprint: &VolumeFingerprint, + volume: &Volume, + ) -> VolumeResult<()> { + let db = library.db().conn(); + + let mut active_model: entities::volume::ActiveModel = entities::volume::Entity::find() + .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(()) + } + + /// Get all system volumes (boot/OS volumes) + pub async fn get_system_volumes(&self) -> Vec { + self.volumes + .read() + .await + .values() + .filter(|v| matches!(v.mount_type, crate::volume::types::MountType::System)) + .cloned() + .collect() + } + + /// Automatically track system volumes for a library + pub async fn auto_track_system_volumes( + &self, + library: &crate::library::Library, + ) -> 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 ); - Ok(false) + + 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 + ); + continue; + } + + // Track the system volume + match self.track_volume(library, &volume.fingerprint, None).await { + Ok(tracked) => { + info!( + "Auto-tracked system volume '{}' in library '{}'", + volume.name, + library.name().await + ); + tracked_volumes.push(tracked); + } + Err(e) => { + warn!( + "Failed to auto-track system volume '{}': {}", + volume.name, e + ); + } + } + } + + Ok(tracked_volumes) } } diff --git a/core-new/src/volume/types.rs b/core-new/src/volume/types.rs index 469a5c538..d8c70853c 100644 --- a/core-new/src/volume/types.rs +++ b/core-new/src/volume/types.rs @@ -128,6 +128,28 @@ pub struct VolumeInfo { pub error_status: Option, } +/// Information about a tracked volume in the database +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackedVolume { + pub id: i32, + pub uuid: uuid::Uuid, + pub fingerprint: VolumeFingerprint, + pub display_name: Option, + pub tracked_at: chrono::DateTime, + pub last_seen_at: chrono::DateTime, + 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, +} + impl From<&Volume> for VolumeInfo { fn from(volume: &Volume) -> Self { Self { diff --git a/core-new/tests/volume_auto_track_test.rs b/core-new/tests/volume_auto_track_test.rs new file mode 100644 index 000000000..6b5102098 --- /dev/null +++ b/core-new/tests/volume_auto_track_test.rs @@ -0,0 +1,197 @@ +//! Test automatic volume tracking functionality + +use sd_core_new::{ + context::CoreContext, + infrastructure::events::EventBus, + library::{LibraryConfig, LibraryManager, LibrarySettings}, + volume::{VolumeDetectionConfig, VolumeManager}, +}; +use std::sync::Arc; +use tempfile::TempDir; +use uuid::Uuid; + +#[tokio::test] +async fn test_auto_track_system_volumes_on_library_open() { + // Setup test environment + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path().join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); + + // Initialize volume manager + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager to detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Get system volumes before library creation + let system_volumes = volume_manager.get_system_volumes().await; + println!("Found {} system volumes", system_volumes.len()); + + // Create library manager + let library_manager = Arc::new(LibraryManager::new_with_dir( + temp_dir.path().join("libraries"), + events.clone(), + )); + + // Create core context + let context = CoreContext::test_with_volume_manager( + data_dir, + volume_manager.clone(), + ) + .await + .expect("Failed to create test context"); + let context = Arc::new(context); + + // Create a library with auto-tracking enabled (default) + let library = library_manager + .create_library("Test Library", None, context.clone()) + .await + .expect("Failed to create library"); + + // Verify system volumes were auto-tracked + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + + // Should have tracked all system volumes + assert_eq!( + tracked_volumes.len(), + system_volumes.len(), + "Should have auto-tracked all system volumes" + ); + + // Verify each system volume is tracked + for sys_vol in &system_volumes { + let is_tracked = tracked_volumes + .iter() + .any(|tv| tv.fingerprint.0 == sys_vol.fingerprint.0); + assert!( + is_tracked, + "System volume '{}' should be tracked", + sys_vol.name + ); + } +} + +#[tokio::test] +async fn test_auto_track_disabled() { + // Setup test environment + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path().join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); + + // Initialize volume manager + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Create library manager + let library_manager = Arc::new(LibraryManager::new_with_dir( + temp_dir.path().join("libraries"), + events.clone(), + )); + + // Create library path manually + let library_path = temp_dir.path().join("libraries").join("test.sdlibrary"); + std::fs::create_dir_all(&library_path).expect("Failed to create library dir"); + + // Create config with auto-tracking disabled + let mut settings = LibrarySettings::default(); + settings.auto_track_system_volumes = false; + + let config = LibraryConfig { + version: 1, + id: Uuid::new_v4(), + name: "Test Library".to_string(), + description: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + settings, + statistics: sd_core_new::library::LibraryStatistics::default(), + }; + + // Save config + let config_json = serde_json::to_string_pretty(&config).expect("Failed to serialize config"); + std::fs::write(library_path.join("library.json"), config_json).expect("Failed to write config"); + + // Create database + let db_path = library_path.join("database.db"); + let db = sd_core_new::infrastructure::database::Database::create(&db_path) + .await + .expect("Failed to create database"); + db.migrate().await.expect("Failed to run migrations"); + + // Create context + let context = CoreContext::test_with_volume_manager( + data_dir, + volume_manager.clone(), + ) + .await + .expect("Failed to create test context"); + let context = Arc::new(context); + + // Open library with auto-tracking disabled + let library = library_manager + .open_library_with_context(&library_path, context.clone()) + .await + .expect("Failed to open library"); + + // Verify no volumes were auto-tracked + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + + assert_eq!( + tracked_volumes.len(), + 0, + "Should not have auto-tracked any volumes" + ); +} + +#[tokio::test] +async fn test_system_volume_properties() { + // Initialize volume manager + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize to detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Get system volumes + let system_volumes = volume_manager.get_system_volumes().await; + + // Verify system volume properties + for volume in system_volumes { + // System volumes should be mounted + assert!(volume.is_mounted, "System volume should be mounted"); + + // System volumes should have capacity info + assert!(volume.total_bytes_capacity > 0, "System volume should have capacity"); + + // System volumes typically have specific names + println!( + "System volume: {} ({}), FS: {}, Capacity: {} GB", + volume.name, + volume.mount_point.display(), + volume.file_system, + volume.total_bytes_capacity / (1024 * 1024 * 1024) + ); + } +} \ No newline at end of file diff --git a/core-new/tests/volume_test_simple.rs b/core-new/tests/volume_test_simple.rs index 4303de913..48cefaa90 100644 --- a/core-new/tests/volume_test_simple.rs +++ b/core-new/tests/volume_test_simple.rs @@ -1,185 +1,151 @@ -//! Simple volume test that works with the existing system -//! -//! This test verifies basic volume functionality without requiring -//! the full action system integration. +//! Simple integration test for volume tracking use sd_core_new::{ - Core, - volume::VolumeExt, + infrastructure::database::entities, + library::LibraryConfig, + volume::{VolumeDetectionConfig, VolumeFingerprint, VolumeManager}, }; use std::sync::Arc; -use tempfile::tempdir; -use tracing::{info, warn}; - -const TEST_VOLUME_NAME: &str = "TestVolume"; +use tempfile::TempDir; +use uuid::Uuid; #[tokio::test] -async fn test_volume_detection_and_tracking() { - // Initialize logging - let _ = tracing_subscriber::fmt::try_init(); - - // Create test data directory - let data_dir = tempdir().unwrap(); - let data_path = data_dir.path().to_path_buf(); - - // Initialize core - let core = Arc::new( - Core::new_with_config(data_path.clone()) - .await - .expect("Failed to create core"), - ); - - // Get volume manager - let volume_manager = core.volumes.clone(); - - // Refresh volumes to ensure we have the latest - volume_manager - .refresh_volumes() - .await - .expect("Failed to refresh volumes"); - - // Get all volumes - let all_volumes = volume_manager.get_all_volumes().await; +async fn test_volume_tracking_basic() { + // Create temp directory for library + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let library_path = temp_dir.path().join("test.sdlibrary"); - info!("Detected {} volumes:", all_volumes.len()); - for volume in &all_volumes { - info!( - " - {} ({}) at {} - {} {} [{}]", + // Create library config + let config = LibraryConfig { + id: Uuid::new_v4(), + name: "Test Library".to_string(), + description: Some("Test library for volume tracking".to_string()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + settings: sd_core_new::library::LibrarySettings::default(), + statistics: sd_core_new::library::LibraryStatistics::default(), + }; + + // Save config + std::fs::create_dir_all(&library_path).expect("Failed to create library dir"); + let config_path = library_path.join("library.json"); + let config_json = serde_json::to_string_pretty(&config).expect("Failed to serialize config"); + std::fs::write(config_path, config_json).expect("Failed to write config"); + + // Create events and volume manager + let events = Arc::new(sd_core_new::infrastructure::events::EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager to detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Stop monitoring to avoid background tasks + volume_manager.stop_monitoring().await; + + // Get all volumes + let volumes = volume_manager.get_all_volumes().await; + println!("Detected {} volumes", volumes.len()); + + // Print volume information + for volume in &volumes { + println!( + "Volume: {} ({}), Mounted: {}, Capacity: {} GB", volume.name, volume.fingerprint, - volume.mount_point.display(), - volume.file_system, - volume.disk_type, - if volume.is_mounted { "mounted" } else { "unmounted" } + volume.is_mounted, + volume.total_bytes_capacity / (1024 * 1024 * 1024) ); } - - // Find TestVolume if it exists - let test_volume = all_volumes - .iter() - .find(|v| v.name == TEST_VOLUME_NAME) - .cloned(); - - if let Some(test_volume) = test_volume { - info!("Found TestVolume!"); - - // Test volume properties - assert!(test_volume.is_mounted, "TestVolume should be mounted"); - assert!( - test_volume.is_available().await, - "TestVolume should be accessible" - ); - - // Test volume fingerprint - let fingerprint = &test_volume.fingerprint; - info!("TestVolume fingerprint: {}", fingerprint); - - // Test volume retrieval by fingerprint - let retrieved_volume = volume_manager - .get_volume(&fingerprint) - .await - .expect("Should be able to retrieve volume by fingerprint"); - - assert_eq!(retrieved_volume.name, TEST_VOLUME_NAME); - assert_eq!(retrieved_volume.fingerprint, test_volume.fingerprint); - - // Test path containment - let test_path = test_volume.mount_point.join("test_file.txt"); - assert!( - test_volume.contains_path(&test_path), - "Volume should contain paths under its mount point" - ); - - // Test volume for path - let volume_for_path = volume_manager - .volume_for_path(&test_volume.mount_point) - .await - .expect("Should find volume for its mount point"); - - assert_eq!(volume_for_path.fingerprint, test_volume.fingerprint); - - info!("All TestVolume tests passed!"); - } else { - warn!( - "TestVolume not found. Available volumes: {:?}", - all_volumes.iter().map(|v| &v.name).collect::>() - ); - println!("SKIPPING TEST: TestVolume not mounted on system"); + + // If we have at least one volume, test fingerprint parsing + if let Some(first_volume) = volumes.first() { + let fingerprint_str = first_volume.fingerprint.to_string(); + let parsed = VolumeFingerprint::from_string(&fingerprint_str) + .expect("Failed to parse fingerprint"); + assert_eq!(parsed.0, fingerprint_str); } - - // Test volume statistics - let stats = volume_manager.get_statistics().await; - - info!("Volume statistics:"); - info!(" Total volumes: {}", stats.total_volumes); - info!(" Mounted volumes: {}", stats.mounted_volumes); - info!(" Total capacity: {} TB", stats.total_capacity / (1024 * 1024 * 1024 * 1024)); - info!(" Total available: {} TB", stats.total_available / (1024 * 1024 * 1024 * 1024)); - - assert_eq!(stats.total_volumes, all_volumes.len()); - assert!(stats.mounted_volumes <= stats.total_volumes); - assert!(stats.total_available <= stats.total_capacity); - - // Clean up - let _ = core.services.stop_all().await; - - info!("Volume test completed successfully"); } #[tokio::test] -async fn test_volume_speed_test() { - // Initialize logging - let _ = tracing_subscriber::fmt::try_init(); - - // Create test data directory - let data_dir = tempdir().unwrap(); - let data_path = data_dir.path().to_path_buf(); - - // Initialize core - let core = Arc::new( - Core::new_with_config(data_path) - .await - .expect("Failed to create core"), - ); - - let volume_manager = core.volumes.clone(); +async fn test_volume_fingerprint_operations() { + // Test fingerprint creation from hex + let hex_string = "abcdef1234567890"; + let fingerprint = VolumeFingerprint::from_hex(hex_string); + assert_eq!(fingerprint.0, hex_string); + assert_eq!(fingerprint.to_string(), hex_string); - // Find a writable volume - let all_volumes = volume_manager.get_all_volumes().await; - let writable_volume = all_volumes - .into_iter() - .find(|v| v.is_mounted && !v.read_only); + // Test fingerprint equality + let fp1 = VolumeFingerprint::from_hex("test123"); + let fp2 = VolumeFingerprint::from_hex("test123"); + let fp3 = VolumeFingerprint::from_hex("test456"); - if let Some(volume) = writable_volume { - info!("Testing speed on volume: {}", volume.name); - - let fingerprint = volume.fingerprint.clone(); - - // Run speed test - match volume_manager.run_speed_test(&fingerprint).await { - Ok(()) => { - // Get updated volume to check results - let updated_volume = volume_manager - .get_volume(&fingerprint) - .await - .expect("Volume should still exist"); - - if let (Some(read_speed), Some(write_speed)) = - (updated_volume.read_speed_mbps, updated_volume.write_speed_mbps) { - info!("Speed test results: {} MB/s read, {} MB/s write", read_speed, write_speed); - assert!(read_speed > 0, "Read speed should be positive"); - assert!(write_speed > 0, "Write speed should be positive"); - } else { - warn!("Speed test completed but no results stored"); - } - } - Err(e) => { - warn!("Speed test failed (this is okay in CI): {}", e); - } - } - } else { - warn!("No writable volume found for speed test"); - } + assert_eq!(fp1, fp2); + assert_ne!(fp1, fp3); +} - let _ = core.services.stop_all().await; +#[tokio::test] +async fn test_volume_entity_conversion() { + use chrono::Utc; + + // Create a volume entity model + let model = entities::volume::Model { + id: 1, + uuid: Uuid::new_v4(), + fingerprint: "test_fingerprint_123".to_string(), + display_name: Some("Test Volume".to_string()), + tracked_at: Utc::now(), + last_seen_at: Utc::now(), + is_online: true, + total_capacity: Some(1000000000), + available_capacity: Some(500000000), + read_speed_mbps: Some(100), + write_speed_mbps: Some(80), + last_speed_test_at: None, + file_system: Some("APFS".to_string()), + mount_point: Some("/Volumes/Test".to_string()), + is_removable: Some(false), + is_network_drive: Some(false), + device_model: Some("Samsung SSD".to_string()), + }; + + // Convert to tracked volume + let tracked = model.to_tracked_volume(); + + // Verify conversion + assert_eq!(tracked.id, model.id); + assert_eq!(tracked.uuid, model.uuid); + assert_eq!(tracked.fingerprint.0, model.fingerprint); + assert_eq!(tracked.display_name, model.display_name); + assert_eq!(tracked.is_online, model.is_online); + assert_eq!(tracked.total_capacity, Some(1000000000)); + assert_eq!(tracked.available_capacity, Some(500000000)); + assert_eq!(tracked.read_speed_mbps, Some(100)); + assert_eq!(tracked.write_speed_mbps, Some(80)); + assert_eq!(tracked.file_system, model.file_system); + assert_eq!(tracked.mount_point, model.mount_point); + assert_eq!(tracked.is_removable, model.is_removable); + assert_eq!(tracked.is_network_drive, model.is_network_drive); + assert_eq!(tracked.device_model, model.device_model); +} + +#[test] +fn test_volume_error_types() { + use sd_core_new::volume::VolumeError; + + // Test error creation and display + let db_error = VolumeError::Database("Connection failed".to_string()); + assert_eq!(db_error.to_string(), "Database error: Connection failed"); + + let already_tracked = VolumeError::AlreadyTracked("vol123".to_string()); + assert_eq!(already_tracked.to_string(), "Volume is already tracked: vol123"); + + let not_tracked = VolumeError::NotTracked("vol456".to_string()); + assert_eq!(not_tracked.to_string(), "Volume is not tracked: vol456"); + + let not_found = VolumeError::NotFound("vol789".to_string()); + assert_eq!(not_found.to_string(), "Volume not found: vol789"); } \ No newline at end of file diff --git a/core-new/tests/volume_tracking_test.rs b/core-new/tests/volume_tracking_test.rs new file mode 100644 index 000000000..bc9a99fca --- /dev/null +++ b/core-new/tests/volume_tracking_test.rs @@ -0,0 +1,335 @@ +//! Integration tests for volume tracking functionality + +use core_new::{ + context::CoreContext, + infrastructure::{ + actions::{Action, manager::ActionManager}, + database::Database, + events::EventBus, + }, + library::{Library, LibraryConfig, LibraryManager}, + operations::volumes::{ + track::action::VolumeTrackAction, + untrack::action::VolumeUntrackAction, + }, + volume::{VolumeDetectionConfig, VolumeFingerprint, VolumeManager}, +}; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use uuid::Uuid; + +/// Helper to create a test library +async fn create_test_library( + context: Arc, + temp_dir: &TempDir, +) -> Arc { + let library_path = temp_dir.path().join("test_library.sdlibrary"); + let config = LibraryConfig { + id: Uuid::new_v4(), + name: "Test Library".to_string(), + description: Some("Test library for volume tracking".to_string()), + ..Default::default() + }; + + context + .library_manager + .create_library(library_path, config) + .await + .expect("Failed to create test library") +} + +/// Helper to get first available volume for testing +async fn get_test_volume(volume_manager: &Arc) -> Option<(VolumeFingerprint, String)> { + let volumes = volume_manager.get_all_volumes().await; + volumes.first().map(|v| (v.fingerprint.clone(), v.name.clone())) +} + +#[tokio::test] +async fn test_volume_tracking_lifecycle() { + // Setup test environment + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path().join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); + + // Initialize core context + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Create test context with volume manager + let context = CoreContext::test_with_volume_manager(data_dir, volume_manager.clone()) + .await + .expect("Failed to create test context"); + let context = Arc::new(context); + + // Create test library + let library = create_test_library(context.clone(), &temp_dir).await; + + // Get a test volume + let (fingerprint, volume_name) = get_test_volume(&volume_manager) + .await + .expect("No volumes available for testing"); + + // Test 1: Track volume + { + let track_action = VolumeTrackAction { + library_id: library.id(), + fingerprint: fingerprint.clone(), + name: Some("My Test Volume".to_string()), + }; + + let action = Action::VolumeTrack { action: track_action }; + let result = context.action_manager.execute(action).await; + + assert!(result.is_ok(), "Failed to track volume: {:?}", result); + + // Verify volume is tracked + let is_tracked = volume_manager + .is_volume_tracked(&library, &fingerprint) + .await + .expect("Failed to check tracking status"); + assert!(is_tracked, "Volume should be tracked"); + + // Get tracked volumes + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + assert_eq!(tracked_volumes.len(), 1, "Should have one tracked volume"); + assert_eq!(tracked_volumes[0].fingerprint, fingerprint); + assert_eq!( + tracked_volumes[0].display_name, + Some("My Test Volume".to_string()) + ); + } + + // Test 2: Try to track same volume again (should fail) + { + let track_action = VolumeTrackAction { + library_id: library.id(), + fingerprint: fingerprint.clone(), + name: Some("Another Name".to_string()), + }; + + let action = Action::VolumeTrack { action: track_action }; + let result = context.action_manager.execute(action).await; + + assert!(result.is_err(), "Should not be able to track volume twice"); + } + + // Test 3: Untrack volume + { + let untrack_action = VolumeUntrackAction { + library_id: library.id(), + fingerprint: fingerprint.clone(), + }; + + let action = Action::VolumeUntrack { action: untrack_action }; + let result = context.action_manager.execute(action).await; + + assert!(result.is_ok(), "Failed to untrack volume: {:?}", result); + + // Verify volume is no longer tracked + let is_tracked = volume_manager + .is_volume_tracked(&library, &fingerprint) + .await + .expect("Failed to check tracking status"); + assert!(!is_tracked, "Volume should not be tracked"); + + // Get tracked volumes (should be empty) + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + assert_eq!(tracked_volumes.len(), 0, "Should have no tracked volumes"); + } + + // Test 4: Try to untrack non-tracked volume (should fail) + { + let untrack_action = VolumeUntrackAction { + library_id: library.id(), + fingerprint: fingerprint.clone(), + }; + + let action = Action::VolumeUntrack { action: untrack_action }; + let result = context.action_manager.execute(action).await; + + assert!(result.is_err(), "Should not be able to untrack non-tracked volume"); + } +} + +#[tokio::test] +async fn test_volume_state_updates() { + // Setup test environment + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path().join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); + + // Initialize core context + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Create test context + let context = CoreContext::test_with_volume_manager(data_dir, volume_manager.clone()) + .await + .expect("Failed to create test context"); + let context = Arc::new(context); + + // Create test library + let library = create_test_library(context.clone(), &temp_dir).await; + + // Get a test volume + let (fingerprint, _) = get_test_volume(&volume_manager) + .await + .expect("No volumes available for testing"); + + // Track volume + let track_action = VolumeTrackAction { + library_id: library.id(), + fingerprint: fingerprint.clone(), + name: None, + }; + + let action = Action::VolumeTrack { action: track_action }; + context + .action_manager + .execute(action) + .await + .expect("Failed to track volume"); + + // Get initial state + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + let initial_state = tracked_volumes[0].clone(); + + // Update volume state (simulate volume change) + if let Some(current_volume) = volume_manager.get_volume(&fingerprint).await { + volume_manager + .update_tracked_volume_state(&library, &fingerprint, ¤t_volume) + .await + .expect("Failed to update volume state"); + + // Get updated state + let tracked_volumes = volume_manager + .get_tracked_volumes(&library) + .await + .expect("Failed to get tracked volumes"); + let updated_state = tracked_volumes[0].clone(); + + // Verify state was updated + assert!(updated_state.last_seen_at > initial_state.last_seen_at); + } +} + +#[tokio::test] +async fn test_multiple_libraries_tracking_same_volume() { + // Setup test environment + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path().join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); + + // Initialize core context + let events = Arc::new(EventBus::default()); + let volume_config = VolumeDetectionConfig::default(); + let volume_manager = Arc::new(VolumeManager::new(volume_config, events.clone())); + + // Initialize volume manager + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Create test context + let context = CoreContext::test_with_volume_manager(data_dir, volume_manager.clone()) + .await + .expect("Failed to create test context"); + let context = Arc::new(context); + + // Create two test libraries + let library1 = create_test_library(context.clone(), &temp_dir).await; + let library2 = create_test_library(context.clone(), &temp_dir).await; + + // Get a test volume + let (fingerprint, _) = get_test_volume(&volume_manager) + .await + .expect("No volumes available for testing"); + + // Track volume in library 1 + let track_action1 = VolumeTrackAction { + library_id: library1.id(), + fingerprint: fingerprint.clone(), + name: Some("Library 1 Volume".to_string()), + }; + + let action1 = Action::VolumeTrack { action: track_action1 }; + context + .action_manager + .execute(action1) + .await + .expect("Failed to track volume in library 1"); + + // Track same volume in library 2 (should work) + let track_action2 = VolumeTrackAction { + library_id: library2.id(), + fingerprint: fingerprint.clone(), + name: Some("Library 2 Volume".to_string()), + }; + + let action2 = Action::VolumeTrack { action: track_action2 }; + context + .action_manager + .execute(action2) + .await + .expect("Failed to track volume in library 2"); + + // Verify both libraries have the volume tracked + let lib1_volumes = volume_manager + .get_tracked_volumes(&library1) + .await + .expect("Failed to get library 1 volumes"); + assert_eq!(lib1_volumes.len(), 1); + assert_eq!(lib1_volumes[0].display_name, Some("Library 1 Volume".to_string())); + + let lib2_volumes = volume_manager + .get_tracked_volumes(&library2) + .await + .expect("Failed to get library 2 volumes"); + assert_eq!(lib2_volumes.len(), 1); + assert_eq!(lib2_volumes[0].display_name, Some("Library 2 Volume".to_string())); + + // Untrack from library 1 + let untrack_action = VolumeUntrackAction { + library_id: library1.id(), + fingerprint: fingerprint.clone(), + }; + + let action = Action::VolumeUntrack { action: untrack_action }; + context + .action_manager + .execute(action) + .await + .expect("Failed to untrack from library 1"); + + // Verify library 2 still has it tracked + let lib2_volumes = volume_manager + .get_tracked_volumes(&library2) + .await + .expect("Failed to get library 2 volumes"); + assert_eq!(lib2_volumes.len(), 1); +} \ No newline at end of file