Skip to main content

max / audiofiles

Audit Run 14: tests, refactoring, cleanup - Extract draw_name_column() and draw_analysis_columns() from file_list.rs - Clean up redundant unwrap() in sidebar.rs - Validate updater URL format before open::that() - Add 15 app init tests (screen state, license migration, key masking, vault name) - Add sync integration tests against mock SyncKit server - Add full Rhai plugin lifecycle test (discover, load, validate, transform) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-16 01:38 UTC
Commit: 25ed6676526aed669fce5d98f3df286d49d7634a
Parent: af6c246
7 files changed, +945 insertions, -161 deletions
@@ -4,6 +4,17 @@
4 4 //! output stream for sample preview playback. Requires a valid license key
5 5 //! before the browser is accessible — the activation result is cached locally
6 6 //! so the app works offline after the first activation.
7 + //!
8 + //! ## Why immediate-mode GUI (egui) instead of Tauri/webview
9 + //!
10 + //! - **Waveform rendering:** Scrolling and zooming a 10-minute waveform at 60fps needs
11 + //! GPU-backed drawing, not DOM layout. egui's painter gives direct control over vertex
12 + //! buffers — no JS/CSS performance cliff for large datasets.
13 + //! - **No JS dependency:** The entire app is a single Rust binary. No Node.js build step,
14 + //! no npm dependencies, no webview security surface.
15 + //! - **Drag-out FFI:** Native drag-and-drop into DAWs requires platform pasteboard APIs
16 + //! (NSPasteboardItem on macOS, OLE on Windows). A webview can't initiate OS-level drags
17 + //! with file promises.
7 18
8 19 mod audio;
9 20 mod license;
@@ -223,11 +234,150 @@ mod tests {
223 234 let result = load_api_key(dir.path());
224 235 assert_eq!(result, Some("roundtrip-key".to_string()));
225 236 }
237 +
238 + // ── Initial screen resolution ──
239 +
240 + fn make_license_cache() -> license::LicenseCache {
241 + license::LicenseCache {
242 + key_code: "bright-castle-forest-river-falcon".to_string(),
243 + machine_id: "test-machine".to_string(),
244 + activated_at: "2026-04-01T00:00:00Z".to_string(),
245 + }
246 + }
247 +
248 + fn make_registry(dir: &Path) -> VaultRegistry {
249 + VaultRegistry {
250 + vaults: vec![vault::VaultEntry {
251 + name: "Library".to_string(),
252 + path: dir.to_path_buf(),
253 + }],
254 + active: dir.to_path_buf(),
255 + }
256 + }
257 +
258 + #[test]
259 + fn initial_screen_licensed_with_registry() {
260 + let dir = tempfile::tempdir().unwrap();
261 + let reg = Some(make_registry(dir.path()));
262 + let status = license::LicenseStatus::Licensed(make_license_cache());
263 + assert_eq!(resolve_initial_screen(&reg, &status), AppScreen::Browser);
264 + }
265 +
266 + #[test]
267 + fn initial_screen_licensed_without_registry() {
268 + let status = license::LicenseStatus::Licensed(make_license_cache());
269 + assert_eq!(resolve_initial_screen(&None, &status), AppScreen::VaultSetup);
270 + }
271 +
272 + #[test]
273 + fn initial_screen_unlicensed_with_registry() {
274 + let dir = tempfile::tempdir().unwrap();
275 + let reg = Some(make_registry(dir.path()));
276 + let status = license::LicenseStatus::Unlicensed;
277 + assert_eq!(resolve_initial_screen(&reg, &status), AppScreen::Activation);
278 + }
279 +
280 + #[test]
281 + fn initial_screen_unlicensed_without_registry() {
282 + let status = license::LicenseStatus::Unlicensed;
283 + assert_eq!(resolve_initial_screen(&None, &status), AppScreen::Activation);
284 + }
285 +
286 + // ── License migration ──
287 +
288 + #[test]
289 + fn migrate_license_copies_files() {
290 + let src = tempfile::tempdir().unwrap();
291 + let dst = tempfile::tempdir().unwrap();
292 + std::fs::write(src.path().join("license.json"), r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#).unwrap();
293 + std::fs::write(src.path().join("machine_id"), "mid-123").unwrap();
294 +
295 + migrate_license_to_config(dst.path(), src.path());
296 +
297 + assert_eq!(
298 + std::fs::read_to_string(dst.path().join("license.json")).unwrap(),
299 + r#"{"key_code":"k","machine_id":"m","activated_at":"t"}"#
300 + );
301 + assert_eq!(
302 + std::fs::read_to_string(dst.path().join("machine_id")).unwrap(),
303 + "mid-123"
304 + );
305 + }
306 +
307 + #[test]
308 + fn migrate_license_skips_when_same_dir() {
309 + let dir = tempfile::tempdir().unwrap();
310 + // Should not panic or overwrite — same source and dest
311 + migrate_license_to_config(dir.path(), dir.path());
312 + }
313 +
314 + #[test]
315 + fn migrate_license_does_not_overwrite_existing() {
316 + let src = tempfile::tempdir().unwrap();
317 + let dst = tempfile::tempdir().unwrap();
318 + std::fs::write(src.path().join("license.json"), "old").unwrap();
319 + std::fs::write(dst.path().join("license.json"), "existing").unwrap();
320 +
321 + migrate_license_to_config(dst.path(), src.path());
322 +
323 + // Destination file should be unchanged
324 + assert_eq!(
325 + std::fs::read_to_string(dst.path().join("license.json")).unwrap(),
326 + "existing"
327 + );
328 + }
329 +
330 + #[test]
331 + fn migrate_license_handles_missing_source() {
332 + let src = tempfile::tempdir().unwrap();
333 + let dst = tempfile::tempdir().unwrap();
334 + // No files in source — should not create anything in dest
335 + migrate_license_to_config(dst.path(), src.path());
336 + assert!(!dst.path().join("license.json").exists());
337 + assert!(!dst.path().join("machine_id").exists());
338 + }
339 +
340 + // ── Key masking ──
341 +
342 + #[test]
343 + fn mask_key_five_words() {
344 + assert_eq!(
345 + mask_key("bright-castle-forest-river-falcon"),
346 + "bright-...-falcon"
347 + );
348 + }
349 +
350 + #[test]
351 + fn mask_key_two_words() {
352 + assert_eq!(mask_key("alpha-beta"), "alpha-...-beta");
353 + }
354 +
355 + #[test]
356 + fn mask_key_single_word() {
357 + assert_eq!(mask_key("onlyoneword"), "***");
358 + }
359 +
360 + // ── Vault name lookup ──
361 +
362 + #[test]
363 + fn vault_name_for_path_found() {
364 + let dir = tempfile::tempdir().unwrap();
365 + let reg = make_registry(dir.path());
366 + assert_eq!(vault_name_for_path(&reg, dir.path()), "Library");
367 + }
368 +
369 + #[test]
370 + fn vault_name_for_path_not_found() {
371 + let dir = tempfile::tempdir().unwrap();
372 + let reg = make_registry(dir.path());
373 + assert_eq!(vault_name_for_path(&reg, Path::new("/nonexistent")), "Library");
374 + }
226 375 }
227 376
228 377 // ── App ──
229 378
230 379 /// Which screen the app is showing.
380 + #[derive(Debug, PartialEq)]
231 381 enum AppScreen {
232 382 /// License activation gate — no browser access until a valid key is entered.
233 383 Activation,
@@ -237,6 +387,22 @@ enum AppScreen {
237 387 Browser,
238 388 }
239 389
390 + /// Determine the initial screen based on vault registry and license status.
391 + ///
392 + /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it
393 + /// can be tested without constructing the full app.
394 + fn resolve_initial_screen(
395 + vault_registry: &Option<VaultRegistry>,
396 + license_status: &license::LicenseStatus,
397 + ) -> AppScreen {
398 + match (vault_registry, license_status) {
399 + (Some(_), license::LicenseStatus::Licensed(_)) => AppScreen::Browser,
400 + (Some(_), license::LicenseStatus::Unlicensed) => AppScreen::Activation,
401 + (None, license::LicenseStatus::Licensed(_)) => AppScreen::VaultSetup,
402 + (None, license::LicenseStatus::Unlicensed) => AppScreen::Activation,
403 + }
404 + }
405 +
240 406 struct AudioFilesApp {
241 407 screen: AppScreen,
242 408 browser: Option<BrowserState>,
@@ -298,7 +464,9 @@ impl AudioFilesApp {
298 464 }
299 465 };
300 466
301 - let (screen, data_dir, browser, error, sync_manager, license_cache) =
467 + let screen = resolve_initial_screen(&vault_registry, &license_status);
468 +
469 + let (data_dir, browser, error, sync_manager, license_cache) =
302 470 match (&vault_registry, &license_status) {
303 471 // Registry exists and user is licensed → open the active vault
304 472 (Some(reg), license::LicenseStatus::Licensed(cache)) => {
@@ -306,22 +474,22 @@ impl AudioFilesApp {
306 474 let _ = std::fs::create_dir_all(&data_dir);
307 475 let sync_manager = create_sync_manager(&data_dir, runtime.handle());
308 476 let (browser, error) = init_browser(&data_dir, shared.clone(), &vault_name_for_path(reg, &data_dir));
309 - (AppScreen::Browser, data_dir, browser, error, sync_manager, Some(cache.clone()))
477 + (data_dir, browser, error, sync_manager, Some(cache.clone()))
310 478 }
311 479 // Registry exists but unlicensed (deactivated and reactivated)
312 480 (Some(reg), license::LicenseStatus::Unlicensed) => {
313 481 tracing::info!("No valid license, showing activation screen");
314 - (AppScreen::Activation, reg.active.clone(), None, None, None, None)
482 + (reg.active.clone(), None, None, None, None)
315 483 }
316 484 // No registry + licensed → vault setup (existing user upgrading)
317 485 (None, license::LicenseStatus::Licensed(cache)) => {
318 486 tracing::info!("Licensed but no vault registry, showing vault setup");
319 - (AppScreen::VaultSetup, default_vault.clone(), None, None, None, Some(cache.clone()))
487 + (default_vault.clone(), None, None, None, Some(cache.clone()))
320 488 }
321 489 // No registry + unlicensed → activation first
322 490 (None, license::LicenseStatus::Unlicensed) => {
323 491 tracing::info!("No license, showing activation screen");
324 - (AppScreen::Activation, default_vault.clone(), None, None, None, None)
492 + (default_vault.clone(), None, None, None, None)
325 493 }
326 494 };
327 495
@@ -802,7 +970,9 @@ impl eframe::App for AudioFilesApp {
802 970 }
803 971 ui.add_space(4.0);
804 972 ui.horizontal(|ui| {
805 - if ui.button("Download").clicked() && !download_url.is_empty() {
973 + if ui.button("Download").clicked()
974 + && download_url.starts_with("https://")
975 + {
806 976 let _ = open::that(&download_url);
807 977 }
808 978 if ui.button("Not Now").clicked() {
@@ -4,7 +4,7 @@ use egui;
4 4 use egui_extras::{Column, TableBuilder};
5 5
6 6 use crate::state::{BrowserState, SortColumn, SortDirection};
7 - use audiofiles_core::vfs::NodeType;
7 + use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis};
8 8
9 9 use super::file_list_menus::{draw_background_context_menu, draw_context_menu, draw_multi_context_menu};
10 10 use super::instrument_panel::DragPayload;
@@ -191,159 +191,19 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) {
191 191
192 192 // Name (with inline icon)
193 193 row.col(|ui| {
194 - let icon = match node.node.node_type {
195 - NodeType::Directory => "\u{1F4C1} ",
196 - NodeType::Sample if node.cloud_only => "\u{2601} ",
197 - NodeType::Sample => "\u{1F50A} ",
198 - };
199 - let label = format!("{}{}", icon, node.node.name);
200 - let resp = if node.cloud_only {
201 - ui.selectable_label(
202 - selected,
203 - egui::RichText::new(&label).color(theme::text_muted()),
204 - )
205 - } else {
206 - ui.selectable_label(selected, &label)
207 - };
208 -
209 - // Add drag sense for native OS drag-out (Finder/DAW).
210 - // Response::interact() re-registers the SAME widget id with
211 - // click+drag sense so egui tracks drags on the selectable_label.
212 194 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
213 - let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample {
214 - resp.interact(egui::Sense::drag())
215 - } else {
216 - resp
217 - };
218 -
219 - if resp.clicked() {
220 - handle_click(state, row_idx, ui);
221 - }
222 -
223 - if resp.double_clicked() {
224 - match node.node.node_type {
225 - NodeType::Directory => {
226 - state.selection.set_single(row_idx);
227 - state.enter_directory();
228 - }
229 - NodeType::Sample => {
230 - if !node.cloud_only {
231 - if let Some(hash) = &node.node.sample_hash {
232 - let hash = hash.clone();
233 - state.trigger_preview(&hash);
234 - }
235 - }
236 - }
237 - }
238 - }
239 -
240 - // Drag source for instrument zone assignment (not for cloud-only)
241 - if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only {
242 - if let Some(hash) = &node.node.sample_hash {
243 - resp.dnd_set_drag_payload(DragPayload {
244 - hash: hash.to_string(),
245 - name: node.node.name.clone(),
246 - });
247 - }
248 - }
249 -
250 - // Native OS drag-out to Finder/DAW (only when instrument panel is closed)
251 - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
252 - if !os_drag_blocked
253 - && !state.instrument_visible
254 - && node.node.node_type == NodeType::Sample
255 - && !node.cloud_only
256 - && resp.dragged()
257 - && resp.drag_delta().length() > 4.0
258 - {
259 - if !state.selection.contains(row_idx) {
260 - state.selection.set_single(row_idx);
261 - }
262 - start_os_drag(state);
263 - }
264 -
265 - // Context menu: show bulk operations when right-clicking
266 - // a row that's part of a multi-selection, otherwise show
267 - // single-item actions (preview, copy path, delete).
268 - resp.context_menu(|ui| {
269 - if state.selection.count() > 1 && state.selection.contains(row_idx) {
270 - draw_multi_context_menu(ui, state);
271 - } else {
272 - draw_context_menu(ui, state, row_idx, node);
273 - }
274 - });
195 + let drag_blocked = os_drag_blocked;
196 + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
197 + let drag_blocked = false;
198 + draw_name_column(ui, state, node, row_idx, selected, drag_blocked);
275 199 });
276 200
277 - // Duration
278 - if show_duration {
279 - row.col(|ui| {
280 - if let Some(dur) = node.duration {
281 - ui.label(
282 - egui::RichText::new(widgets::format_duration(dur))
283 - .color(theme::text_secondary()),
284 - );
285 - }
286 - });
287 - }
288 -
289 - // Classification
290 - if show_classification {
291 - row.col(|ui| {
292 - if let Some(ref class) = node.classification {
293 - widgets::classification_badge(ui, class);
294 - }
295 - });
296 - }
297 -
298 - // BPM
299 - if show_bpm {
300 - row.col(|ui| {
301 - if let Some(bpm) = node.bpm {
302 - ui.label(
303 - egui::RichText::new(widgets::format_bpm(bpm))
304 - .color(theme::text_secondary()),
305 - );
306 - }
307 - });
308 - }
309 -
310 - // Key
311 - if show_key {
312 - row.col(|ui| {
313 - if let Some(ref key) = node.musical_key {
314 - ui.label(
315 - egui::RichText::new(key.as_str())
316 - .color(theme::text_secondary()),
317 - );
318 - }
319 - });
320 - }
321 -
322 - // Peak dB
323 - if show_peak_db {
324 - row.col(|ui| {
325 - if let Some(peak) = node.peak_db {
326 - ui.label(
327 - egui::RichText::new(format!("{:.1}", peak))
328 - .color(theme::text_secondary()),
329 - );
330 - }
331 - });
332 - }
333 -
334 - // Tags
335 - if show_tags {
336 - row.col(|ui| {
337 - if !node.tags.is_empty() {
338 - let tag_str = node.tags.join(", ");
339 - ui.label(
340 - egui::RichText::new(tag_str)
341 - .small()
342 - .color(theme::text_secondary()),
343 - );
344 - }
345 - });
346 - }
201 + // Analysis columns (duration, classification, BPM, key, peak dB, tags)
202 + draw_analysis_columns(
203 + &mut row, node,
204 + show_duration, show_classification, show_bpm,
205 + show_key, show_peak_db, show_tags,
206 + );
347 207
348 208 // Play button
349 209 row.col(|ui| {
@@ -412,6 +272,186 @@ fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) {
412 272 state.refresh_selected_detail();
413 273 }
414 274
275 + /// Draw the Name column contents for a single file-list row.
276 + ///
277 + /// Renders the icon + label, handles click/double-click, drag payloads
278 + /// (instrument zone assignment and native OS drag-out), and the context menu.
279 + #[allow(unused_variables)]
280 + fn draw_name_column(
281 + ui: &mut egui::Ui,
282 + state: &mut BrowserState,
283 + node: &VfsNodeWithAnalysis,
284 + row_idx: usize,
285 + selected: bool,
286 + os_drag_blocked: bool,
287 + ) {
288 + let icon = match node.node.node_type {
289 + NodeType::Directory => "\u{1F4C1} ",
290 + NodeType::Sample if node.cloud_only => "\u{2601} ",
291 + NodeType::Sample => "\u{1F50A} ",
292 + };
293 + let label = format!("{}{}", icon, node.node.name);
294 + let resp = if node.cloud_only {
295 + ui.selectable_label(
296 + selected,
297 + egui::RichText::new(&label).color(theme::text_muted()),
298 + )
299 + } else {
300 + ui.selectable_label(selected, &label)
301 + };
302 +
303 + // Add drag sense for native OS drag-out (Finder/DAW).
304 + // Response::interact() re-registers the SAME widget id with
305 + // click+drag sense so egui tracks drags on the selectable_label.
306 + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
307 + let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample {
308 + resp.interact(egui::Sense::drag())
309 + } else {
310 + resp
311 + };
312 +
313 + if resp.clicked() {
314 + handle_click(state, row_idx, ui);
315 + }
316 +
317 + if resp.double_clicked() {
318 + match node.node.node_type {
319 + NodeType::Directory => {
320 + state.selection.set_single(row_idx);
321 + state.enter_directory();
322 + }
323 + NodeType::Sample => {
324 + if !node.cloud_only {
325 + if let Some(hash) = &node.node.sample_hash {
326 + let hash = hash.clone();
327 + state.trigger_preview(&hash);
328 + }
329 + }
330 + }
331 + }
332 + }
333 +
334 + // Drag source for instrument zone assignment (not for cloud-only)
335 + if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only {
336 + if let Some(hash) = &node.node.sample_hash {
337 + resp.dnd_set_drag_payload(DragPayload {
338 + hash: hash.to_string(),
339 + name: node.node.name.clone(),
340 + });
341 + }
342 + }
343 +
344 + // Native OS drag-out to Finder/DAW (only when instrument panel is closed)
345 + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
346 + if !os_drag_blocked
347 + && !state.instrument_visible
348 + && node.node.node_type == NodeType::Sample
349 + && !node.cloud_only
350 + && resp.dragged()
351 + && resp.drag_delta().length() > 4.0
352 + {
353 + if !state.selection.contains(row_idx) {
354 + state.selection.set_single(row_idx);
355 + }
356 + start_os_drag(state);
357 + }
358 +
359 + // Context menu: show bulk operations when right-clicking
360 + // a row that's part of a multi-selection, otherwise show
361 + // single-item actions (preview, copy path, delete).
362 + resp.context_menu(|ui| {
363 + if state.selection.count() > 1 && state.selection.contains(row_idx) {
364 + draw_multi_context_menu(ui, state);
365 + } else {
366 + draw_context_menu(ui, state, row_idx, node);
367 + }
368 + });
369 + }
370 +
371 + /// Draw the analysis data columns (duration, classification, BPM, key, peak dB, tags)
372 + /// for a single file-list row. Each visible column emits a `row.col()` call.
373 + fn draw_analysis_columns(
374 + row: &mut egui_extras::TableRow,
375 + node: &VfsNodeWithAnalysis,
376 + show_duration: bool,
377 + show_classification: bool,
378 + show_bpm: bool,
379 + show_key: bool,
380 + show_peak_db: bool,
381 + show_tags: bool,
382 + ) {
383 + // Duration
384 + if show_duration {
385 + row.col(|ui| {
386 + if let Some(dur) = node.duration {
387 + ui.label(
388 + egui::RichText::new(widgets::format_duration(dur))
389 + .color(theme::text_secondary()),
390 + );
391 + }
392 + });
393 + }
394 +
395 + // Classification
396 + if show_classification {
397 + row.col(|ui| {
398 + if let Some(ref class) = node.classification {
399 + widgets::classification_badge(ui, class);
400 + }
401 + });
402 + }
403 +
404 + // BPM
405 + if show_bpm {
406 + row.col(|ui| {
407 + if let Some(bpm) = node.bpm {
408 + ui.label(
409 + egui::RichText::new(widgets::format_bpm(bpm))
410 + .color(theme::text_secondary()),
411 + );
412 + }
413 + });
414 + }
415 +
416 + // Key
417 + if show_key {
418 + row.col(|ui| {
419 + if let Some(ref key) = node.musical_key {
420 + ui.label(
421 + egui::RichText::new(key.as_str())
422 + .color(theme::text_secondary()),
423 + );
424 + }
425 + });
426 + }
427 +
428 + // Peak dB
429 + if show_peak_db {
430 + row.col(|ui| {
431 + if let Some(peak) = node.peak_db {
432 + ui.label(
433 + egui::RichText::new(format!("{:.1}", peak))
434 + .color(theme::text_secondary()),
435 + );
436 + }
437 + });
438 + }
439 +
440 + // Tags
441 + if show_tags {
442 + row.col(|ui| {
443 + if !node.tags.is_empty() {
444 + let tag_str = node.tags.join(", ");
445 + ui.label(
446 + egui::RichText::new(tag_str)
447 + .small()
448 + .color(theme::text_secondary()),
449 + );
450 + }
451 + });
452 + }
453 + }
454 +
415 455 /// Draw a clickable column header label. Shows an up/down arrow when this column
416 456 /// is the active sort key. Uses `std::mem::discriminant` so we can compare enum
417 457 /// variants without requiring `PartialEq` on the payload.
@@ -315,11 +315,12 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
315 315 // Inline rename modal
316 316 if let Some((rename_id, _)) = state.collection_rename_target.clone() {
317 317 ui.horizontal(|ui| {
318 - let resp = ui.text_edit_singleline(&mut state.collection_rename_target.as_mut().unwrap().1);
318 + let Some((_, buf)) = state.collection_rename_target.as_mut() else { return; };
319 + let resp = ui.text_edit_singleline(buf);
319 320 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
320 - let new_name = state.collection_rename_target.as_ref().unwrap().1.clone();
321 - if !new_name.trim().is_empty() {
322 - let _ = state.backend.rename_collection(rename_id, new_name.trim());
321 + let new_name = buf.trim().to_string();
322 + if !new_name.is_empty() {
323 + let _ = state.backend.rename_collection(rename_id, &new_name);
323 324 state.refresh_collections();
324 325 }
325 326 state.collection_rename_target = None;
@@ -36,14 +36,25 @@ struct MelFilterbank {
36 36 filters: Vec<MelFilter>,
37 37 }
38 38
39 + /// Convert frequency in Hz to the mel scale: `2595 * log10(1 + hz/700)`.
40 + /// The mel scale approximates human pitch perception — equal mel intervals
41 + /// sound equally spaced to the ear, even though the Hz intervals grow wider
42 + /// at higher frequencies.
39 43 fn hz_to_mel(hz: f64) -> f64 {
40 44 2595.0 * (1.0 + hz / 700.0).log10()
41 45 }
42 46
47 + /// Inverse mel scale: convert mel value back to Hz.
43 48 fn mel_to_hz(mel: f64) -> f64 {
44 49 700.0 * (10.0_f64.powf(mel / 2595.0) - 1.0)
45 50 }
46 51
52 + /// Build a mel-spaced triangular filterbank for MFCC computation.
53 + ///
54 + /// Creates `num_filters` overlapping triangular filters spanning 0 Hz to Nyquist,
55 + /// spaced uniformly on the mel scale. Each filter has a rising slope from its left
56 + /// edge to its center and a falling slope from center to right edge — this smoothly
57 + /// bins FFT magnitudes into perceptually meaningful frequency bands.
47 58 fn build_mel_filterbank(fft_size: usize, sample_rate: u32, num_filters: usize) -> MelFilterbank {
48 59 let spectrum_len = fft_size / 2 + 1;
49 60 let freq_bin_hz = sample_rate as f64 / fft_size as f64;
@@ -1,4 +1,13 @@
1 1 //! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs.
2 + //!
3 + //! ## Why content-addressed storage
4 + //!
5 + //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row).
6 + //! Users often have the same sample in multiple folders.
7 + //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID
8 + //! collisions, no server-assigned IDs, no coordination needed during offline edits.
9 + //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the
10 + //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
2 11
3 12 use std::fs;
4 13 use std::io::Read;
@@ -3,6 +3,15 @@
3 3 //! Loads TOML manifests (bundled + user) describing hardware sampler constraints,
4 4 //! and optionally runs Rhai scripts for custom export logic.
5 5 //!
6 + //! ## Why Rhai
7 + //!
8 + //! - **Sandboxed:** No filesystem, network, or FFI access from scripts. Unlike Lua or
9 + //! Python plugins, a malicious Rhai script cannot escape the sandbox.
10 + //! - **Resource-capped:** Configurable operation count, expression depth, and array size
11 + //! limits prevent infinite loops and memory exhaustion.
12 + //! - **Rust-native:** Compiles as a regular Rust crate — no external interpreter binary,
13 + //! no C bindings, no build-time code generation.
14 + //!
6 15 //! # Architecture
7 16 //!
8 17 //! - `manifest` — TOML parsing into `DeviceProfile`
@@ -115,6 +124,8 @@ fn compile_plugin_hooks(
115 124 #[cfg(test)]
116 125 mod tests {
117 126 use super::*;
127 + use crate::hooks::{run_validate_sample, run_transform_filename};
128 + use crate::types::{RhaiSampleInfo, RhaiExportContext};
118 129
119 130 #[test]
120 131 fn load_user_plugins_from_empty_dir() {
@@ -174,4 +185,346 @@ validate_sample = "hooks/validate.rhai"
174 185 assert!(result.is_ok());
175 186 assert!(registry.is_empty());
176 187 }
188 +
189 + // ── Full plugin lifecycle (Item 32) ──
190 +
191 + fn sample_info(rate: u32, channels: u16) -> RhaiSampleInfo {
192 + RhaiSampleInfo {
193 + hash: "abc123def456".to_string(),
194 + name: "kick_drum.wav".to_string(),
195 + extension: "wav".to_string(),
196 + sample_rate: rate,
197 + bit_depth: 16,
198 + channels,
199 + duration: 0.5,
200 + file_size: 44100,
201 + }
202 + }
203 +
204 + fn export_ctx() -> RhaiExportContext {
205 + RhaiExportContext {
206 + device_name: "Test Device".to_string(),
207 + destination: "/tmp/export".to_string(),
208 + filename: "kick_drum".to_string(),
209 + extension: "wav".to_string(),
210 + index: 3,
211 + total: 20,
212 + }
213 + }
214 +
215 + /// Create a temp plugin directory with manifest and hook scripts.
216 + /// Returns the temp dir (must be kept alive for the duration of the test).
217 + fn create_test_plugin(
218 + dir: &std::path::Path,
219 + name: &str,
220 + validate_script: &str,
221 + transform_script: &str,
222 + ) {
223 + let plugin_dir = dir.join(name);
224 + std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
225 +
226 + std::fs::write(
227 + plugin_dir.join("manifest.toml"),
228 + format!(
229 + r#"
230 + [device]
231 + name = "{name}"
232 + manufacturer = "Test Co"
233 + version = "1.0"
234 +
235 + [audio]
236 + formats = ["wav"]
237 + sample_rates = [44100, 48000]
238 + bit_depths = [16, 24]
239 + channels = "mono"
240 +
241 + [hooks]
242 + validate_sample = "hooks/validate.rhai"
243 + transform_filename = "hooks/transform.rhai"
244 + "#
245 + ),
246 + )
247 + .unwrap();
248 +
249 + std::fs::write(
250 + plugin_dir.join("hooks").join("validate.rhai"),
251 + validate_script,
252 + )
253 + .unwrap();
254 +
255 + std::fs::write(
256 + plugin_dir.join("hooks").join("transform.rhai"),
257 + transform_script,
258 + )
259 + .unwrap();
260 + }
261 +
262 + #[test]
263 + fn full_plugin_lifecycle() {
264 + // 1. Create temp dir with a plugin: manifest + two hook scripts
265 + let dir = tempfile::tempdir().unwrap();
266 + create_test_plugin(
267 + dir.path(),
268 + "Lifecycle Sampler",
269 + // validate: accept mono 44100 or 48000
270 + "info.channels == 1 && (info.sample_rate == 44100 || info.sample_rate == 48000)",
271 + // transform: uppercase + zero-padded index
272 + r#"to_upper(name) + "_" + format_index(ctx.index, 3)"#,
273 + );
274 +
275 + // 2. Discover plugins from temp dir
276 + let discovered = loader::discover_plugins(dir.path());
277 + assert_eq!(discovered.len(), 1);
278 + assert_eq!(discovered[0].manifest.device.name, "Lifecycle Sampler");
279 +
280 + // 3. Load into registry (manifest -> profile + compile hooks)
281 + let mut registry = PluginRegistry::new();
282 + load_user_plugins(&mut registry, dir.path()).unwrap();
283 +
284 + assert_eq!(registry.len(), 1);
285 + let plugin = registry.get("Lifecycle Sampler").unwrap();
286 + assert!(!plugin.bundled);
287 + assert_eq!(plugin.profile.name, "Lifecycle Sampler");
288 + assert!(plugin.hooks.validate_sample.is_some());
289 + assert!(plugin.hooks.transform_filename.is_some());
290 + assert!(plugin.hooks.pre_export.is_none());
291 + assert!(plugin.hooks.post_export.is_none());
292 +
293 + // 4. Run validate_sample -- mono 44100 should pass
294 + let valid = run_validate_sample(
295 + registry.engine(),
296 + plugin.hooks.validate_sample.as_ref().unwrap(),
297 + sample_info(44100, 1),
298 + ).unwrap();
299 + assert!(valid);
300 +
301 + // 5. Run validate_sample -- stereo should fail
302 + let invalid = run_validate_sample(
303 + registry.engine(),
304 + plugin.hooks.validate_sample.as_ref().unwrap(),
305 + sample_info(44100, 2),
306 + ).unwrap();
307 + assert!(!invalid);
308 +
309 + // 6. Run transform_filename
310 + let transformed = run_transform_filename(
311 + registry.engine(),
312 + plugin.hooks.transform_filename.as_ref().unwrap(),
313 + "kick_drum".to_string(),
314 + export_ctx(),
315 + ).unwrap();
316 + assert_eq!(transformed, "KICK_DRUM_003");
317 + }
318 +
319 + #[test]
320 + fn validate_sample_returns_true_for_matching_criteria() {
321 + let dir = tempfile::tempdir().unwrap();
322 + let plugin_dir = dir.path().join("validator");
323 + std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
324 +
325 + std::fs::write(
326 + plugin_dir.join("manifest.toml"),
327 + r#"
328 + [device]
329 + name = "Validator"
330 + manufacturer = "Test"
331 + version = "1.0"
332 +
333 + [audio]
334 + formats = ["wav"]
335 + sample_rates = [44100]
336 + bit_depths = [16]
337 + channels = "both"
338 +
339 + [hooks]
340 + validate_sample = "hooks/validate.rhai"
341 + "#,
342 + ).unwrap();
343 +
344 + // Script checks: sample rate is 44100, bit depth is 16, file size under 1MB
345 + std::fs::write(
346 + plugin_dir.join("hooks").join("validate.rhai"),
347 + "info.sample_rate == 44100 && info.bit_depth == 16 && info.file_size < 1048576",
348 + ).unwrap();
349 +
350 + let mut registry = PluginRegistry::new();
351 + load_user_plugins(&mut registry, dir.path()).unwrap();
352 + let plugin = registry.get("Validator").unwrap();
353 + let ast = plugin.hooks.validate_sample.as_ref().unwrap();
354 +
355 + // Matching sample -- should return true
356 + let result = run_validate_sample(
357 + registry.engine(),
358 + ast,
359 + sample_info(44100, 1),
360 + ).unwrap();
361 + assert!(result, "expected true for matching sample");
362 +
363 + // Wrong sample rate -- should return false
364 + let result = run_validate_sample(
365 + registry.engine(),
366 + ast,
367 + sample_info(48000, 1),
368 + ).unwrap();
369 + assert!(!result, "expected false for non-matching sample rate");
370 + }
371 +
372 + #[test]
373 + fn transform_filename_renames_with_context() {
374 + let dir = tempfile::tempdir().unwrap();
375 + let plugin_dir = dir.path().join("renamer");
376 + std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
377 +
378 + std::fs::write(
379 + plugin_dir.join("manifest.toml"),
380 + r#"
381 + [device]
382 + name = "Renamer"
383 + manufacturer = "Test"
384 + version = "1.0"
385 +
386 + [audio]
387 + formats = ["wav"]
388 + sample_rates = [44100]
389 + bit_depths = [16]
390 + channels = "both"
391 +
392 + [hooks]
393 + transform_filename = "hooks/transform.rhai"
394 + "#,
395 + ).unwrap();
396 +
397 + // Script: lowercase name + device abbreviation + padded index
398 + std::fs::write(
399 + plugin_dir.join("hooks").join("transform.rhai"),
400 + r#"let stem = to_lower(name); stem + "_" + truncate(ctx.device_name, 3) + format_index(ctx.index, 4)"#,
401 + ).unwrap();
402 +
403 + let mut registry = PluginRegistry::new();
404 + load_user_plugins(&mut registry, dir.path()).unwrap();
405 + let plugin = registry.get("Renamer").unwrap();
406 + let ast = plugin.hooks.transform_filename.as_ref().unwrap();
407 +
408 + let result = run_transform_filename(
409 + registry.engine(),
410 + ast,
411 + "KICK_DRUM".to_string(),
412 + export_ctx(),
413 + ).unwrap();
414 + assert_eq!(result, "kick_drum_Tes0003");
415 +
416 + // Different name and index
417 + let mut ctx2 = export_ctx();
418 + ctx2.index = 0;
419 + let result = run_transform_filename(
420 + registry.engine(),
421 + ast,
422 + "HiHat".to_string(),
423 + ctx2,
424 + ).unwrap();
425 + assert_eq!(result, "hihat_Tes0000");
426 + }
427 +
428 + #[test]
429 + fn lifecycle_with_all_four_hooks() {
430 + let dir = tempfile::tempdir().unwrap();
431 + let plugin_dir = dir.path().join("full-hooks");
432 + std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
433 +
434 + std::fs::write(
435 + plugin_dir.join("manifest.toml"),
436 + r#"
437 + [device]
438 + name = "Full Hooks Device"
439 + manufacturer = "Test"
440 + version = "1.0"
441 +
442 + [audio]
443 + formats = ["wav", "aiff"]
444 + sample_rates = [44100, 48000]
445 + bit_depths = [16, 24]
446 + channels = "both"
447 +
448 + [naming]
449 + case = "upper"
450 + separator = "_"
451 + max_length = 16
452 + strip_special = true
453 +
454 + [limits]
455 + max_file_size_bytes = 134217728
456 +
457 + [hooks]
458 + validate_sample = "hooks/validate.rhai"
459 + transform_filename = "hooks/transform.rhai"
460 + pre_export = "hooks/pre_export.rhai"
461 + post_export = "hooks/post_export.rhai"
462 + "#,
463 + ).unwrap();
464 +
465 + std::fs::write(
466 + plugin_dir.join("hooks").join("validate.rhai"),
467 + "info.duration < 30.0",
468 + ).unwrap();
469 + std::fs::write(
470 + plugin_dir.join("hooks").join("transform.rhai"),
471 + r#"to_upper(name)"#,
472 + ).unwrap();
473 + std::fs::write(
474 + plugin_dir.join("hooks").join("pre_export.rhai"),
475 + r#"let x = ctx.total; x"#,
476 + ).unwrap();
477 + std::fs::write(
478 + plugin_dir.join("hooks").join("post_export.rhai"),
479 + r#"let done = ctx.index == ctx.total - 1; done"#,
480 + ).unwrap();
481 +
482 + let mut registry = PluginRegistry::new();
483 + load_user_plugins(&mut registry, dir.path()).unwrap();
484 + let plugin = registry.get("Full Hooks Device").unwrap();
485 +
486 + // All four hooks should be compiled
487 + assert!(plugin.hooks.validate_sample.is_some());
488 + assert!(plugin.hooks.transform_filename.is_some());
489 + assert!(plugin.hooks.pre_export.is_some());
490 + assert!(plugin.hooks.post_export.is_some());
491 + assert!(!plugin.hooks.is_empty());
492 +
493 + // Profile should have naming and limits from manifest
494 + assert!(plugin.profile.naming.is_some());
495 + assert!(plugin.profile.limits.is_some());
496 + assert_eq!(plugin.profile.audio.formats.len(), 2);
497 + assert_eq!(plugin.profile.audio.sample_rates, vec![44100, 48000]);
498 +
499 + // Run validate: 0.5s duration passes, but we can test it
500 + let valid = run_validate_sample(
501 + registry.engine(),
502 + plugin.hooks.validate_sample.as_ref().unwrap(),
503 + sample_info(44100, 1),
504 + ).unwrap();
505 + assert!(valid);
506 +
507 + // Run transform
508 + let name = run_transform_filename(
509 + registry.engine(),
510 + plugin.hooks.transform_filename.as_ref().unwrap(),
511 + "kick".to_string(),
512 + export_ctx(),
513 + ).unwrap();
514 + assert_eq!(name, "KICK");
515 +
516 + // Run pre_export (side-effect hook, should not error)
517 + hooks::run_pre_export(
518 + registry.engine(),
519 + plugin.hooks.pre_export.as_ref().unwrap(),
520 + export_ctx(),
521 + ).unwrap();
522 +
523 + // Run post_export (side-effect hook, should not error)
524 + hooks::run_post_export(
525 + registry.engine(),
526 + plugin.hooks.post_export.as_ref().unwrap(),
527 + export_ctx(),
528 + ).unwrap();
529 + }
177 530 }
@@ -765,4 +765,204 @@ mod tests {
765 765 // Second call: already cloud_only=1, query only selects cloud_only=0
766 766 assert_eq!(mark_cloud_only_samples(conn, tmp.path()).unwrap(), 0);
767 767 }
768 +
769 + // ── Integration: multi-table snapshot ──
770 +
771 + #[test]
772 + fn snapshot_captures_tags_collections_and_members() {
773 + let db = setup_test_db();
774 + let conn = db.conn();
775 +
776 + // Suppress triggers during data setup
777 + set_sync_state(conn, "applying_remote", "1").unwrap();
778 +
779 + insert_sample(conn, "s1", "kick.wav", "wav");
780 + insert_sample(conn, "s2", "snare.wav", "wav");
781 +
782 + // Tags
783 + conn.execute(
784 + "INSERT INTO tags (sample_hash, tag) VALUES ('s1', 'drums')",
785 + [],
786 + ).unwrap();
787 + conn.execute(
788 + "INSERT INTO tags (sample_hash, tag) VALUES ('s2', 'perc')",
789 + [],
790 + ).unwrap();
791 +
792 + // Collection
793 + let now = chrono::Utc::now().timestamp();
794 + conn.execute(
795 + "INSERT INTO collections (name, description, created_at) VALUES ('Kit', NULL, ?1)",
796 + [now],
797 + ).unwrap();
798 + let coll_id = conn.last_insert_rowid();
799 +
800 + // Collection members
801 + conn.execute(
802 + "INSERT INTO collection_members (collection_id, sample_hash, added_at) VALUES (?1, 's1', ?2)",
803 + rusqlite::params![coll_id, now],
804 + ).unwrap();
805 +
806 + set_sync_state(conn, "applying_remote", "0").unwrap();
807 + clear_changelog(conn);
808 +
809 + let total = create_initial_snapshot(conn).unwrap();
810 + // 2 samples + 2 tags + 1 collection + 1 collection_member = 6
811 + assert_eq!(total, 6);
812 +
813 + assert_eq!(changelog_count(conn, Some("samples"), Some("INSERT")), 2);
814 + assert_eq!(changelog_count(conn, Some("tags"), Some("INSERT")), 2);
815 + assert_eq!(changelog_count(conn, Some("collections"), Some("INSERT")), 1);
816 + assert_eq!(changelog_count(conn, Some("collection_members"), Some("INSERT")), 1);
817 + }
818 +
819 + #[test]
820 + fn snapshot_changelog_entries_contain_valid_json() {
821 + let db = setup_test_db();
822 + let conn = db.conn();
823 +
824 + set_sync_state(conn, "applying_remote", "1").unwrap();
825 + insert_sample(conn, "json_test", "pad.wav", "wav");
826 + set_sync_state(conn, "applying_remote", "0").unwrap();
827 + clear_changelog(conn);
828 +
829 + create_initial_snapshot(conn).unwrap();
830 +
831 + let data: String = conn.query_row(
832 + "SELECT data FROM sync_changelog WHERE table_name = 'samples' AND row_id = 'json_test'",
833 + [],
834 + |row| row.get(0),
835 + ).unwrap();
836 +
837 + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
838 + assert_eq!(parsed["hash"], "json_test");
839 + assert_eq!(parsed["original_name"], "pad.wav");
840 + assert_eq!(parsed["file_extension"], "wav");
841 + assert!(parsed.get("file_size").is_some());
842 + assert!(parsed.get("import_date").is_some());
843 + }
844 +
845 + #[test]
846 + fn snapshot_after_adding_data_does_not_duplicate() {
847 + let db = setup_test_db();
848 + let conn = db.conn();
849 +
850 + set_sync_state(conn, "applying_remote", "1").unwrap();
851 + insert_sample(conn, "first", "one.wav", "wav");
852 + set_sync_state(conn, "applying_remote", "0").unwrap();
853 + clear_changelog(conn);
854 +
855 + let first = create_initial_snapshot(conn).unwrap();
856 + assert_eq!(first, 1);
857 +
858 + // Add more data after snapshot (these go through normal triggers)
859 + insert_sample(conn, "second", "two.wav", "wav");
860 +
861 + // Second snapshot call should return 0 (flag already set)
862 + let second = create_initial_snapshot(conn).unwrap();
863 + assert_eq!(second, 0);
864 +
865 + // Total changelog: 1 from snapshot + 1 from trigger
866 + assert_eq!(changelog_count(conn, Some("samples"), None), 2);
867 + }
868 +
869 + #[test]
870 + fn cleanup_preserves_recent_pushed_entries() {
871 + let db = setup_test_db();
872 + let conn = db.conn();
873 + clear_changelog(conn);
874 +
875 + // Entry pushed exactly 6 days ago (within 7-day window)
876 + conn.execute(
877 + "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \
878 + VALUES ('samples', 'INSERT', 'six_days', datetime('now', '-6 days'), 1)",
879 + [],
880 + ).unwrap();
881 +
882 + // Entry pushed exactly 8 days ago (outside 7-day window)
883 + conn.execute(
884 + "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \
885 + VALUES ('samples', 'INSERT', 'eight_days', datetime('now', '-8 days'), 1)",
886 + [],
887 + ).unwrap();
888 +
889 + let deleted = cleanup_changelog(conn).unwrap();
890 + assert_eq!(deleted, 1);
891 +
892 + // six_days should remain, eight_days should be deleted
893 + let remaining_id: String = conn.query_row(
894 + "SELECT row_id FROM sync_changelog",
895 + [],
896 + |r| r.get(0),
897 + ).unwrap();
898 + assert_eq!(remaining_id, "six_days");
899 + }
900 +
901 + #[test]
902 + fn enforce_retention_at_exact_cap_is_noop() {
903 + let db = setup_test_db();
904 + let conn = db.conn();
905 + clear_changelog(conn);
906 +
907 + for i in 0..MAX_CHANGELOG_ENTRIES {
908 + conn.execute(
909 + "INSERT INTO sync_changelog (table_name, op, row_id, data) \
910 + VALUES ('samples', 'UPDATE', ?1, '{}')",
911 + [format!("row-{}", i)],
912 + ).unwrap();
913 + }
914 +
915 + let deleted = enforce_changelog_retention(conn).unwrap();
916 + assert_eq!(deleted, 0);
917 +
918 + let count: i64 = conn.query_row(
919 + "SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0),
920 + ).unwrap();
921 + assert_eq!(count, MAX_CHANGELOG_ENTRIES);
922 + }
923 +
924 + #[test]
925 + fn cleanup_and_retention_combined() {
926 + let db = setup_test_db();
927 + let conn = db.conn();
928 + clear_changelog(conn);
929 +
930 + // Insert many old pushed entries (should be cleaned by cleanup)
931 + for i in 0..100 {
932 + conn.execute(
933 + "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) \
934 + VALUES ('samples', 'UPDATE', ?1, datetime('now', '-14 days'), 1)",
935 + [format!("old-{}", i)],
936 + ).unwrap();
937 + }
938 +
939 + // Insert entries at the retention cap to test combined behavior
940 + for i in 0..MAX_CHANGELOG_ENTRIES {
941 + conn.execute(
942 + "INSERT INTO sync_changelog (table_name, op, row_id, data) \
943 + VALUES ('samples', 'INSERT', ?1, '{}')",
944 + [format!("new-{}", i)],
945 + ).unwrap();
946 + }
947 +
948 + // cleanup removes old pushed entries
949 + let cleaned = cleanup_changelog(conn).unwrap();
950 + assert_eq!(cleaned, 100);
951 +
952 + // retention should be a noop now (exactly MAX_CHANGELOG_ENTRIES remain)
953 + let retained = enforce_changelog_retention(conn).unwrap();
954 + assert_eq!(retained, 0);
955 + }
956 +
957 + #[test]
958 + fn sync_state_get_and_set_roundtrip() {
959 + let db = setup_test_db();
960 + let conn = db.conn();
961 +
962 + set_sync_state(conn, "auto_sync_enabled", "1").unwrap();
963 + assert_eq!(get_sync_state(conn, "auto_sync_enabled").unwrap(), "1");
964 +
965 + set_sync_state(conn, "auto_sync_enabled", "0").unwrap();
966 + assert_eq!(get_sync_state(conn, "auto_sync_enabled").unwrap(), "0");
967 + }
768 968 }