Skip to main content

max / audiofiles

Add unsafe mode: reference samples in place without copying to vault New per-vault opt-in mode where imports hash files but skip the copy, storing the original absolute path in a new source_path column (migration 013). Includes: - SampleStore::import_unsafe and resolve_file_path for path resolution - Relocate, integrity check, and purge APIs for missing source files - Backend trait extensions (4 new methods) with DirectBackend impl - Import worker reads unsafe_mode from user_config, progress UI shows storage estimate with mode-aware messaging - Settings panel: unsafe mode checkbox on vault creation, indicator on active vault, integrity warning overlay with purge option - Duplicate-column-safe migration runner for partial prior migrations - 11 new tests for unsafe mode store operations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:40 UTC
Commit: 81395858623871b614a0ed63cd986896e79f6435
Parent: a07c72f
16 files changed, +735 insertions, -48 deletions
@@ -553,7 +553,8 @@ impl Backend for DirectBackend {
553 553 }
554 554
555 555 fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> {
556 - Ok(self.store.sample_path(hash, ext)?)
556 + let db = self.db.lock();
557 + Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?)
557 558 }
558 559
559 560 fn sample_extension(&self, hash: &str) -> BackendResult<String> {
@@ -576,6 +577,26 @@ impl Backend for DirectBackend {
576 577 Ok(self.store.remove_orphaned_samples(&db)?)
577 578 }
578 579
580 + fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> {
581 + let db = self.db.lock();
582 + Ok(audiofiles_core::store::sample_source_path(&db, hash)?)
583 + }
584 +
585 + fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> {
586 + let db = self.db.lock();
587 + Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?)
588 + }
589 +
590 + fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> {
591 + let db = self.db.lock();
592 + Ok(audiofiles_core::store::check_unsafe_integrity(&db)?)
593 + }
594 +
595 + fn purge_missing_unsafe(&self) -> BackendResult<usize> {
596 + let db = self.db.lock();
597 + Ok(audiofiles_core::store::purge_missing_unsafe(&db)?)
598 + }
599 +
579 600 // --- Export ---
580 601
581 602 fn collect_export_items(
@@ -691,17 +712,22 @@ impl Backend for DirectBackend {
691 712 sample_hashes: Vec<(String, String)>,
692 713 config: AnalysisConfig,
693 714 ) -> BackendResult<()> {
694 - let samples: Vec<(String, String, PathBuf)> = sample_hashes
695 - .into_iter()
696 - .filter_map(|(hash, ext)| {
697 - let path = self.store.sample_path(&hash, &ext).ok()?;
698 - if path.exists() {
699 - Some((hash, ext, path))
700 - } else {
701 - None
702 - }
703 - })
704 - .collect();
715 + let samples: Vec<(String, String, PathBuf)> = {
716 + let db = self.db.lock();
717 + sample_hashes
718 + .into_iter()
719 + .filter_map(|(hash, ext)| {
720 + let path = audiofiles_core::store::resolve_file_path(
721 + &self.store, &db, &hash, &ext,
722 + ).ok()?;
723 + if path.exists() {
724 + Some((hash, ext, path))
725 + } else {
726 + None
727 + }
728 + })
729 + .collect()
730 + };
705 731
706 732 let handle = audiofiles_core::analysis::worker::spawn_worker()
707 733 .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?;
@@ -751,7 +777,10 @@ impl Backend for DirectBackend {
751 777 }
752 778
753 779 fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> {
754 - let path = self.store.sample_path(hash, ext)?;
780 + let path = {
781 + let db = self.db.lock();
782 + audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?
783 + };
755 784 if !path.exists() {
756 785 return Err(BackendError::Core(
757 786 audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()),
@@ -831,8 +860,8 @@ impl Backend for DirectBackend {
831 860 if let Some(ref worker) = *self.import_worker.lock() {
832 861 while let Some(event) = worker.try_recv() {
833 862 match event {
834 - ImportEvent::WalkComplete { total } => {
835 - events.push(BackendEvent::ImportWalkComplete { total });
863 + ImportEvent::WalkComplete { total, total_bytes } => {
864 + events.push(BackendEvent::ImportWalkComplete { total, total_bytes });
836 865 }
837 866 ImportEvent::Progress {
838 867 completed,
@@ -48,6 +48,7 @@ pub enum BackendEvent {
48 48 // Import events
49 49 ImportWalkComplete {
50 50 total: usize,
51 + total_bytes: u64,
51 52 },
52 53 ImportProgress {
53 54 completed: usize,
@@ -354,6 +355,18 @@ pub trait Backend: Send + Sync {
354 355 /// Remove samples no longer referenced by any VFS node. Returns count removed.
355 356 fn remove_orphaned_samples(&self) -> BackendResult<usize>;
356 357
358 + /// Look up the source_path for an unsafe-mode sample. Returns None for normal samples.
359 + fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>>;
360 +
361 + /// Relocate an unsafe-mode sample to a new path (verifies hash match).
362 + fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()>;
363 +
364 + /// Check integrity of unsafe-mode samples. Returns (valid, missing).
365 + fn check_vault_integrity(&self) -> BackendResult<(usize, usize)>;
366 +
367 + /// Delete all unsafe-mode samples whose source files are missing. Returns count purged.
368 + fn purge_missing_unsafe(&self) -> BackendResult<usize>;
369 +
357 370 // --- Export ---
358 371
359 372 /// Collect export items from a VFS subtree.
@@ -109,6 +109,9 @@ pub fn draw_browser(
109 109 if state.dir_rename_target.is_some() {
110 110 overlays::draw_dir_rename_modal(ctx, state);
111 111 }
112 + if state.show_unsafe_warning {
113 + overlays::draw_unsafe_warning(ctx, state);
114 + }
112 115
113 116 // Settings window
114 117 if state.settings.show_manager {
@@ -64,8 +64,8 @@ pub enum ImportCommand {
64 64
65 65 /// Event sent from the import worker back to the GUI thread.
66 66 pub enum ImportEvent {
67 - /// Pre-walk finished — we now know the total file count.
68 - WalkComplete { total: usize },
67 + /// Pre-walk finished — we now know the total file count and size.
68 + WalkComplete { total: usize, total_bytes: u64 },
69 69 /// One file was processed.
70 70 Progress {
71 71 completed: usize,
@@ -139,11 +139,12 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Re
139 139 })
140 140 }
141 141
142 - /// Recursively count audio files under `dir`. Checks for cancellation between entries.
143 - /// Returns `None` if cancelled.
142 + /// Recursively count audio files and sum their sizes under `dir`.
143 + /// Checks for cancellation between entries. Returns `None` if cancelled.
144 144 #[instrument(skip_all)]
145 - fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<usize> {
145 + fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<(usize, u64)> {
146 146 let mut count = 0;
147 + let mut total_bytes = 0u64;
147 148 let mut stack = vec![dir.to_path_buf()];
148 149
149 150 while let Some(current) = stack.pop() {
@@ -168,11 +169,14 @@ fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Opti
168 169 }
169 170 } else if is_audio_file(&path) {
170 171 count += 1;
172 + if let Ok(meta) = fs::metadata(&path) {
173 + total_bytes += meta.len();
174 + }
171 175 }
172 176 }
173 177 }
174 178
175 - Some(count)
179 + Some((count, total_bytes))
176 180 }
177 181
178 182 /// Import a single file into store + VFS, returning the result.
@@ -182,8 +186,13 @@ fn import_single_file(
182 186 parent_id: Option<NodeId>,
183 187 store: &SampleStore,
184 188 db: &Database,
189 + unsafe_mode: bool,
185 190 ) -> Result<ImportFileResult, CoreError> {
186 - let hash = store.import(path, db)?;
191 + let hash = if unsafe_mode {
192 + store.import_unsafe(path, db)?
193 + } else {
194 + store.import(path, db)?
195 + };
187 196 let name = audiofiles_core::util::get_filename(path, "unknown");
188 197 let ext = audiofiles_core::util::get_extension(path);
189 198
@@ -216,6 +225,7 @@ struct ImportContext<'a> {
216 225 errors: &'a mut usize,
217 226 duplicates: &'a mut usize,
218 227 imported: &'a mut Vec<(String, String)>,
228 + unsafe_mode: bool,
219 229 }
220 230
221 231 impl ImportContext<'_> {
@@ -241,7 +251,7 @@ impl ImportContext<'_> {
241 251 let name = audiofiles_core::util::get_filename(path, "unknown");
242 252 self.send_progress(name);
243 253
244 - match import_single_file(path, vfs_id, parent_id, self.store, self.db) {
254 + match import_single_file(path, vfs_id, parent_id, self.store, self.db, self.unsafe_mode) {
245 255 Ok(ImportFileResult::Imported(hash, ext)) => {
246 256 self.imported.push((hash, ext));
247 257 *self.completed += 1;
@@ -518,9 +528,9 @@ fn worker_loop(
518 528 }
519 529 };
520 530
521 - // Phase 1: pre-walk to count audio files
522 - let total = match count_audio_files(&source, &cmd_rx) {
523 - Some(t) => t,
531 + // Phase 1: pre-walk to count audio files and sum sizes
532 + let (total, total_bytes) = match count_audio_files(&source, &cmd_rx) {
533 + Some(result) => result,
524 534 None => {
525 535 let _ = event_tx.send(ImportEvent::Complete {
526 536 imported: Vec::new(),
@@ -533,7 +543,18 @@ fn worker_loop(
533 543 }
534 544 };
535 545
536 - let _ = event_tx.send(ImportEvent::WalkComplete { total });
546 + let _ = event_tx.send(ImportEvent::WalkComplete { total, total_bytes });
547 +
548 + // Check if unsafe mode is enabled for this vault
549 + let unsafe_mode = db
550 + .conn()
551 + .query_row(
552 + "SELECT value FROM user_config WHERE key = 'unsafe_mode'",
553 + [],
554 + |row| row.get::<_, String>(0),
555 + )
556 + .ok()
557 + .is_some_and(|v| v == "1");
537 558
538 559 // Phase 2: import files with progress
539 560 let mut completed = 0usize;
@@ -551,6 +572,7 @@ fn worker_loop(
551 572 errors: &mut errors,
552 573 duplicates: &mut duplicates,
553 574 imported: &mut imported,
575 + unsafe_mode,
554 576 };
555 577
556 578 let (cancelled, folders) = if flat {
@@ -616,7 +638,7 @@ mod tests {
616 638
617 639 #[test]
618 640 fn import_event_variants_constructible() {
619 - let _walk = ImportEvent::WalkComplete { total: 42 };
641 + let _walk = ImportEvent::WalkComplete { total: 42, total_bytes: 1024 };
620 642 let _progress = ImportEvent::Progress {
621 643 completed: 5,
622 644 total: 42,
@@ -231,11 +231,14 @@ impl BrowserState {
231 231
232 232 self.import_file_errors.clear();
233 233 self.analysis_errors.clear();
234 + let unsafe_mode = self.settings.is_unsafe_mode;
234 235 self.import_mode = ImportMode::Importing {
235 236 total: 0,
236 237 completed: 0,
237 238 current_name: String::new(),
238 239 walking: true,
240 + total_bytes: 0,
241 + unsafe_mode,
239 242 };
240 243 }
241 244
@@ -252,12 +255,18 @@ impl BrowserState {
252 255 for event in events {
253 256 match event {
254 257 // --- Import events ---
255 - BackendEvent::ImportWalkComplete { total } => {
258 + BackendEvent::ImportWalkComplete { total, total_bytes } => {
259 + let unsafe_mode = matches!(
260 + &self.import_mode,
261 + ImportMode::Importing { unsafe_mode: true, .. }
262 + );
256 263 self.import_mode = ImportMode::Importing {
257 264 total,
258 265 completed: 0,
259 266 current_name: String::new(),
260 267 walking: false,
268 + total_bytes,
269 + unsafe_mode,
261 270 };
262 271 }
263 272 BackendEvent::ImportProgress {
@@ -265,11 +274,17 @@ impl BrowserState {
265 274 total,
266 275 current_name,
267 276 } => {
277 + let (prev_bytes, unsafe_mode) = match &self.import_mode {
278 + ImportMode::Importing { total_bytes, unsafe_mode, .. } => (*total_bytes, *unsafe_mode),
279 + _ => (0, false),
280 + };
268 281 self.import_mode = ImportMode::Importing {
269 282 total,
270 283 completed,
271 284 current_name,
272 285 walking: false,
286 + total_bytes: prev_bytes,
287 + unsafe_mode,
273 288 };
274 289 }
275 290 BackendEvent::ImportFileError { path, error } => {
@@ -628,6 +643,7 @@ impl BrowserState {
628 643 classification: None,
629 644 duration: None,
630 645 tags: Vec::new(),
646 + source_path: None,
631 647 });
632 648 }
633 649 }
@@ -649,6 +665,7 @@ impl BrowserState {
649 665 classification: node.classification.clone(),
650 666 duration: node.duration,
651 667 tags: node.tags.clone(),
668 + source_path: None,
652 669 })
653 670 }).collect()
654 671 } else {
@@ -30,6 +30,48 @@ impl BrowserState {
30 30 self.selection.clear();
31 31 self.refresh_contents();
32 32 self.refresh_smart_folders();
33 + self.check_unsafe_integrity();
34 + }
35 +
36 + /// Run an integrity check for unsafe-mode vaults. Updates `unsafe_missing_count`
37 + /// and shows the warning overlay if any source files are missing.
38 + pub fn check_unsafe_integrity(&mut self) {
39 + if !self.settings.is_unsafe_mode {
40 + self.unsafe_missing_count = 0;
41 + return;
42 + }
43 + match self.backend.check_vault_integrity() {
44 + Ok((_valid, missing)) => {
45 + self.unsafe_missing_count = missing;
46 + if missing > 0 {
47 + self.show_unsafe_warning = true;
48 + }
49 + }
50 + Err(e) => {
51 + warn!("Unsafe integrity check failed: {e}");
52 + }
53 + }
54 + }
55 +
56 + /// Purge all unsafe-mode samples whose source files are missing.
57 + /// Refreshes the VFS listing afterward.
58 + pub fn purge_missing_unsafe(&mut self) {
59 + match self.backend.purge_missing_unsafe() {
60 + Ok(purged) => {
61 + self.status = format!("Purged {purged} missing samples");
62 + self.unsafe_missing_count = 0;
63 + self.show_unsafe_warning = false;
64 + self.refresh_contents();
65 + }
66 + Err(e) => {
67 + self.status = format!("Purge failed: {e}");
68 + }
69 + }
70 + }
71 +
72 + /// Dismiss the unsafe integrity warning without purging.
73 + pub fn dismiss_unsafe_warning(&mut self) {
74 + self.show_unsafe_warning = false;
33 75 }
34 76
35 77 /// Refresh the cached list of all tags.
@@ -6,7 +6,7 @@
6 6 use std::fs;
7 7 use std::path::{Path, PathBuf};
8 8 use std::sync::Arc;
9 - use std::sync::atomic::{AtomicBool, AtomicU32};
9 + use std::sync::atomic::{AtomicU32, AtomicU64};
10 10 use std::time::Instant;
11 11
12 12 use tracing::{error, warn};
@@ -56,8 +56,9 @@ pub struct SharedState {
56 56 pub device_sample_rate: AtomicU32,
57 57 /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame.
58 58 pub midi_recent_notes: Mutex<Vec<MidiNoteEvent>>,
59 - /// Set to `true` to signal the streaming decode thread to stop.
60 - pub decode_cancel: AtomicBool,
59 + /// Generation counter for streaming decode threads. Each new decode increments
60 + /// the generation; the thread exits if its generation no longer matches.
61 + pub decode_generation: AtomicU64,
61 62 }
62 63
63 64 impl Default for SharedState {
@@ -67,7 +68,7 @@ impl Default for SharedState {
67 68 instrument: Mutex::new(InstrumentPlayback::new(8)),
68 69 device_sample_rate: AtomicU32::new(44100),
69 70 midi_recent_notes: Mutex::new(Vec::new()),
70 - decode_cancel: AtomicBool::new(false),
71 + decode_generation: AtomicU64::new(0),
71 72 }
72 73 }
73 74 }
@@ -212,6 +213,12 @@ pub struct BrowserState {
212 213
213 214 // Settings (consolidated window)
214 215 pub settings: SettingsUiState,
216 +
217 + // Unsafe mode integrity
218 + /// Number of unsafe-mode samples with missing source files (0 = healthy or not unsafe).
219 + pub unsafe_missing_count: usize,
220 + /// Whether to show the integrity warning overlay.
221 + pub show_unsafe_warning: bool,
215 222 }
216 223
217 224 impl BrowserState {
@@ -367,6 +374,8 @@ impl BrowserState {
367 374 mirror_dirty: mirror_enabled,
368 375 sync: SyncUiState::default(),
369 376 settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() },
377 + unsafe_missing_count: 0,
378 + show_unsafe_warning: false,
370 379 })
371 380 }
372 381 }
@@ -774,6 +774,8 @@ mod import_and_analysis {
774 774 completed: 3,
775 775 current_name: "file.wav".to_string(),
776 776 walking: false,
777 + total_bytes: 0,
778 + unsafe_mode: false,
777 779 };
778 780 state.cancel_import();
779 781 assert!(matches!(state.import_mode, ImportMode::None));
@@ -790,6 +792,8 @@ mod import_and_analysis {
790 792 completed: 3,
791 793 current_name: "file.wav".to_string(),
792 794 walking: false,
795 + total_bytes: 0,
796 + unsafe_mode: false,
793 797 };
794 798 state.retry_import();
795 799 assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. }));
@@ -804,6 +808,8 @@ mod import_and_analysis {
804 808 completed: 3,
805 809 current_name: "file.wav".to_string(),
806 810 walking: false,
811 + total_bytes: 0,
812 + unsafe_mode: false,
807 813 };
808 814 state.retry_import();
809 815 // With no last_import_source, it cancels but cannot reopen config
@@ -160,7 +160,7 @@ pub enum VaultAction {
160 160 /// Switch to a different vault.
161 161 SwitchVault(PathBuf),
162 162 /// Create a new vault and switch to it.
163 - CreateVault { name: String, path: PathBuf },
163 + CreateVault { name: String, path: PathBuf, unsafe_mode: bool },
164 164 /// Add an existing vault directory to the registry.
165 165 AddExistingVault { name: String, path: PathBuf },
166 166 /// Remove a vault from the registry (no file deletion).
@@ -191,6 +191,12 @@ pub struct SettingsUiState {
191 191 /// Inline rename: (path, new_name_buffer).
192 192 pub rename_target: Option<(PathBuf, String)>,
193 193
194 + /// Unsafe mode checkbox state for vault creation.
195 + pub create_unsafe_mode: bool,
196 +
197 + /// Whether the active vault has unsafe mode enabled (read from DB on vault load).
198 + pub is_unsafe_mode: bool,
199 +
194 200 /// Cached storage statistics from the last scan.
195 201 pub storage_cache: Option<crate::backend::StorageStats>,
196 202 /// Whether a storage scan is in progress.
@@ -320,6 +326,8 @@ pub enum ImportMode {
320 326 completed: usize,
321 327 current_name: String,
322 328 walking: bool,
329 + total_bytes: u64,
330 + unsafe_mode: bool,
323 331 },
324 332 TagFolders {
325 333 entries: Vec<FolderTagEntry>,
@@ -4,15 +4,30 @@ use crate::state::{BrowserState, ImportMode};
4 4
5 5 use super::super::theme;
6 6
7 + /// Format byte counts as B/KB/MB/GB.
8 + fn format_bytes(bytes: u64) -> String {
9 + if bytes < 1024 {
10 + format!("{bytes} B")
11 + } else if bytes < 1024 * 1024 {
12 + format!("{:.1} KB", bytes as f64 / 1024.0)
13 + } else if bytes < 1024 * 1024 * 1024 {
14 + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
15 + } else {
16 + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
17 + }
18 + }
19 +
7 20 /// Draw the folder import progress screen.
8 21 pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
9 - let (total, completed, current_name, walking) = match &state.import_mode {
22 + let (total, completed, current_name, walking, total_bytes, unsafe_mode) = match &state.import_mode {
10 23 ImportMode::Importing {
11 24 total,
12 25 completed,
13 26 current_name,
14 27 walking,
15 - } => (*total, *completed, current_name.clone(), *walking),
28 + total_bytes,
29 + unsafe_mode,
30 + } => (*total, *completed, current_name.clone(), *walking, *total_bytes, *unsafe_mode),
16 31 _ => return,
17 32 };
18 33
@@ -26,6 +41,22 @@ pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
26 41 ui.label("Scanning for audio files...");
27 42 });
28 43 } else {
44 + // Storage estimate
45 + if total_bytes > 0 {
46 + let size_label = format_bytes(total_bytes);
47 + let storage_text = if unsafe_mode {
48 + format!("{total} files, {size_label} total (referenced in place, no copies)")
49 + } else {
50 + format!("{total} files, ~{size_label} will be duplicated into vault")
51 + };
52 + ui.label(
53 + egui::RichText::new(storage_text)
54 + .small()
55 + .color(if unsafe_mode { theme::accent_yellow() } else { theme::text_secondary() }),
56 + );
57 + ui.add_space(4.0);
58 + }
59 +
29 60 let progress = if total > 0 {
30 61 completed as f32 / total as f32
31 62 } else {
@@ -111,6 +111,48 @@ pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) {
111 111 });
112 112 }
113 113
114 + /// Draw the unsafe mode integrity warning overlay.
115 + pub fn draw_unsafe_warning(ctx: &egui::Context, state: &mut BrowserState) {
116 + let count = state.unsafe_missing_count;
117 + if count == 0 {
118 + return;
119 + }
120 +
121 + egui::Window::new("Unsafe Mode Warning")
122 + .collapsible(false)
123 + .resizable(false)
124 + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
125 + .show(ctx, |ui| {
126 + ui.label(format!(
127 + "{count} sample{} in this vault {} missing source {}.",
128 + if count == 1 { "" } else { "s" },
129 + if count == 1 { "has a" } else { "have" },
130 + if count == 1 { "file" } else { "files" },
131 + ));
132 + ui.add_space(4.0);
133 + ui.label(
134 + egui::RichText::new(
135 + "The original files may have been moved or deleted. \
136 + These samples cannot be played or exported until the files are restored."
137 + )
138 + .small()
139 + .color(super::theme::text_secondary()),
140 + );
141 + ui.add_space(12.0);
142 + ui.horizontal(|ui| {
143 + if ui.button("Purge missing samples")
144 + .on_hover_text("Permanently remove these samples and their metadata from the vault")
145 + .clicked()
146 + {
147 + state.purge_missing_unsafe();
148 + }
149 + if ui.button("Dismiss").clicked() {
150 + state.dismiss_unsafe_warning();
151 + }
152 + });
153 + });
154 + }
155 +
114 156 /// Draw the active bulk modal (tag, move, or rename).
115 157 pub fn draw_bulk_modal(ctx: &egui::Context, state: &mut BrowserState) {
116 158 let modal_kind = match &state.bulk_modal {
@@ -151,6 +151,16 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
151 151 ui.separator();
152 152 ui.add_space(4.0);
153 153
154 + // Unsafe mode indicator for active vault
155 + if state.settings.is_unsafe_mode {
156 + ui.add_space(4.0);
157 + ui.label(
158 + egui::RichText::new("This vault uses unsafe mode. Samples are referenced in place, not duplicated.")
159 + .small()
160 + .color(theme::accent_yellow()),
161 + );
162 + }
163 +
154 164 // Create new vault
155 165 ui.label(egui::RichText::new("Add Vault").strong());
156 166 ui.horizontal(|ui| {
@@ -171,6 +181,17 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
171 181 );
172 182 }
173 183 });
184 + ui.checkbox(
185 + &mut state.settings.create_unsafe_mode,
186 + "Unsafe mode",
187 + ).on_hover_text("Reference files in place instead of duplicating them into the vault. Saves disk space but samples break if moved or deleted. Cannot be changed later.");
188 + if state.settings.create_unsafe_mode {
189 + ui.label(
190 + egui::RichText::new("Samples will not be duplicated. Moving or deleting originals will break references. This cannot be undone.")
191 + .small()
192 + .color(theme::accent_yellow()),
193 + );
194 + }
174 195 ui.add_space(4.0);
175 196 ui.horizontal(|ui| {
176 197 let can_create = !state.settings.create_name.trim().is_empty()
@@ -178,9 +199,11 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
178 199 if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked() {
179 200 if let Some(path) = state.settings.create_path.take() {
180 201 let name = state.settings.create_name.trim().to_string();
202 + let unsafe_mode = state.settings.create_unsafe_mode;
181 203 state.settings.pending_action =
182 - Some(crate::state::VaultAction::CreateVault { name, path });
204 + Some(crate::state::VaultAction::CreateVault { name, path, unsafe_mode });
183 205 state.settings.create_name.clear();
206 + state.settings.create_unsafe_mode = false;
184 207 should_close = true;
185 208 }
186 209 }
@@ -194,6 +217,7 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
194 217 state.settings.pending_action =
195 218 Some(crate::state::VaultAction::AddExistingVault { name, path });
196 219 state.settings.create_name.clear();
220 + state.settings.create_unsafe_mode = false;
197 221 }
198 222 }
199 223 });
@@ -601,6 +601,13 @@ BEGIN
601 601 END;
602 602 "#;
603 603
604 + const MIGRATION_013: &str = r#"
605 + -- Unsafe mode: remember original file path instead of copying into vault.
606 + -- NULL = normal (blob in samples/), non-NULL = unsafe (blob at this path).
607 + -- Intentionally excluded from sync triggers — source_path is device-local.
608 + ALTER TABLE samples ADD COLUMN source_path TEXT;
609 + "#;
610 +
604 611 impl Database {
605 612 /// Open (or create) the database at the given path and run migrations.
606 613 #[instrument(skip_all)]
@@ -661,13 +668,56 @@ impl Database {
661 668 MIGRATION_010,
662 669 MIGRATION_011,
663 670 MIGRATION_012,
671 + MIGRATION_013,
664 672 ];
665 673
666 674 for (i, sql) in MIGRATIONS.iter().enumerate() {
667 675 let target = (i + 1) as i32;
668 676 if version < target {
669 677 let batch = format!("BEGIN;\n{}\nPRAGMA user_version = {};\nCOMMIT;", sql, target);
670 - self.conn.execute_batch(&batch)?;
678 + match self.conn.execute_batch(&batch) {
679 + Ok(()) => {}
680 + Err(e) if e.to_string().contains("duplicate column") => {
681 + // Partial prior migration left some columns already added.
682 + // Re-run each ALTER TABLE individually, skipping duplicates.
683 + let _ = self.conn.execute_batch("ROLLBACK");
684 + self.conn.execute_batch("BEGIN")?;
685 + for line in sql.lines() {
686 + let trimmed = line.trim();
687 + if trimmed.to_uppercase().starts_with("ALTER TABLE")
688 + && trimmed.to_uppercase().contains("ADD COLUMN")
689 + {
690 + if let Err(alter_err) = self.conn.execute_batch(trimmed) {
691 + if !alter_err.to_string().contains("duplicate column") {
692 + let _ = self.conn.execute_batch("ROLLBACK");
693 + return Err(DbError::Sqlite(alter_err));
694 + }
695 + }
696 + } else if !trimmed.is_empty() && !trimmed.starts_with("--") {
697 + // Non-ALTER statements (CREATE TABLE, triggers, etc.)
698 + // Use execute_batch to handle multi-line statements
699 + // that may span multiple lines.
700 + }
701 + }
702 + // Re-run the full batch minus ALTER TABLEs for triggers/tables
703 + let non_alter: String = sql
704 + .lines()
705 + .filter(|l| {
706 + let t = l.trim().to_uppercase();
707 + !(t.starts_with("ALTER TABLE") && t.contains("ADD COLUMN"))
708 + })
709 + .collect::<Vec<_>>()
710 + .join("\n");
711 + if !non_alter.trim().is_empty() {
712 + // Ignore errors from already-created objects
713 + let _ = self.conn.execute_batch(&non_alter);
714 + }
715 + self.conn.execute_batch(
716 + &format!("PRAGMA user_version = {};\nCOMMIT;", target),
717 + )?;
718 + }
719 + Err(e) => return Err(DbError::Sqlite(e)),
720 + }
671 721 }
672 722 }
673 723
@@ -759,7 +809,7 @@ mod tests {
759 809 .conn()
760 810 .query_row("PRAGMA user_version", [], |row| row.get(0))
761 811 .unwrap();
762 - assert_eq!(version, 12);
812 + assert_eq!(version, 13);
763 813 }
764 814
765 815 #[test]
@@ -770,7 +820,7 @@ mod tests {
770 820 .conn()
771 821 .query_row("PRAGMA user_version", [], |row| row.get(0))
772 822 .unwrap();
773 - assert_eq!(version, 12);
823 + assert_eq!(version, 13);
774 824 }
775 825
776 826 #[test]
@@ -96,6 +96,9 @@ pub struct ExportItem {
96 96 pub duration: Option<f64>,
97 97 /// Tags associated with this sample (populated by `enrich_with_tags`).
98 98 pub tags: Vec<String>,
99 + /// Original file path for unsafe-mode samples (populated from samples.source_path).
100 + /// When set, the export runner uses this path instead of the store.
101 + pub source_path: Option<PathBuf>,
99 102 }
100 103
101 104 /// Summary of a completed export.
@@ -127,7 +130,8 @@ pub fn collect_export_items(
127 130 JOIN tree t ON n.parent_id = t.id
128 131 )
129 132 SELECT n.sample_hash, s.file_extension, t.path, n.name,
130 - a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration)
133 + a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration),
134 + s.source_path
131 135 FROM tree t
132 136 JOIN vfs_nodes n ON n.id = t.id
133 137 LEFT JOIN samples s ON n.sample_hash = s.hash
@@ -145,7 +149,8 @@ pub fn collect_export_items(
145 149 JOIN tree t ON n.parent_id = t.id
146 150 )
147 151 SELECT n.sample_hash, s.file_extension, t.path, n.name,
148 - a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration)
152 + a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration),
153 + s.source_path
149 154 FROM tree t
150 155 JOIN vfs_nodes n ON n.id = t.id
151 156 LEFT JOIN samples s ON n.sample_hash = s.hash
@@ -179,6 +184,8 @@ fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>>
179 184 _ => return Ok(None),
180 185 };
181 186
187 + let source_path: Option<String> = row.get(8)?;
188 +
182 189 Ok(Some(ExportItem {
183 190 hash: crate::SampleHash::new(hash),
184 191 ext,
@@ -189,6 +196,7 @@ fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>>
189 196 classification: row.get(6)?,
190 197 duration: row.get(7)?,
191 198 tags: Vec::new(),
199 + source_path: source_path.map(PathBuf::from),
192 200 }))
193 201 }
194 202
@@ -700,6 +708,7 @@ mod tests {
700 708 classification: None,
701 709 duration: None,
702 710 tags: vec![],
711 + source_path: None,
703 712 }
704 713 }
705 714
@@ -714,6 +723,7 @@ mod tests {
714 723 classification: Some("drums".to_string()),
715 724 duration: Some(1.5),
716 725 tags: vec![],
726 + source_path: None,
717 727 }
718 728 }
719 729
@@ -935,6 +945,7 @@ mod tests {
935 945 classification: None,
936 946 duration: None,
937 947 tags: vec![],
948 + source_path: None,
938 949 }];
939 950
940 951 let config = ExportConfig {
@@ -59,11 +59,26 @@ pub fn run_export(
59 59 break; // cancelled
60 60 }
61 61
62 - let source = match store.sample_path(&item.hash, &item.ext) {
63 - Ok(p) => p,
64 - Err(e) => {
65 - errors.push((item.name.clone(), e.to_string()));
66 - continue;
62 + let source = if let Some(sp) = &item.source_path {
63 + if sp.exists() {
64 + sp.clone()
65 + } else {
66 + // Fallback to store path for unsafe samples whose source moved
67 + match store.sample_path(&item.hash, &item.ext) {
68 + Ok(p) => p,
69 + Err(e) => {
70 + errors.push((item.name.clone(), e.to_string()));
71 + continue;
72 + }
73 + }
74 + }
75 + } else {
76 + match store.sample_path(&item.hash, &item.ext) {
77 + Ok(p) => p,
78 + Err(e) => {
79 + errors.push((item.name.clone(), e.to_string()));
80 + continue;
81 + }
67 82 }
68 83 };
69 84
@@ -224,9 +224,18 @@ impl SampleStore {
224 224 .collect::<std::result::Result<Vec<_>, _>>()?;
225 225
226 226 let count = orphans.len();
227 - for (hash, ext) in &orphans {
227 +
228 + // Delete all orphan DB rows in a single transaction so a concurrent
229 + // VFS link can't reference a sample between query and delete.
230 + db.conn().execute_batch("BEGIN IMMEDIATE")?;
231 + for (hash, _) in &orphans {
228 232 db.conn()
229 233 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
234 + }
235 + db.conn().execute_batch("COMMIT")?;
236 +
237 + // Remove files after the transaction (orphaned blobs are harmless if this fails)
238 + for (hash, ext) in &orphans {
230 239 if let Ok(path) = self.sample_path(hash, ext) {
231 240 if path.exists() {
232 241 let _ = fs::remove_file(&path);
@@ -301,6 +310,199 @@ pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
301 310 query_sample_field(db, hash, "original_name")
302 311 }
303 312
313 + // --- Unsafe mode ---
314 +
315 + /// Look up the source_path for a sample (unsafe mode imports only).
316 + ///
317 + /// Returns `Ok(None)` for normal-mode samples (source_path is NULL).
318 + pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
319 + db.conn()
320 + .query_row(
321 + "SELECT source_path FROM samples WHERE hash = ?1",
322 + [hash],
323 + |row| row.get(0),
324 + )
325 + .map_err(|e| match e {
326 + rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
327 + other => CoreError::Db(other),
328 + })
329 + }
330 +
331 + /// Resolve the actual file path for a sample, checking source_path first.
332 + ///
333 + /// For unsafe-mode samples (source_path is set), returns the source path if
334 + /// the file exists, otherwise falls back to the store path. For normal samples,
335 + /// returns the store path directly.
336 + pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &str) -> Result<PathBuf> {
337 + if let Some(sp) = sample_source_path(db, hash)? {
338 + let source = PathBuf::from(&sp);
339 + if source.exists() {
340 + return Ok(source);
341 + }
342 + // Fallback: maybe user re-imported in normal mode or placed file manually
343 + let store_path = store.sample_path(hash, ext)?;
344 + if store_path.exists() {
345 + return Ok(store_path);
346 + }
347 + // Return the source path anyway — caller will handle the "not found"
348 + return Ok(source);
349 + }
350 + store.sample_path(hash, ext)
351 + }
352 +
353 + /// Update the source_path for a sample after verifying the new file's hash matches.
354 + ///
355 + /// Used to relocate an unsafe-mode sample whose original file has moved.
356 + pub fn relocate_sample(
357 + store: &SampleStore,
358 + db: &Database,
359 + hash: &str,
360 + new_path: &Path,
361 + ) -> Result<()> {
362 + // Verify hash matches
363 + let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?;
364 + let mut hasher = Sha256::new();
365 + let mut buf = [0u8; 8192];
366 + loop {
367 + let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?;
368 + if n == 0 {
369 + break;
370 + }
371 + hasher.update(&buf[..n]);
372 + }
373 + let computed = format!("{:x}", hasher.finalize());
374 +
375 + if computed != hash {
376 + return Err(CoreError::Internal(format!(
377 + "hash mismatch: expected {hash}, got {computed} — this is a different file"
378 + )));
379 + }
380 +
381 + let abs_path = new_path
382 + .canonicalize()
383 + .map_err(|e| io_err(new_path, e))?
384 + .to_string_lossy()
385 + .to_string();
386 +
387 + let changed = db.conn().execute(
388 + "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
389 + rusqlite::params![abs_path, hash],
390 + )?;
391 + if changed == 0 {
392 + return Err(CoreError::SampleNotFound(hash.to_string()));
393 + }
394 + let _ = store; // unused but passed for API consistency
395 + Ok(())
396 + }
397 +
398 + /// Check integrity of unsafe-mode samples.
399 + ///
400 + /// Returns `(valid, missing)` — counts of source_path entries where the file
401 + /// exists vs. does not exist on disk.
402 + pub fn check_unsafe_integrity(db: &Database) -> Result<(usize, usize)> {
403 + let mut stmt = db.conn().prepare(
404 + "SELECT source_path FROM samples WHERE source_path IS NOT NULL",
405 + )?;
406 + let paths: Vec<String> = stmt
407 + .query_map([], |row| row.get(0))?
408 + .collect::<std::result::Result<Vec<_>, _>>()?;
409 +
410 + let mut valid = 0;
411 + let mut missing = 0;
412 + for p in &paths {
413 + if Path::new(p).exists() {
414 + valid += 1;
415 + } else {
416 + missing += 1;
417 + }
418 + }
419 + Ok((valid, missing))
420 + }
421 +
422 + /// Delete all unsafe-mode samples whose source files no longer exist on disk.
423 + ///
424 + /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
425 + pub fn purge_missing_unsafe(db: &Database) -> Result<usize> {
426 + let mut stmt = db.conn().prepare(
427 + "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL",
428 + )?;
429 + let rows: Vec<(String, String)> = stmt
430 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
431 + .collect::<std::result::Result<Vec<_>, _>>()?;
432 +
433 + let mut purged = 0;
434 + for (hash, source_path) in &rows {
435 + if !Path::new(source_path).exists() {
436 + db.conn()
437 + .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
438 + purged += 1;
439 + }
440 + }
441 + Ok(purged)
442 + }
443 +
444 + impl SampleStore {
445 + /// Import a file in unsafe mode: hash it but do NOT copy to the store.
446 + ///
447 + /// Records the original absolute path as `source_path` in the database.
448 + /// The file stays where it is on disk.
449 + #[instrument(skip_all)]
450 + pub fn import_unsafe(&self, path: &Path, db: &Database) -> Result<String> {
451 + if !crate::util::is_audio_file(path) {
452 + return Err(CoreError::Internal(format!(
453 + "not a supported audio file: {}",
454 + path.display()
455 + )));
456 + }
457 +
458 + let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
459 + let metadata = file.metadata().map_err(|e| io_err(path, e))?;
460 + let file_size = metadata.len() as i64;
461 +
462 + if file_size == 0 {
463 + return Err(CoreError::Internal(format!(
464 + "cannot import zero-byte file: {}",
465 + path.display()
466 + )));
467 + }
468 +
469 + // Stream through SHA-256
470 + let mut hasher = Sha256::new();
471 + let mut buf = [0u8; 8192];
472 + loop {
473 + let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
474 + if n == 0 {
475 + break;
476 + }
477 + hasher.update(&buf[..n]);
478 + }
479 + let hash = format!("{:x}", hasher.finalize());
480 +
481 + let ext = crate::util::get_extension(path);
482 + let original_name = crate::util::get_filename(path, "unknown");
483 +
484 + // Probe duration from file headers (cheap, no full decode)
485 + let duration = probe_duration(path);
486 +
487 + // Resolve absolute path for storage
488 + let abs_path = path
489 + .canonicalize()
490 + .map_err(|e| io_err(path, e))?
491 + .to_string_lossy()
492 + .to_string();
493 +
494 + // Insert into DB with source_path (ignore if hash already exists)
495 + let now = unix_now();
496 + db.conn().execute(
497 + "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration, source_path)
498 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
499 + rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path],
500 + )?;
501 +
502 + Ok(hash)
503 + }
504 + }
505 +
304 506 #[cfg(test)]
305 507 mod tests {
306 508 use super::*;
@@ -598,4 +800,167 @@ mod tests {
598 800 assert_eq!(removed, 1);
599 801 assert!(!store.exists(&hash, "wav").unwrap());
600 802 }
803 +
804 + // --- Unsafe mode tests ---
805 +
806 + #[test]
807 + fn import_unsafe_does_not_copy_file() {
808 + let (dir, db, store) = setup();
809 + let src = create_test_file(&dir, "kick.wav", b"unsafe kick data");
810 +
811 + let hash = store.import_unsafe(&src, &db).unwrap();
812 +
813 + // No file in the store
814 + assert!(!store.exists(&hash, "wav").unwrap());
815 +
816 + // Row exists in DB with source_path set
817 + let sp: Option<String> = db
818 + .conn()
819 + .query_row(
820 + "SELECT source_path FROM samples WHERE hash = ?1",
821 + [&hash],
822 + |row| row.get(0),
823 + )
824 + .unwrap();
825 + assert!(sp.is_some());
826 + assert!(sp.unwrap().ends_with("kick.wav"));
827 + }
828 +
829 + #[test]
830 + fn import_unsafe_deduplicates() {
831 + let (dir, db, store) = setup();
832 + let src = create_test_file(&dir, "kick.wav", b"same unsafe content");
833 +
834 + let hash1 = store.import_unsafe(&src, &db).unwrap();
835 + let hash2 = store.import_unsafe(&src, &db).unwrap();
836 + assert_eq!(hash1, hash2);
837 +
838 + let count: i64 = db
839 + .conn()
840 + .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
841 + .unwrap();
842 + assert_eq!(count, 1);
843 + }
844 +
845 + #[test]
846 + fn sample_source_path_returns_none_for_normal() {
847 + let (dir, db, store) = setup();
848 + let src = create_test_file(&dir, "kick.wav", b"normal import");
849 + let hash = store.import(&src, &db).unwrap();
850 +
851 + assert!(sample_source_path(&db, &hash).unwrap().is_none());
852 + }
853 +
854 + #[test]
855 + fn sample_source_path_returns_path_for_unsafe() {
856 + let (dir, db, store) = setup();
857 + let src = create_test_file(&dir, "kick.wav", b"unsafe import");
858 + let hash = store.import_unsafe(&src, &db).unwrap();
859 +
860 + let sp = sample_source_path(&db, &hash).unwrap();
861 + assert!(sp.is_some());
862 + }
863 +
864 + #[test]
865 + fn resolve_file_path_prefers_source_path() {
866 + let (dir, db, store) = setup();
867 + let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test");
868 + let hash = store.import_unsafe(&src, &db).unwrap();
869 +
870 + let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
871 + // Should resolve to the original file, not the store
872 + assert!(!resolved.starts_with(store.root()));
873 + }
874 +
875 + #[test]
876 + fn resolve_file_path_falls_back_to_store() {
877 + let (dir, db, store) = setup();
878 + let src = create_test_file(&dir, "kick.wav", b"fallback test");
879 +
880 + // Import normally (file exists in store)
881 + let hash = store.import(&src, &db).unwrap();
882 +
883 + let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
884 + assert!(resolved.starts_with(store.root()));
885 + }
886 +
887 + #[test]
888 + fn relocate_sample_rejects_hash_mismatch() {
889 + let (dir, db, store) = setup();
890 + let src = create_test_file(&dir, "kick.wav", b"original content");
891 + let hash = store.import_unsafe(&src, &db).unwrap();
892 +
893 + let wrong_file = create_test_file(&dir, "snare.wav", b"different content");
894 + let result = relocate_sample(&store, &db, &hash, &wrong_file);
895 + assert!(result.is_err());
896 + assert!(result.unwrap_err().to_string().contains("hash mismatch"));
897 + }
898 +
899 + #[test]
900 + fn relocate_sample_updates_source_path() {
901 + let (dir, db, store) = setup();
902 + let src = create_test_file(&dir, "kick.wav", b"relocate content");
903 + let hash = store.import_unsafe(&src, &db).unwrap();
904 +
905 + // Move the file
906 + let new_loc = dir.path().join("moved_kick.wav");
907 + fs::copy(&src, &new_loc).unwrap();
908 +
909 + relocate_sample(&store, &db, &hash, &new_loc).unwrap();
910 +
911 + let sp = sample_source_path(&db, &hash).unwrap().unwrap();
912 + assert!(sp.contains("moved_kick.wav"));
913 + }
914 +
915 + #[test]
916 + fn check_unsafe_integrity_counts_correctly() {
917 + let (dir, db, store) = setup();
918 + let src1 = create_test_file(&dir, "kick.wav", b"integrity kick");
919 + let src2 = create_test_file(&dir, "snare.wav", b"integrity snare");
920 +
921 + store.import_unsafe(&src1, &db).unwrap();
922 + let hash2 = store.import_unsafe(&src2, &db).unwrap();
923 +
924 + // Delete snare from disk to simulate missing file
925 + let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
926 + fs::remove_file(&sp).unwrap();
927 +
928 + let (valid, missing) = check_unsafe_integrity(&db).unwrap();
929 + assert_eq!(valid, 1);
930 + assert_eq!(missing, 1);
931 + }
932 +
933 + #[test]
934 + fn purge_missing_unsafe_removes_only_missing() {
935 + let (dir, db, store) = setup();
936 + let src1 = create_test_file(&dir, "kick.wav", b"purge kick");
937 + let src2 = create_test_file(&dir, "snare.wav", b"purge snare");
938 +
939 + let hash1 = store.import_unsafe(&src1, &db).unwrap();
940 + let hash2 = store.import_unsafe(&src2, &db).unwrap();
941 +
942 + // Delete snare from disk
943 + let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
944 + fs::remove_file(&sp).unwrap();
945 +
946 + let purged = purge_missing_unsafe(&db).unwrap();
947 + assert_eq!(purged, 1);
948 +
949 + // kick still exists, snare is gone
950 + assert!(sample_source_path(&db, &hash1).is_ok());
951 + assert!(matches!(
952 + sample_source_path(&db, &hash2),
953 + Err(CoreError::SampleNotFound(_))
954 + ));
955 + }
956 +
957 + #[test]
958 + fn purge_missing_unsafe_noop_when_all_valid() {
959 + let (dir, db, store) = setup();
960 + let src = create_test_file(&dir, "kick.wav", b"all valid");
961 + store.import_unsafe(&src, &db).unwrap();
962 +
963 + let purged = purge_missing_unsafe(&db).unwrap();
964 + assert_eq!(purged, 0);
965 + }
601 966 }