Skip to main content

max / audiofiles

Observability: log backend failures instead of swallowing, instrument writes, clean clippy Added state::log_backend_err and routed ~35 previously-silent `let _ = self.backend.*` call sites through it — tag applications, VFS edit delete/create, undo ops, start_analysis/import/export, record_edit_history, and preference writes now log on failure instead of vanishing. Pure cancel_* sends stay fire-and-forget. Instrumented 19 mutating Backend methods (create/rename/delete VFS + nodes, tags, collections, tag policy, config) with #[instrument] so writes carry a tracing span. Cleared the pre-existing clippy tail so the workspace is warning-free: WorkerHandle::send results asserted in the worker tests, SearchFilter struct-update literals, unused hash2 binding, an intentional set_readonly(false) allow, and moved strip_traversal ahead of the resolve.rs test module. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:35 UTC
Commit: 729d3ddb28dd6021ce89ce39f2dea59c278cec19
Parent: b0cc2b0
13 files changed, +113 insertions, -80 deletions
@@ -521,16 +521,19 @@ impl Backend for DirectBackend {
521 521 Ok(vfs::list_vfs(&db)?)
522 522 }
523 523
524 + #[instrument(skip(self))]
524 525 fn create_vfs(&self, name: &str) -> BackendResult<VfsId> {
525 526 let db = self.db.lock();
526 527 Ok(vfs::create_vfs(&db, name)?)
527 528 }
528 529
530 + #[instrument(skip(self))]
529 531 fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> {
530 532 let db = self.db.lock();
531 533 Ok(vfs::rename_vfs(&db, id, new_name)?)
532 534 }
533 535
536 + #[instrument(skip(self))]
534 537 fn delete_vfs(&self, id: VfsId) -> BackendResult<()> {
535 538 let db = self.db.lock();
536 539 Ok(vfs::delete_vfs(&db, id)?)
@@ -554,6 +557,7 @@ impl Backend for DirectBackend {
554 557 Ok(vfs::list_children(&db, vfs_id, parent_id)?)
555 558 }
556 559
560 + #[instrument(skip(self))]
557 561 fn create_directory(
558 562 &self,
559 563 vfs_id: VfsId,
@@ -564,6 +568,7 @@ impl Backend for DirectBackend {
564 568 Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?)
565 569 }
566 570
571 + #[instrument(skip(self))]
567 572 fn create_sample_link(
568 573 &self,
569 574 vfs_id: VfsId,
@@ -585,21 +590,25 @@ impl Backend for DirectBackend {
585 590 Ok(vfs::get_breadcrumb(&db, node_id)?)
586 591 }
587 592
593 + #[instrument(skip(self))]
588 594 fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> {
589 595 let db = self.db.lock();
590 596 Ok(vfs::rename_node(&db, id, new_name)?)
591 597 }
592 598
599 + #[instrument(skip(self))]
593 600 fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()> {
594 601 let db = self.db.lock();
595 602 Ok(vfs::move_node(&db, id, new_parent_id)?)
596 603 }
597 604
605 + #[instrument(skip(self))]
598 606 fn delete_node(&self, id: NodeId) -> BackendResult<()> {
599 607 let db = self.db.lock();
600 608 Ok(vfs::delete_node(&db, id)?)
601 609 }
602 610
611 + #[instrument(skip_all)]
603 612 fn restore_node(&self, node: &VfsNode) -> BackendResult<()> {
604 613 let db = self.db.lock();
605 614 Ok(vfs::restore_node(&db, node)?)
@@ -626,11 +635,13 @@ impl Backend for DirectBackend {
626 635
627 636 // --- Tags ---
628 637
638 + #[instrument(skip(self))]
629 639 fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
630 640 let db = self.db.lock();
631 641 Ok(tags::add_tag(&db, hash, tag)?)
632 642 }
633 643
644 + #[instrument(skip(self))]
634 645 fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
635 646 let db = self.db.lock();
636 647 Ok(tags::remove_tag(&db, hash, tag)?)
@@ -646,11 +657,13 @@ impl Backend for DirectBackend {
646 657 Ok(tags::list_all_tags(&db)?)
647 658 }
648 659
660 + #[instrument(skip(self, hashes))]
649 661 fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> {
650 662 let db = self.db.lock();
651 663 Ok(tags::bulk_add_tag(&db, hashes, tag)?)
652 664 }
653 665
666 + #[instrument(skip(self, hashes))]
654 667 fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> {
655 668 let db = self.db.lock();
656 669 Ok(tags::bulk_remove_tag(&db, hashes, tag)?)
@@ -695,6 +708,7 @@ impl Backend for DirectBackend {
695 708 Ok(collections::list_collections(&db)?)
696 709 }
697 710
711 + #[instrument(skip(self))]
698 712 fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId> {
699 713 let db = self.db.lock();
700 714 Ok(collections::create_collection(&db, name, description)?)
@@ -710,6 +724,7 @@ impl Backend for DirectBackend {
710 724 Ok(collections::rename_collection(&db, id, new_name)?)
711 725 }
712 726
727 + #[instrument(skip(self))]
713 728 fn delete_collection(&self, id: CollectionId) -> BackendResult<()> {
714 729 let db = self.db.lock();
715 730 Ok(collections::delete_collection(&db, id)?)
@@ -937,6 +952,7 @@ impl Backend for DirectBackend {
937 952 Ok(audiofiles_core::analysis::exemplar::get_policy(&db, tag)?)
938 953 }
939 954
955 + #[instrument(skip(self))]
940 956 fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()> {
941 957 let db = self.db.lock();
942 958 Ok(audiofiles_core::analysis::exemplar::set_policy(&db, tag, review, auto)?)
@@ -1386,6 +1402,7 @@ impl Backend for DirectBackend {
1386 1402 Ok(result)
1387 1403 }
1388 1404
1405 + #[instrument(skip(self, value))]
1389 1406 fn set_config(&self, key: &str, value: &str) -> BackendResult<()> {
1390 1407 let db = self.db.lock();
1391 1408 db.conn()
@@ -160,10 +160,10 @@ mod tests {
160 160 let handle = spawn_search_worker(db_path).unwrap();
161 161 // A query against an empty library must still produce an event (empty
162 162 // results or a clean error), never a hang or a panic that kills the worker.
163 - handle.send(SearchCommand::FindSimilar {
163 + assert!(handle.send(SearchCommand::FindSimilar {
164 164 hash: "deadbeef".to_string(),
165 165 limit: 10,
166 - });
166 + }));
167 167 match recv_one(&handle) {
168 168 SearchEvent::SimilarResults { source, hashes } => {
169 169 assert_eq!(source, "deadbeef");
@@ -174,11 +174,11 @@ mod tests {
174 174 }
175 175
176 176 // Invalidate + a second query still respond (worker survived).
177 - handle.send(SearchCommand::Invalidate);
178 - handle.send(SearchCommand::FindNearDuplicates {
177 + assert!(handle.send(SearchCommand::Invalidate));
178 + assert!(handle.send(SearchCommand::FindNearDuplicates {
179 179 hash: "deadbeef".to_string(),
180 180 limit: 10,
181 - });
181 + }));
182 182 let _ = recv_one(&handle);
183 183 drop(handle); // Drop joins the thread; a wedged worker would hang here.
184 184 }
@@ -113,7 +113,7 @@ mod tests {
113 113 let _db = Database::open(&db_path).unwrap();
114 114
115 115 let handle = spawn_loose_files_worker(db_path).unwrap();
116 - handle.send(LooseFilesCommand::CheckIntegrity);
116 + assert!(handle.send(LooseFilesCommand::CheckIntegrity));
117 117
118 118 let mut got = false;
119 119 for _ in 0..50 {
@@ -200,39 +200,39 @@ impl BrowserState {
200 200 match op {
201 201 UndoOp::BulkDelete { nodes, tags: tag_data } => {
202 202 for node in &nodes {
203 - let _ = self.backend.restore_node(node);
203 + super::log_backend_err("restore_node (undo delete)", self.backend.restore_node(node));
204 204 }
205 205 for (hash, sample_tags) in &tag_data {
206 206 for tag in sample_tags {
207 - let _ = self.backend.add_tag(hash, tag);
207 + super::log_backend_err("add_tag (undo delete)", self.backend.add_tag(hash, tag));
208 208 }
209 209 }
210 210 self.status = "Undo: restored deleted items".to_string();
211 211 }
212 212 UndoOp::BulkMove { moves } => {
213 213 for (node_id, old_parent) in &moves {
214 - let _ = self.backend.move_node(*node_id, *old_parent);
214 + super::log_backend_err("move_node (undo move)", self.backend.move_node(*node_id, *old_parent));
215 215 }
216 216 self.status = "Undo: moved items back".to_string();
217 217 }
218 218 UndoOp::BulkRename { renames } => {
219 219 for (node_id, old_name) in &renames {
220 - let _ = self.backend.rename_node(*node_id, old_name);
220 + super::log_backend_err("rename_node (undo rename)", self.backend.rename_node(*node_id, old_name));
221 221 }
222 222 self.status = "Undo: restored original names".to_string();
223 223 }
224 224 UndoOp::BulkTagAdd { tag, hashes } => {
225 225 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
226 - let _ = self.backend.bulk_remove_tag(&refs, &tag);
226 + super::log_backend_err("bulk_remove_tag (undo tag add)", self.backend.bulk_remove_tag(&refs, &tag));
227 227 self.status = "Undo: removed tag".to_string();
228 228 }
229 229 UndoOp::BulkTagRemove { tag, hashes } => {
230 230 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
231 - let _ = self.backend.bulk_add_tag(&refs, &tag);
231 + super::log_backend_err("bulk_add_tag (undo tag remove)", self.backend.bulk_add_tag(&refs, &tag));
232 232 self.status = "Undo: re-added tag".to_string();
233 233 }
234 234 UndoOp::TagRemove { hash, tag } => {
235 - let _ = self.backend.add_tag(&hash, &tag);
235 + super::log_backend_err("add_tag (undo tag remove)", self.backend.add_tag(&hash, &tag));
236 236 self.status = format!("Undo: restored tag \"{tag}\"");
237 237 }
238 238 }
@@ -175,10 +175,10 @@ impl BrowserState {
175 175 /// when off (default) the signal is left untouched and only reported.
176 176 pub fn toggle_forge_auto_trim_overshoot(&mut self) {
177 177 self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot;
178 - let _ = self.backend.set_config(
178 + super::log_backend_err("set_config forge_auto_trim_overshoot", self.backend.set_config(
179 179 crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY,
180 180 if self.forge_auto_trim_overshoot { "true" } else { "false" },
181 - );
181 + ));
182 182 }
183 183
184 184 /// Batch trim leading/trailing silence across the current selection. Reuses
@@ -73,7 +73,7 @@ impl BrowserState {
73 73 self.status = format!("Failed to save analysis for {n} samples: {e}");
74 74 return;
75 75 }
76 - let _ = self.backend.apply_rules_to_samples(&hashes);
76 + super::log_backend_err("apply_rules_to_samples", self.backend.apply_rules_to_samples(&hashes));
77 77 }
78 78
79 79 /// Quick-import a single file or directory via drag-and-drop into the current folder.
@@ -276,7 +276,7 @@ impl BrowserState {
276 276 };
277 277 self.status = format!("Analyzing {total} samples...");
278 278
279 - let _ = self.backend.start_analysis(sample_hashes, config);
279 + super::log_backend_err("start_analysis (import)", self.backend.start_analysis(sample_hashes, config));
280 280 }
281 281
282 282 /// Cancel the running analysis batch and land in the acknowledgement
@@ -428,7 +428,7 @@ impl BrowserState {
428 428 }
429 429 };
430 430
431 - let _ = self.backend.start_import(&source, strategy_desc);
431 + super::log_backend_err("start_import", self.backend.start_import(&source, strategy_desc));
432 432
433 433 self.import_file_errors.clear();
434 434 self.analysis_errors.clear();
@@ -669,7 +669,7 @@ impl BrowserState {
669 669 // Auto-accept all suggestions in quick mode
670 670 for item in &items {
671 671 for s in &item.suggestions {
672 - let _ = self.backend.add_tag(&item.hash, &s.suggestion.tag);
672 + super::log_backend_err("add_tag (import suggestion)", self.backend.add_tag(&item.hash, &s.suggestion.tag));
673 673 }
674 674 }
675 675 self.quick_import = false;
@@ -977,7 +977,7 @@ impl BrowserState {
977 977 continue;
978 978 }
979 979 for (hash, _ext) in &entry.folder.samples {
980 - let _ = self.backend.add_tag(hash, tag_str);
980 + super::log_backend_err("add_tag (batch)", self.backend.add_tag(hash, tag_str));
981 981 applied += 1;
982 982 }
983 983 }
@@ -1026,7 +1026,7 @@ impl BrowserState {
1026 1026 for item in items {
1027 1027 for sug in &item.suggestions {
1028 1028 if sug.accepted {
1029 - let _ = self.backend.add_tag(&item.hash, &sug.suggestion.tag);
1029 + super::log_backend_err("add_tag (suggestion)", self.backend.add_tag(&item.hash, &sug.suggestion.tag));
1030 1030 applied += 1;
1031 1031 }
1032 1032 }
@@ -1150,7 +1150,7 @@ impl BrowserState {
1150 1150 .unwrap_or_default()
1151 1151 };
1152 1152
1153 - let _ = self.backend.enrich_export_with_tags(&mut items);
1153 + super::log_backend_err("enrich_export_with_tags", self.backend.enrich_export_with_tags(&mut items));
1154 1154
1155 1155 if items.is_empty() {
1156 1156 self.status = "No samples to export".to_string();
@@ -1192,7 +1192,7 @@ impl BrowserState {
1192 1192 // still useful in the UI for "files already written remain at <path>".
1193 1193 self.last_export_destination = Some(config.destination.clone());
1194 1194 self.operation_progress = Some(crate::state::OperationProgress::new());
1195 - let _ = self.backend.start_export(items, config);
1195 + super::log_backend_err("start_export", self.backend.start_export(items, config));
1196 1196
1197 1197 self.import_mode = ImportMode::Exporting {
1198 1198 completed: 0,
@@ -1471,7 +1471,7 @@ impl BrowserState {
1471 1471 super::EditResultMode::Replace => "replace",
1472 1472 super::EditResultMode::Sibling => "sibling",
1473 1473 };
1474 - let _ = self.backend.set_config("edit_result_mode", mode_str);
1474 + super::log_backend_err("set_config edit_result_mode", self.backend.set_config("edit_result_mode", mode_str));
1475 1475 self.edit.result_mode = Some(mode);
1476 1476 }
1477 1477
@@ -1541,7 +1541,7 @@ impl BrowserState {
1541 1541 };
1542 1542
1543 1543 // 2. Record in edit_history
1544 - let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation);
1544 + super::log_backend_err("record_edit_history", self.backend.record_edit_history(&source_hash, &new_hash, &operation));
1545 1545
1546 1546 // 3. Update VFS based on mode
1547 1547 let Some(vfs_id) = self.current_vfs_id() else {
@@ -1568,8 +1568,8 @@ impl BrowserState {
1568 1568 let parent_id = node.node.parent_id;
1569 1569 let name = node.node.name.clone();
1570 1570 replace_targets.push((parent_id, name.clone()));
1571 - let _ = self.backend.delete_node(node.node.id);
1572 - let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash);
1571 + super::log_backend_err("delete_node (edit VFS update)", self.backend.delete_node(node.node.id));
1572 + super::log_backend_err("create_sample_link (edit replace)", self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash));
1573 1573 }
1574 1574 }
1575 1575 super::EditResultMode::Sibling => {
@@ -1605,7 +1605,7 @@ impl BrowserState {
1605 1605
1606 1606 // 4. Trigger analysis on the new sample
1607 1607 let hashes = vec![(new_hash, new_ext)];
1608 - let _ = self.backend.start_analysis(hashes, AnalysisConfig::default());
1608 + super::log_backend_err("start_analysis (post-edit)", self.backend.start_analysis(hashes, AnalysisConfig::default()));
1609 1609
1610 1610 // 5. Refresh the file list
1611 1611 self.refresh_contents();
@@ -1634,21 +1634,24 @@ impl BrowserState {
1634 1634 .find_nodes_by_hashes(entry.vfs_id, &result_hashes)
1635 1635 {
1636 1636 for node in &result_nodes {
1637 - let _ = self.backend.delete_node(node.node.id);
1637 + super::log_backend_err("delete_node (edit VFS update)", self.backend.delete_node(node.node.id));
1638 1638 }
1639 1639 }
1640 1640 for (parent_id, name) in &entry.replace_targets {
1641 - let _ = self.backend.create_sample_link(
1642 - entry.vfs_id,
1643 - *parent_id,
1644 - name,
1645 - &entry.source_hash,
1641 + super::log_backend_err(
1642 + "create_sample_link (edit undo)",
1643 + self.backend.create_sample_link(
1644 + entry.vfs_id,
1645 + *parent_id,
1646 + name,
1647 + &entry.source_hash,
1648 + ),
1646 1649 );
1647 1650 }
1648 1651 }
1649 1652 super::EditResultMode::Sibling => {
1650 1653 if let Some(id) = entry.sibling_node_id {
1651 - let _ = self.backend.delete_node(id);
1654 + super::log_backend_err("delete_node (edit undo sibling)", self.backend.delete_node(id));
1652 1655 }
1653 1656 }
1654 1657 }
@@ -142,14 +142,14 @@ impl BrowserState {
142 142 /// Dismiss the first-launch hint and persist the preference.
143 143 pub fn dismiss_first_launch_hint(&mut self) {
144 144 self.show_first_launch_hint = false;
145 - let _ = self.backend.set_config("hints_dismissed", "1");
145 + super::log_backend_err("set_config hints_dismissed", self.backend.set_config("hints_dismissed", "1"));
146 146 }
147 147
148 148 /// Re-surface the welcome screen for a user who dismissed it. Persists the
149 149 /// reset so the welcome renders again on next launch until re-dismissed.
150 150 pub fn show_welcome(&mut self) {
151 151 self.show_first_launch_hint = true;
152 - let _ = self.backend.set_config("hints_dismissed", "0");
152 + super::log_backend_err("set_config hints_dismissed", self.backend.set_config("hints_dismissed", "0"));
153 153 }
154 154
155 155 /// Dismiss the sync-intro banner and persist the preference. Called both
@@ -157,7 +157,7 @@ impl BrowserState {
157 157 /// clicks "Set up sync" — the banner should not re-appear after either.
158 158 pub fn dismiss_sync_intro(&mut self) {
159 159 self.show_sync_intro = false;
160 - let _ = self.backend.set_config("sync_intro_dismissed", "1");
160 + super::log_backend_err("set_config sync_intro_dismissed", self.backend.set_config("sync_intro_dismissed", "1"));
161 161 }
162 162
163 163 /// Whether the user has work in progress that would be interrupted by a
@@ -173,7 +173,7 @@ impl BrowserState {
173 173 /// Used from Settings / Help to bring back onboarding context after dismissal.
174 174 pub fn reset_vfs_explanation(&mut self) {
175 175 self.show_vfs_banner = true;
176 - let _ = self.backend.set_config("vfs_explained", "0");
176 + super::log_backend_err("set_config vfs_explained", self.backend.set_config("vfs_explained", "0"));
177 177 }
178 178
179 179 /// Globally rename a tag: every sample that carries `old_tag` will instead
@@ -355,7 +355,7 @@ impl BrowserState {
355 355
356 356 /// Save the current theme ID to the user_config table.
357 357 pub fn save_theme_preference(&self) {
358 - let _ = self.backend.set_config("theme", &self.current_theme_id);
358 + super::log_backend_err("set_config theme", self.backend.set_config("theme", &self.current_theme_id));
359 359 }
360 360
361 361 /// Reset column visibility, sort, and row density to defaults. The Settings
@@ -368,7 +368,7 @@ impl BrowserState {
368 368 self.sort_column = SortColumn::Name;
369 369 self.sort_direction = SortDirection::Ascending;
370 370 self.save_column_config();
371 - let _ = self.backend.set_config("row_height", "24");
371 + super::log_backend_err("set_config row_height", self.backend.set_config("row_height", "24"));
372 372 self.status = "Columns reset to defaults".to_string();
373 373 }
374 374
@@ -395,7 +395,7 @@ impl BrowserState {
395 395 fn save_dismissed_suggestions(&self) {
396 396 // unwrap is safe: HashMap<String, Vec<String>> serialises cleanly.
397 397 let json = serde_json::to_string(&self.dismissed_suggestions).unwrap();
398 - let _ = self.backend.set_config("suggestions.dismissed", &json);
398 + super::log_backend_err("set_config suggestions.dismissed", self.backend.set_config("suggestions.dismissed", &json));
399 399 }
400 400
401 401 /// Mark a tag suggestion as rejected for the given classification so it
@@ -453,7 +453,7 @@ impl BrowserState {
453 453 // unwrap is safe: ColumnConfig contains only primitive fields (bools, enums)
454 454 // with derived Serialize impls, so serialisation cannot fail.
455 455 let json = serde_json::to_string(&self.column_config).unwrap();
456 - let _ = self.backend.set_config("column_config", &json);
456 + super::log_backend_err("set_config column_config", self.backend.set_config("column_config", &json));
457 457 }
458 458
459 459 // --- VFS Mirror ---
@@ -25,6 +25,19 @@ use audiofiles_core::{CollectionId, NodeId, VfsId};
25 25 pub use audiofiles_core::vfs::VfsNodeWithAnalysis;
26 26 use parking_lot::Mutex;
27 27
28 + /// Log a backend operation failure instead of silently swallowing it.
29 + ///
30 + /// Used at call sites that previously wrote `let _ = self.backend.op(...)`: a
31 + /// failed preference write, tag application, or undo step should be visible in
32 + /// the log rather than lost. These are fire-and-observe (the UI has already
33 + /// moved on), so they warn rather than surface a banner — but they are no longer
34 + /// invisible. `op` is a short static label identifying the call site.
35 + pub(crate) fn log_backend_err<T>(op: &str, result: crate::backend::BackendResult<T>) {
36 + if let Err(e) = result {
37 + tracing::warn!(op, error = %e, "backend operation failed");
38 + }
39 + }
40 +
28 41 /// A cheap-to-clone handle to the current folder's contents.
29 42 ///
30 43 /// Wraps `Arc<Vec<VfsNodeWithAnalysis>>` so the per-frame `clone()` in the file
@@ -299,10 +299,10 @@ impl BrowserState {
299 299 self.similarity_source_name = None;
300 300 // Persist the selection by VFS id (not index — indices shift when
301 301 // vaults are added or removed) so it restores on next launch.
302 - let _ = self.backend.set_config(
302 + super::log_backend_err("set_config current_vfs_id", self.backend.set_config(
303 303 "current_vfs_id",
304 304 &self.vfs_list[self.current_vfs_idx].id.as_i64().to_string(),
305 - );
305 + ));
306 306 self.refresh_contents();
307 307 self.refresh_collections();
308 308 self.status = format!("Switched to: {}", self.vfs_list[self.current_vfs_idx].name);
@@ -312,10 +312,10 @@ impl BrowserState {
312 312 /// Toggle the left sidebar and persist the choice across restarts.
313 313 pub fn toggle_sidebar(&mut self) {
314 314 self.sidebar_visible = !self.sidebar_visible;
315 - let _ = self.backend.set_config(
315 + super::log_backend_err("set_config sidebar_visible", self.backend.set_config(
316 316 "sidebar_visible",
317 317 if self.sidebar_visible { "1" } else { "0" },
318 - );
318 + ));
319 319 }
320 320
321 321 /// Toggle the right detail panel and persist the choice across restarts.
@@ -328,18 +328,20 @@ impl BrowserState {
328 328 pub fn set_detail_visible(&mut self, visible: bool) {
329 329 if self.detail_visible != visible {
330 330 self.detail_visible = visible;
331 - let _ = self
332 - .backend
333 - .set_config("detail_visible", if visible { "1" } else { "0" });
331 + super::log_backend_err(
332 + "set_config detail_visible",
333 + self.backend
334 + .set_config("detail_visible", if visible { "1" } else { "0" }),
335 + );
334 336 }
335 337 }
336 338
337 339 /// Toggle the filter panel and persist the choice across restarts.
338 340 pub fn toggle_filter_panel(&mut self) {
339 341 self.filter_panel_open = !self.filter_panel_open;
340 - let _ = self.backend.set_config(
342 + super::log_backend_err("set_config filter_panel_open", self.backend.set_config(
341 343 "filter_panel_open",
342 344 if self.filter_panel_open { "1" } else { "0" },
343 - );
345 + ));
344 346 }
345 347 }
@@ -94,7 +94,7 @@ impl BrowserState {
94 94 /// Toggle loop mode and persist the setting.
95 95 pub fn toggle_loop(&mut self) {
96 96 self.loop_enabled = !self.loop_enabled;
97 - let _ = self.backend.set_config("preview_loop", if self.loop_enabled { "1" } else { "0" });
97 + super::log_backend_err("set_config preview_loop", self.backend.set_config("preview_loop", if self.loop_enabled { "1" } else { "0" }));
98 98 // Sync to the live playback state
99 99 self.shared.preview.lock().loop_enabled = self.loop_enabled;
100 100 }
@@ -102,7 +102,7 @@ impl BrowserState {
102 102 /// Toggle autoplay mode and persist the setting.
103 103 pub fn toggle_autoplay(&mut self) {
104 104 self.autoplay = !self.autoplay;
105 - let _ = self.backend.set_config("preview_autoplay", if self.autoplay { "1" } else { "0" });
105 + super::log_backend_err("set_config preview_autoplay", self.backend.set_config("preview_autoplay", if self.autoplay { "1" } else { "0" }));
106 106 }
107 107
108 108 // --- Instrument ---
@@ -119,6 +119,23 @@ pub fn resolve_output_names(
119 119 names
120 120 }
121 121
122 + /// Neutralize path-traversal in a single output filename: strip separators and
123 + /// NUL bytes, and defuse a bare `..` stem. Applied to every output name —
124 + /// including untrusted plugin-supplied overrides — so the primitive is safe
125 + /// regardless of caller.
126 + fn strip_traversal(name: &str) -> String {
127 + // Strip separators and NUL first: with those gone, the name is a single
128 + // path component, so `dir.join(name)` cannot climb out of `dir`.
129 + let out = name.replace(['/', '\\', '\0'], "_");
130 + // The only remaining escape is a component that IS `.` or `..` — those
131 + // resolve to the current/parent directory on join. Defuse them; every other
132 + // string (including a benign `..foo` or `.._x.wav`) stays in-directory.
133 + if out == "." || out == ".." {
134 + return "_".repeat(out.len());
135 + }
136 + out
137 + }
138 +
122 139 #[cfg(test)]
123 140 mod tests {
124 141 use super::*;
@@ -189,20 +206,3 @@ mod tests {
189 206 assert_eq!(strip_traversal("../x.wav"), ".._x.wav");
190 207 }
191 208 }
192 -
193 - /// Neutralize path-traversal in a single output filename: strip separators and
194 - /// NUL bytes, and defuse a bare `..` stem. Applied to every output name —
195 - /// including untrusted plugin-supplied overrides — so the primitive is safe
196 - /// regardless of caller.
197 - fn strip_traversal(name: &str) -> String {
198 - // Strip separators and NUL first: with those gone, the name is a single
199 - // path component, so `dir.join(name)` cannot climb out of `dir`.
200 - let out = name.replace(['/', '\\', '\0'], "_");
201 - // The only remaining escape is a component that IS `.` or `..` — those
202 - // resolve to the current/parent directory on join. Defuse them; every other
203 - // string (including a benign `..foo` or `.._x.wav`) stays in-directory.
204 - if out == "." || out == ".." {
205 - return "_".repeat(out.len());
206 - }
207 - out
208 - }
@@ -393,8 +393,7 @@ mod tests {
393 393 fn fts_index_stays_synced_through_rename_and_delete() {
394 394 let (db, vfs_id) = setup_with_samples();
395 395 // setup has "kick.wav" (hash1) and "snare.wav" (hash2).
396 - let mut filter = SearchFilter::default();
397 - filter.text_query = "kick".to_string();
396 + let filter = SearchFilter { text_query: "kick".to_string(), ..Default::default() };
398 397 let nodes = search_in_folder(&db, &filter, vfs_id, None).unwrap();
399 398 assert_eq!(nodes.len(), 1, "kick.wav should match before rename");
400 399 let kick_id = nodes[0].node.id;
@@ -406,8 +405,7 @@ mod tests {
406 405 0,
407 406 "kick must no longer match after rename"
408 407 );
409 - let mut hat = SearchFilter::default();
410 - hat.text_query = "hat".to_string();
408 + let hat = SearchFilter { text_query: "hat".to_string(), ..Default::default() };
411 409 assert_eq!(
412 410 search_in_folder(&db, &hat, vfs_id, None).unwrap().len(),
413 411 1,
@@ -426,8 +424,7 @@ mod tests {
426 424 #[test]
427 425 fn text_search_uses_the_fts_trigram_index() {
428 426 let (db, vfs_id) = setup_with_samples();
429 - let mut filter = SearchFilter::default();
430 - filter.text_query = "kick".to_string();
427 + let filter = SearchFilter { text_query: "kick".to_string(), ..Default::default() };
431 428
432 429 // Build the same SQL the search uses and confirm its plan reaches the
433 430 // FTS virtual table rather than a linear scan of vfs_nodes by name.
@@ -465,8 +462,7 @@ mod tests {
465 462 insert_fake_sample(&db, &hash);
466 463 vfs::create_sample_link(&db, vfs_id, None, &format!("kick_{i}.wav"), &hash).unwrap();
467 464 }
468 - let mut filter = SearchFilter::default();
469 - filter.text_query = "kick".to_string();
465 + let filter = SearchFilter { text_query: "kick".to_string(), ..Default::default() };
470 466 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
471 467 assert_eq!(results.len(), SEARCH_RESULT_LIMIT);
472 468 }
@@ -1453,6 +1453,8 @@ mod tests {
1453 1453 // mirror write-through guard), so clear that first to simulate bit-rot.
1454 1454 let stored_path = store.sample_path(&hash, "wav").unwrap();
1455 1455 let mut perms = fs::metadata(&stored_path).unwrap().permissions();
1456 + // Intentionally clear read-only to simulate bit-rot on a stored blob.
1457 + #[allow(clippy::permissions_set_readonly_false)]
1456 1458 perms.set_readonly(false);
1457 1459 fs::set_permissions(&stored_path, perms).unwrap();
1458 1460 fs::write(&stored_path, b"corrupted data").unwrap();
@@ -1589,7 +1591,7 @@ mod tests {
1589 1591 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
1590 1592 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
1591 1593 let hash1 = store.import(&src1, &db).unwrap();
1592 - let hash2 = store.import(&src2, &db).unwrap();
1594 + let _hash2 = store.import(&src2, &db).unwrap();
1593 1595
1594 1596 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1595 1597 crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap();