feat(volumes): Implement persistent volume tracking with automatic system volume detection

- Add database migration for volumes table with comprehensive schema
- Create SeaORM entity for volume persistence
- Implement VolumeManager database operations:
  - track_volume() - persists volume tracking in library database
  - untrack_volume() - removes volume tracking
  - get_tracked_volumes() - retrieves all tracked volumes
  - auto_track_system_volumes() - automatically tracks system/boot volumes
- Update volume action handlers to use database operations
- Add VolumeMonitorService for background volume state updates
- Add automatic system volume tracking on library creation/open
- Add configuration options:
  - auto_track_system_volumes (default: true) - tracks system drives automatically
  - auto_track_external_volumes (default: false) - tracks external drives when connected
- Add comprehensive integration tests for volume tracking lifecycle

System volumes like "Macintosh HD" are now automatically tracked when libraries
are created or opened, providing a seamless user experience.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jamie Pine
2025-07-24 02:40:15 -04:00
parent 55fdaacfdd
commit f087320ceb
16 changed files with 1444 additions and 237 deletions

View File

@@ -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;
pub use audit_log::ActiveModel as AuditLogActive;
pub use volume::ActiveModel as VolumeActive;

View File

@@ -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<String>,
pub tracked_at: DateTimeUtc,
pub last_seen_at: DateTimeUtc,
pub is_online: bool,
pub total_capacity: Option<i64>,
pub available_capacity: Option<i64>,
pub read_speed_mbps: Option<i32>,
pub write_speed_mbps: Option<i32>,
pub last_speed_test_at: Option<DateTimeUtc>,
pub file_system: Option<String>,
pub mount_point: Option<String>,
pub is_removable: Option<bool>,
pub is_network_drive: Option<bool>,
pub device_model: Option<String>,
}
#[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(),
}
}
}

View File

@@ -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,
}

View File

@@ -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;
mod m20240102_000001_add_audit_log;
mod m20240103_000001_create_volumes;

View File

@@ -58,6 +58,12 @@ pub struct LibrarySettings {
/// Maximum file size to index (in bytes)
pub max_file_size: Option<u64>,
/// 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
}
}
}

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<DeviceService>,
/// Networking service for device connections
pub networking: Option<Arc<NetworkingService>>,
/// Volume monitoring service
pub volume_monitor: Option<Arc<VolumeMonitorService>>,
/// Library key manager
pub library_key_manager: Arc<LibraryKeyManager>,
/// 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<Arc<NetworkingService>> {
self.networking.clone()
}
/// Initialize volume monitor service
pub fn init_volume_monitor(
&mut self,
volume_manager: Arc<crate::volume::VolumeManager>,
library_manager: std::sync::Weak<crate::library::LibraryManager>,
) {
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

View File

@@ -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<VolumeManager>,
library_manager: Weak<LibraryManager>,
config: VolumeMonitorConfig,
running: RwLock<bool>,
handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
}
impl VolumeMonitorService {
/// Create a new volume monitor service
pub fn new(
volume_manager: Arc<VolumeManager>,
library_manager: Weak<LibraryManager>,
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<VolumeManager>,
library_manager: Weak<LibraryManager>,
config: VolumeMonitorConfig,
running: Arc<RwLock<bool>>,
) {
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,
&current_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"
}
}

View File

@@ -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 {

View File

@@ -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<RwLock<bool>>,
/// Weak reference to library manager for database operations
library_manager: RwLock<Option<Weak<LibraryManager>>>,
}
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<LibraryManager>) {
*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<String>,
) -> 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<entities::volume::Model> {
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<Vec<crate::domain::volume::Volume>> {
// TODO: Query database for tracked volumes
// library_ctx.db.volume().find_by_library(library.id()).await
) -> VolumeResult<Vec<TrackedVolume>> {
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<TrackedVolume> = 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<bool> {
// 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<bool> {
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<Volume> {
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<Vec<entities::volume::Model>> {
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)
}
}

View File

@@ -128,6 +128,28 @@ pub struct VolumeInfo {
pub error_status: Option<String>,
}
/// 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<String>,
pub tracked_at: chrono::DateTime<chrono::Utc>,
pub last_seen_at: chrono::DateTime<chrono::Utc>,
pub is_online: bool,
pub total_capacity: Option<u64>,
pub available_capacity: Option<u64>,
pub read_speed_mbps: Option<u32>,
pub write_speed_mbps: Option<u32>,
pub last_speed_test_at: Option<chrono::DateTime<chrono::Utc>>,
pub file_system: Option<String>,
pub mount_point: Option<String>,
pub is_removable: Option<bool>,
pub is_network_drive: Option<bool>,
pub device_model: Option<String>,
}
impl From<&Volume> for VolumeInfo {
fn from(volume: &Volume) -> Self {
Self {

View File

@@ -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)
);
}
}

View File

@@ -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::<Vec<_>>()
);
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");
}

View File

@@ -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<CoreContext>,
temp_dir: &TempDir,
) -> Arc<Library> {
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<VolumeManager>) -> 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, &current_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);
}