use std::path::Path; use sysinfo::{DiskKind, Disks}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StorageProfile { Fast, Balanced, Conservative, } #[derive(Debug, Clone, Copy)] pub struct RuntimeAdaptiveProfile { profile: StorageProfile, ema_ms_per_item: Option, } impl StorageProfile { pub fn index_batch_size(self) -> usize { match self { StorageProfile::Fast => 300, StorageProfile::Balanced => 180, StorageProfile::Conservative => 90, } } pub fn worker_batch_size(self) -> usize { match self { StorageProfile::Fast => 16, StorageProfile::Balanced => 10, StorageProfile::Conservative => 6, } } pub fn worker_fetch_size(self) -> usize { match self { StorageProfile::Fast => 96, StorageProfile::Balanced => 48, StorageProfile::Conservative => 24, } } pub fn thumbnail_workers(self, available_parallelism: usize) -> usize { match self { StorageProfile::Fast => (available_parallelism / 3).clamp(2, 4), StorageProfile::Balanced => (available_parallelism / 4).clamp(2, 3), StorageProfile::Conservative => 1, } } } impl RuntimeAdaptiveProfile { pub fn new(initial_profile: StorageProfile) -> Self { Self { profile: initial_profile, ema_ms_per_item: None, } } pub fn profile(self) -> StorageProfile { self.profile } pub fn observe_scan_batch(&mut self, item_count: usize, elapsed: std::time::Duration) { if item_count == 0 { return; } let ms_per_item = elapsed.as_secs_f64() * 1000.0 / item_count as f64; self.ema_ms_per_item = Some(match self.ema_ms_per_item { Some(existing) => (existing * 0.7) + (ms_per_item * 0.3), None => ms_per_item, }); let ema = self.ema_ms_per_item.unwrap_or(ms_per_item); self.profile = if ema >= 8.0 { StorageProfile::Conservative } else if ema >= 3.0 { StorageProfile::Balanced } else { StorageProfile::Fast }; } } pub fn detect_storage_profile(path: &Path) -> StorageProfile { let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let disks = Disks::new_with_refreshed_list(); let best_match = disks .list() .iter() .filter(|disk| canonical.starts_with(disk.mount_point())) .max_by_key(|disk| disk.mount_point().as_os_str().len()); let Some(disk) = best_match else { return fallback_profile_for_path(&canonical); }; if disk.is_removable() { return StorageProfile::Conservative; } match disk.kind() { DiskKind::SSD => StorageProfile::Fast, DiskKind::HDD => StorageProfile::Conservative, DiskKind::Unknown(_) => fallback_profile_for_path(&canonical), } } fn fallback_profile_for_path(path: &Path) -> StorageProfile { let path_str = path.to_string_lossy().to_lowercase(); if path_str.starts_with("\\\\") { return StorageProfile::Conservative; } if cfg!(target_os = "windows") { let drive = path_str.chars().next(); if drive.is_some_and(|letter| !matches!(letter, 'c' | 'd')) { return StorageProfile::Balanced; } } StorageProfile::Balanced } #[cfg(test)] mod tests { use super::*; use std::time::Duration; #[test] fn thumbnail_workers_scale_with_parallelism_within_clamps() { assert_eq!(StorageProfile::Fast.thumbnail_workers(3), 2); assert_eq!(StorageProfile::Fast.thumbnail_workers(12), 4); assert_eq!(StorageProfile::Fast.thumbnail_workers(64), 4); assert_eq!(StorageProfile::Balanced.thumbnail_workers(4), 2); assert_eq!(StorageProfile::Balanced.thumbnail_workers(12), 3); assert_eq!(StorageProfile::Balanced.thumbnail_workers(64), 3); assert_eq!(StorageProfile::Conservative.thumbnail_workers(64), 1); } #[test] fn adaptive_profile_tracks_scan_speed() { let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Balanced); assert_eq!(adaptive.profile(), StorageProfile::Balanced); // 1 ms/item → fast storage. adaptive.observe_scan_batch(10, Duration::from_millis(10)); assert_eq!(adaptive.profile(), StorageProfile::Fast); // A very slow batch drags the EMA over the conservative threshold. adaptive.observe_scan_batch(1, Duration::from_millis(100)); assert_eq!(adaptive.profile(), StorageProfile::Conservative); } #[test] fn adaptive_profile_ignores_empty_batches() { let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Fast); adaptive.observe_scan_batch(0, Duration::from_secs(10)); assert_eq!(adaptive.profile(), StorageProfile::Fast); } #[test] fn fallback_profile_treats_unc_paths_as_conservative() { assert_eq!( fallback_profile_for_path(Path::new("\\\\server\\share\\photos")), StorageProfile::Conservative ); assert_eq!( fallback_profile_for_path(Path::new("C:\\photos")), StorageProfile::Balanced ); } }