mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
feat(volume): Introduce volume classification system for improved UX and management
- Added a new design document outlining the volume classification strategy to enhance user experience by filtering out non-user-relevant volumes. - Implemented a classification system that categorizes volumes into types (Primary, UserData, External, etc.) based on platform-specific logic. - Updated VolumeManager to auto-track only user-relevant volumes, reducing clutter and improving performance. - Enhanced CLI commands to support filtering and displaying volume types, allowing users to customize their view of volumes. - Modified database schema to include new fields for volume classification, visibility, and auto-tracking eligibility. - Introduced comprehensive tests for the new classification logic and volume management features. This update significantly improves the volume management experience by providing a cleaner interface and more relevant information to users.
This commit is contained in:
848
core-new/docs/design/VOLUME_CLASSIFICATION_DESIGN.md
Normal file
848
core-new/docs/design/VOLUME_CLASSIFICATION_DESIGN.md
Normal file
@@ -0,0 +1,848 @@
|
||||
# Volume Classification and UX Enhancement Design
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** Spacedrive Team
|
||||
**Date:** 2025-01-26
|
||||
**Version:** 1.0
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Problem Statement](#problem-statement)
|
||||
2. [Goals](#goals)
|
||||
3. [Non-Goals](#non-goals)
|
||||
4. [Background](#background)
|
||||
5. [Design Overview](#design-overview)
|
||||
6. [Detailed Design](#detailed-design)
|
||||
7. [Implementation Plan](#implementation-plan)
|
||||
8. [Platform Considerations](#platform-considerations)
|
||||
9. [Migration Strategy](#migration-strategy)
|
||||
10. [Testing Strategy](#testing-strategy)
|
||||
11. [Security Considerations](#security-considerations)
|
||||
12. [Alternatives Considered](#alternatives-considered)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, Spacedrive auto-tracks all detected system volumes, leading to several UX issues:
|
||||
|
||||
### Current Problems
|
||||
|
||||
1. **Visual Clutter**: Users see system-internal volumes (VM, Preboot, Update, Hardware) that aren't relevant for file management
|
||||
2. **Cognitive Overhead**: 13+ volumes displayed when only 3-4 are user-relevant
|
||||
3. **Storage Confusion**: System volumes show capacity/usage that doesn't reflect user storage
|
||||
4. **Auto-tracking Noise**: System volumes are automatically tracked, creating database bloat
|
||||
5. **Cross-platform Inconsistency**: No unified approach to volume relevance across macOS, Windows, Linux
|
||||
|
||||
### User Impact
|
||||
|
||||
- **File Manager UX**: Users expect to see only their actual storage devices (like Finder, Explorer)
|
||||
- **Storage Management**: Difficulty identifying which volumes contain their files
|
||||
- **Performance**: Unnecessary indexing and tracking of system volumes
|
||||
- **Confusion**: Technical mount points exposed to end users
|
||||
|
||||
## Goals
|
||||
|
||||
### Primary Goals
|
||||
|
||||
1. **Clean UX**: Show only user-relevant volumes by default
|
||||
2. **Smart Auto-tracking**: Only auto-track volumes that contain user data
|
||||
3. **Platform Awareness**: Understand OS-specific volume hierarchies
|
||||
4. **Flexibility**: Allow power users to see/manage system volumes when needed
|
||||
5. **Backwards Compatibility**: Don't break existing tracked volumes
|
||||
|
||||
### Secondary Goals
|
||||
|
||||
1. **Performance**: Reduce database size by not tracking system volumes
|
||||
2. **Consistency**: Unified volume classification across platforms
|
||||
3. **Extensibility**: Framework for future volume type additions
|
||||
4. **User Control**: Preferences for volume display and tracking behavior
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. **File System Analysis**: Not analyzing directory contents to classify volumes
|
||||
2. **Dynamic Reclassification**: Volume types are determined at detection time
|
||||
3. **Custom User Categories**: Not supporting user-defined volume types in v1
|
||||
4. **Volume Merging**: Not combining related volumes into single entities
|
||||
|
||||
## Background
|
||||
|
||||
### Current Architecture
|
||||
|
||||
```rust
|
||||
// Current Volume struct (simplified)
|
||||
pub struct Volume {
|
||||
pub fingerprint: VolumeFingerprint,
|
||||
pub name: String,
|
||||
pub mount_point: PathBuf,
|
||||
pub mount_type: MountType, // System, External, Network
|
||||
pub is_mounted: bool,
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
// Current auto-tracking (tracks all system volumes)
|
||||
pub async fn auto_track_system_volumes(&self, library: &Library) -> VolumeResult<Vec<Model>> {
|
||||
let system_volumes = self.get_system_volumes().await; // All MountType::System
|
||||
for volume in system_volumes {
|
||||
self.track_volume(library, &volume.fingerprint, Some(volume.name.clone())).await?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Volume Hierarchies
|
||||
|
||||
**macOS (APFS Container Model)**
|
||||
|
||||
```
|
||||
/ (Macintosh HD) - Primary system drive
|
||||
├── /System/Volumes/Data - User data (separate volume)
|
||||
├── /System/Volumes/VM - Virtual memory
|
||||
├── /System/Volumes/Preboot - Boot support
|
||||
├── /System/Volumes/Update - System updates
|
||||
├── /System/Volumes/Hardware - Hardware support
|
||||
└── /Volumes/* - External/user drives
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```
|
||||
C:\ - Primary system + user data
|
||||
D:\, E:\, etc. - Secondary drives
|
||||
Recovery partitions - System recovery
|
||||
EFI System Partition - Boot system
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```
|
||||
/ - Root filesystem
|
||||
/home - User data (often separate partition)
|
||||
/boot - Boot partition
|
||||
/proc, /sys, /dev - Virtual filesystems
|
||||
/media/*, /mnt/* - Removable/external media
|
||||
```
|
||||
|
||||
## Design Overview
|
||||
|
||||
### Core Concept: Volume Type Classification
|
||||
|
||||
Replace the simple `MountType` enum with a more sophisticated `VolumeType` that captures user intent and OS semantics.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VolumeType {
|
||||
Primary, // Main system drive with user data
|
||||
UserData, // Dedicated user data volumes
|
||||
External, // Removable/external storage
|
||||
Secondary, // Additional internal storage
|
||||
System, // OS internal volumes (hidden by default)
|
||||
Network, // Network attached storage
|
||||
Unknown, // Fallback for unclassified
|
||||
}
|
||||
```
|
||||
|
||||
### Classification Pipeline
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Volume Detection] --> B[Platform Classifier]
|
||||
B --> C[Mount Point Analysis]
|
||||
C --> D[Filesystem Type Check]
|
||||
D --> E[Hardware Detection]
|
||||
E --> F[VolumeType Assignment]
|
||||
F --> G[Auto-tracking Decision]
|
||||
F --> H[UI Display Decision]
|
||||
```
|
||||
|
||||
### UX Improvements
|
||||
|
||||
1. **Default View**: Show only `Primary`, `UserData`, `External`, `Secondary`, `Network`
|
||||
2. **System View**: Optional flag to show `System` volumes
|
||||
3. **Auto-tracking**: Only track non-`System` volumes by default
|
||||
4. **Visual Indicators**: Clear type indicators in CLI/UI
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### 1. Core Type Definitions
|
||||
|
||||
```rust
|
||||
// src/volume/types.rs
|
||||
|
||||
/// Classification of volume types for UX and auto-tracking decisions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VolumeType {
|
||||
/// Primary system drive containing OS and user data
|
||||
/// Examples: C:\ on Windows, / on Linux, Macintosh HD on macOS
|
||||
Primary,
|
||||
|
||||
/// Dedicated user data volumes (separate from OS)
|
||||
/// Examples: /System/Volumes/Data on macOS, separate /home on Linux
|
||||
UserData,
|
||||
|
||||
/// External or removable storage devices
|
||||
/// Examples: USB drives, external HDDs, /Volumes/* on macOS
|
||||
External,
|
||||
|
||||
/// Secondary internal storage (additional drives/partitions)
|
||||
/// Examples: D:, E: drives on Windows, additional mounted drives
|
||||
Secondary,
|
||||
|
||||
/// System/OS internal volumes (hidden from normal view)
|
||||
/// Examples: /System/Volumes/* on macOS, Recovery partitions
|
||||
System,
|
||||
|
||||
/// Network attached storage
|
||||
/// Examples: SMB mounts, NFS, cloud storage
|
||||
Network,
|
||||
|
||||
/// Unknown or unclassified volumes
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl VolumeType {
|
||||
/// Should this volume type be auto-tracked by default?
|
||||
pub fn auto_track_by_default(&self) -> bool {
|
||||
match self {
|
||||
VolumeType::Primary | VolumeType::UserData
|
||||
| VolumeType::External | VolumeType::Secondary
|
||||
| VolumeType::Network => true,
|
||||
VolumeType::System | VolumeType::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Should this volume be shown in the default UI view?
|
||||
pub fn show_by_default(&self) -> bool {
|
||||
!matches!(self, VolumeType::System | VolumeType::Unknown)
|
||||
}
|
||||
|
||||
/// User-friendly display name for the volume type
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
VolumeType::Primary => "Primary Drive",
|
||||
VolumeType::UserData => "User Data",
|
||||
VolumeType::External => "External Drive",
|
||||
VolumeType::Secondary => "Secondary Drive",
|
||||
VolumeType::System => "System Volume",
|
||||
VolumeType::Network => "Network Drive",
|
||||
VolumeType::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon/indicator for CLI display
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
VolumeType::Primary => "[PRI]",
|
||||
VolumeType::UserData => "[USR]",
|
||||
VolumeType::External => "[EXT]",
|
||||
VolumeType::Secondary => "[SEC]",
|
||||
VolumeType::System => "[SYS]",
|
||||
VolumeType::Network => "[NET]",
|
||||
VolumeType::Unknown => "[UNK]",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced volume information with classification
|
||||
pub struct Volume {
|
||||
// ... existing fields ...
|
||||
|
||||
/// Classification of this volume for UX decisions
|
||||
pub volume_type: VolumeType,
|
||||
|
||||
/// Whether this volume should be visible in default views
|
||||
pub is_user_visible: bool,
|
||||
|
||||
/// Whether this volume should be auto-tracked
|
||||
pub auto_track_eligible: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Platform-Specific Classification
|
||||
|
||||
```rust
|
||||
// src/volume/classification.rs
|
||||
|
||||
pub trait VolumeClassifier {
|
||||
fn classify(&self, volume_info: &VolumeDetectionInfo) -> VolumeType;
|
||||
}
|
||||
|
||||
pub struct MacOSClassifier;
|
||||
impl VolumeClassifier for MacOSClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Primary system drive
|
||||
"/" => VolumeType::Primary,
|
||||
|
||||
// User data volume (modern macOS separates this)
|
||||
path if path.starts_with("/System/Volumes/Data") => VolumeType::UserData,
|
||||
|
||||
// System internal volumes
|
||||
path if path.starts_with("/System/Volumes/") => VolumeType::System,
|
||||
|
||||
// External drives
|
||||
path if path.starts_with("/Volumes/") => {
|
||||
if info.is_removable.unwrap_or(false) {
|
||||
VolumeType::External
|
||||
} else {
|
||||
// Could be user-created APFS volume
|
||||
VolumeType::Secondary
|
||||
}
|
||||
},
|
||||
|
||||
// Network mounts
|
||||
path if path.starts_with("/Network/") => VolumeType::Network,
|
||||
|
||||
// macOS autofs system
|
||||
path if mount_str.contains("auto_home") ||
|
||||
info.file_system == FileSystem::Other("autofs".to_string()) => VolumeType::System,
|
||||
|
||||
_ => VolumeType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WindowsClassifier;
|
||||
impl VolumeClassifier for WindowsClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Primary system drive (usually C:)
|
||||
"C:\\" => VolumeType::Primary,
|
||||
|
||||
// Recovery and EFI partitions
|
||||
path if path.contains("Recovery")
|
||||
|| path.contains("EFI")
|
||||
|| info.file_system == FileSystem::Fat32 && info.total_bytes_capacity < 1_000_000_000 => {
|
||||
VolumeType::System
|
||||
},
|
||||
|
||||
// Other drive letters
|
||||
path if path.len() == 3 && path.ends_with(":\\") => {
|
||||
if info.is_removable.unwrap_or(false) {
|
||||
VolumeType::External
|
||||
} else {
|
||||
VolumeType::Secondary
|
||||
}
|
||||
},
|
||||
|
||||
// Network drives
|
||||
path if path.starts_with("\\\\") => VolumeType::Network,
|
||||
|
||||
_ => VolumeType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LinuxClassifier;
|
||||
impl VolumeClassifier for LinuxClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Root filesystem
|
||||
"/" => VolumeType::Primary,
|
||||
|
||||
// User data partition
|
||||
"/home" => VolumeType::UserData,
|
||||
|
||||
// System/virtual filesystems
|
||||
path if path.starts_with("/proc")
|
||||
|| path.starts_with("/sys")
|
||||
|| path.starts_with("/dev")
|
||||
|| path.starts_with("/boot") => VolumeType::System,
|
||||
|
||||
// External/removable media
|
||||
path if path.starts_with("/media/")
|
||||
|| path.starts_with("/mnt/")
|
||||
|| info.is_removable.unwrap_or(false) => VolumeType::External,
|
||||
|
||||
// Network mounts
|
||||
path if info.file_system == FileSystem::Other("nfs".to_string())
|
||||
|| info.file_system == FileSystem::Other("cifs".to_string()) => VolumeType::Network,
|
||||
|
||||
_ => VolumeType::Secondary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_classifier() -> Box<dyn VolumeClassifier> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return Box::new(MacOSClassifier);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return Box::new(WindowsClassifier);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return Box::new(LinuxClassifier);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return Box::new(UnknownClassifier);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Updated Volume Detection
|
||||
|
||||
```rust
|
||||
// src/volume/os_detection.rs
|
||||
|
||||
pub async fn detect_volumes(device_id: Uuid) -> VolumeResult<Vec<Volume>> {
|
||||
let classifier = classification::get_classifier();
|
||||
let raw_volumes = detect_raw_volumes().await?;
|
||||
|
||||
let mut volumes = Vec::new();
|
||||
for raw_volume in raw_volumes {
|
||||
let volume_type = classifier.classify(&raw_volume);
|
||||
|
||||
let volume = Volume {
|
||||
fingerprint: VolumeFingerprint::new(device_id, &raw_volume),
|
||||
device_id,
|
||||
name: raw_volume.name,
|
||||
volume_type,
|
||||
mount_type: determine_mount_type(&volume_type),
|
||||
mount_point: raw_volume.mount_point,
|
||||
is_user_visible: volume_type.show_by_default(),
|
||||
auto_track_eligible: volume_type.auto_track_by_default(),
|
||||
// ... other fields
|
||||
};
|
||||
|
||||
volumes.push(volume);
|
||||
}
|
||||
|
||||
Ok(volumes)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Enhanced Auto-tracking Logic
|
||||
|
||||
```rust
|
||||
// src/volume/manager.rs
|
||||
|
||||
impl VolumeManager {
|
||||
/// Auto-track user-relevant volumes only
|
||||
pub async fn auto_track_user_volumes(
|
||||
&self,
|
||||
library: &crate::library::Library,
|
||||
) -> VolumeResult<Vec<entities::volume::Model>> {
|
||||
let eligible_volumes: Vec<_> = self.volumes
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|v| v.auto_track_eligible)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut tracked_volumes = Vec::new();
|
||||
|
||||
info!(
|
||||
"Auto-tracking {} user-relevant volumes for library '{}'",
|
||||
eligible_volumes.len(),
|
||||
library.name().await
|
||||
);
|
||||
|
||||
for volume in eligible_volumes {
|
||||
// Skip if already tracked
|
||||
if self.is_volume_tracked(library, &volume.fingerprint).await? {
|
||||
debug!("Volume '{}' ({:?}) already tracked in library",
|
||||
volume.name, volume.volume_type);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.track_volume(library, &volume.fingerprint, Some(volume.name.clone())).await {
|
||||
Ok(tracked) => {
|
||||
info!(
|
||||
"Auto-tracked {} volume '{}' in library '{}'",
|
||||
volume.volume_type.display_name(),
|
||||
volume.name,
|
||||
library.name().await
|
||||
);
|
||||
tracked_volumes.push(tracked);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to auto-track {} volume '{}': {}",
|
||||
volume.volume_type.display_name(),
|
||||
volume.name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tracked_volumes)
|
||||
}
|
||||
|
||||
/// Get volumes filtered by type and visibility
|
||||
pub async fn get_user_visible_volumes(&self) -> Vec<Volume> {
|
||||
self.volumes
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|v| v.is_user_visible)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all volumes including system volumes
|
||||
pub async fn get_all_volumes_with_system(&self) -> Vec<Volume> {
|
||||
self.volumes.read().await.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Enhanced CLI Interface
|
||||
|
||||
```rust
|
||||
// src/infrastructure/cli/commands/volume.rs
|
||||
|
||||
#[derive(Debug, Clone, Subcommand, Serialize, Deserialize)]
|
||||
pub enum VolumeCommands {
|
||||
/// List volumes (user-visible by default)
|
||||
List {
|
||||
/// Include system volumes in output
|
||||
#[arg(long)]
|
||||
include_system: bool,
|
||||
|
||||
/// Filter by volume type
|
||||
#[arg(long, value_enum)]
|
||||
type_filter: Option<VolumeTypeFilter>,
|
||||
|
||||
/// Show volume type column
|
||||
#[arg(long)]
|
||||
show_types: bool,
|
||||
},
|
||||
// ... other commands
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize)]
|
||||
pub enum VolumeTypeFilter {
|
||||
Primary,
|
||||
UserData,
|
||||
External,
|
||||
Secondary,
|
||||
System,
|
||||
Network,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// Enhanced volume list formatting
|
||||
fn format_volume_list(
|
||||
volumes: Vec<Volume>,
|
||||
tracked_info: HashMap<VolumeFingerprint, TrackedVolume>,
|
||||
show_types: bool,
|
||||
include_system: bool,
|
||||
) -> comfy_table::Table {
|
||||
let mut table = Table::new();
|
||||
|
||||
if show_types {
|
||||
table.set_header(vec!["Type", "Name", "Mount Point", "File System", "Capacity", "Available", "Status", "Tracked"]);
|
||||
} else {
|
||||
table.set_header(vec!["Name", "Mount Point", "File System", "Capacity", "Available", "Status", "Tracked"]);
|
||||
}
|
||||
|
||||
let filtered_volumes: Vec<_> = volumes.into_iter()
|
||||
.filter(|v| include_system || v.is_user_visible)
|
||||
.collect();
|
||||
|
||||
for volume in filtered_volumes {
|
||||
let tracked_status = if let Some(tracked) = tracked_info.get(&volume.fingerprint) {
|
||||
format!("Yes ({})", tracked.display_name.as_deref().unwrap_or(&volume.name))
|
||||
} else {
|
||||
"No".to_string()
|
||||
};
|
||||
|
||||
let mut row = Vec::new();
|
||||
|
||||
if show_types {
|
||||
row.push(format!("{} {}", volume.volume_type.icon(), volume.volume_type.display_name()));
|
||||
}
|
||||
|
||||
row.extend([
|
||||
volume.name,
|
||||
volume.mount_point.display().to_string(),
|
||||
volume.file_system.to_string(),
|
||||
format_bytes(volume.total_bytes_capacity),
|
||||
format_bytes(volume.total_bytes_available),
|
||||
if volume.is_mounted { "Mounted" } else { "Unmounted" }.to_string(),
|
||||
tracked_status,
|
||||
]);
|
||||
|
||||
table.add_row(row);
|
||||
}
|
||||
|
||||
table
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Database Schema Updates
|
||||
|
||||
```rust
|
||||
// Add volume_type to database schema
|
||||
// src/infrastructure/database/entities/volume.rs
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "volumes")]
|
||||
pub struct Model {
|
||||
// ... existing fields ...
|
||||
|
||||
/// Volume type classification
|
||||
pub volume_type: String, // Serialized VolumeType
|
||||
|
||||
/// Whether volume is visible in default UI
|
||||
pub is_user_visible: Option<bool>,
|
||||
|
||||
/// Whether volume is eligible for auto-tracking
|
||||
pub auto_track_eligible: Option<bool>,
|
||||
}
|
||||
|
||||
// Migration to add new columns
|
||||
// src/infrastructure/database/migration/m20250126_000001_add_volume_classification.rs
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Classification (Week 1)
|
||||
|
||||
- [ ] Add `VolumeType` enum and classification traits
|
||||
- [ ] Implement platform-specific classifiers
|
||||
- [ ] Update `Volume` struct with new fields
|
||||
- [ ] Add database migration for new fields
|
||||
|
||||
### Phase 2: Volume Detection Integration (Week 1)
|
||||
|
||||
- [ ] Update volume detection to use classifiers
|
||||
- [ ] Modify auto-tracking logic to respect `auto_track_eligible`
|
||||
- [ ] Update volume manager methods
|
||||
- [ ] Add comprehensive tests for classification
|
||||
|
||||
### Phase 3: CLI Enhancement (Week 2)
|
||||
|
||||
- [ ] Add CLI flags for system volume display
|
||||
- [ ] Enhance volume list formatting with types
|
||||
- [ ] Add volume type filtering options
|
||||
- [ ] Update help text and documentation
|
||||
|
||||
### Phase 4: Migration and Testing (Week 2)
|
||||
|
||||
- [ ] Create migration script for existing volumes
|
||||
- [ ] Add integration tests across platforms
|
||||
- [ ] Performance testing with large volume sets
|
||||
- [ ] User acceptance testing
|
||||
|
||||
### Phase 5: Advanced Features (Future)
|
||||
|
||||
- [ ] User preferences for volume display
|
||||
- [ ] Custom volume type rules
|
||||
- [ ] Volume grouping/organization
|
||||
- [ ] Integration with file manager UI
|
||||
|
||||
## Platform Considerations
|
||||
|
||||
### macOS Specifics
|
||||
|
||||
- **APFS Containers**: Multiple volumes in single container
|
||||
- **System Volume Group**: Related system volumes
|
||||
- **Sealed System Volume**: Read-only system partition
|
||||
- **Data Volume**: Separate user data volume
|
||||
|
||||
### Windows Specifics
|
||||
|
||||
- **Drive Letters**: Single-letter mount points
|
||||
- **Hidden Partitions**: Recovery, EFI partitions
|
||||
- **Dynamic Disks**: Spanned/striped volumes
|
||||
- **Junction Points**: Directory-level mounts
|
||||
|
||||
### Linux Specifics
|
||||
|
||||
- **Virtual Filesystems**: /proc, /sys, /dev
|
||||
- **Bind Mounts**: Same filesystem at multiple points
|
||||
- **Network Filesystems**: NFS, CIFS, SSHFS
|
||||
- **Container Filesystems**: Docker, LXC volumes
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Existing Volume Handling
|
||||
|
||||
1. **Backward Compatibility**: Existing tracked volumes remain tracked
|
||||
2. **Gradual Migration**: Classify existing volumes on next refresh
|
||||
3. **Default Behavior**: System volumes stop auto-tracking for new libraries
|
||||
4. **User Choice**: Allow users to manually track/untrack any volume
|
||||
|
||||
### Database Migration
|
||||
|
||||
```sql
|
||||
-- Add new columns with defaults
|
||||
ALTER TABLE volumes ADD COLUMN volume_type TEXT DEFAULT 'Unknown';
|
||||
ALTER TABLE volumes ADD COLUMN is_user_visible BOOLEAN DEFAULT true;
|
||||
ALTER TABLE volumes ADD COLUMN auto_track_eligible BOOLEAN DEFAULT true;
|
||||
|
||||
-- Backfill existing volumes based on mount_point patterns
|
||||
UPDATE volumes SET volume_type = 'System'
|
||||
WHERE mount_point LIKE '/System/Volumes/%' AND mount_point != '/System/Volumes/Data';
|
||||
|
||||
UPDATE volumes SET volume_type = 'External'
|
||||
WHERE mount_point LIKE '/Volumes/%';
|
||||
|
||||
UPDATE volumes SET volume_type = 'Primary'
|
||||
WHERE mount_point = '/';
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Platform classifier logic
|
||||
- Volume type determination
|
||||
- Auto-tracking eligibility
|
||||
- UI filtering logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Volume detection with classification
|
||||
- Auto-tracking behavior changes
|
||||
- CLI output formatting
|
||||
- Database migration
|
||||
|
||||
### Platform Tests
|
||||
|
||||
- macOS system volume detection
|
||||
- Windows drive letter handling
|
||||
- Linux virtual filesystem filtering
|
||||
- Cross-platform consistency
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- Volume detection with classification overhead
|
||||
- Database query performance with new indexes
|
||||
- Memory usage with additional volume metadata
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Information Disclosure
|
||||
|
||||
- **System Volume Exposure**: Hiding system volumes reduces information leakage
|
||||
- **Mount Point Sanitization**: Ensure mount paths don't expose sensitive info
|
||||
- **Volume Enumeration**: Limit volume discovery to accessible mounts
|
||||
|
||||
### Access Control
|
||||
|
||||
- **Permission Checks**: Verify read access before classifying volumes
|
||||
- **Privilege Escalation**: Don't require elevated permissions for classification
|
||||
- **User Context**: Classify volumes based on current user's perspective
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Configuration-Based Classification
|
||||
|
||||
**Approach**: User-defined rules for volume classification
|
||||
**Pros**: Fully customizable, handles edge cases
|
||||
**Cons**: Complex setup, inconsistent defaults, maintenance burden
|
||||
**Decision**: Rejected - Too complex for initial implementation
|
||||
|
||||
### 2. Content-Based Classification
|
||||
|
||||
**Approach**: Analyze directory contents to determine volume purpose
|
||||
**Pros**: More accurate classification, adapts to user behavior
|
||||
**Cons**: Performance overhead, privacy concerns, complexity
|
||||
**Decision**: Rejected - Out of scope for v1, privacy issues
|
||||
|
||||
### 3. Simple Blacklist/Whitelist
|
||||
|
||||
**Approach**: Hard-coded lists of paths to show/hide
|
||||
**Pros**: Simple implementation, predictable behavior
|
||||
**Cons**: Brittle, platform-specific, hard to maintain
|
||||
**Decision**: Rejected - Not flexible enough, maintenance nightmare
|
||||
|
||||
### 4. No Classification (Status Quo)
|
||||
|
||||
**Approach**: Keep current behavior, show all volumes
|
||||
**Pros**: No implementation effort, backward compatible
|
||||
**Cons**: Poor UX, cluttered interface, user confusion
|
||||
**Decision**: Rejected - UX problems too significant
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### User Experience
|
||||
|
||||
- **Volume Count Reduction**: 50%+ reduction in default volume list
|
||||
- **User Comprehension**: A/B testing shows improved understanding
|
||||
- **Support Requests**: Fewer volume-related confusion tickets
|
||||
|
||||
### Technical Metrics
|
||||
|
||||
- **Classification Accuracy**: 95%+ correct volume type assignment
|
||||
- **Performance Impact**: <10ms additional detection overhead
|
||||
- **Database Size**: Reduced tracking overhead for system volumes
|
||||
|
||||
### Adoption Metrics
|
||||
|
||||
- **CLI Usage**: Increased usage of volume commands
|
||||
- **Feature Discovery**: Users find relevant volumes faster
|
||||
- **System Volume Access**: <5% users need `--include-system` flag
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Example Outputs
|
||||
|
||||
### Before (Current)
|
||||
|
||||
```bash
|
||||
$ sd volume list
|
||||
┌──────────────┬─────────────────────────────────┬─────────────┬──────────┬───────────┬─────────┬─────────────────┐
|
||||
│ Name │ Mount Point │ File System │ Capacity │ Available │ Status │ Tracked │
|
||||
├──────────────┼─────────────────────────────────┼─────────────┼──────────┼───────────┼─────────┼─────────────────┤
|
||||
│ Samsung │ /Volumes/Samsung │ Unknown │ 2.0 TB │ 301.0 GB │ Mounted │ No │
|
||||
│ mnt1 │ /System/Volumes/Update/SFR/mnt1 │ APFS │ 5.2 GB │ 3.3 GB │ Mounted │ Yes (mnt1) │
|
||||
│ iSCPreboot │ /System/Volumes/iSCPreboot │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ Yes (iSCPreboot)│
|
||||
│ Preboot │ /System/Volumes/Preboot │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Preboot) │
|
||||
│ xarts │ /System/Volumes/xarts │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ Yes (xarts) │
|
||||
│ Untitled │ /Volumes/Untitled │ APFS │ 995.0 GB │ 8.2 GB │ Mounted │ No │
|
||||
│ Hardware │ /System/Volumes/Hardware │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ Yes (Hardware) │
|
||||
│ - │ - │ Unknown │ Unknown │ Unknown │ Mounted │ Yes (-) │
|
||||
│ VM │ /System/Volumes/VM │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (VM) │
|
||||
│ Data │ /System/Volumes/Data │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Data) │
|
||||
│ mnt1 │ /System/Volumes/Update/mnt1 │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (mnt1) │
|
||||
│ Macintosh HD │ / │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Macintosh) │
|
||||
│ Update │ /System/Volumes/Update │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Update) │
|
||||
└──────────────┴─────────────────────────────────┴─────────────┴──────────┴───────────┴─────────┴─────────────────┘
|
||||
13 volumes found
|
||||
```
|
||||
|
||||
### After (Proposed Default)
|
||||
|
||||
```bash
|
||||
$ sd volume list
|
||||
┌──────────────┬─────────────────┬─────────────┬──────────┬───────────┬─────────┬─────────────────┐
|
||||
│ Name │ Mount Point │ File System │ Capacity │ Available │ Status │ Tracked │
|
||||
├──────────────┼─────────────────┼─────────────┼──────────┼───────────┼─────────┼─────────────────┤
|
||||
│ Macintosh HD │ / │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Macintosh) │
|
||||
│ Data │ /System/.../Data│ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Data) │
|
||||
│ Samsung │ /Volumes/Samsung│ Unknown │ 2.0 TB │ 301.0 GB │ Mounted │ No │
|
||||
│ Untitled │ /Volumes/Untitled│ APFS │ 995.0 GB │ 8.2 GB │ Mounted │ No │
|
||||
└──────────────┴─────────────────┴─────────────┴──────────┴───────────┴─────────┴─────────────────┘
|
||||
4 volumes found (9 system volumes hidden, use --include-system to show)
|
||||
```
|
||||
|
||||
### After (With System Volumes)
|
||||
|
||||
```bash
|
||||
$ sd volume list --include-system --show-types
|
||||
┌─────────────────────┬──────────────┬─────────────────────────────────┬─────────────┬──────────┬───────────┬─────────┬─────────────────┐
|
||||
│ Type │ Name │ Mount Point │ File System │ Capacity │ Available │ Status │ Tracked │
|
||||
├─────────────────────┼──────────────┼─────────────────────────────────┼─────────────┼──────────┼───────────┼─────────┼─────────────────┤
|
||||
│ [PRI] Primary Drive │ Macintosh HD │ / │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Macintosh) │
|
||||
│ [USR] User Data │ Data │ /System/Volumes/Data │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ Yes (Data) │
|
||||
│ [EXT] External Drive│ Samsung │ /Volumes/Samsung │ Unknown │ 2.0 TB │ 301.0 GB │ Mounted │ No │
|
||||
│ [SEC] Secondary Drive│ Untitled │ /Volumes/Untitled │ APFS │ 995.0 GB │ 8.2 GB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ VM │ /System/Volumes/VM │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ Preboot │ /System/Volumes/Preboot │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ Update │ /System/Volumes/Update │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ Hardware │ /System/Volumes/Hardware │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ iSCPreboot │ /System/Volumes/iSCPreboot │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ xarts │ /System/Volumes/xarts │ APFS │ 524.0 MB │ 502.0 MB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ mnt1 │ /System/Volumes/Update/mnt1 │ APFS │ 990.0 GB │ 3.3 GB │ Mounted │ No │
|
||||
│ [SYS] System Volume │ mnt1 │ /System/Volumes/Update/SFR/mnt1 │ APFS │ 5.2 GB │ 3.3 GB │ Mounted │ No │
|
||||
│ [UNK] Unknown │ - │ - │ Unknown │ Unknown │ Unknown │ Mounted │ No │
|
||||
└─────────────────────┴──────────────┴─────────────────────────────────┴─────────────┴──────────┴───────────┴─────────┴─────────────────┘
|
||||
13 volumes found
|
||||
```
|
||||
@@ -7,219 +7,244 @@
|
||||
//! - Getting current library info
|
||||
|
||||
use crate::infrastructure::cli::daemon::{DaemonClient, DaemonCommand, DaemonResponse};
|
||||
use crate::infrastructure::cli::output::messages::LibraryInfo as OutputLibraryInfo;
|
||||
use crate::infrastructure::cli::output::{CliOutput, Message};
|
||||
use crate::infrastructure::cli::output::messages::{LibraryInfo as OutputLibraryInfo};
|
||||
use clap::Subcommand;
|
||||
use comfy_table::Table;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum LibraryCommands {
|
||||
/// Create a new library
|
||||
Create {
|
||||
/// Library name
|
||||
name: String,
|
||||
/// Path where to create the library
|
||||
#[arg(short, long)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
/// Create a new library
|
||||
Create {
|
||||
/// Library name
|
||||
name: String,
|
||||
/// Path where to create the library
|
||||
#[arg(short, long)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Open and switch to a library
|
||||
Open {
|
||||
/// Path to the library
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Open and switch to a library
|
||||
Open {
|
||||
/// Path to the library
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
/// Switch to a different library
|
||||
Switch {
|
||||
/// Library ID or name
|
||||
identifier: String,
|
||||
},
|
||||
/// Switch to a different library
|
||||
Switch {
|
||||
/// Library ID or name
|
||||
identifier: String,
|
||||
},
|
||||
|
||||
/// List all libraries
|
||||
List {
|
||||
/// Show detailed information
|
||||
#[arg(short, long)]
|
||||
detailed: bool,
|
||||
},
|
||||
/// List all libraries
|
||||
List {
|
||||
/// Show detailed information
|
||||
#[arg(long)]
|
||||
detailed: bool,
|
||||
},
|
||||
|
||||
/// Show current library info
|
||||
Current,
|
||||
/// Show current library info
|
||||
Current,
|
||||
|
||||
/// Close the current library
|
||||
Close,
|
||||
/// Close the current library
|
||||
Close,
|
||||
|
||||
/// Delete a library
|
||||
Delete {
|
||||
/// Library ID to delete
|
||||
id: String,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Delete a library
|
||||
Delete {
|
||||
/// Library ID to delete
|
||||
id: String,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_library_command(
|
||||
cmd: LibraryCommands,
|
||||
instance_name: Option<String>,
|
||||
mut output: CliOutput,
|
||||
cmd: LibraryCommands,
|
||||
instance_name: Option<String>,
|
||||
mut output: CliOutput,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = DaemonClient::new_with_instance(instance_name.clone());
|
||||
let mut client = DaemonClient::new_with_instance(instance_name.clone());
|
||||
|
||||
match cmd {
|
||||
LibraryCommands::Create { name, path } => {
|
||||
output.info(&format!("Creating library '{}'...", name))?;
|
||||
match cmd {
|
||||
LibraryCommands::Create { name, path } => {
|
||||
output.info(&format!("Creating library '{}'...", name))?;
|
||||
|
||||
match client
|
||||
.send_command(DaemonCommand::CreateLibrary {
|
||||
name: name.clone(),
|
||||
path,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::LibraryCreated { id, name, path }) => {
|
||||
output.print(Message::LibraryCreated { name, id, path })?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to create library: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!("Failed to communicate with daemon: {}", e)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error("Unexpected response from daemon".to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
match client
|
||||
.send_command(DaemonCommand::CreateLibrary {
|
||||
name: name.clone(),
|
||||
path,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::LibraryCreated { id, name, path }) => {
|
||||
output.print(Message::LibraryCreated { name, id, path })?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to create library: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!(
|
||||
"Failed to communicate with daemon: {}",
|
||||
e
|
||||
)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error(
|
||||
"Unexpected response from daemon".to_string(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryCommands::Open { path } => {
|
||||
output.info(&format!("Opening library at {}...", path.display()))?;
|
||||
output.error(Message::Error("Open command not yet implemented".to_string()))?;
|
||||
output.info("Use 'spacedrive library create' to create a new library")?;
|
||||
}
|
||||
LibraryCommands::Open { path } => {
|
||||
output.info(&format!("Opening library at {}...", path.display()))?;
|
||||
output.error(Message::Error(
|
||||
"Open command not yet implemented".to_string(),
|
||||
))?;
|
||||
output.info("Use 'spacedrive library create' to create a new library")?;
|
||||
}
|
||||
|
||||
LibraryCommands::List { detailed } => {
|
||||
match client
|
||||
.send_command(DaemonCommand::ListLibraries)
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::Libraries(libraries)) => {
|
||||
if libraries.is_empty() {
|
||||
output.print(Message::NoLibrariesFound)?;
|
||||
} else {
|
||||
let output_libs: Vec<OutputLibraryInfo> = libraries.into_iter()
|
||||
.map(|lib| OutputLibraryInfo {
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
path: lib.path,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if detailed || matches!(output.format(), crate::infrastructure::cli::output::OutputFormat::Json) {
|
||||
output.print(Message::LibraryList { libraries: output_libs })?;
|
||||
} else {
|
||||
// For non-detailed human output, use a table
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec!["ID", "Name", "Path"]);
|
||||
LibraryCommands::List { detailed } => {
|
||||
match client.send_command(DaemonCommand::ListLibraries).await {
|
||||
Ok(DaemonResponse::Libraries(libraries)) => {
|
||||
if libraries.is_empty() {
|
||||
output.print(Message::NoLibrariesFound)?;
|
||||
} else {
|
||||
let output_libs: Vec<OutputLibraryInfo> = libraries
|
||||
.into_iter()
|
||||
.map(|lib| OutputLibraryInfo {
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
path: lib.path,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for lib in output_libs {
|
||||
table.add_row(vec![
|
||||
lib.id.to_string(),
|
||||
lib.name,
|
||||
lib.path.display().to_string(),
|
||||
]);
|
||||
}
|
||||
if detailed
|
||||
|| matches!(
|
||||
output.format(),
|
||||
crate::infrastructure::cli::output::OutputFormat::Json
|
||||
) {
|
||||
output.print(Message::LibraryList {
|
||||
libraries: output_libs,
|
||||
})?;
|
||||
} else {
|
||||
// For non-detailed human output, use a table
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec!["ID", "Name", "Path"]);
|
||||
|
||||
output.section()
|
||||
.table(table)
|
||||
.render()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to list libraries: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!("Failed to communicate with daemon: {}", e)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error("Unexpected response from daemon".to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for lib in output_libs {
|
||||
table.add_row(vec![
|
||||
lib.id.to_string(),
|
||||
lib.name,
|
||||
lib.path.display().to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
LibraryCommands::Switch { identifier } => {
|
||||
match client
|
||||
.send_command(DaemonCommand::SwitchLibrary {
|
||||
id: identifier.parse()?,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::Ok) => {
|
||||
output.success("Switched library successfully")?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to switch library: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!("Failed to communicate with daemon: {}", e)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error("Unexpected response from daemon".to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.section().table(table).render()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to list libraries: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!(
|
||||
"Failed to communicate with daemon: {}",
|
||||
e
|
||||
)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error(
|
||||
"Unexpected response from daemon".to_string(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryCommands::Current => {
|
||||
match client
|
||||
.send_command(DaemonCommand::GetCurrentLibrary)
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::CurrentLibrary(lib_opt)) => {
|
||||
let library = lib_opt.map(|lib| OutputLibraryInfo {
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
path: lib.path,
|
||||
});
|
||||
output.print(Message::CurrentLibrary { library })?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Error: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!("Failed to communicate with daemon: {}", e)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error("Unexpected response from daemon".to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryCommands::Switch { identifier } => {
|
||||
match client
|
||||
.send_command(DaemonCommand::SwitchLibrary {
|
||||
id: identifier.parse()?,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::Ok) => {
|
||||
output.success("Switched library successfully")?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Failed to switch library: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!(
|
||||
"Failed to communicate with daemon: {}",
|
||||
e
|
||||
)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error(
|
||||
"Unexpected response from daemon".to_string(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryCommands::Close => {
|
||||
output.info("Closing current library...")?;
|
||||
output.error(Message::Error("Close command not yet implemented".to_string()))?;
|
||||
output.info("This command will be available in a future update")?;
|
||||
}
|
||||
LibraryCommands::Current => {
|
||||
match client.send_command(DaemonCommand::GetCurrentLibrary).await {
|
||||
Ok(DaemonResponse::CurrentLibrary(lib_opt)) => {
|
||||
let library = lib_opt.map(|lib| OutputLibraryInfo {
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
path: lib.path,
|
||||
});
|
||||
output.print(Message::CurrentLibrary { library })?;
|
||||
}
|
||||
Ok(DaemonResponse::Error(e)) => {
|
||||
output.error(Message::Error(format!("Error: {}", e)))?;
|
||||
}
|
||||
Err(e) => {
|
||||
output.error(Message::Error(format!(
|
||||
"Failed to communicate with daemon: {}",
|
||||
e
|
||||
)))?;
|
||||
}
|
||||
_ => {
|
||||
output.error(Message::Error(
|
||||
"Unexpected response from daemon".to_string(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryCommands::Delete { id, yes } => {
|
||||
if !yes {
|
||||
use dialoguer::Confirm;
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(format!("Are you sure you want to delete library '{}'?", id))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output.info("Operation cancelled")?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
output.info(&format!("Deleting library {}...", id))?;
|
||||
output.error(Message::Error("Delete command not yet implemented".to_string()))?;
|
||||
output.info("This command will be available in a future update")?;
|
||||
}
|
||||
}
|
||||
LibraryCommands::Close => {
|
||||
output.info("Closing current library...")?;
|
||||
output.error(Message::Error(
|
||||
"Close command not yet implemented".to_string(),
|
||||
))?;
|
||||
output.info("This command will be available in a future update")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
LibraryCommands::Delete { id, yes } => {
|
||||
if !yes {
|
||||
use dialoguer::Confirm;
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(format!("Are you sure you want to delete library '{}'?", id))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output.info("Operation cancelled")?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
output.info(&format!("Deleting library {}...", id))?;
|
||||
output.error(Message::Error(
|
||||
"Delete command not yet implemented".to_string(),
|
||||
))?;
|
||||
output.info("This command will be available in a future update")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,11 +7,34 @@ use comfy_table::Table;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, clap::ValueEnum, Serialize, Deserialize)]
|
||||
pub enum VolumeTypeFilter {
|
||||
Primary,
|
||||
UserData,
|
||||
External,
|
||||
Secondary,
|
||||
System,
|
||||
Network,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Volume management commands
|
||||
#[derive(Debug, Clone, Subcommand, Serialize, Deserialize)]
|
||||
pub enum VolumeCommands {
|
||||
/// List all volumes
|
||||
List,
|
||||
List {
|
||||
/// Include system volumes (hidden by default)
|
||||
#[arg(long)]
|
||||
include_system: bool,
|
||||
|
||||
/// Filter by volume type
|
||||
#[arg(long, value_enum)]
|
||||
type_filter: Option<VolumeTypeFilter>,
|
||||
|
||||
/// Show volume type classifications
|
||||
#[arg(long)]
|
||||
show_types: bool,
|
||||
},
|
||||
/// Show details for a specific volume
|
||||
Get {
|
||||
/// Volume fingerprint
|
||||
@@ -49,11 +72,19 @@ pub async fn handle_volume_command(
|
||||
let mut client = DaemonClient::new_with_instance(instance_name.clone());
|
||||
|
||||
match cmd {
|
||||
VolumeCommands::List => {
|
||||
VolumeCommands::List {
|
||||
include_system,
|
||||
type_filter,
|
||||
show_types,
|
||||
} => {
|
||||
output.info("Fetching volumes...")?;
|
||||
|
||||
match client
|
||||
.send_command(DaemonCommand::Volume(VolumeCommands::List))
|
||||
.send_command(DaemonCommand::Volume(VolumeCommands::List {
|
||||
include_system,
|
||||
type_filter: type_filter.clone(),
|
||||
show_types,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(DaemonResponse::VolumeListWithTracking(volume_infos)) => {
|
||||
@@ -63,7 +94,7 @@ pub async fn handle_volume_command(
|
||||
output.info(&format!("Found {} volume(s):", volume_infos.len()))?;
|
||||
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec![
|
||||
let mut headers = vec![
|
||||
"Name",
|
||||
"Mount Point",
|
||||
"File System",
|
||||
@@ -71,13 +102,38 @@ pub async fn handle_volume_command(
|
||||
"Available",
|
||||
"Status",
|
||||
"Tracked",
|
||||
]);
|
||||
];
|
||||
|
||||
if show_types {
|
||||
headers.insert(3, "Type");
|
||||
}
|
||||
|
||||
table.set_header(headers);
|
||||
|
||||
for volume_info in volume_infos {
|
||||
let volume = volume_info["volume"].as_object().unwrap();
|
||||
let is_tracked = volume_info["is_tracked"].as_bool().unwrap_or(false);
|
||||
let tracked_name = volume_info["tracked_name"].as_str();
|
||||
|
||||
// Apply filtering based on CLI flags
|
||||
let is_user_visible =
|
||||
volume["is_user_visible"].as_bool().unwrap_or(true);
|
||||
let volume_type_str =
|
||||
volume["volume_type"].as_str().unwrap_or("Unknown");
|
||||
|
||||
// Skip system volumes unless --include-system is specified
|
||||
if !include_system && !is_user_visible {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply type filter if specified
|
||||
if let Some(ref filter) = type_filter {
|
||||
let filter_str = format!("{:?}", filter);
|
||||
if volume_type_str != filter_str {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let name = volume["name"].as_str().unwrap_or("Unknown");
|
||||
let mount_point = volume["mount_point"].as_str().unwrap_or("Unknown");
|
||||
let file_system = volume["file_system"].as_str().unwrap_or("Unknown");
|
||||
@@ -112,15 +168,34 @@ pub async fn handle_volume_command(
|
||||
"No".to_string()
|
||||
};
|
||||
|
||||
table.add_row(vec![
|
||||
let mut row = vec![
|
||||
name.to_string(),
|
||||
mount_point.to_string(),
|
||||
file_system.to_string(),
|
||||
];
|
||||
|
||||
if show_types {
|
||||
// Get display name for the volume type
|
||||
let type_display = match volume_type_str {
|
||||
"Primary" => "[PRI]",
|
||||
"UserData" => "[USR]",
|
||||
"External" => "[EXT]",
|
||||
"Secondary" => "[SEC]",
|
||||
"System" => "[SYS]",
|
||||
"Network" => "[NET]",
|
||||
_ => "[UNK]",
|
||||
};
|
||||
row.push(type_display.to_string());
|
||||
}
|
||||
|
||||
row.extend(vec![
|
||||
capacity_str,
|
||||
available_str,
|
||||
status.to_string(),
|
||||
tracked_status,
|
||||
]);
|
||||
|
||||
table.add_row(row);
|
||||
}
|
||||
|
||||
output.section().table(table).render()?;
|
||||
|
||||
@@ -37,7 +37,11 @@ impl CommandHandler for VolumeHandler {
|
||||
) -> DaemonResponse {
|
||||
match cmd {
|
||||
DaemonCommand::Volume(volume_cmd) => match volume_cmd {
|
||||
VolumeCommands::List => {
|
||||
VolumeCommands::List {
|
||||
include_system,
|
||||
type_filter,
|
||||
show_types,
|
||||
} => {
|
||||
// Get all volumes
|
||||
let volumes = core.volumes.get_all_volumes().await;
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ pub struct Model {
|
||||
pub is_removable: Option<bool>,
|
||||
pub is_network_drive: Option<bool>,
|
||||
pub device_model: Option<String>,
|
||||
/// Volume type classification
|
||||
pub volume_type: Option<String>,
|
||||
/// Whether volume is visible in default UI
|
||||
pub is_user_visible: Option<bool>,
|
||||
/// Whether volume is eligible for auto-tracking
|
||||
pub auto_track_eligible: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -48,8 +54,8 @@ impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl Model {
|
||||
/// Convert database model to tracked volume
|
||||
pub fn to_tracked_volume(&self) -> TrackedVolume {
|
||||
TrackedVolume {
|
||||
pub fn to_tracked_volume(&self) -> crate::volume::types::TrackedVolume {
|
||||
crate::volume::types::TrackedVolume {
|
||||
id: self.id,
|
||||
uuid: self.uuid,
|
||||
device_id: self.device_id,
|
||||
@@ -68,6 +74,9 @@ impl Model {
|
||||
is_removable: self.is_removable,
|
||||
is_network_drive: self.is_network_drive,
|
||||
device_model: self.device_model.clone(),
|
||||
volume_type: self.volume_type.as_deref().unwrap_or("Unknown").to_string(),
|
||||
is_user_visible: self.is_user_visible,
|
||||
auto_track_eligible: self.auto_track_eligible,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,25 @@ impl MigrationTrait for Migration {
|
||||
.col(ColumnDef::new(Volumes::IsRemovable).boolean())
|
||||
.col(ColumnDef::new(Volumes::IsNetworkDrive).boolean())
|
||||
.col(ColumnDef::new(Volumes::DeviceModel).text())
|
||||
// Volume classification fields
|
||||
.col(
|
||||
ColumnDef::new(Volumes::VolumeType)
|
||||
.text()
|
||||
.not_null()
|
||||
.default("Unknown"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Volumes::IsUserVisible)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Volumes::AutoTrackEligible)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_volumes_device_id")
|
||||
@@ -132,6 +151,10 @@ enum Volumes {
|
||||
IsRemovable,
|
||||
IsNetworkDrive,
|
||||
DeviceModel,
|
||||
// Volume classification fields
|
||||
VolumeType,
|
||||
IsUserVisible,
|
||||
AutoTrackEligible,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
|
||||
@@ -245,16 +245,18 @@ 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);
|
||||
}
|
||||
|
||||
// Auto-track user-relevant volumes for this library
|
||||
info!(
|
||||
"Auto-tracking user-relevant volumes for library {}",
|
||||
config.name
|
||||
);
|
||||
if let Err(e) = context
|
||||
.volume_manager
|
||||
.auto_track_user_volumes(&library)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to auto-track user-relevant volumes: {}", e);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
|
||||
173
core-new/src/volume/classification.rs
Normal file
173
core-new/src/volume/classification.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Volume classification system for platform-aware volume type detection
|
||||
|
||||
use crate::volume::types::{FileSystem, VolumeType};
|
||||
use std::path::Path;
|
||||
|
||||
/// Information needed for volume classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeDetectionInfo {
|
||||
pub mount_point: std::path::PathBuf,
|
||||
pub file_system: FileSystem,
|
||||
pub total_bytes_capacity: u64,
|
||||
pub is_removable: Option<bool>,
|
||||
pub is_network_drive: Option<bool>,
|
||||
pub device_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for platform-specific volume classification
|
||||
pub trait VolumeClassifier {
|
||||
fn classify(&self, volume_info: &VolumeDetectionInfo) -> VolumeType;
|
||||
}
|
||||
|
||||
/// macOS volume classifier
|
||||
pub struct MacOSClassifier;
|
||||
|
||||
impl VolumeClassifier for MacOSClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Primary system drive
|
||||
"/" => VolumeType::Primary,
|
||||
|
||||
// User data volume (modern macOS separates this)
|
||||
path if path.starts_with("/System/Volumes/Data") => VolumeType::UserData,
|
||||
|
||||
// System internal volumes
|
||||
path if path.starts_with("/System/Volumes/") => VolumeType::System,
|
||||
|
||||
// macOS autofs system
|
||||
path if mount_str.contains("auto_home")
|
||||
|| info.file_system == FileSystem::Other("autofs".to_string()) =>
|
||||
{
|
||||
VolumeType::System
|
||||
}
|
||||
|
||||
// External drives
|
||||
path if path.starts_with("/Volumes/") => {
|
||||
if info.is_removable.unwrap_or(false) {
|
||||
VolumeType::External
|
||||
} else {
|
||||
// Could be user-created APFS volume
|
||||
VolumeType::Secondary
|
||||
}
|
||||
}
|
||||
|
||||
// Network mounts
|
||||
path if path.starts_with("/Network/") => VolumeType::Network,
|
||||
|
||||
_ => VolumeType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows volume classifier
|
||||
pub struct WindowsClassifier;
|
||||
|
||||
impl VolumeClassifier for WindowsClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Primary system drive (usually C:)
|
||||
"C:\\" => VolumeType::Primary,
|
||||
|
||||
// Recovery and EFI partitions
|
||||
path if path.contains("Recovery")
|
||||
|| path.contains("EFI")
|
||||
|| (info.file_system == FileSystem::FAT32
|
||||
&& info.total_bytes_capacity < 1_000_000_000) =>
|
||||
{
|
||||
VolumeType::System
|
||||
}
|
||||
|
||||
// Other drive letters
|
||||
path if path.len() == 3 && path.ends_with(":\\") => {
|
||||
if info.is_removable.unwrap_or(false) {
|
||||
VolumeType::External
|
||||
} else {
|
||||
VolumeType::Secondary
|
||||
}
|
||||
}
|
||||
|
||||
// Network drives
|
||||
path if path.starts_with("\\\\") => VolumeType::Network,
|
||||
|
||||
_ => VolumeType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux volume classifier
|
||||
pub struct LinuxClassifier;
|
||||
|
||||
impl VolumeClassifier for LinuxClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
let mount_str = info.mount_point.to_string_lossy();
|
||||
|
||||
match mount_str.as_ref() {
|
||||
// Root filesystem
|
||||
"/" => VolumeType::Primary,
|
||||
|
||||
// User data partition
|
||||
"/home" => VolumeType::UserData,
|
||||
|
||||
// System/virtual filesystems
|
||||
path if path.starts_with("/proc")
|
||||
|| path.starts_with("/sys")
|
||||
|| path.starts_with("/dev")
|
||||
|| path.starts_with("/boot") =>
|
||||
{
|
||||
VolumeType::System
|
||||
}
|
||||
|
||||
// External/removable media
|
||||
path if path.starts_with("/media/")
|
||||
|| path.starts_with("/mnt/")
|
||||
|| info.is_removable.unwrap_or(false) =>
|
||||
{
|
||||
VolumeType::External
|
||||
}
|
||||
|
||||
// Network mounts
|
||||
path if info.file_system == FileSystem::Other("nfs".to_string())
|
||||
|| info.file_system == FileSystem::Other("cifs".to_string()) =>
|
||||
{
|
||||
VolumeType::Network
|
||||
}
|
||||
|
||||
_ => VolumeType::Secondary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback classifier for unknown platforms
|
||||
pub struct UnknownClassifier;
|
||||
|
||||
impl VolumeClassifier for UnknownClassifier {
|
||||
fn classify(&self, info: &VolumeDetectionInfo) -> VolumeType {
|
||||
// Basic classification based on common patterns
|
||||
if info.is_removable.unwrap_or(false) {
|
||||
VolumeType::External
|
||||
} else if info.is_network_drive.unwrap_or(false) {
|
||||
VolumeType::Network
|
||||
} else {
|
||||
VolumeType::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the appropriate classifier for the current platform
|
||||
pub fn get_classifier() -> Box<dyn VolumeClassifier> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return Box::new(MacOSClassifier);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return Box::new(WindowsClassifier);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return Box::new(LinuxClassifier);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return Box::new(UnknownClassifier);
|
||||
}
|
||||
@@ -441,6 +441,10 @@ impl VolumeManager {
|
||||
is_removable: Set(Some(is_removable)),
|
||||
is_network_drive: Set(Some(is_network_drive)),
|
||||
device_model: Set(volume.hardware_id.clone()),
|
||||
// Save volume classification fields
|
||||
volume_type: Set(Some(format!("{:?}", volume.volume_type))),
|
||||
is_user_visible: Set(Some(volume.is_user_visible)),
|
||||
auto_track_eligible: Set(Some(volume.auto_track_eligible)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -648,35 +652,46 @@ impl VolumeManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Automatically track system volumes for a library
|
||||
pub async fn auto_track_system_volumes(
|
||||
/// Automatically track user-relevant volumes for a library
|
||||
pub async fn auto_track_user_volumes(
|
||||
&self,
|
||||
library: &crate::library::Library,
|
||||
) -> VolumeResult<Vec<entities::volume::Model>> {
|
||||
let system_volumes = self.get_system_volumes().await;
|
||||
let eligible_volumes: Vec<_> = self
|
||||
.volumes
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|v| v.auto_track_eligible)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut tracked_volumes = Vec::new();
|
||||
|
||||
info!(
|
||||
"Auto-tracking {} system volumes for library '{}'",
|
||||
system_volumes.len(),
|
||||
"Auto-tracking {} user-relevant volumes for library '{}'",
|
||||
eligible_volumes.len(),
|
||||
library.name().await
|
||||
);
|
||||
|
||||
for volume in system_volumes {
|
||||
for volume in eligible_volumes {
|
||||
// Skip if already tracked
|
||||
if self.is_volume_tracked(library, &volume.fingerprint).await? {
|
||||
debug!("System volume '{}' already tracked in library", volume.name);
|
||||
debug!(
|
||||
"Volume '{}' ({:?}) already tracked in library",
|
||||
volume.name, volume.volume_type
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track the system volume
|
||||
match self
|
||||
.track_volume(library, &volume.fingerprint, Some(volume.name.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(tracked) => {
|
||||
info!(
|
||||
"Auto-tracked system volume '{}' in library '{}'",
|
||||
"Auto-tracked {} volume '{}' in library '{}'",
|
||||
volume.volume_type.display_name(),
|
||||
volume.name,
|
||||
library.name().await
|
||||
);
|
||||
@@ -684,8 +699,10 @@ impl VolumeManager {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to auto-track system volume '{}': {}",
|
||||
volume.name, e
|
||||
"Failed to auto-track {} volume '{}': {}",
|
||||
volume.volume_type.display_name(),
|
||||
volume.name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -694,6 +711,15 @@ impl VolumeManager {
|
||||
Ok(tracked_volumes)
|
||||
}
|
||||
|
||||
/// Automatically track system volumes for a library (legacy - use auto_track_user_volumes instead)
|
||||
pub async fn auto_track_system_volumes(
|
||||
&self,
|
||||
library: &crate::library::Library,
|
||||
) -> VolumeResult<Vec<entities::volume::Model>> {
|
||||
// Use the new filtered auto-tracking
|
||||
self.auto_track_user_volumes(library).await
|
||||
}
|
||||
|
||||
/// Save speed test results to all libraries where this volume is tracked
|
||||
pub async fn save_speed_test_results(
|
||||
&self,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
//! across different platforms. It's designed to integrate with the copy system for optimal
|
||||
//! file operation routing.
|
||||
|
||||
mod error;
|
||||
mod manager;
|
||||
mod os_detection;
|
||||
mod speed;
|
||||
pub mod classification;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod os_detection;
|
||||
pub mod speed;
|
||||
pub mod types;
|
||||
|
||||
pub use error::VolumeError;
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
//! Platform-specific volume detection
|
||||
|
||||
use crate::volume::{
|
||||
classification::{get_classifier, VolumeDetectionInfo},
|
||||
error::{VolumeError, VolumeResult},
|
||||
types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig},
|
||||
types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task;
|
||||
use tokio::{process::Command, task};
|
||||
use tracing::{debug, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Detect all volumes on the current system
|
||||
/// Classify a volume using the platform-specific classifier
|
||||
fn classify_volume(
|
||||
mount_point: &PathBuf,
|
||||
file_system: &FileSystem,
|
||||
name: &str,
|
||||
) -> crate::volume::types::VolumeType {
|
||||
let classifier = get_classifier();
|
||||
let detection_info = VolumeDetectionInfo {
|
||||
mount_point: mount_point.clone(),
|
||||
file_system: file_system.clone(),
|
||||
total_bytes_capacity: 0, // We don't have this info yet in some contexts
|
||||
is_removable: None, // Would need additional detection
|
||||
is_network_drive: None, // Would need additional detection
|
||||
device_model: None, // Would need additional detection
|
||||
};
|
||||
|
||||
classifier.classify(&detection_info)
|
||||
}
|
||||
|
||||
/// Detect all volumes on the system
|
||||
#[instrument(skip(config))]
|
||||
pub async fn detect_volumes(
|
||||
device_id: uuid::Uuid,
|
||||
@@ -26,7 +48,7 @@ pub async fn detect_volumes(
|
||||
let volumes = windows::detect_volumes(device_id, config).await?;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
let volumes = unsupported::detect_volumes(device_id, config).await?;
|
||||
let volumes = Vec::new();
|
||||
|
||||
debug!(
|
||||
"Detected {} volumes for device {}",
|
||||
@@ -116,8 +138,9 @@ mod macos {
|
||||
|
||||
let volume = Volume::new(
|
||||
device_id,
|
||||
name,
|
||||
name.clone(),
|
||||
mount_type,
|
||||
classify_volume(&mount_path, &file_system, &name),
|
||||
mount_path,
|
||||
vec![], // Additional mount points would need diskutil parsing
|
||||
disk_type,
|
||||
@@ -225,8 +248,9 @@ mod macos {
|
||||
|
||||
let volume = Volume::new(
|
||||
device_id,
|
||||
name,
|
||||
name.clone(),
|
||||
mount_type,
|
||||
classify_volume(&mount_path, &file_system, &name),
|
||||
mount_path,
|
||||
vec![], // Additional mount points would need diskutil parsing
|
||||
disk_type,
|
||||
|
||||
@@ -3,6 +3,83 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Classification of volume types for UX and auto-tracking decisions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VolumeType {
|
||||
/// Primary system drive containing OS and user data
|
||||
/// Examples: C:\ on Windows, / on Linux, Macintosh HD on macOS
|
||||
Primary,
|
||||
|
||||
/// Dedicated user data volumes (separate from OS)
|
||||
/// Examples: /System/Volumes/Data on macOS, separate /home on Linux
|
||||
UserData,
|
||||
|
||||
/// External or removable storage devices
|
||||
/// Examples: USB drives, external HDDs, /Volumes/* on macOS
|
||||
External,
|
||||
|
||||
/// Secondary internal storage (additional drives/partitions)
|
||||
/// Examples: D:, E: drives on Windows, additional mounted drives
|
||||
Secondary,
|
||||
|
||||
/// System/OS internal volumes (hidden from normal view)
|
||||
/// Examples: /System/Volumes/* on macOS, Recovery partitions
|
||||
System,
|
||||
|
||||
/// Network attached storage
|
||||
/// Examples: SMB mounts, NFS, cloud storage
|
||||
Network,
|
||||
|
||||
/// Unknown or unclassified volumes
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl VolumeType {
|
||||
/// Should this volume type be auto-tracked by default?
|
||||
pub fn auto_track_by_default(&self) -> bool {
|
||||
match self {
|
||||
VolumeType::Primary
|
||||
| VolumeType::UserData
|
||||
| VolumeType::External
|
||||
| VolumeType::Secondary
|
||||
| VolumeType::Network => true,
|
||||
VolumeType::System | VolumeType::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Should this volume be shown in the default UI view?
|
||||
pub fn show_by_default(&self) -> bool {
|
||||
!matches!(self, VolumeType::System | VolumeType::Unknown)
|
||||
}
|
||||
|
||||
/// User-friendly display name for the volume type
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
VolumeType::Primary => "Primary Drive",
|
||||
VolumeType::UserData => "User Data",
|
||||
VolumeType::External => "External Drive",
|
||||
VolumeType::Secondary => "Secondary Drive",
|
||||
VolumeType::System => "System Volume",
|
||||
VolumeType::Network => "Network Drive",
|
||||
VolumeType::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon/indicator for CLI display
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
VolumeType::Primary => "[PRI]",
|
||||
VolumeType::UserData => "[USR]",
|
||||
VolumeType::External => "[EXT]",
|
||||
VolumeType::Secondary => "[SEC]",
|
||||
VolumeType::System => "[SYS]",
|
||||
VolumeType::Network => "[NET]",
|
||||
VolumeType::Unknown => "[UNK]",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fingerprint of a volume, used to identify it uniquely across sessions
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
|
||||
@@ -10,18 +87,13 @@ pub struct VolumeFingerprint(pub String);
|
||||
|
||||
impl VolumeFingerprint {
|
||||
/// Create a new volume fingerprint from volume properties
|
||||
pub fn new(volume: &Volume) -> Self {
|
||||
pub fn new(device_id: uuid::Uuid, mount_point: &PathBuf, name: &str) -> Self {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(volume.device_id.as_bytes());
|
||||
hasher.update(volume.mount_point.to_string_lossy().as_bytes());
|
||||
hasher.update(volume.name.as_bytes());
|
||||
hasher.update(&volume.total_bytes_capacity.to_be_bytes());
|
||||
hasher.update(volume.file_system.to_string().as_bytes());
|
||||
|
||||
// Include hardware identifier if available
|
||||
if let Some(ref hw_id) = volume.hardware_id {
|
||||
hasher.update(hw_id.as_bytes());
|
||||
}
|
||||
hasher.update(device_id.as_bytes());
|
||||
hasher.update(mount_point.to_string_lossy().as_bytes());
|
||||
hasher.update(name.as_bytes());
|
||||
hasher.update(&(mount_point.to_string_lossy().len() as u64).to_be_bytes());
|
||||
hasher.update(&(name.len() as u64).to_be_bytes());
|
||||
|
||||
Self(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
@@ -87,6 +159,8 @@ pub struct Volume {
|
||||
pub name: String,
|
||||
/// Type of mount (system, external, etc)
|
||||
pub mount_type: MountType,
|
||||
/// Classification of this volume for UX decisions
|
||||
pub volume_type: VolumeType,
|
||||
/// Primary path where the volume is mounted
|
||||
pub mount_point: PathBuf,
|
||||
/// Additional mount points (for APFS volumes, etc.)
|
||||
@@ -118,6 +192,12 @@ pub struct Volume {
|
||||
/// Write speed in megabytes per second
|
||||
pub write_speed_mbps: Option<u64>,
|
||||
|
||||
/// Whether this volume should be visible in default views
|
||||
pub is_user_visible: bool,
|
||||
|
||||
/// Whether this volume should be auto-tracked
|
||||
pub auto_track_eligible: bool,
|
||||
|
||||
/// When this volume information was last updated
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -153,6 +233,9 @@ pub struct TrackedVolume {
|
||||
pub is_removable: Option<bool>,
|
||||
pub is_network_drive: Option<bool>,
|
||||
pub device_model: Option<String>,
|
||||
pub volume_type: String,
|
||||
pub is_user_visible: Option<bool>,
|
||||
pub auto_track_eligible: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<&Volume> for VolumeInfo {
|
||||
@@ -173,6 +256,7 @@ impl Volume {
|
||||
device_id: uuid::Uuid,
|
||||
name: String,
|
||||
mount_type: MountType,
|
||||
volume_type: VolumeType,
|
||||
mount_point: PathBuf,
|
||||
mount_points: Vec<PathBuf>,
|
||||
disk_type: DiskType,
|
||||
@@ -182,11 +266,14 @@ impl Volume {
|
||||
read_only: bool,
|
||||
hardware_id: Option<String>,
|
||||
) -> Self {
|
||||
let volume = Self {
|
||||
fingerprint: VolumeFingerprint::from_hex(""), // Will be set after creation
|
||||
let fingerprint = VolumeFingerprint::new(device_id, &mount_point, &name);
|
||||
|
||||
Self {
|
||||
fingerprint,
|
||||
device_id,
|
||||
name,
|
||||
mount_type,
|
||||
volume_type,
|
||||
mount_point,
|
||||
mount_points,
|
||||
is_mounted: true,
|
||||
@@ -199,13 +286,10 @@ impl Volume {
|
||||
total_bytes_available,
|
||||
read_speed_mbps: None,
|
||||
write_speed_mbps: None,
|
||||
is_user_visible: volume_type.show_by_default(),
|
||||
auto_track_eligible: volume_type.auto_track_by_default(),
|
||||
last_updated: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// Generate fingerprint after creation
|
||||
let mut volume = volume;
|
||||
volume.fingerprint = VolumeFingerprint::new(&volume);
|
||||
volume
|
||||
}
|
||||
}
|
||||
|
||||
/// Update volume information
|
||||
@@ -428,6 +512,7 @@ mod tests {
|
||||
uuid::Uuid::new_v4(),
|
||||
"Test Volume".to_string(),
|
||||
MountType::External,
|
||||
VolumeType::External,
|
||||
PathBuf::from("/mnt/test"),
|
||||
vec![],
|
||||
DiskType::SSD,
|
||||
@@ -438,11 +523,13 @@ mod tests {
|
||||
Some("test-hw-id".to_string()),
|
||||
);
|
||||
|
||||
let fingerprint = VolumeFingerprint::new(&volume);
|
||||
let fingerprint =
|
||||
VolumeFingerprint::new(&volume.device_id, &volume.mount_point, &volume.name);
|
||||
assert!(!fingerprint.0.is_empty());
|
||||
|
||||
// Same volume should produce same fingerprint
|
||||
let fingerprint2 = VolumeFingerprint::new(&volume);
|
||||
let fingerprint2 =
|
||||
VolumeFingerprint::new(&volume.device_id, &volume.mount_point, &volume.name);
|
||||
assert_eq!(fingerprint, fingerprint2);
|
||||
}
|
||||
|
||||
@@ -452,6 +539,7 @@ mod tests {
|
||||
uuid::Uuid::new_v4(),
|
||||
"Test".to_string(),
|
||||
MountType::System,
|
||||
VolumeType::System,
|
||||
PathBuf::from("/home"),
|
||||
vec![PathBuf::from("/home"), PathBuf::from("/mnt/home")],
|
||||
DiskType::SSD,
|
||||
|
||||
Reference in New Issue
Block a user