app_lib/
lib.rs

1//! AUDIO_HAXOR — Tauri v2 desktop app for audio plugin management.
2//!
3//! This crate provides the Rust backend for scanning audio plugins (VST2/VST3/AU/CLAP),
4//! audio samples, DAW project files, and presets. It includes KVR Audio version
5//! checking, scan history with diffing, and export to JSON/TOML/CSV/TSV/PDF.
6//!
7//! # Modules
8//!
9//! - [`scanner`] — Plugin filesystem scanner with architecture detection
10//! - [`scanner_skip_dirs`] — Shared directory-name blocklist for recursive scans
11//! - [`audio_extensions`] — Canonical audio sample extension list (scanner, walker, App Info)
12//! - [`audio_scanner`] — Audio sample discovery and metadata extraction
13//! - [`daw_scanner`] — DAW project scanner (14+ formats)
14//! - [`preset_scanner`] — Plugin preset discovery
15//! - [`audio_engine`] — Spawns the `audio-engine` AudioEngine (JUCE: devices, playback, VST3/AU scan) via stdin/stdout JSON
16//! - [`kvr`] — KVR Audio scraper and version checker
17//! - [`history`] — Scan history persistence, diffing, and preferences
18//! - [`content_hash`] — SHA-256 file hashing for byte-identical duplicate detection
19
20pub mod app_i18n;
21pub mod audio_engine;
22pub mod audio_extensions;
23pub mod audio_scanner;
24pub mod bpm;
25pub mod bulk_stat;
26pub mod content_hash;
27pub mod daw_scanner;
28pub mod db;
29pub mod file_watcher;
30pub mod history;
31pub mod key_detect;
32pub mod kvr;
33pub mod lufs;
34pub mod midi;
35pub mod midi_scanner;
36pub mod native_menu;
37mod open_with_app;
38pub mod pdf_meta;
39pub mod path_norm;
40pub mod pdf_scanner;
41pub mod preset_scanner;
42pub mod scanner;
43pub mod scanner_skip_dirs;
44pub mod similarity;
45pub mod tray_menu;
46mod tray_popover_escape_macos;
47pub mod unified_walker;
48pub mod xref;
49
50/// Shared utility: format bytes to human-readable string.
51pub fn format_size(bytes: u64) -> String {
52    if bytes == 0 {
53        return "0 B".into();
54    }
55    let units = ["B", "KB", "MB", "GB", "TB"];
56    let i = (bytes as f64).log(1024.0).floor() as usize;
57    let i = i.min(units.len() - 1);
58    format!("{:.1} {}", bytes as f64 / 1024f64.powi(i as i32), units[i])
59}
60
61use history::{AudioSample, DawProject, KvrCacheUpdateEntry, PdfFile, PresetFile};
62use path_norm::normalize_path_for_db;
63use scanner::PluginInfo;
64use serde::{Deserialize, Serialize};
65use std::collections::{HashMap, HashSet};
66use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
67use std::sync::Arc;
68use tauri::{AppHandle, Emitter, Manager};
69
70/// Domain string for SQLite `directory_scan_state` — shared by unified and standalone walkers.
71pub const DIRECTORY_SCAN_INCREMENTAL_DOMAIN: &str = "unified";
72
73/// Cached `app.log` verbosity: `0` = quiet (suppress selected normal-level chatter), `1` = normal, `2` = verbose (extra scan/KVR diagnostics).
74static LOG_VERBOSITY_LEVEL: AtomicU8 = AtomicU8::new(1);
75
76/// Set by `pdf_metadata_extract_abort`; checked between PDF page-count extraction chunks so the UI can stop CPU-heavy work when the PDF tab is hidden or the window is idle.
77static PDF_META_EXTRACT_ABORT: AtomicBool = AtomicBool::new(false);
78
79#[inline]
80pub fn log_verbosity_level() -> u8 {
81    LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed)
82}
83
84fn refresh_log_verbosity_from_prefs() {
85    let level = history::get_preference("logVerbosity")
86        .and_then(|v| v.as_str().map(std::string::ToString::to_string))
87        .unwrap_or_else(|| "normal".to_string());
88    let n = match level.as_str() {
89        "quiet" => 0u8,
90        "verbose" => 2u8,
91        _ => 1u8,
92    };
93    LOG_VERBOSITY_LEVEL.store(n, Ordering::Relaxed);
94}
95
96/// Fingerprint cache keys in SQLite are written with [`normalize_path_for_db`]; align in-memory
97/// lookups and inserts so `contains_key` matches paths from the UI (`allAudioSamples`).
98fn normalize_fingerprint_cache_map(
99    cache: HashMap<String, similarity::AudioFingerprint>,
100) -> HashMap<String, similarity::AudioFingerprint> {
101    cache
102        .into_iter()
103        .map(|(k, mut v)| {
104            let nk = normalize_path_for_db(&k);
105            v.path = nk.clone();
106            (nk, v)
107        })
108        .collect()
109}
110
111fn should_suppress_app_log_line(msg: &str) -> bool {
112    if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) != 0 {
113        return false;
114    }
115    // Optional normal-level prefixes to hide in Quiet (high-volume `write_app_log` only).
116    const PREFIXES: &[&str] = &[];
117    if PREFIXES.is_empty() {
118        return false;
119    }
120    let m = msg.trim_start();
121    PREFIXES.iter().any(|p| m.starts_with(p))
122}
123
124fn incremental_directory_scan_enabled() -> bool {
125    let prefs = history::load_preferences();
126    prefs
127        .get("incrementalDirectoryScan")
128        .and_then(|v| v.as_str())
129        .map(|s| s != "off")
130        .unwrap_or(true)
131}
132
133fn load_incremental_dir_state_for_walk() -> Option<Arc<unified_walker::IncrementalDirState>> {
134    if !incremental_directory_scan_enabled() {
135        return None;
136    }
137    match db::global().unified_scan_incremental_snapshot_is_trusted() {
138        Ok(false) => {
139            crate::write_app_log(
140                "SCAN INCREMENTAL — last unified scan did not finish successfully; full walk"
141                    .into(),
142            );
143            None
144        }
145        Err(e) => {
146            crate::write_app_log(format!(
147                "SCAN INCREMENTAL — could not read unified scan outcome ({e}); full walk",
148            ));
149            None
150        }
151        Ok(true) => match db::global().load_directory_scan_snapshot(DIRECTORY_SCAN_INCREMENTAL_DOMAIN)
152        {
153            Ok(m) => {
154                let n = m.len();
155                crate::app_log_verbose(move || {
156                    format!("SCAN VERBOSE — incremental snapshot loaded: {n} directory keys")
157                });
158                Some(Arc::new(unified_walker::IncrementalDirState::new(m)))
159            }
160            Err(e) => {
161                crate::write_app_log(format!(
162                    "SCAN INCREMENTAL — load directory snapshot failed ({e}); full walk",
163                ));
164                None
165            }
166        },
167    }
168}
169
170fn persist_incremental_dir_state_after_walk(
171    inc: Option<&Arc<unified_walker::IncrementalDirState>>,
172    scan_id_for_audit: &str,
173) {
174    let Some(inc) = inc else {
175        return;
176    };
177    let pending = inc.take_pending();
178    if pending.is_empty() {
179        return;
180    }
181    let _ = db::global().upsert_directory_scan_batch(
182        DIRECTORY_SCAN_INCREMENTAL_DOMAIN,
183        &pending,
184        Some(scan_id_for_audit),
185    );
186}
187
188// ── Export / Import types ──
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ExportPayload {
192    pub version: String,
193    pub exported_at: String,
194    pub plugins: Vec<ExportPlugin>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ExportPlugin {
199    pub name: String,
200    #[serde(rename = "type")]
201    pub plugin_type: String,
202    pub version: String,
203    pub manufacturer: String,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub manufacturer_url: Option<String>,
206    pub path: String,
207    pub size: String,
208    #[serde(rename = "sizeBytes", default)]
209    pub size_bytes: u64,
210    pub modified: String,
211    #[serde(default)]
212    pub architectures: Vec<String>,
213}
214
215// Shared state for cancellation
216struct ScanState {
217    scanning: AtomicBool,
218    stop_scan: AtomicBool,
219}
220
221struct UpdateState {
222    checking: AtomicBool,
223    stop_updates: AtomicBool,
224}
225
226struct AudioScanState {
227    scanning: AtomicBool,
228    stop_scan: AtomicBool,
229}
230
231struct DawScanState {
232    scanning: AtomicBool,
233    stop_scan: AtomicBool,
234}
235
236struct PresetScanState {
237    scanning: AtomicBool,
238    stop_scan: AtomicBool,
239}
240
241struct MidiScanState {
242    scanning: AtomicBool,
243    stop_scan: AtomicBool,
244}
245
246struct PdfScanState {
247    scanning: AtomicBool,
248    stop_scan: AtomicBool,
249}
250
251/// Tracks active directory paths being walked by each scanner for live status display.
252struct WalkerStatus {
253    plugin_dirs: Arc<std::sync::Mutex<Vec<String>>>,
254    audio_dirs: Arc<std::sync::Mutex<Vec<String>>>,
255    daw_dirs: Arc<std::sync::Mutex<Vec<String>>>,
256    preset_dirs: Arc<std::sync::Mutex<Vec<String>>>,
257    midi_dirs: Arc<std::sync::Mutex<Vec<String>>>,
258    pdf_dirs: Arc<std::sync::Mutex<Vec<String>>>,
259    /// True while `scan_unified` is active. Frontend walker-status tiles
260    /// collapse 4 → 1 display when this is true (the single walker fans its
261    /// dir-push out to all 4 `*_dirs` lists; showing all 4 would be redundant).
262    unified_scanning: AtomicBool,
263}
264
265// ── Plugin update types ──
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268struct UpdatedPlugin {
269    #[serde(flatten)]
270    plugin: PluginInfo,
271    #[serde(rename = "currentVersion")]
272    current_version: String,
273    #[serde(rename = "latestVersion")]
274    latest_version: String,
275    #[serde(rename = "hasUpdate")]
276    has_update: bool,
277    #[serde(rename = "updateUrl")]
278    update_url: Option<String>,
279    #[serde(rename = "kvrUrl")]
280    kvr_url: Option<String>,
281    #[serde(rename = "hasPlatformDownload")]
282    has_platform_download: bool,
283    source: String,
284}
285
286// ── IPC: offload blocking work to Tokio's blocking pool (keeps async runtime + window responsive)
287
288#[inline]
289async fn blocking<T, F>(f: F) -> Result<T, String>
290where
291    T: Send + 'static,
292    F: FnOnce() -> T + Send + 'static,
293{
294    tokio::task::spawn_blocking(f)
295        .await
296        .map_err(|e| format!("spawn_blocking: {e}"))
297}
298
299#[inline]
300async fn blocking_res<T, F>(f: F) -> Result<T, String>
301where
302    T: Send + 'static,
303    F: FnOnce() -> Result<T, String> + Send + 'static,
304{
305    tokio::task::spawn_blocking(f)
306        .await
307        .map_err(|e| format!("spawn_blocking: {e}"))?
308}
309
310// ── Tauri commands ──
311
312/// Package + git metadata baked in at compile time (`build.rs` → `AUDIO_HAXOR_GIT_*` env vars).
313#[derive(Debug, Clone, Serialize)]
314#[serde(rename_all = "camelCase")]
315pub struct BuildInfo {
316    pub version: String,
317    pub git_sha_short: String,
318    pub git_sha_full: String,
319    pub git_commit_date: String,
320}
321
322#[tauri::command]
323fn get_build_info(app: AppHandle) -> BuildInfo {
324    BuildInfo {
325        version: app.package_info().version.to_string(),
326        git_sha_short: env!("AUDIO_HAXOR_GIT_SHA_SHORT").to_string(),
327        git_sha_full: env!("AUDIO_HAXOR_GIT_SHA_FULL").to_string(),
328        git_commit_date: env!("AUDIO_HAXOR_GIT_COMMIT_DATE").to_string(),
329    }
330}
331
332#[tauri::command]
333fn get_version(app: AppHandle) -> String {
334    app.package_info().version.to_string()
335}
336
337#[tauri::command]
338fn get_walker_status(app: AppHandle) -> serde_json::Value {
339    let ws = app.state::<WalkerStatus>();
340    let plugin = ws
341        .plugin_dirs
342        .lock()
343        .unwrap_or_else(|e| e.into_inner())
344        .clone();
345    let audio = ws
346        .audio_dirs
347        .lock()
348        .unwrap_or_else(|e| e.into_inner())
349        .clone();
350    let daw = ws
351        .daw_dirs
352        .lock()
353        .unwrap_or_else(|e| e.into_inner())
354        .clone();
355    let preset = ws
356        .preset_dirs
357        .lock()
358        .unwrap_or_else(|e| e.into_inner())
359        .clone();
360    let midi = ws
361        .midi_dirs
362        .lock()
363        .unwrap_or_else(|e| e.into_inner())
364        .clone();
365    let pdf = ws
366        .pdf_dirs
367        .lock()
368        .unwrap_or_else(|e| e.into_inner())
369        .clone();
370    let pool_threads = num_cpus::get().max(4);
371    let plugin_scanning = app.state::<ScanState>().scanning.load(Ordering::Relaxed);
372    let audio_scanning = app
373        .state::<AudioScanState>()
374        .scanning
375        .load(Ordering::Relaxed);
376    let daw_scanning = app.state::<DawScanState>().scanning.load(Ordering::Relaxed);
377    let preset_scanning = app
378        .state::<PresetScanState>()
379        .scanning
380        .load(Ordering::Relaxed);
381    let pdf_scanning = app.state::<PdfScanState>().scanning.load(Ordering::Relaxed);
382    let midi_scanning = app
383        .state::<MidiScanState>()
384        .scanning
385        .load(Ordering::Relaxed);
386    let unified_scanning = ws.unified_scanning.load(Ordering::Relaxed);
387    serde_json::json!({
388        "plugin": plugin,
389        "audio": audio,
390        "daw": daw,
391        "preset": preset,
392        "midi": midi,
393        "pdf": pdf,
394        "poolThreads": pool_threads,
395        "pluginScanning": plugin_scanning,
396        "audioScanning": audio_scanning,
397        "dawScanning": daw_scanning,
398        "presetScanning": preset_scanning,
399        "midiScanning": midi_scanning,
400        "pdfScanning": pdf_scanning,
401        "unifiedScanning": unified_scanning,
402    })
403}
404
405#[tauri::command]
406async fn scan_plugins(
407    app: AppHandle,
408    custom_roots: Option<Vec<String>>,
409    exclude_paths: Option<Vec<String>>,
410) -> Result<serde_json::Value, String> {
411    let state = app.state::<ScanState>();
412
413    if state.scanning.swap(true, Ordering::SeqCst) {
414        return Err("Scan already in progress".into());
415    }
416    state.stop_scan.store(false, Ordering::SeqCst);
417    let scan_start = Instant::now();
418    append_log(format!(
419        "SCAN START — plugins | roots: {:?}",
420        custom_roots.as_deref().unwrap_or(&[])
421    ));
422
423    let app_handle = app.clone();
424    let result = tokio::task::spawn_blocking(move || {
425        let scan_state = app_handle.state::<ScanState>();
426        let directories = if let Some(ref extra) = custom_roots {
427            let custom: Vec<String> = extra
428                .iter()
429                .filter(|r| std::path::Path::new(r).exists())
430                .cloned()
431                .collect();
432            if custom.is_empty() {
433                scanner::get_vst_directories()
434            } else {
435                custom
436            }
437        } else {
438            scanner::get_vst_directories()
439        };
440        let plugin_scan_id = history::gen_id();
441        let now_iso = history::now_iso();
442        let db = db::global();
443        // Plugin discovery must not use the shared unified incremental map: `record_scanned_dir`
444        // would mark each VST root as "already scanned", and `should_skip` would skip the entire
445        // root on the next plugin run (or skip immediately if a unified walk already recorded it).
446        let plugin_paths = scanner::discover_plugins(&directories, None);
447        let total = plugin_paths.len();
448
449        let _ = db.plugin_scan_parent_create(&plugin_scan_id, &now_iso, &directories);
450
451        let _ = app_handle.emit(
452            "scan-progress",
453            serde_json::json!({
454                "phase": "start",
455                "total": total,
456                "processed": 0
457            }),
458        );
459
460        // Deduplicate and exclude already-scanned paths
461        let exclude_set: HashSet<String> = exclude_paths.unwrap_or_default().into_iter().collect();
462        let mut seen = HashSet::new();
463        let unique_paths: Vec<_> = plugin_paths
464            .into_iter()
465            .filter(|p| {
466                let s = p.to_string_lossy().to_string();
467                !exclude_set.contains(&s) && seen.insert(s)
468            })
469            .collect();
470
471        // Process plugins in parallel, streaming results to UI via channel
472        use rayon::prelude::*;
473        let prefs = history::load_preferences();
474        let batch_size = prefs
475            .get("batchSize")
476            .and_then(|v| {
477                v.as_str()
478                    .and_then(|s| s.parse::<usize>().ok())
479                    .or(v.as_u64().map(|n| n as usize))
480            })
481            .unwrap_or(100)
482            .clamp(10, 200);
483        let chan_buf = prefs
484            .get("channelBuffer")
485            .and_then(|v| {
486                v.as_str()
487                    .and_then(|s| s.parse::<usize>().ok())
488                    .or(v.as_u64().map(|n| n as usize))
489            })
490            .unwrap_or(256)
491            .clamp(64, 512);
492        let (tx, rx) = std::sync::mpsc::sync_channel::<scanner::PluginInfo>(chan_buf);
493        // Share stop flag directly with rayon workers for immediate cancellation
494        let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
495        let stop_flag2 = stop_flag.clone();
496        let plugin_dirs = Arc::clone(&app_handle.state::<WalkerStatus>().plugin_dirs);
497
498        // Dedicated thread pool so plugin scanning doesn't starve other scanners
499        let pool = rayon::ThreadPoolBuilder::new()
500            .num_threads(num_cpus::get().max(4))
501            .build()
502            .unwrap_or_else(|e| {
503                let msg = format!("Thread pool creation failed ({e}), retrying with 2 threads");
504                eprintln!("{msg}");
505                append_log(msg);
506                rayon::ThreadPoolBuilder::new()
507                    .num_threads(2)
508                    .build()
509                    .expect("fallback 2-thread pool")
510            });
511        std::thread::spawn(move || {
512            pool.install(|| {
513                unique_paths.par_iter().for_each(|p| {
514                    if stop_flag2.load(Ordering::Relaxed) {
515                        return;
516                    }
517                    // Track plugin path
518                    {
519                        let mut ad = plugin_dirs.lock().unwrap_or_else(|e| e.into_inner());
520                        ad.push(p.to_string_lossy().to_string());
521                        if ad.len() > 30 {
522                            let excess = ad.len() - 30;
523                            ad.drain(..excess);
524                        }
525                    }
526                    if let Some(info) = scanner::get_plugin_info(p) {
527                        if stop_flag2.load(Ordering::Relaxed) {
528                            return;
529                        }
530                        let _ = tx.send(info);
531                    }
532                });
533            });
534        });
535
536        let mut all_plugins = Vec::new();
537        let mut batch = Vec::new();
538        let mut processed = 0usize;
539
540        // Use try_recv with short timeout so stop signal is checked frequently
541        loop {
542            if scan_state.stop_scan.load(Ordering::Relaxed) {
543                stop_flag.store(true, Ordering::Relaxed);
544                // Drain channel to unblock workers
545                while rx.try_recv().is_ok() {}
546                break;
547            }
548            let info = match rx.recv_timeout(std::time::Duration::from_millis(10)) {
549                Ok(info) => info,
550                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
551                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
552            };
553            batch.push(info);
554            processed += 1;
555            if batch.len() >= batch_size || processed == total {
556                let _ = db.insert_plugin_batch(&plugin_scan_id, &batch);
557                all_plugins.extend(batch.clone());
558                let _ = app_handle.emit(
559                    "scan-progress",
560                    serde_json::json!({
561                        "phase": "scanning",
562                        "plugins": batch,
563                        "processed": processed,
564                        "total": total
565                    }),
566                );
567                batch.clear();
568            }
569        }
570        if !batch.is_empty() {
571            let _ = db.insert_plugin_batch(&plugin_scan_id, &batch);
572            all_plugins.extend(batch.clone());
573            let _ = app_handle.emit(
574                "scan-progress",
575                serde_json::json!({
576                    "phase": "scanning",
577                    "plugins": batch,
578                    "processed": processed,
579                    "total": total
580                }),
581            );
582        }
583
584        let was_stopped = scan_state.stop_scan.load(Ordering::Relaxed);
585        all_plugins.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
586        let roots: Vec<String> = directories.clone();
587        let _ = db.plugin_scan_parent_finalize(
588            &plugin_scan_id,
589            all_plugins.len(),
590            &directories,
591            &roots,
592        );
593        let _ = db.set_plugin_scan_complete(&plugin_scan_id, !was_stopped);
594        db.checkpoint();
595
596        serde_json::json!({
597            "plugins": all_plugins,
598            "directories": directories,
599            "snapshotId": plugin_scan_id,
600            "stopped": was_stopped
601        })
602    })
603    .await;
604
605    state.scanning.store(false, Ordering::SeqCst);
606    {
607        let ws = app.state::<WalkerStatus>();
608        let mut ad = ws.plugin_dirs.lock().unwrap_or_else(|e| e.into_inner());
609        ad.clear();
610    }
611    let elapsed = scan_start.elapsed();
612    match &result {
613        Ok(v) => append_log(format!(
614            "SCAN END — plugins | {}s | {} found",
615            elapsed.as_secs(),
616            v.get("plugins")
617                .and_then(|p| p.as_array())
618                .map(|a| a.len())
619                .unwrap_or(0)
620        )),
621        Err(e) => append_log(format!(
622            "SCAN ERROR — plugins | {}s | {}",
623            elapsed.as_secs(),
624            e
625        )),
626    }
627    result.map_err(|e| e.to_string())
628}
629
630#[tauri::command]
631async fn stop_scan(app: AppHandle) -> Result<(), String> {
632    append_log("SCAN STOP — plugins (user requested)".into());
633    let state = app.state::<ScanState>();
634    state.stop_scan.store(true, Ordering::SeqCst);
635    Ok(())
636}
637
638#[tauri::command]
639async fn check_updates(
640    app: AppHandle,
641    plugins: Vec<PluginInfo>,
642) -> Result<Vec<UpdatedPlugin>, String> {
643    let state = app.state::<UpdateState>();
644    if state.checking.swap(true, Ordering::SeqCst) {
645        return Err("Update check already in progress".into());
646    }
647    state.stop_updates.store(false, Ordering::SeqCst);
648
649    // Load KVR cache to skip already-checked plugins (resume from previous run)
650    let kvr_cache = history::load_kvr_cache();
651
652    let total = plugins.len();
653    #[cfg(not(test))]
654    append_log(format!("UPDATE CHECK — {} plugins", total));
655    let _ = app.emit(
656        "update-progress",
657        serde_json::json!({
658            "phase": "start",
659            "total": total,
660            "processed": 0
661        }),
662    );
663
664    // Deduplicate by manufacturer+name
665    let mut search_groups: std::collections::HashMap<String, (PluginInfo, Vec<PluginInfo>)> =
666        std::collections::HashMap::new();
667    for plugin in &plugins {
668        let key = format!("{}|||{}", plugin.manufacturer, plugin.name).to_lowercase();
669        search_groups
670            .entry(key)
671            .or_insert_with(|| (plugin.clone(), Vec::new()))
672            .1
673            .push(plugin.clone());
674    }
675
676    let groups: Vec<(PluginInfo, Vec<PluginInfo>)> = search_groups.into_values().collect();
677    let mut results: std::collections::HashMap<String, UpdatedPlugin> =
678        std::collections::HashMap::new();
679    let mut processed = 0usize;
680
681    for (representative, siblings) in &groups {
682        if state.stop_updates.load(Ordering::SeqCst) {
683            break;
684        }
685
686        let cache_key =
687            format!("{}|||{}", representative.manufacturer, representative.name).to_lowercase();
688
689        // Use cached result if available
690        let update_result = if let Some(cached) = kvr_cache.get(&cache_key) {
691            Some(kvr::UpdateResult {
692                latest_version: cached
693                    .latest_version
694                    .clone()
695                    .unwrap_or_else(|| representative.version.clone()),
696                has_update: cached.has_update,
697                update_url: cached.update_url.clone(),
698                kvr_url: cached.kvr_url.clone(),
699                has_platform_download: cached.update_url.is_some(),
700                source: cached.source.clone(),
701            })
702        } else {
703            kvr::find_latest_version(
704                &representative.name,
705                &representative.manufacturer,
706                &representative.version,
707            )
708            .await
709        };
710
711        let mut batch_plugins = Vec::new();
712        for sibling in siblings {
713            let current_version = sibling.version.clone();
714            let updated = if let Some(ref result) = update_result {
715                let has_update = kvr::compare_versions(&result.latest_version, &current_version)
716                    == std::cmp::Ordering::Greater
717                    && current_version != "Unknown";
718                UpdatedPlugin {
719                    plugin: sibling.clone(),
720                    current_version,
721                    latest_version: result.latest_version.clone(),
722                    has_update,
723                    update_url: result.update_url.clone(),
724                    kvr_url: result.kvr_url.clone(),
725                    has_platform_download: result.has_platform_download,
726                    source: result.source.clone(),
727                }
728            } else {
729                UpdatedPlugin {
730                    plugin: sibling.clone(),
731                    current_version: current_version.clone(),
732                    latest_version: current_version,
733                    has_update: false,
734                    update_url: None,
735                    kvr_url: None,
736                    has_platform_download: false,
737                    source: "not-found".into(),
738                }
739            };
740
741            results.insert(sibling.path.clone(), updated.clone());
742            batch_plugins.push(updated);
743            processed += 1;
744        }
745
746        let _ = app.emit(
747            "update-progress",
748            serde_json::json!({
749                "phase": "checking",
750                "plugins": batch_plugins,
751                "processed": processed,
752                "total": total
753            }),
754        );
755
756        // Only rate-limit when we actually hit the network
757        if !kvr_cache.contains_key(&cache_key) {
758            crate::app_log_verbose(|| {
759                format!(
760                    "UPDATE VERBOSE — KVR network fetch | {} | {}",
761                    representative.name, representative.manufacturer
762                )
763            });
764            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
765        }
766    }
767
768    state.checking.store(false, Ordering::SeqCst);
769
770    let final_plugins: Vec<UpdatedPlugin> = plugins
771        .iter()
772        .map(|p| {
773            results.remove(&p.path).unwrap_or_else(|| UpdatedPlugin {
774                plugin: p.clone(),
775                current_version: p.version.clone(),
776                latest_version: p.version.clone(),
777                has_update: false,
778                update_url: None,
779                kvr_url: None,
780                has_platform_download: false,
781                source: "not-found".into(),
782            })
783        })
784        .collect();
785
786    Ok(final_plugins)
787}
788
789#[tauri::command]
790async fn stop_updates(app: AppHandle) -> Result<(), String> {
791    append_log("UPDATE STOP — user cancelled update check".into());
792    let state = app.state::<UpdateState>();
793    state.stop_updates.store(true, Ordering::SeqCst);
794    Ok(())
795}
796
797#[tauri::command]
798async fn resolve_kvr(direct_url: String, plugin_name: String) -> Result<kvr::KvrResult, String> {
799    Ok(kvr::resolve_kvr(&direct_url, &plugin_name).await)
800}
801
802// History commands — all backed by SQLite via db::global()
803#[tauri::command]
804async fn history_get_scans() -> Result<Vec<serde_json::Value>, String> {
805    blocking_res(|| db::global().get_plugin_scans()).await
806}
807
808#[tauri::command]
809async fn history_get_detail(id: String) -> Result<history::ScanSnapshot, String> {
810    blocking_res(move || db::global().get_plugin_scan_detail(&id)).await
811}
812
813#[tauri::command]
814async fn history_delete(id: String) -> Result<(), String> {
815    blocking_res(move || db::global().delete_plugin_scan(&id)).await
816}
817
818#[tauri::command]
819async fn history_clear() -> Result<(), String> {
820    #[cfg(not(test))]
821    append_log("HISTORY CLEAR — plugins (all scan history deleted)".into());
822    blocking_res(|| db::global().clear_plugin_history()).await
823}
824
825#[tauri::command]
826async fn history_diff(old_id: String, new_id: String) -> Option<history::ScanDiff> {
827    tokio::task::spawn_blocking(move || {
828        let old = db::global().get_plugin_scan_detail(&old_id).ok()?;
829        let new = db::global().get_plugin_scan_detail(&new_id).ok()?;
830        Some(history::compute_plugin_diff(&old, &new))
831    })
832    .await
833    .ok()
834    .flatten()
835}
836
837#[tauri::command]
838async fn history_latest() -> Result<Option<history::ScanSnapshot>, String> {
839    blocking_res(|| db::global().get_latest_plugin_scan()).await
840}
841
842#[tauri::command]
843async fn kvr_cache_get(
844) -> Result<std::collections::HashMap<String, history::KvrCacheEntry>, String> {
845    blocking_res(|| db::global().load_kvr_cache()).await
846}
847
848#[tauri::command]
849async fn kvr_cache_update(entries: Vec<KvrCacheUpdateEntry>) -> Result<(), String> {
850    blocking_res(move || db::global().update_kvr_cache(&entries)).await
851}
852
853// Audio scanner commands
854#[tauri::command]
855async fn scan_audio_samples(
856    app: AppHandle,
857    custom_roots: Option<Vec<String>>,
858    exclude_paths: Option<Vec<String>>,
859) -> Result<serde_json::Value, String> {
860    let state = app.state::<AudioScanState>();
861    let scan_start = Instant::now();
862    append_log(format!(
863        "SCAN START — audio | roots: {:?}",
864        custom_roots.as_deref().unwrap_or(&[])
865    ));
866    if state.scanning.swap(true, Ordering::SeqCst) {
867        return Err("Audio scan already in progress".into());
868    }
869    state.stop_scan.store(false, Ordering::SeqCst);
870
871    let _ = app.emit(
872        "audio-scan-progress",
873        serde_json::json!({
874            "phase": "status",
875            "message": "Walking filesystem directories parallelized for audio files..."
876        }),
877    );
878
879    let app_handle = app.clone();
880    let result = tokio::task::spawn_blocking(move || {
881        let audio_state = app_handle.state::<AudioScanState>();
882        let roots = if let Some(ref extra) = custom_roots {
883            let custom: Vec<std::path::PathBuf> = extra
884                .iter()
885                .map(std::path::PathBuf::from)
886                .filter(|p| p.exists())
887                .collect();
888            if custom.is_empty() {
889                audio_scanner::get_audio_roots()
890            } else {
891                custom
892            }
893        } else {
894            audio_scanner::get_audio_roots()
895        };
896        let root_strs: Vec<String> = roots
897            .iter()
898            .map(|r| r.to_string_lossy().to_string())
899            .collect();
900        let now_iso = history::now_iso();
901        let audio_scan_id = history::gen_id();
902        let db = db::global();
903        let _ = db.audio_scan_parent_create(&audio_scan_id, &now_iso, &root_strs);
904
905        let mut audio_count: u64 = 0;
906        let mut audio_bytes: u64 = 0;
907        let mut audio_format_counts: HashMap<String, usize> = HashMap::new();
908        let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
909        let incremental_state = load_incremental_dir_state_for_walk();
910
911        audio_scanner::walk_for_audio(
912            &roots,
913            &mut |batch, _found| {
914                for s in batch.iter() {
915                    audio_bytes += s.size;
916                    *audio_format_counts.entry(s.format.clone()).or_insert(0) += 1;
917                }
918                let inserted = db.insert_audio_batch(&audio_scan_id, batch).unwrap_or(0);
919                audio_count += inserted;
920                let _ = app_handle.emit(
921                    "audio-scan-progress",
922                    serde_json::json!({
923                        "phase": "scanning",
924                        "samples": batch,
925                        "found": audio_count,
926                    }),
927                );
928            },
929            &|| audio_state.stop_scan.load(Ordering::SeqCst),
930            exclude_set,
931            Some(Arc::clone(&app_handle.state::<WalkerStatus>().audio_dirs)),
932            incremental_state.clone(),
933        );
934
935        persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &audio_scan_id);
936
937        // Clear walker status
938        {
939            let ws = app_handle.state::<WalkerStatus>();
940            let mut ad = ws.audio_dirs.lock().unwrap_or_else(|e| e.into_inner());
941            ad.clear();
942        }
943
944        let was_stopped = audio_state.stop_scan.load(Ordering::Relaxed);
945        let _ = db.audio_scan_parent_finalize(
946            &audio_scan_id,
947            audio_count,
948            audio_bytes,
949            &audio_format_counts,
950        );
951        let _ = db.set_audio_scan_complete(&audio_scan_id, !was_stopped);
952        db.checkpoint();
953        serde_json::json!({
954            "samples": [],
955            "roots": root_strs,
956            "stopped": was_stopped,
957            "streamed": true,
958            "audioScanId": audio_scan_id,
959            "audioCount": audio_count,
960        })
961    })
962    .await;
963
964    state.scanning.store(false, Ordering::SeqCst);
965    let elapsed = scan_start.elapsed();
966    match &result {
967        Ok(v) => append_log(format!(
968            "SCAN END — audio | {}s | {} found",
969            elapsed.as_secs(),
970            v.get("audioCount")
971                .and_then(|n| n.as_u64())
972                .or_else(|| {
973                    v.get("samples")
974                        .and_then(|p| p.as_array())
975                        .map(|a| a.len() as u64)
976                })
977                .unwrap_or(0)
978        )),
979        Err(e) => append_log(format!(
980            "SCAN ERROR — audio | {}s | {}",
981            elapsed.as_secs(),
982            e
983        )),
984    }
985    result.map_err(|e| e.to_string())
986}
987
988#[tauri::command]
989async fn stop_audio_scan(app: AppHandle) -> Result<(), String> {
990    append_log("SCAN STOP — audio (user requested)".into());
991    let state = app.state::<AudioScanState>();
992    state.stop_scan.store(true, Ordering::SeqCst);
993    Ok(())
994}
995
996#[tauri::command]
997async fn get_audio_metadata(file_path: String) -> audio_scanner::AudioMetadata {
998    let fallback_path = file_path.clone();
999    tokio::task::spawn_blocking(move || audio_scanner::get_audio_metadata(&file_path))
1000        .await
1001        .unwrap_or_else(|_| audio_scanner::get_audio_metadata(&fallback_path))
1002}
1003
1004// Audio history commands — SQLite backed
1005#[tauri::command]
1006async fn audio_history_save(
1007    samples: Vec<AudioSample>,
1008    roots: Option<Vec<String>>,
1009) -> Result<history::AudioScanSnapshot, String> {
1010    let roots = roots.unwrap_or_default();
1011    blocking_res(move || {
1012        let snap = history::build_audio_snapshot(&samples, &roots);
1013        db::global().save_audio_scan_full(&snap)?;
1014        db::global().checkpoint();
1015        Ok(snap)
1016    })
1017    .await
1018}
1019
1020#[tauri::command]
1021async fn audio_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1022    blocking_res(|| db::global().get_audio_scans_list()).await
1023}
1024
1025#[tauri::command]
1026async fn audio_history_get_detail(id: String) -> Result<history::AudioScanSnapshot, String> {
1027    blocking_res(move || db::global().get_audio_scan_detail(&id)).await
1028}
1029
1030#[tauri::command]
1031async fn audio_history_delete(id: String) -> Result<(), String> {
1032    blocking_res(move || db::global().delete_audio_scan(&id)).await
1033}
1034
1035#[tauri::command]
1036async fn audio_history_clear() -> Result<(), String> {
1037    #[cfg(not(test))]
1038    append_log("HISTORY CLEAR — audio samples (all scan history deleted)".into());
1039    blocking_res(|| db::global().clear_audio_history()).await
1040}
1041
1042#[tauri::command]
1043async fn audio_history_latest() -> Result<Option<history::AudioScanSnapshot>, String> {
1044    blocking_res(|| db::global().get_latest_audio_scan()).await
1045}
1046
1047#[tauri::command]
1048async fn audio_history_diff(old_id: String, new_id: String) -> Option<history::AudioScanDiff> {
1049    tokio::task::spawn_blocking(move || {
1050        let old = db::global().get_audio_scan_detail(&old_id).ok()?;
1051        let new = db::global().get_audio_scan_detail(&new_id).ok()?;
1052        Some(history::compute_audio_diff(&old, &new))
1053    })
1054    .await
1055    .ok()
1056    .flatten()
1057}
1058
1059// DAW scanner commands
1060#[tauri::command]
1061async fn scan_daw_projects(
1062    app: AppHandle,
1063    custom_roots: Option<Vec<String>>,
1064    exclude_paths: Option<Vec<String>>,
1065) -> Result<serde_json::Value, String> {
1066    let state = app.state::<DawScanState>();
1067    let scan_start = Instant::now();
1068    append_log(format!(
1069        "SCAN START — daw | roots: {:?}",
1070        custom_roots.as_deref().unwrap_or(&[])
1071    ));
1072    if state.scanning.swap(true, Ordering::SeqCst) {
1073        return Err("DAW scan already in progress".into());
1074    }
1075    state.stop_scan.store(false, Ordering::SeqCst);
1076
1077    let _ = app.emit(
1078        "daw-scan-progress",
1079        serde_json::json!({
1080            "phase": "status",
1081            "message": "Walking filesystem directories parallelized for DAW project files..."
1082        }),
1083    );
1084
1085    let app_handle = app.clone();
1086    let result = tokio::task::spawn_blocking(move || {
1087        let daw_state = app_handle.state::<DawScanState>();
1088        let roots = if let Some(ref extra) = custom_roots {
1089            let custom: Vec<std::path::PathBuf> = extra
1090                .iter()
1091                .map(std::path::PathBuf::from)
1092                .filter(|p| p.exists())
1093                .collect();
1094            if custom.is_empty() {
1095                daw_scanner::get_daw_roots()
1096            } else {
1097                custom
1098            }
1099        } else {
1100            daw_scanner::get_daw_roots()
1101        };
1102        let root_strs: Vec<String> = roots
1103            .iter()
1104            .map(|r| r.to_string_lossy().to_string())
1105            .collect();
1106        let now_iso = history::now_iso();
1107        let daw_scan_id = history::gen_id();
1108        let db = db::global();
1109        let _ = db.daw_scan_parent_create(&daw_scan_id, &now_iso, &root_strs);
1110
1111        let mut daw_count: u64 = 0;
1112        let mut daw_bytes: u64 = 0;
1113        let mut daw_daw_counts: HashMap<String, usize> = HashMap::new();
1114        let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1115        let incremental_state = load_incremental_dir_state_for_walk();
1116
1117        daw_scanner::walk_for_daw(
1118            &roots,
1119            &mut |batch, _found| {
1120                let inserted_idx = db.insert_daw_batch(&daw_scan_id, batch).unwrap_or_default();
1121                let deduped: Vec<&DawProject> = inserted_idx.iter().map(|&i| &batch[i]).collect();
1122                for p in &deduped {
1123                    daw_bytes += p.size;
1124                    *daw_daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
1125                }
1126                daw_count += deduped.len() as u64;
1127                let _ = app_handle.emit(
1128                    "daw-scan-progress",
1129                    serde_json::json!({
1130                        "phase": "scanning",
1131                        "projects": deduped,
1132                        "found": daw_count,
1133                    }),
1134                );
1135            },
1136            &|| daw_state.stop_scan.load(Ordering::SeqCst),
1137            exclude_set,
1138            {
1139                let prefs = history::load_preferences();
1140                prefs
1141                    .get("includeAbletonBackups")
1142                    .and_then(|v| v.as_str())
1143                    .map(|s| s == "on")
1144                    .unwrap_or(false)
1145            },
1146            Some(Arc::clone(&app_handle.state::<WalkerStatus>().daw_dirs)),
1147            incremental_state.clone(),
1148        );
1149
1150        persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &daw_scan_id);
1151
1152        {
1153            let ws = app_handle.state::<WalkerStatus>();
1154            let mut ad = ws.daw_dirs.lock().unwrap_or_else(|e| e.into_inner());
1155            ad.clear();
1156        }
1157        let was_stopped = daw_state.stop_scan.load(Ordering::Relaxed);
1158        let _ = db.daw_scan_parent_finalize(
1159            &daw_scan_id,
1160            daw_count as usize,
1161            daw_bytes,
1162            &daw_daw_counts,
1163        );
1164        let _ = db.set_daw_scan_complete(&daw_scan_id, !was_stopped);
1165        db.checkpoint();
1166        serde_json::json!({
1167            "projects": [],
1168            "roots": root_strs,
1169            "stopped": was_stopped,
1170            "streamed": true,
1171            "dawScanId": daw_scan_id,
1172            "dawCount": daw_count,
1173        })
1174    })
1175    .await;
1176
1177    state.scanning.store(false, Ordering::SeqCst);
1178    let elapsed = scan_start.elapsed();
1179    match &result {
1180        Ok(v) => append_log(format!(
1181            "SCAN END — daw | {}s | {} found",
1182            elapsed.as_secs(),
1183            v.get("dawCount")
1184                .and_then(|n| n.as_u64())
1185                .or_else(|| {
1186                    v.get("projects")
1187                        .and_then(|p| p.as_array())
1188                        .map(|a| a.len() as u64)
1189                })
1190                .unwrap_or(0)
1191        )),
1192        Err(e) => append_log(format!("SCAN ERROR — daw | {}s | {}", elapsed.as_secs(), e)),
1193    }
1194    result.map_err(|e| e.to_string())
1195}
1196
1197#[tauri::command]
1198async fn stop_daw_scan(app: AppHandle) -> Result<(), String> {
1199    append_log("SCAN STOP — daw (user requested)".into());
1200    let state = app.state::<DawScanState>();
1201    state.stop_scan.store(true, Ordering::SeqCst);
1202    Ok(())
1203}
1204
1205// DAW history commands — SQLite backed
1206#[tauri::command]
1207async fn daw_history_save(
1208    projects: Vec<DawProject>,
1209    roots: Option<Vec<String>>,
1210) -> Result<history::DawScanSnapshot, String> {
1211    let roots = roots.unwrap_or_default();
1212    blocking_res(move || {
1213        let snap = history::build_daw_snapshot(&projects, &roots);
1214        db::global().save_daw_scan(&snap)?;
1215        db::global().checkpoint();
1216        Ok(snap)
1217    })
1218    .await
1219}
1220
1221#[tauri::command]
1222async fn daw_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1223    blocking_res(|| db::global().get_daw_scans()).await
1224}
1225
1226#[tauri::command]
1227async fn daw_history_get_detail(id: String) -> Result<history::DawScanSnapshot, String> {
1228    blocking_res(move || db::global().get_daw_scan_detail(&id)).await
1229}
1230
1231#[tauri::command]
1232async fn daw_history_delete(id: String) -> Result<(), String> {
1233    blocking_res(move || db::global().delete_daw_scan(&id)).await
1234}
1235
1236#[tauri::command]
1237async fn daw_history_clear() -> Result<(), String> {
1238    #[cfg(not(test))]
1239    append_log("HISTORY CLEAR — DAW projects".into());
1240    blocking_res(|| db::global().clear_daw_history()).await
1241}
1242
1243#[tauri::command]
1244async fn daw_history_latest() -> Result<Option<history::DawScanSnapshot>, String> {
1245    blocking_res(|| db::global().get_latest_daw_scan()).await
1246}
1247
1248#[tauri::command]
1249async fn daw_history_diff(old_id: String, new_id: String) -> Option<history::DawScanDiff> {
1250    tokio::task::spawn_blocking(move || {
1251        let old = db::global().get_daw_scan_detail(&old_id).ok()?;
1252        let new = db::global().get_daw_scan_detail(&new_id).ok()?;
1253        Some(history::compute_daw_diff(&old, &new))
1254    })
1255    .await
1256    .ok()
1257    .flatten()
1258}
1259
1260// Preset scanner commands
1261#[tauri::command]
1262async fn scan_presets(
1263    app: AppHandle,
1264    custom_roots: Option<Vec<String>>,
1265    exclude_paths: Option<Vec<String>>,
1266) -> Result<serde_json::Value, String> {
1267    let state = app.state::<PresetScanState>();
1268    let scan_start = Instant::now();
1269    append_log(format!(
1270        "SCAN START — presets | roots: {:?}",
1271        custom_roots.as_deref().unwrap_or(&[])
1272    ));
1273    if state.scanning.swap(true, Ordering::SeqCst) {
1274        return Err("Preset scan already in progress".into());
1275    }
1276    state.stop_scan.store(false, Ordering::SeqCst);
1277
1278    let _ = app.emit(
1279        "preset-scan-progress",
1280        serde_json::json!({
1281            "phase": "status",
1282            "message": "Walking filesystem directories parallelized for preset files..."
1283        }),
1284    );
1285
1286    let app_handle = app.clone();
1287    let result = tokio::task::spawn_blocking(move || {
1288        let preset_state = app_handle.state::<PresetScanState>();
1289        let roots = if let Some(ref extra) = custom_roots {
1290            let custom: Vec<std::path::PathBuf> = extra
1291                .iter()
1292                .map(std::path::PathBuf::from)
1293                .filter(|p| p.exists())
1294                .collect();
1295            if custom.is_empty() {
1296                preset_scanner::get_preset_roots()
1297            } else {
1298                custom
1299            }
1300        } else {
1301            preset_scanner::get_preset_roots()
1302        };
1303        let root_strs: Vec<String> = roots
1304            .iter()
1305            .map(|r| r.to_string_lossy().to_string())
1306            .collect();
1307        let now_iso = history::now_iso();
1308        let preset_scan_id = history::gen_id();
1309        let db = db::global();
1310        let _ = db.preset_scan_parent_create(&preset_scan_id, &now_iso, &root_strs);
1311
1312        let mut preset_count: u64 = 0;
1313        let mut preset_bytes: u64 = 0;
1314        let mut preset_format_counts: HashMap<String, usize> = HashMap::new();
1315        let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1316        let incremental_state = load_incremental_dir_state_for_walk();
1317
1318        preset_scanner::walk_for_presets(
1319            &roots,
1320            &mut |batch, _found| {
1321                for p in batch.iter() {
1322                    preset_bytes += p.size;
1323                    *preset_format_counts.entry(p.format.clone()).or_insert(0) += 1;
1324                }
1325                let inserted = db.insert_preset_batch(&preset_scan_id, batch).unwrap_or(0);
1326                preset_count += inserted;
1327                let _ = app_handle.emit(
1328                    "preset-scan-progress",
1329                    serde_json::json!({
1330                        "phase": "scanning",
1331                        "presets": batch,
1332                        "found": preset_count,
1333                    }),
1334                );
1335            },
1336            &|| preset_state.stop_scan.load(Ordering::SeqCst),
1337            exclude_set,
1338            Some(Arc::clone(&app_handle.state::<WalkerStatus>().preset_dirs)),
1339            incremental_state.clone(),
1340        );
1341
1342        persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &preset_scan_id);
1343
1344        {
1345            let ws = app_handle.state::<WalkerStatus>();
1346            let mut ad = ws.preset_dirs.lock().unwrap_or_else(|e| e.into_inner());
1347            ad.clear();
1348        }
1349        let was_stopped = preset_state.stop_scan.load(Ordering::Relaxed);
1350        let _ = db.preset_scan_parent_finalize(
1351            &preset_scan_id,
1352            preset_count as usize,
1353            preset_bytes,
1354            &preset_format_counts,
1355        );
1356        let _ = db.set_preset_scan_complete(&preset_scan_id, !was_stopped);
1357        db.checkpoint();
1358        serde_json::json!({
1359            "presets": [],
1360            "roots": root_strs,
1361            "stopped": was_stopped,
1362            "streamed": true,
1363            "presetScanId": preset_scan_id,
1364            "presetCount": preset_count,
1365        })
1366    })
1367    .await;
1368
1369    state.scanning.store(false, Ordering::SeqCst);
1370    let elapsed = scan_start.elapsed();
1371    match &result {
1372        Ok(v) => append_log(format!(
1373            "SCAN END — presets | {}s | {} found",
1374            elapsed.as_secs(),
1375            v.get("presetCount")
1376                .and_then(|n| n.as_u64())
1377                .or_else(|| {
1378                    v.get("presets")
1379                        .and_then(|p| p.as_array())
1380                        .map(|a| a.len() as u64)
1381                })
1382                .unwrap_or(0)
1383        )),
1384        Err(e) => append_log(format!(
1385            "SCAN ERROR — presets | {}s | {}",
1386            elapsed.as_secs(),
1387            e
1388        )),
1389    }
1390    result.map_err(|e| e.to_string())
1391}
1392
1393#[tauri::command]
1394async fn stop_preset_scan(app: AppHandle) -> Result<(), String> {
1395    append_log("SCAN STOP — presets (user requested)".into());
1396    let state = app.state::<PresetScanState>();
1397    state.stop_scan.store(true, Ordering::SeqCst);
1398    Ok(())
1399}
1400
1401// Preset history commands — SQLite backed
1402#[tauri::command]
1403async fn preset_history_save(
1404    presets: Vec<PresetFile>,
1405    roots: Option<Vec<String>>,
1406) -> Result<history::PresetScanSnapshot, String> {
1407    let roots = roots.unwrap_or_default();
1408    blocking_res(move || {
1409        let snap = history::build_preset_snapshot(&presets, &roots);
1410        db::global().save_preset_scan(&snap)?;
1411        db::global().checkpoint();
1412        Ok(snap)
1413    })
1414    .await
1415}
1416
1417#[tauri::command]
1418async fn preset_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1419    blocking_res(|| db::global().get_preset_scans()).await
1420}
1421
1422#[tauri::command]
1423async fn preset_history_get_detail(id: String) -> Result<history::PresetScanSnapshot, String> {
1424    blocking_res(move || db::global().get_preset_scan_detail(&id)).await
1425}
1426
1427#[tauri::command]
1428async fn preset_history_delete(id: String) -> Result<(), String> {
1429    blocking_res(move || db::global().delete_preset_scan(&id)).await
1430}
1431
1432#[tauri::command]
1433async fn preset_history_clear() -> Result<(), String> {
1434    #[cfg(not(test))]
1435    append_log("HISTORY CLEAR — presets".into());
1436    blocking_res(|| db::global().clear_preset_history()).await
1437}
1438
1439#[tauri::command]
1440async fn preset_history_latest() -> Result<Option<history::PresetScanSnapshot>, String> {
1441    blocking_res(|| db::global().get_latest_preset_scan()).await
1442}
1443
1444#[tauri::command]
1445async fn preset_history_diff(old_id: String, new_id: String) -> Option<history::PresetScanDiff> {
1446    tokio::task::spawn_blocking(move || {
1447        let old = db::global().get_preset_scan_detail(&old_id).ok()?;
1448        let new = db::global().get_preset_scan_detail(&new_id).ok()?;
1449        Some(history::compute_preset_diff(&old, &new))
1450    })
1451    .await
1452    .ok()
1453    .flatten()
1454}
1455
1456// MIDI scanner commands — dedicated MIDI walker, fully independent of preset scan.
1457#[tauri::command]
1458async fn scan_midi_files(
1459    app: AppHandle,
1460    custom_roots: Option<Vec<String>>,
1461    exclude_paths: Option<Vec<String>>,
1462) -> Result<serde_json::Value, String> {
1463    let state = app.state::<MidiScanState>();
1464    let scan_start = Instant::now();
1465    append_log(format!(
1466        "SCAN START — midi | roots: {:?}",
1467        custom_roots.as_deref().unwrap_or(&[])
1468    ));
1469    if state.scanning.swap(true, Ordering::SeqCst) {
1470        return Err("MIDI scan already in progress".into());
1471    }
1472    state.stop_scan.store(false, Ordering::SeqCst);
1473
1474    let _ = app.emit(
1475        "midi-scan-progress",
1476        serde_json::json!({
1477            "phase": "status",
1478            "message": "Walking filesystem directories parallelized for MIDI files..."
1479        }),
1480    );
1481
1482    let app_handle = app.clone();
1483    let result = tokio::task::spawn_blocking(move || {
1484        let midi_state = app_handle.state::<MidiScanState>();
1485        let roots = if let Some(ref extra) = custom_roots {
1486            let custom: Vec<std::path::PathBuf> = extra
1487                .iter()
1488                .map(std::path::PathBuf::from)
1489                .filter(|p| p.exists())
1490                .collect();
1491            if custom.is_empty() {
1492                midi_scanner::get_midi_roots()
1493            } else {
1494                custom
1495            }
1496        } else {
1497            midi_scanner::get_midi_roots()
1498        };
1499        let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1500        let root_strs: Vec<String> = roots
1501            .iter()
1502            .map(|r| r.to_string_lossy().to_string())
1503            .collect();
1504
1505        // Streaming save: create parent row upfront, insert each batch directly
1506        // to the DB, finalize totals at end. Keeps memory bounded at 6M+ scale.
1507        let now_iso = history::now_iso();
1508        let midi_scan_id = history::gen_id();
1509        let db = db::global();
1510        let _ = db.midi_scan_parent_create(&midi_scan_id, &now_iso, &root_strs);
1511
1512        let mut midi_count: u64 = 0;
1513        let mut midi_bytes: u64 = 0;
1514        let mut midi_format_counts: HashMap<String, usize> = HashMap::new();
1515        let incremental_state = load_incremental_dir_state_for_walk();
1516
1517        midi_scanner::walk_for_midi(
1518            &roots,
1519            &mut |batch, found| {
1520                for m in batch {
1521                    midi_bytes += m.size;
1522                    *midi_format_counts.entry(m.format.clone()).or_insert(0) += 1;
1523                }
1524                midi_count += batch.len() as u64;
1525                let _ = db.insert_midi_batch(&midi_scan_id, batch);
1526                let _ = app_handle.emit(
1527                    "midi-scan-progress",
1528                    serde_json::json!({
1529                        "phase": "scanning",
1530                        "midiFiles": batch,
1531                        "found": found
1532                    }),
1533                );
1534            },
1535            &|| midi_state.stop_scan.load(Ordering::SeqCst),
1536            exclude_set,
1537            Some(Arc::clone(&app_handle.state::<WalkerStatus>().midi_dirs)),
1538            incremental_state.clone(),
1539        );
1540
1541        persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &midi_scan_id);
1542
1543        {
1544            let ws = app_handle.state::<WalkerStatus>();
1545            let mut ad = ws.midi_dirs.lock().unwrap_or_else(|e| e.into_inner());
1546            ad.clear();
1547        }
1548        let was_stopped = midi_state.stop_scan.load(Ordering::Relaxed);
1549        let _ = db.midi_scan_parent_finalize(
1550            &midi_scan_id,
1551            midi_count as usize,
1552            midi_bytes,
1553            &midi_format_counts,
1554        );
1555        let _ = db.set_midi_scan_complete(&midi_scan_id, !was_stopped);
1556        db.checkpoint();
1557        serde_json::json!({
1558            "midiCount": midi_count,
1559            "roots": root_strs,
1560            "stopped": was_stopped,
1561            "midiScanId": midi_scan_id,
1562            "streamed": true
1563        })
1564    })
1565    .await;
1566
1567    state.scanning.store(false, Ordering::SeqCst);
1568    let elapsed = scan_start.elapsed();
1569    match &result {
1570        Ok(v) => append_log(format!(
1571            "SCAN END — midi | {}s | {} found",
1572            elapsed.as_secs(),
1573            v.get("midiCount").and_then(|x| x.as_u64()).unwrap_or(0)
1574        )),
1575        Err(e) => append_log(format!(
1576            "SCAN ERROR — midi | {}s | {}",
1577            elapsed.as_secs(),
1578            e
1579        )),
1580    }
1581    result.map_err(|e| e.to_string())
1582}
1583
1584#[tauri::command]
1585async fn stop_midi_scan(app: AppHandle) -> Result<(), String> {
1586    append_log("SCAN STOP — midi (user requested)".into());
1587    let state = app.state::<MidiScanState>();
1588    state.stop_scan.store(true, Ordering::SeqCst);
1589    Ok(())
1590}
1591
1592#[tauri::command]
1593async fn midi_history_save(
1594    midi_files: Vec<history::MidiFile>,
1595    roots: Option<Vec<String>>,
1596) -> Result<history::MidiScanSnapshot, String> {
1597    let roots = roots.unwrap_or_default();
1598    blocking_res(move || {
1599        let snap = history::build_midi_snapshot(&midi_files, &roots);
1600        db::global().save_midi_scan(&snap)?;
1601        db::global().checkpoint();
1602        Ok(snap)
1603    })
1604    .await
1605}
1606
1607#[tauri::command]
1608async fn midi_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
1609    blocking_res(|| db::global().get_midi_scans()).await
1610}
1611
1612#[tauri::command]
1613async fn midi_history_get_detail(id: String) -> Result<history::MidiScanSnapshot, String> {
1614    blocking_res(move || db::global().get_midi_scan_detail(&id)).await
1615}
1616
1617#[tauri::command]
1618async fn midi_history_delete(id: String) -> Result<(), String> {
1619    blocking_res(move || db::global().delete_midi_scan(&id)).await
1620}
1621
1622#[tauri::command]
1623async fn midi_history_clear() -> Result<(), String> {
1624    #[cfg(not(test))]
1625    append_log("HISTORY CLEAR — midi".into());
1626    blocking_res(|| db::global().clear_midi_history()).await
1627}
1628
1629#[tauri::command]
1630async fn midi_history_latest() -> Result<Option<history::MidiScanSnapshot>, String> {
1631    blocking_res(|| db::global().get_latest_midi_scan()).await
1632}
1633
1634#[tauri::command]
1635async fn midi_history_diff(old_id: String, new_id: String) -> Option<history::MidiScanDiff> {
1636    tokio::task::spawn_blocking(move || {
1637        let old = db::global().get_midi_scan_detail(&old_id).ok()?;
1638        let new = db::global().get_midi_scan_detail(&new_id).ok()?;
1639        Some(history::compute_midi_diff(&old, &new))
1640    })
1641    .await
1642    .ok()
1643    .flatten()
1644}
1645
1646#[tauri::command(rename_all = "snake_case")]
1647async fn db_query_midi(
1648    search: Option<String>,
1649    format_filter: Option<String>,
1650    sort_key: Option<String>,
1651    sort_asc: Option<bool>,
1652    search_regex: Option<bool>,
1653    offset: Option<u64>,
1654    limit: Option<u64>,
1655) -> Result<db::MidiQueryResult, String> {
1656    let search_regex = search_regex.unwrap_or(false);
1657    tokio::task::spawn_blocking(move || {
1658        db::global().query_midi(
1659            search.as_deref(),
1660            format_filter.as_deref(),
1661            sort_key.as_deref().unwrap_or("name"),
1662            sort_asc.unwrap_or(true),
1663            search_regex,
1664            offset.unwrap_or(0),
1665            limit.unwrap_or(500),
1666        )
1667    })
1668    .await
1669    .map_err(|e| format!("db_query_midi task: {e}"))?
1670}
1671
1672#[tauri::command(rename_all = "snake_case")]
1673async fn db_midi_filter_stats(
1674    search: Option<String>,
1675    format_filter: Option<String>,
1676    search_regex: Option<bool>,
1677) -> Result<db::FilterStatsResult, String> {
1678    let search_regex = search_regex.unwrap_or(false);
1679    tokio::task::spawn_blocking(move || {
1680        db::global().midi_filter_stats(
1681            search.as_deref(),
1682            format_filter.as_deref(),
1683            search_regex,
1684        )
1685    })
1686    .await
1687    .map_err(|e| format!("db_midi_filter_stats task: {e}"))?
1688}
1689
1690// PDF scanner commands
1691#[tauri::command]
1692async fn scan_pdfs(
1693    app: AppHandle,
1694    custom_roots: Option<Vec<String>>,
1695    exclude_paths: Option<Vec<String>>,
1696) -> Result<serde_json::Value, String> {
1697    let state = app.state::<PdfScanState>();
1698    let scan_start = Instant::now();
1699    append_log(format!(
1700        "SCAN START — pdfs | roots: {:?}",
1701        custom_roots.as_deref().unwrap_or(&[])
1702    ));
1703    if state.scanning.swap(true, Ordering::SeqCst) {
1704        return Err("PDF scan already in progress".into());
1705    }
1706    state.stop_scan.store(false, Ordering::SeqCst);
1707
1708    let _ = app.emit(
1709        "pdf-scan-progress",
1710        serde_json::json!({
1711            "phase": "status",
1712            "message": "Walking filesystem directories parallelized for PDF files..."
1713        }),
1714    );
1715
1716    let app_handle = app.clone();
1717    let result = tokio::task::spawn_blocking(move || {
1718        let pdf_state = app_handle.state::<PdfScanState>();
1719        let roots = if let Some(ref extra) = custom_roots {
1720            let custom: Vec<std::path::PathBuf> = extra
1721                .iter()
1722                .map(std::path::PathBuf::from)
1723                .filter(|p| p.exists())
1724                .collect();
1725            if custom.is_empty() {
1726                pdf_scanner::get_pdf_roots()
1727            } else {
1728                custom
1729            }
1730        } else {
1731            pdf_scanner::get_pdf_roots()
1732        };
1733        let root_strs: Vec<String> = roots
1734            .iter()
1735            .map(|r| r.to_string_lossy().to_string())
1736            .collect();
1737        let now_iso = history::now_iso();
1738        let pdf_scan_id = history::gen_id();
1739        let db = db::global();
1740        let _ = db.pdf_scan_parent_create(&pdf_scan_id, &now_iso, &root_strs);
1741
1742        let mut pdf_count: u64 = 0;
1743        let mut pdf_bytes: u64 = 0;
1744        let exclude_set = exclude_paths.map(|v| v.into_iter().collect::<HashSet<String>>());
1745        let incremental_state = load_incremental_dir_state_for_walk();
1746
1747        pdf_scanner::walk_for_pdfs(
1748            &roots,
1749            &mut |batch, _found| {
1750                for p in batch.iter() {
1751                    pdf_bytes += p.size;
1752                }
1753                let inserted = db.insert_pdf_batch(&pdf_scan_id, batch).unwrap_or(0);
1754                pdf_count += inserted;
1755                let _ = app_handle.emit(
1756                    "pdf-scan-progress",
1757                    serde_json::json!({
1758                        "phase": "scanning",
1759                        "pdfs": batch,
1760                        "found": pdf_count,
1761                    }),
1762                );
1763            },
1764            &|| pdf_state.stop_scan.load(Ordering::SeqCst),
1765            exclude_set,
1766            Some(Arc::clone(&app_handle.state::<WalkerStatus>().pdf_dirs)),
1767            incremental_state.clone(),
1768        );
1769
1770        persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &pdf_scan_id);
1771
1772        {
1773            let ws = app_handle.state::<WalkerStatus>();
1774            let mut ad = ws.pdf_dirs.lock().unwrap_or_else(|e| e.into_inner());
1775            ad.clear();
1776        }
1777        let was_stopped = pdf_state.stop_scan.load(Ordering::Relaxed);
1778        let _ = db.pdf_scan_parent_finalize(&pdf_scan_id, pdf_count as usize, pdf_bytes);
1779        let _ = db.set_pdf_scan_complete(&pdf_scan_id, !was_stopped);
1780        db.checkpoint();
1781        serde_json::json!({
1782            "pdfs": [],
1783            "roots": root_strs,
1784            "stopped": was_stopped,
1785            "streamed": true,
1786            "pdfScanId": pdf_scan_id,
1787            "pdfCount": pdf_count,
1788        })
1789    })
1790    .await;
1791
1792    state.scanning.store(false, Ordering::SeqCst);
1793    let elapsed = scan_start.elapsed();
1794    match &result {
1795        Ok(v) => append_log(format!(
1796            "SCAN END — pdfs | {}s | {} found",
1797            elapsed.as_secs(),
1798            v.get("pdfCount")
1799                .and_then(|n| n.as_u64())
1800                .or_else(|| {
1801                    v.get("pdfs")
1802                        .and_then(|p| p.as_array())
1803                        .map(|a| a.len() as u64)
1804                })
1805                .unwrap_or(0)
1806        )),
1807        Err(e) => append_log(format!(
1808            "SCAN ERROR — pdfs | {}s | {}",
1809            elapsed.as_secs(),
1810            e
1811        )),
1812    }
1813    result.map_err(|e| e.to_string())
1814}
1815
1816#[tauri::command]
1817async fn stop_pdf_scan(app: AppHandle) -> Result<(), String> {
1818    append_log("SCAN STOP — pdfs (user requested)".into());
1819    let state = app.state::<PdfScanState>();
1820    state.stop_scan.store(true, Ordering::SeqCst);
1821    Ok(())
1822}
1823
1824// ── Unified home-tree scan ──
1825// Walks the union of audio/daw/preset/pdf roots ONCE and classifies files in
1826// place, emitting the same per-type events (`audio-scan-progress`,
1827// `daw-scan-progress`, `preset-scan-progress`, `pdf-scan-progress`) so
1828// frontend listeners work unchanged. Saves 4x filesystem traversals on
1829// overlapping roots (especially valuable on SMB shares where every readdir
1830// is a network roundtrip).
1831#[tauri::command]
1832async fn scan_unified(
1833    app: AppHandle,
1834    audio_custom_roots: Option<Vec<String>>,
1835    audio_exclude_paths: Option<Vec<String>>,
1836    daw_custom_roots: Option<Vec<String>>,
1837    daw_exclude_paths: Option<Vec<String>>,
1838    daw_include_backups: Option<bool>,
1839    preset_custom_roots: Option<Vec<String>>,
1840    preset_exclude_paths: Option<Vec<String>>,
1841    pdf_custom_roots: Option<Vec<String>>,
1842    pdf_exclude_paths: Option<Vec<String>>,
1843) -> Result<serde_json::Value, String> {
1844    let scan_start = Instant::now();
1845    append_log("SCAN START — unified (audio+daw+preset+pdf)".into());
1846
1847    // Acquire all 4 scanning flags atomically; rollback if any is taken.
1848    let audio_state = app.state::<AudioScanState>();
1849    let daw_state = app.state::<DawScanState>();
1850    let preset_state = app.state::<PresetScanState>();
1851    let pdf_state = app.state::<PdfScanState>();
1852
1853    if audio_state.scanning.swap(true, Ordering::SeqCst) {
1854        return Err("Audio scan already in progress".into());
1855    }
1856    if daw_state.scanning.swap(true, Ordering::SeqCst) {
1857        audio_state.scanning.store(false, Ordering::SeqCst);
1858        return Err("DAW scan already in progress".into());
1859    }
1860    if preset_state.scanning.swap(true, Ordering::SeqCst) {
1861        audio_state.scanning.store(false, Ordering::SeqCst);
1862        daw_state.scanning.store(false, Ordering::SeqCst);
1863        return Err("Preset scan already in progress".into());
1864    }
1865    if pdf_state.scanning.swap(true, Ordering::SeqCst) {
1866        audio_state.scanning.store(false, Ordering::SeqCst);
1867        daw_state.scanning.store(false, Ordering::SeqCst);
1868        preset_state.scanning.store(false, Ordering::SeqCst);
1869        return Err("PDF scan already in progress".into());
1870    }
1871    // Do NOT clear stop flags here — `prepare_unified_scan` clears stale flags
1872    // when Scan All begins; if the user hit Stop during the frontend delay, flags
1873    // stay true and we honour that below.
1874    if audio_state.stop_scan.load(Ordering::SeqCst)
1875        || daw_state.stop_scan.load(Ordering::SeqCst)
1876        || preset_state.stop_scan.load(Ordering::SeqCst)
1877        || pdf_state.stop_scan.load(Ordering::SeqCst)
1878    {
1879        audio_state.scanning.store(false, Ordering::SeqCst);
1880        daw_state.scanning.store(false, Ordering::SeqCst);
1881        preset_state.scanning.store(false, Ordering::SeqCst);
1882        pdf_state.scanning.store(false, Ordering::SeqCst);
1883        app.state::<WalkerStatus>()
1884            .unified_scanning
1885            .store(false, Ordering::SeqCst);
1886        append_log("SCAN CANCELLED — unified (stop before walk)".into());
1887        return Ok(serde_json::json!({
1888            "audioCount": 0u64,
1889            "dawCount": 0u64,
1890            "presetCount": 0u64,
1891            "pdfCount": 0u64,
1892            "audioRoots": serde_json::json!([]),
1893            "dawRoots": serde_json::json!([]),
1894            "presetRoots": serde_json::json!([]),
1895            "pdfRoots": serde_json::json!([]),
1896            "audioScanId": "",
1897            "dawScanId": "",
1898            "presetScanId": "",
1899            "pdfScanId": "",
1900            "unifiedRunId": "",
1901            "stopped": true,
1902            "streamed": true,
1903        }));
1904    }
1905    // Signal walker-status tiles to collapse 4 → 1 while we hold the walker.
1906    app.state::<WalkerStatus>()
1907        .unified_scanning
1908        .store(true, Ordering::SeqCst);
1909
1910    // Kick off four status messages on the same event streams so the tabs
1911    // show "scanning" immediately.
1912    for ev in [
1913        "audio-scan-progress",
1914        "daw-scan-progress",
1915        "preset-scan-progress",
1916        "pdf-scan-progress",
1917    ] {
1918        let _ = app.emit(
1919            ev,
1920            serde_json::json!({
1921                "phase": "status",
1922                "message": "Walking filesystem (unified) — single traversal classifying all types..."
1923            }),
1924        );
1925    }
1926
1927    let app_handle = app.clone();
1928    let result = tokio::task::spawn_blocking(move || -> Result<serde_json::Value, String> {
1929        let resolve = |custom: Option<Vec<String>>,
1930                       default: &dyn Fn() -> Vec<std::path::PathBuf>|
1931         -> Vec<std::path::PathBuf> {
1932            if let Some(extra) = custom {
1933                let v: Vec<std::path::PathBuf> = extra
1934                    .into_iter()
1935                    .map(std::path::PathBuf::from)
1936                    .filter(|p| p.exists())
1937                    .collect();
1938                if v.is_empty() {
1939                    default()
1940                } else {
1941                    v
1942                }
1943            } else {
1944                default()
1945            }
1946        };
1947        let audio_roots = resolve(audio_custom_roots, &audio_scanner::get_audio_roots);
1948        let daw_roots = resolve(daw_custom_roots, &daw_scanner::get_daw_roots);
1949        let preset_roots = resolve(preset_custom_roots, &preset_scanner::get_preset_roots);
1950        let pdf_roots = resolve(pdf_custom_roots, &pdf_scanner::get_pdf_roots);
1951
1952        let spec = unified_walker::UnifiedSpec {
1953            audio_roots: audio_roots.clone(),
1954            audio_exclude: audio_exclude_paths.into_iter().flatten().collect(),
1955            daw_roots: daw_roots.clone(),
1956            daw_exclude: daw_exclude_paths.into_iter().flatten().collect(),
1957            daw_include_backups: daw_include_backups.unwrap_or(false),
1958            preset_roots: preset_roots.clone(),
1959            preset_exclude: preset_exclude_paths.into_iter().flatten().collect(),
1960            pdf_roots: pdf_roots.clone(),
1961            pdf_exclude: pdf_exclude_paths.into_iter().flatten().collect(),
1962        };
1963
1964        // Streaming architecture: create 4 parent scan rows upfront, batch-insert
1965        // rows into the DB during the walker callback, and finalize totals at end.
1966        // This keeps memory O(batch_size) regardless of total file count.
1967        let now_iso = history::now_iso();
1968        let audio_scan_id = history::gen_id();
1969        let daw_scan_id = history::gen_id();
1970        let preset_scan_id = history::gen_id();
1971        let pdf_scan_id = history::gen_id();
1972
1973        let to_strs = |v: &[std::path::PathBuf]| -> Vec<String> {
1974            v.iter().map(|r| r.to_string_lossy().to_string()).collect()
1975        };
1976        let audio_roots_strs = to_strs(&audio_roots);
1977        let daw_roots_strs = to_strs(&daw_roots);
1978        let preset_roots_strs = to_strs(&preset_roots);
1979        let pdf_roots_strs = to_strs(&pdf_roots);
1980
1981        let unified_run_id = history::gen_id();
1982        let roots_json = serde_json::json!({
1983            "audio": &audio_roots_strs,
1984            "daw": &daw_roots_strs,
1985            "preset": &preset_roots_strs,
1986            "pdf": &pdf_roots_strs,
1987        })
1988        .to_string();
1989
1990        let incremental_state = load_incremental_dir_state_for_walk();
1991        let db = db::global();
1992        let _ = db.unified_scan_run_start(
1993            &unified_run_id,
1994            &now_iso,
1995            &audio_scan_id,
1996            &daw_scan_id,
1997            &preset_scan_id,
1998            &pdf_scan_id,
1999            &roots_json,
2000        );
2001
2002        let _ = db.audio_scan_parent_create(&audio_scan_id, &now_iso, &audio_roots_strs);
2003        let _ = db.daw_scan_parent_create(&daw_scan_id, &now_iso, &daw_roots_strs);
2004        let _ = db.preset_scan_parent_create(&preset_scan_id, &now_iso, &preset_roots_strs);
2005        let _ = db.pdf_scan_parent_create(&pdf_scan_id, &now_iso, &pdf_roots_strs);
2006
2007        let mut audio_count: u64 = 0;
2008        let mut daw_count: u64 = 0;
2009        let mut preset_count: u64 = 0;
2010        let mut pdf_count: u64 = 0;
2011        let mut audio_bytes: u64 = 0;
2012        let mut daw_bytes: u64 = 0;
2013        let mut preset_bytes: u64 = 0;
2014        let mut pdf_bytes: u64 = 0;
2015        let mut audio_format_counts: HashMap<String, usize> = HashMap::new();
2016        let mut daw_daw_counts: HashMap<String, usize> = HashMap::new();
2017        let mut preset_format_counts: HashMap<String, usize> = HashMap::new();
2018
2019        let audio_state2 = app_handle.state::<AudioScanState>();
2020        let daw_state2 = app_handle.state::<DawScanState>();
2021        let preset_state2 = app_handle.state::<PresetScanState>();
2022        let pdf_state2 = app_handle.state::<PdfScanState>();
2023
2024        let closure_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2025            unified_walker::walk_unified(
2026                &spec,
2027                &mut |batch, _counts| {
2028                    use unified_walker::ClassifiedBatch;
2029                    match batch {
2030                        ClassifiedBatch::Audio(b) => {
2031                            for s in &b {
2032                                audio_bytes += s.size;
2033                                *audio_format_counts.entry(s.format.clone()).or_insert(0) += 1;
2034                            }
2035                            let inserted = db.insert_audio_batch(&audio_scan_id, &b).unwrap_or(0);
2036                            audio_count += inserted;
2037                            let _ = app_handle.emit(
2038                                "audio-scan-progress",
2039                                serde_json::json!({
2040                                    "phase": "scanning",
2041                                    "samples": &b,
2042                                    "found": audio_count,
2043                                }),
2044                            );
2045                        }
2046                        ClassifiedBatch::Daw(b) => {
2047                            let inserted_idx =
2048                                db.insert_daw_batch(&daw_scan_id, &b).unwrap_or_default();
2049                            let deduped: Vec<&DawProject> =
2050                                inserted_idx.iter().map(|&i| &b[i]).collect();
2051                            for p in &deduped {
2052                                daw_bytes += p.size;
2053                                *daw_daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
2054                            }
2055                            daw_count += deduped.len() as u64;
2056                            let _ = app_handle.emit(
2057                                "daw-scan-progress",
2058                                serde_json::json!({
2059                                    "phase": "scanning",
2060                                    "projects": &deduped,
2061                                    "found": daw_count,
2062                                }),
2063                            );
2064                        }
2065                        ClassifiedBatch::Preset(b) => {
2066                            for p in &b {
2067                                preset_bytes += p.size;
2068                                *preset_format_counts.entry(p.format.clone()).or_insert(0) += 1;
2069                            }
2070                            let inserted = db.insert_preset_batch(&preset_scan_id, &b).unwrap_or(0);
2071                            preset_count += inserted;
2072                            let _ = app_handle.emit(
2073                                "preset-scan-progress",
2074                                serde_json::json!({
2075                                    "phase": "scanning",
2076                                    "presets": &b,
2077                                    "found": preset_count,
2078                                }),
2079                            );
2080                        }
2081                        ClassifiedBatch::Pdf(b) => {
2082                            for p in &b {
2083                                pdf_bytes += p.size;
2084                            }
2085                            let inserted = db.insert_pdf_batch(&pdf_scan_id, &b).unwrap_or(0);
2086                            pdf_count += inserted;
2087                            let _ = app_handle.emit(
2088                                "pdf-scan-progress",
2089                                serde_json::json!({
2090                                    "phase": "scanning",
2091                                    "pdfs": &b,
2092                                    "found": pdf_count,
2093                                }),
2094                            );
2095                        }
2096                    }
2097                },
2098                &|| {
2099                    // Any individual stop_* command cancels the unified scan.
2100                    audio_state2.stop_scan.load(Ordering::SeqCst)
2101                        || daw_state2.stop_scan.load(Ordering::SeqCst)
2102                        || preset_state2.stop_scan.load(Ordering::SeqCst)
2103                        || pdf_state2.stop_scan.load(Ordering::SeqCst)
2104                },
2105                // Fan the walker's current-dir updates into all 4 WalkerStatus
2106                // lists so each walker-status tile shows live progress.
2107                {
2108                    let ws = app_handle.state::<WalkerStatus>();
2109                    vec![
2110                        Arc::clone(&ws.audio_dirs),
2111                        Arc::clone(&ws.daw_dirs),
2112                        Arc::clone(&ws.preset_dirs),
2113                        Arc::clone(&ws.pdf_dirs),
2114                    ]
2115                },
2116                incremental_state.clone(),
2117            );
2118
2119            let stopped = audio_state2.stop_scan.load(Ordering::Relaxed)
2120                || daw_state2.stop_scan.load(Ordering::Relaxed)
2121                || preset_state2.stop_scan.load(Ordering::Relaxed)
2122                || pdf_state2.stop_scan.load(Ordering::Relaxed);
2123
2124            if !stopped {
2125                persist_incremental_dir_state_after_walk(incremental_state.as_ref(), &audio_scan_id);
2126            }
2127
2128            // Clear WalkerStatus dir lists so tiles return to idle state.
2129            {
2130                let ws = app_handle.state::<WalkerStatus>();
2131                for sink in [&ws.audio_dirs, &ws.daw_dirs, &ws.preset_dirs, &ws.pdf_dirs] {
2132                    sink.lock().unwrap_or_else(|e| e.into_inner()).clear();
2133                }
2134            }
2135
2136            // Finalize parent scan rows with real totals now that streaming is done.
2137            let _ = db.audio_scan_parent_finalize(
2138                &audio_scan_id,
2139                audio_count,
2140                audio_bytes,
2141                &audio_format_counts,
2142            );
2143            let _ = db.daw_scan_parent_finalize(
2144                &daw_scan_id,
2145                daw_count as usize,
2146                daw_bytes,
2147                &daw_daw_counts,
2148            );
2149            let _ = db.preset_scan_parent_finalize(
2150                &preset_scan_id,
2151                preset_count as usize,
2152                preset_bytes,
2153                &preset_format_counts,
2154            );
2155            let _ = db.pdf_scan_parent_finalize(&pdf_scan_id, pdf_count as usize, pdf_bytes);
2156            let complete = !stopped;
2157            let _ = db.set_audio_scan_complete(&audio_scan_id, complete);
2158            let _ = db.set_daw_scan_complete(&daw_scan_id, complete);
2159            let _ = db.set_preset_scan_complete(&preset_scan_id, complete);
2160            let _ = db.set_pdf_scan_complete(&pdf_scan_id, complete);
2161            db.checkpoint();
2162
2163            let finished_at = history::now_iso();
2164            if stopped {
2165                let _ = db.unified_scan_run_finish(&finished_at, "stopped", None, None);
2166            } else {
2167                let _ = db.unified_scan_run_finish(&finished_at, "complete", None, None);
2168            }
2169
2170            serde_json::json!({
2171                "audioCount": audio_count,
2172                "dawCount": daw_count,
2173                "presetCount": preset_count,
2174                "pdfCount": pdf_count,
2175                "audioRoots": audio_roots_strs,
2176                "dawRoots": daw_roots_strs,
2177                "presetRoots": preset_roots_strs,
2178                "pdfRoots": pdf_roots_strs,
2179                "audioScanId": audio_scan_id,
2180                "dawScanId": daw_scan_id,
2181                "presetScanId": preset_scan_id,
2182                "pdfScanId": pdf_scan_id,
2183                "unifiedRunId": unified_run_id,
2184                "stopped": stopped,
2185                "streamed": true,
2186            })
2187        }));
2188
2189        match closure_result {
2190            Ok(v) => Ok(v),
2191            Err(_) => {
2192                let _ = db.unified_scan_run_finish(
2193                    &history::now_iso(),
2194                    "error",
2195                    Some("panic"),
2196                    None,
2197                );
2198                Err("unified scan panicked".into())
2199            }
2200        }
2201    })
2202    .await;
2203
2204    let result: Result<serde_json::Value, String> = match result {
2205        Ok(inner) => inner,
2206        Err(e) => Err(e.to_string()),
2207    };
2208
2209    audio_state.scanning.store(false, Ordering::SeqCst);
2210    daw_state.scanning.store(false, Ordering::SeqCst);
2211    preset_state.scanning.store(false, Ordering::SeqCst);
2212    pdf_state.scanning.store(false, Ordering::SeqCst);
2213    app.state::<WalkerStatus>()
2214        .unified_scanning
2215        .store(false, Ordering::SeqCst);
2216
2217    let elapsed = scan_start.elapsed();
2218    match &result {
2219        Ok(v) => append_log(format!(
2220            "SCAN END — unified | {}s | audio:{} daw:{} preset:{} pdf:{}",
2221            elapsed.as_secs(),
2222            v.get("audioCount").and_then(|x| x.as_u64()).unwrap_or(0),
2223            v.get("dawCount").and_then(|x| x.as_u64()).unwrap_or(0),
2224            v.get("presetCount").and_then(|x| x.as_u64()).unwrap_or(0),
2225            v.get("pdfCount").and_then(|x| x.as_u64()).unwrap_or(0),
2226        )),
2227        Err(e) => append_log(format!(
2228            "SCAN ERROR — unified | {}s | {}",
2229            elapsed.as_secs(),
2230            e
2231        )),
2232    }
2233    result
2234}
2235
2236#[tauri::command]
2237async fn get_unified_scan_run() -> Result<db::UnifiedScanRunRow, String> {
2238    blocking_res(|| db::global().get_unified_scan_run()).await
2239}
2240
2241/// Clears unified stop flags **before** the `scan_unified` invoke (after the
2242/// frontend's listener-registration delay). Without this, `scan_unified` would
2243/// reset `stop_scan` to false at entry and erase a Stop All that happened during
2244/// that delay — scans looked like they "could not stop".
2245#[tauri::command]
2246async fn prepare_unified_scan(app: AppHandle) -> Result<(), String> {
2247    app.state::<AudioScanState>()
2248        .stop_scan
2249        .store(false, Ordering::SeqCst);
2250    app.state::<DawScanState>()
2251        .stop_scan
2252        .store(false, Ordering::SeqCst);
2253    app.state::<PresetScanState>()
2254        .stop_scan
2255        .store(false, Ordering::SeqCst);
2256    app.state::<PdfScanState>()
2257        .stop_scan
2258        .store(false, Ordering::SeqCst);
2259    Ok(())
2260}
2261
2262// Stops a running unified scan by setting stop flags on all four per-type
2263// scan states. The scan loop checks these each iteration and breaks out.
2264#[tauri::command]
2265async fn stop_unified_scan(app: AppHandle) -> Result<(), String> {
2266    append_log("SCAN STOP — unified (user requested)".into());
2267    app.state::<AudioScanState>()
2268        .stop_scan
2269        .store(true, Ordering::SeqCst);
2270    app.state::<DawScanState>()
2271        .stop_scan
2272        .store(true, Ordering::SeqCst);
2273    app.state::<PresetScanState>()
2274        .stop_scan
2275        .store(true, Ordering::SeqCst);
2276    app.state::<PdfScanState>()
2277        .stop_scan
2278        .store(true, Ordering::SeqCst);
2279    Ok(())
2280}
2281
2282#[tauri::command]
2283async fn pdf_history_save(
2284    pdfs: Vec<PdfFile>,
2285    roots: Option<Vec<String>>,
2286) -> Result<history::PdfScanSnapshot, String> {
2287    let roots = roots.unwrap_or_default();
2288    blocking_res(move || {
2289        let snap = history::build_pdf_snapshot(&pdfs, &roots);
2290        db::global().save_pdf_scan(&snap)?;
2291        db::global().checkpoint();
2292        Ok(snap)
2293    })
2294    .await
2295}
2296
2297#[tauri::command]
2298async fn pdf_history_get_scans() -> Result<Vec<serde_json::Value>, String> {
2299    blocking_res(|| db::global().get_pdf_scans()).await
2300}
2301
2302#[tauri::command]
2303async fn pdf_history_get_detail(id: String) -> Result<history::PdfScanSnapshot, String> {
2304    blocking_res(move || db::global().get_pdf_scan_detail(&id)).await
2305}
2306
2307#[tauri::command]
2308async fn pdf_history_delete(id: String) -> Result<(), String> {
2309    blocking_res(move || db::global().delete_pdf_scan(&id)).await
2310}
2311
2312#[tauri::command]
2313async fn pdf_history_clear() -> Result<(), String> {
2314    #[cfg(not(test))]
2315    append_log("HISTORY CLEAR — pdfs".into());
2316    blocking_res(|| db::global().clear_pdf_history()).await
2317}
2318
2319#[tauri::command]
2320async fn pdf_history_latest() -> Result<Option<history::PdfScanSnapshot>, String> {
2321    blocking_res(|| db::global().get_latest_pdf_scan()).await
2322}
2323
2324#[tauri::command]
2325async fn pdf_history_diff(old_id: String, new_id: String) -> Option<history::PdfScanDiff> {
2326    tokio::task::spawn_blocking(move || {
2327        let old = db::global().get_pdf_scan_detail(&old_id).ok()?;
2328        let new = db::global().get_pdf_scan_detail(&new_id).ok()?;
2329        Some(history::compute_pdf_diff(&old, &new))
2330    })
2331    .await
2332    .ok()
2333    .flatten()
2334}
2335
2336#[tauri::command]
2337async fn open_pdf_file(file_path: String) -> Result<(), String> {
2338    open_plugin_folder(file_path).await
2339}
2340
2341#[tauri::command]
2342async fn pdf_metadata_get(paths: Vec<String>) -> Result<serde_json::Value, String> {
2343    tokio::task::spawn_blocking(move || {
2344        let map = db::global().get_pdf_metadata(&paths)?;
2345        let mut out = serde_json::Map::new();
2346        for (k, v) in map {
2347            out.insert(
2348                k,
2349                match v {
2350                    Some(n) => serde_json::json!(n),
2351                    None => serde_json::Value::Null,
2352                },
2353            );
2354        }
2355        Ok::<serde_json::Value, String>(serde_json::Value::Object(out))
2356    })
2357    .await
2358    .map_err(|e| e.to_string())?
2359}
2360
2361#[tauri::command]
2362fn pdf_metadata_extract_abort() {
2363    PDF_META_EXTRACT_ABORT.store(true, Ordering::Relaxed);
2364}
2365
2366#[tauri::command]
2367async fn pdf_metadata_extract_batch(
2368    app: AppHandle,
2369    paths: Vec<String>,
2370) -> Result<serde_json::Value, String> {
2371    tokio::task::spawn_blocking(move || {
2372        PDF_META_EXTRACT_ABORT.store(false, Ordering::Relaxed);
2373        let total = paths.len();
2374        if total == 0 {
2375            return serde_json::json!({ "extracted": 0, "total": 0, "aborted": false });
2376        }
2377        let _ = app.emit(
2378            "pdf-metadata-progress",
2379            serde_json::json!({ "phase": "start", "total": total }),
2380        );
2381        // Chunk so we can emit progress + persist incrementally
2382        const CHUNK: usize = 100;
2383        let mut done = 0usize;
2384        let mut extracted = 0usize;
2385        for chunk in paths.chunks(CHUNK) {
2386            if PDF_META_EXTRACT_ABORT.load(Ordering::Relaxed) {
2387                let _ = app.emit(
2388                    "pdf-metadata-progress",
2389                    serde_json::json!({
2390                        "phase": "aborted", "done": done, "total": total, "extracted": extracted
2391                    }),
2392                );
2393                let _ = app.emit(
2394                    "pdf-metadata-progress",
2395                    serde_json::json!({
2396                        "phase": "done", "extracted": extracted, "total": total, "aborted": true
2397                    }),
2398                );
2399                return serde_json::json!({ "extracted": extracted, "total": total, "aborted": true });
2400            }
2401            let pairs = pdf_meta::extract_pages_batch(chunk);
2402            // Build a HashMap for O(1) lookup instead of O(n) linear scan per element
2403            let pairs_map: std::collections::HashMap<&String, u32> =
2404                pairs.iter().map(|(p, n)| (p, *n)).collect();
2405            let mut rows: Vec<(String, Option<u32>)> = Vec::with_capacity(chunk.len());
2406            for p in chunk {
2407                rows.push((p.clone(), pairs_map.get(p).copied()));
2408            }
2409            let _ = db::global().save_pdf_metadata(&rows);
2410            extracted += pairs.len();
2411            done += chunk.len();
2412            let _ = app.emit(
2413                "pdf-metadata-progress",
2414                serde_json::json!({
2415                    "phase": "progress", "done": done, "total": total, "extracted": extracted
2416                }),
2417            );
2418        }
2419        let _ = app.emit(
2420            "pdf-metadata-progress",
2421            serde_json::json!({ "phase": "done", "extracted": extracted, "total": total, "aborted": false }),
2422        );
2423        serde_json::json!({ "extracted": extracted, "total": total, "aborted": false })
2424    })
2425    .await
2426    .map_err(|e| e.to_string())
2427}
2428
2429/// Paths in the PDF library (`pdf_library`) with no `pdf_metadata` row yet — used to kick off
2430/// background page-count extraction for the whole inventory, not only the latest scan.
2431#[tauri::command]
2432async fn pdf_metadata_unindexed(limit: Option<u64>) -> Result<Vec<String>, String> {
2433    let lim = limit.unwrap_or(100000);
2434    blocking_res(move || db::global().unindexed_pdf_paths(lim)).await
2435}
2436
2437#[tauri::command]
2438async fn open_preset_folder(file_path: String) -> Result<(), String> {
2439    open_plugin_folder(file_path).await
2440}
2441
2442#[tauri::command]
2443async fn open_daw_folder(file_path: String) -> Result<(), String> {
2444    open_plugin_folder(file_path).await
2445}
2446
2447#[tauri::command]
2448async fn open_daw_project(file_path: String) -> Result<(), String> {
2449    let path = std::path::Path::new(&file_path);
2450    if !path.exists() {
2451        return Err(format!("File not found: {}", file_path));
2452    }
2453
2454    #[cfg(target_os = "macos")]
2455    {
2456        let output = std::process::Command::new("open")
2457            .arg(&file_path)
2458            .output()
2459            .map_err(|e| e.to_string())?;
2460        if !output.status.success() {
2461            let stderr = String::from_utf8_lossy(&output.stderr);
2462            if stderr.contains("No application can open") || stderr.contains("no application set") {
2463                return Err("No application installed to open this project file".to_string());
2464            }
2465            return Err(format!("Failed to open project: {}", stderr.trim()));
2466        }
2467    }
2468
2469    #[cfg(target_os = "windows")]
2470    {
2471        let output = std::process::Command::new("cmd")
2472            .args(["/C", "start", "", &file_path])
2473            .output()
2474            .map_err(|e| e.to_string())?;
2475        if !output.status.success() {
2476            return Err("No application installed to open this project file".to_string());
2477        }
2478    }
2479
2480    #[cfg(target_os = "linux")]
2481    {
2482        let output = std::process::Command::new("xdg-open")
2483            .arg(&file_path)
2484            .output()
2485            .map_err(|e| format!("No application installed to open this project file: {}", e))?;
2486        if !output.status.success() {
2487            return Err("No application installed to open this project file".to_string());
2488        }
2489    }
2490
2491    Ok(())
2492}
2493
2494#[tauri::command]
2495async fn extract_project_plugins(file_path: String) -> Result<Vec<xref::PluginRef>, String> {
2496    let mut result = xref::extract_plugins(&file_path);
2497    // Enrich empty manufacturers from scanned plugin database
2498    if result.iter().any(|p| p.manufacturer.is_empty()) {
2499        if let Ok(all) = db::global().query_plugins(None, None, None, "name", true, false, 0, 100000) {
2500            let mfg_map: std::collections::HashMap<String, String> = all
2501                .plugins
2502                .iter()
2503                .filter(|p| !p.manufacturer.is_empty())
2504                .map(|p| (p.name.to_lowercase(), p.manufacturer.clone()))
2505                .collect();
2506            for p in &mut result {
2507                if p.manufacturer.is_empty() {
2508                    if let Some(mfg) = mfg_map.get(&p.name.to_lowercase()) {
2509                        p.manufacturer = mfg.clone();
2510                    }
2511                }
2512            }
2513        }
2514    }
2515    #[cfg(not(test))]
2516    append_log(format!(
2517        "XREF EXTRACT — {} | {} plugins found",
2518        file_path,
2519        result.len()
2520    ));
2521    Ok(result)
2522}
2523
2524fn read_als_xml_impl(file_path: &str) -> Result<String, String> {
2525    use flate2::read::GzDecoder;
2526    use std::io::Read;
2527    let data = std::fs::read(file_path).map_err(|e| e.to_string())?;
2528    let mut decoder = GzDecoder::new(&data[..]);
2529    const MAX_XML_SIZE: usize = 20_000_000; // 20MB cap to prevent WebView OOM
2530    let mut xml = String::new();
2531    decoder
2532        .read_to_string(&mut xml)
2533        .map_err(|e| format!("Not a valid gzip file: {}", e))?;
2534    if xml.len() > MAX_XML_SIZE {
2535        xml.truncate(MAX_XML_SIZE);
2536        xml.push_str("\n<!-- TRUNCATED: file too large for viewer -->");
2537    }
2538    Ok(xml)
2539}
2540
2541#[tauri::command]
2542async fn read_als_xml(file_path: String) -> Result<String, String> {
2543    blocking_res(move || read_als_xml_impl(&file_path)).await
2544}
2545
2546#[tauri::command]
2547async fn estimate_bpm(file_path: String) -> Result<Option<f64>, String> {
2548    Ok(bpm::estimate_bpm(&file_path))
2549}
2550
2551#[tauri::command]
2552async fn detect_audio_key(file_path: String) -> Result<Option<String>, String> {
2553    tokio::task::spawn_blocking(move || key_detect::detect_key(&file_path))
2554        .await
2555        .map_err(|e| e.to_string())
2556}
2557
2558#[tauri::command]
2559async fn measure_lufs(file_path: String) -> Result<Option<f64>, String> {
2560    tokio::task::spawn_blocking(move || lufs::measure_lufs(&file_path))
2561        .await
2562        .map_err(|e| e.to_string())
2563}
2564
2565/// Batch analyze: BPM + Key + LUFS for multiple files in parallel, save to SQLite.
2566/// Analyzes files in parallel (rayon), batch-writes to DB, returns results
2567/// directly so the frontend can update visible rows without extra IPC.
2568///
2569/// Uses a **small dedicated rayon pool** (at most 4 threads) so a full batch does not claim every
2570/// CPU core during a library scan — unbounded default rayon was starving other work (including
2571/// audio I/O in the separate engine process on the same machine).
2572#[tauri::command]
2573async fn batch_analyze(paths: Vec<String>) -> Result<serde_json::Value, String> {
2574    let inner = tokio::task::spawn_blocking(move || {
2575        use rayon::prelude::*;
2576        if paths.is_empty() {
2577            return Ok(serde_json::json!({ "count": 0, "results": [] }));
2578        }
2579        const MAX_BATCH_ANALYSIS_THREADS: usize = 4;
2580        let num_threads = std::cmp::min(paths.len(), MAX_BATCH_ANALYSIS_THREADS).max(1);
2581        let pool = rayon::ThreadPoolBuilder::new()
2582            .num_threads(num_threads)
2583            .build()
2584            .expect("batch_analyze rayon pool");
2585        let results: Vec<db::AnalysisBatchRow> = pool.install(|| {
2586            paths
2587                .par_iter()
2588                .map(|path| {
2589                    let bpm_val = bpm::estimate_bpm(path);
2590                    let key_val = key_detect::detect_key(path);
2591                    let lufs_val = lufs::measure_lufs(path);
2592                    (path.clone(), bpm_val, key_val, lufs_val)
2593                })
2594                .collect()
2595        });
2596        // Batch all DB writes in a single transaction
2597        let count = db::global().batch_update_analysis(&results)?;
2598        // Return results so frontend skips N individual dbGetAnalysis IPC calls
2599        let items: Vec<serde_json::Value> = results
2600            .iter()
2601            .map(|(path, bpm, key, lufs)| {
2602                let bpm_exhausted = bpm.is_none() && key.is_some() && lufs.is_some();
2603                serde_json::json!({
2604                    "path": path,
2605                    "bpm": bpm,
2606                    "key": key,
2607                    "lufs": lufs,
2608                    "bpmExhausted": bpm_exhausted,
2609                })
2610            })
2611            .collect();
2612        Ok(serde_json::json!({ "count": count, "results": items }))
2613    })
2614    .await
2615    .map_err(|e| e.to_string())?;
2616    inner
2617}
2618
2619#[tauri::command]
2620async fn compute_fingerprint(
2621    file_path: String,
2622) -> Result<Option<similarity::AudioFingerprint>, String> {
2623    tokio::task::spawn_blocking(move || similarity::compute_fingerprint(&file_path))
2624        .await
2625        .map_err(|e| e.to_string())
2626}
2627
2628#[tauri::command]
2629async fn build_fingerprint_cache(
2630    app: AppHandle,
2631    candidate_paths: Vec<String>,
2632) -> Result<serde_json::Value, String> {
2633    tokio::task::spawn_blocking(move || {
2634        let fp_json = db::global()
2635            .read_cache("fingerprint-cache.json")
2636            .unwrap_or_default();
2637        let raw: HashMap<String, similarity::AudioFingerprint> =
2638            serde_json::from_value(fp_json).unwrap_or_default();
2639        let mut cache = normalize_fingerprint_cache_map(raw);
2640        use rayon::prelude::*;
2641        let uncached: Vec<&String> = candidate_paths
2642            .iter()
2643            .filter(|p| !cache.contains_key(&normalize_path_for_db(p.as_str())))
2644            .collect();
2645        let total = uncached.len();
2646        if total == 0 {
2647            return serde_json::json!({ "built": 0, "cached": cache.len() });
2648        }
2649        let _ = app.emit(
2650            "fingerprint-build-progress",
2651            serde_json::json!({
2652                "phase": "start", "total": total, "cached": cache.len()
2653            }),
2654        );
2655        const CHUNK: usize = 500;
2656        let mut done = 0usize;
2657        for chunk in uncached.chunks(CHUNK) {
2658            let new_fps: Vec<similarity::AudioFingerprint> = chunk
2659                .par_iter()
2660                .filter_map(|p| similarity::compute_fingerprint(p))
2661                .collect();
2662            for mut fp in new_fps {
2663                let k = normalize_path_for_db(&fp.path);
2664                fp.path = k.clone();
2665                cache.insert(k, fp);
2666            }
2667            done += chunk.len();
2668            let _ = app.emit(
2669                "fingerprint-build-progress",
2670                serde_json::json!({
2671                    "phase": "progress", "done": done, "total": total
2672                }),
2673            );
2674            if let Ok(val) = serde_json::to_value(&cache) {
2675                let _ = db::global().write_cache("fingerprint-cache.json", &val);
2676            }
2677        }
2678        let _ = app.emit(
2679            "fingerprint-build-progress",
2680            serde_json::json!({ "phase": "done", "built": done, "cached": cache.len() }),
2681        );
2682        serde_json::json!({ "built": done, "cached": cache.len() })
2683    })
2684    .await
2685    .map_err(|e| e.to_string())
2686}
2687
2688#[tauri::command]
2689async fn find_similar_samples(
2690    app: AppHandle,
2691    file_path: String,
2692    candidate_paths: Vec<String>,
2693    max_results: usize,
2694) -> Result<Vec<serde_json::Value>, String> {
2695    tokio::task::spawn_blocking(move || {
2696        // Load cached fingerprints from SQLite
2697        let fp_json = db::global()
2698            .read_cache("fingerprint-cache.json")
2699            .unwrap_or_default();
2700        let raw: HashMap<String, similarity::AudioFingerprint> =
2701            serde_json::from_value(fp_json).unwrap_or_default();
2702        let mut cache = normalize_fingerprint_cache_map(raw);
2703
2704        let file_key = normalize_path_for_db(&file_path);
2705        // Compute reference fingerprint (use cache if available)
2706        let reference = if let Some(fp) = cache.get(&file_key) {
2707            fp.clone()
2708        } else {
2709            match similarity::compute_fingerprint(&file_path) {
2710                Some(mut fp) => {
2711                    let k = normalize_path_for_db(&fp.path);
2712                    fp.path = k.clone();
2713                    cache.insert(k.clone(), fp.clone());
2714                    fp
2715                }
2716                None => return vec![],
2717            }
2718        };
2719
2720        // Compute missing fingerprints in parallel
2721        use rayon::prelude::*;
2722        let uncached: Vec<&String> = candidate_paths
2723            .iter()
2724            .filter(|p| !cache.contains_key(&normalize_path_for_db(p.as_str())))
2725            .collect();
2726
2727        if !uncached.is_empty() {
2728            // Emit progress (explicit counts — `total` alone was uncached-only and confused the UI)
2729            let uncached_count = uncached.len();
2730            let candidate_count = candidate_paths.len();
2731            let cached_count = candidate_count.saturating_sub(uncached_count);
2732            let _ = app.emit(
2733                "similarity-progress",
2734                serde_json::json!({
2735                    "phase": "computing",
2736                    "candidate_count": candidate_count,
2737                    "uncached_count": uncached_count,
2738                    "cached_count": cached_count,
2739                    "total": uncached_count,
2740                    "cached": cached_count
2741                }),
2742            );
2743
2744            let new_fps: Vec<similarity::AudioFingerprint> = uncached
2745                .par_iter()
2746                .filter_map(|p| similarity::compute_fingerprint(p))
2747                .collect();
2748
2749            for mut fp in new_fps {
2750                let k = normalize_path_for_db(&fp.path);
2751                fp.path = k.clone();
2752                cache.insert(k, fp);
2753            }
2754
2755            // Save cache to SQLite
2756            if let Ok(val) = serde_json::to_value(&cache) {
2757                let _ = db::global().write_cache("fingerprint-cache.json", &val);
2758            }
2759        }
2760
2761        // Collect cached fingerprints for candidates
2762        let candidates: Vec<similarity::AudioFingerprint> = candidate_paths
2763            .iter()
2764            .filter_map(|p| cache.get(&normalize_path_for_db(p.as_str())).cloned())
2765            .collect();
2766
2767        similarity::find_similar(&reference, &candidates, max_results)
2768            .into_iter()
2769            .map(|(path, distance)| {
2770                serde_json::json!({
2771                    "path": path,
2772                    "distance": distance,
2773                    "similarity": (1.0 - distance.min(1.0)) * 100.0
2774                })
2775            })
2776            .collect()
2777    })
2778    .await
2779    .map_err(|e| e.to_string())
2780}
2781
2782/// Byte-identical files across the scanned library (SHA-256 after grouping by stored size).
2783#[tauri::command]
2784async fn find_content_duplicates(app: AppHandle) -> Result<serde_json::Value, String> {
2785    let app_pb = Arc::new(app);
2786    tokio::task::spawn_blocking(move || {
2787        let entries = db::global().library_paths_for_content_hash()?;
2788        let progress = Some((app_pb, 25usize));
2789        let r = content_hash::find_byte_duplicate_groups(entries, progress);
2790        serde_json::to_value(&r).map_err(|e| e.to_string())
2791    })
2792    .await
2793    .map_err(|e| e.to_string())?
2794}
2795
2796#[tauri::command]
2797async fn open_file_default(file_path: String) -> Result<(), String> {
2798    blocking_res(move || {
2799        let path = std::path::Path::new(&file_path);
2800        if !path.exists() {
2801            return Err(format!("File not found: {}", file_path));
2802        }
2803        #[cfg(target_os = "macos")]
2804        {
2805            let output = std::process::Command::new("open")
2806                .arg(&file_path)
2807                .output()
2808                .map_err(|e| e.to_string())?;
2809            if !output.status.success() {
2810                let stderr = String::from_utf8_lossy(&output.stderr);
2811                return Err(format!("open failed: {}", stderr.trim()));
2812            }
2813        }
2814        #[cfg(target_os = "windows")]
2815        {
2816            let output = std::process::Command::new("cmd")
2817                .args(["/C", "start", "", &file_path])
2818                .output()
2819                .map_err(|e| e.to_string())?;
2820            if !output.status.success() {
2821                return Err("start failed".into());
2822            }
2823        }
2824        #[cfg(target_os = "linux")]
2825        {
2826            let output = std::process::Command::new("xdg-open")
2827                .arg(&file_path)
2828                .output()
2829                .map_err(|e| e.to_string())?;
2830            if !output.status.success() {
2831                return Err("xdg-open failed".into());
2832            }
2833        }
2834        Ok(())
2835    })
2836    .await
2837}
2838
2839#[tauri::command]
2840async fn open_with_app(file_path: String, app_name: String) -> Result<(), String> {
2841    blocking_res(move || {
2842        let path = std::path::Path::new(&file_path);
2843        open_with_app::open_with_application(path, &app_name)
2844    })
2845    .await
2846}
2847
2848#[tauri::command]
2849async fn open_update_url(url: String) -> Result<(), String> {
2850    opener::open(&url).map_err(|e| e.to_string())
2851}
2852
2853#[tauri::command]
2854async fn open_plugin_folder(plugin_path: String) -> Result<(), String> {
2855    #[cfg(target_os = "macos")]
2856    {
2857        /* All filesystem + subprocess work runs on a **detached OS thread**, not on the Tauri
2858         * async runtime or a `spawn_blocking` worker. The FIRST Reveal in Finder during a session
2859         * pays a multi-second cold-start cost (Finder loads its scripting support, icon/preview
2860         * caches, Launch Services, etc.) — and when the user's audio library lives on an SMB
2861         * share, `canonicalize()` / `is_file()` are network round-trips that can block for
2862         * several seconds on first access. Running any of that inline on the tokio worker
2863         * holding this async command starves other IPC (including the audio-engine
2864         * `playback_status` poll that shares a stdin/stdout mutex with `playback_set_dsp`),
2865         * which manifests as an app-wide lockup + audio dropout on the first Reveal click.
2866         * Detaching to a fresh OS thread gets the entire cold-start cost off every path the
2867         * audio callback / IPC poll cares about. Finder is also pre-warmed at app startup (see
2868         * `setup` in `run()`) so scripting + Launch Services are loaded before audio plays. */
2869        let plugin_path_owned = plugin_path.clone();
2870        std::thread::spawn(move || {
2871            let raw = plugin_path_owned.trim();
2872            let p = std::path::Path::new(raw);
2873            let target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
2874            if target.is_file() {
2875                let _ = std::process::Command::new("open")
2876                    .arg("-R")
2877                    .arg(&target)
2878                    .spawn();
2879            } else if target.is_dir() {
2880                let _ = std::process::Command::new("open").arg(&target).spawn();
2881            } else if let Some(parent) = p.parent() {
2882                if !parent.as_os_str().is_empty() {
2883                    let pp = parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf());
2884                    let _ = std::process::Command::new("open").arg(&pp).spawn();
2885                }
2886            }
2887        });
2888    }
2889    #[cfg(target_os = "windows")]
2890    {
2891        std::process::Command::new("explorer")
2892            .arg(format!("/select,{}", plugin_path))
2893            .spawn()
2894            .map_err(|e| e.to_string())?;
2895    }
2896    #[cfg(target_os = "linux")]
2897    {
2898        let parent = std::path::Path::new(&plugin_path)
2899            .parent()
2900            .map(|p| p.to_string_lossy().to_string())
2901            .unwrap_or_default();
2902        opener::open(&parent).map_err(|e| e.to_string())?;
2903    }
2904    Ok(())
2905}
2906
2907#[tauri::command]
2908async fn open_audio_folder(file_path: String) -> Result<(), String> {
2909    open_plugin_folder(file_path).await
2910}
2911
2912// ── Preferences commands ──
2913
2914#[tauri::command]
2915async fn prefs_get_all() -> history::PrefsMap {
2916    blocking(|| history::load_preferences())
2917        .await
2918        .unwrap_or_default()
2919}
2920
2921#[tauri::command]
2922async fn prefs_set(app: AppHandle, key: String, value: serde_json::Value) {
2923    let refresh_log = key == "logVerbosity";
2924    let tray_theme = if key == "theme" {
2925        let s = match &value {
2926            serde_json::Value::String(t) => t.as_str(),
2927            _ => "",
2928        };
2929        Some(if s == "light" {
2930            "light".to_string()
2931        } else {
2932            "dark".to_string()
2933        })
2934    } else {
2935        None
2936    };
2937    let _ = blocking_res(move || {
2938        history::set_preference(&key, value);
2939        Ok(())
2940    })
2941    .await;
2942    if refresh_log {
2943        refresh_log_verbosity_from_prefs();
2944    }
2945    if let Some(ref t) = tray_theme {
2946        tray_menu::emit_tray_popover_ui_theme(&app, t);
2947    }
2948}
2949
2950#[tauri::command]
2951async fn prefs_remove(key: String) {
2952    let _ = blocking_res(move || {
2953        history::remove_preference(&key);
2954        Ok(())
2955    })
2956    .await;
2957}
2958
2959#[tauri::command]
2960async fn prefs_save_all(prefs: history::PrefsMap) {
2961    let _ = blocking_res(move || {
2962        history::save_preferences(&prefs);
2963        Ok(())
2964    })
2965    .await;
2966    refresh_log_verbosity_from_prefs();
2967}
2968
2969#[tauri::command]
2970async fn open_prefs_file() -> Result<(), String> {
2971    let path = history::get_preferences_path();
2972    opener::open(&path).map_err(|e| e.to_string())
2973}
2974
2975#[tauri::command]
2976async fn get_prefs_path() -> String {
2977    blocking(|| {
2978        history::get_preferences_path()
2979            .to_string_lossy()
2980            .to_string()
2981    })
2982    .await
2983    .unwrap_or_default()
2984}
2985
2986// Cache file read/write — backed by SQLite
2987#[tauri::command]
2988async fn read_cache_file(name: String) -> Result<serde_json::Value, String> {
2989    blocking_res(move || db::global().read_cache(&name)).await
2990}
2991
2992#[tauri::command]
2993async fn write_cache_file(name: String, data: serde_json::Value) -> Result<(), String> {
2994    blocking_res(move || db::global().write_cache(&name, &data)).await
2995}
2996
2997#[tauri::command]
2998async fn audio_engine_invoke(request: serde_json::Value) -> Result<serde_json::Value, String> {
2999    let payload = audio_engine::normalize_ipc_request_payload(&request);
3000    let v = tokio::task::spawn_blocking({
3001        let payload = payload.clone();
3002        move || audio_engine::spawn_audio_engine_request(&payload)
3003    })
3004    .await
3005    .map_err(|e| format!("audio-engine spawn_blocking: {e}"))??;
3006    if v.get("ok") == Some(&serde_json::Value::Bool(false)) {
3007        let cmd = payload
3008            .get("cmd")
3009            .and_then(|c| c.as_str())
3010            .unwrap_or("?");
3011        let err = v
3012            .get("error")
3013            .and_then(|e| e.as_str())
3014            .unwrap_or("?");
3015        write_app_log(format!("audio-engine [{cmd}] {err}"));
3016    }
3017    Ok(v)
3018}
3019
3020#[tauri::command]
3021fn audio_engine_restart() -> Result<(), String> {
3022    audio_engine::restart_audio_engine_child()
3023}
3024
3025#[tauri::command]
3026fn audio_engine_eof_watchdog_start(app: AppHandle) -> Result<(), String> {
3027    audio_engine::audio_engine_eof_watchdog_start(app);
3028    Ok(())
3029}
3030
3031#[tauri::command]
3032fn audio_engine_eof_watchdog_stop() -> Result<(), String> {
3033    audio_engine::audio_engine_eof_watchdog_stop();
3034    Ok(())
3035}
3036
3037#[tauri::command]
3038fn append_log(msg: String) {
3039    write_app_log(msg);
3040}
3041
3042fn write_app_log_line(msg: &str) {
3043    let path = history::get_data_dir().join("app.log");
3044    // Ensure dir exists
3045    if let Some(parent) = path.parent() {
3046        let _ = std::fs::create_dir_all(parent);
3047    }
3048    // Rotate if > 5MB — rename to app.log.1 (drop prior backup); if rename fails, truncate in place
3049    const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024;
3050    if let Ok(meta) = std::fs::metadata(&path) {
3051        if meta.len() > MAX_LOG_SIZE {
3052            let backup = path.with_extension("log.1");
3053            let _ = std::fs::remove_file(&backup);
3054            if std::fs::rename(&path, &backup).is_err() {
3055                let _ = std::fs::write(&path, "");
3056            }
3057        }
3058    }
3059    let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
3060    let line = format!("[{}] {}\n", timestamp, msg);
3061    let _ = std::fs::OpenOptions::new()
3062        .create(true)
3063        .append(true)
3064        .open(&path)
3065        .and_then(|mut f| {
3066            use std::io::Write;
3067            f.write_all(line.as_bytes())
3068        });
3069}
3070
3071/// Extra diagnostics when `logVerbosity` is `verbose`. `f` runs only if verbose (no `format!` cost otherwise).
3072pub fn app_log_verbose<F: FnOnce() -> String>(f: F) {
3073    if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) < 2 {
3074        return;
3075    }
3076    write_app_log_line(&f());
3077}
3078
3079/// Like [`app_log_verbose`] when the message is already a `String`.
3080pub fn write_app_log_verbose(msg: String) {
3081    if LOG_VERBOSITY_LEVEL.load(Ordering::Relaxed) < 2 {
3082        return;
3083    }
3084    write_app_log_line(&msg);
3085}
3086
3087/// Public log-append entry point callable from any module. Writes a
3088/// timestamped line to `<data-dir>/app.log`, rotating to `.log.1` at 5MB.
3089/// The `append_log` Tauri command delegates to this.
3090pub fn write_app_log(msg: String) {
3091    if should_suppress_app_log_line(&msg) {
3092        return;
3093    }
3094    write_app_log_line(&msg);
3095}
3096
3097#[tauri::command]
3098async fn read_log() -> Result<String, String> {
3099    blocking_res(|| {
3100        let path = history::get_data_dir().join("app.log");
3101        match std::fs::read_to_string(&path) {
3102            Ok(s) => Ok(s),
3103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
3104            Err(e) => Err(e.to_string()),
3105        }
3106    })
3107    .await
3108}
3109
3110#[tauri::command]
3111async fn clear_log() -> Result<(), String> {
3112    blocking_res(|| {
3113        let path = history::ensure_data_dir().join("app.log");
3114        std::fs::write(&path, "").map_err(|e| e.to_string())
3115    })
3116    .await
3117}
3118
3119/// Generic project file reader: returns {type: "xml"|"tree", content: ...}
3120/// XML formats get raw XML string, binary formats get structured JSON tree.
3121#[tauri::command]
3122async fn read_project_file(file_path: String) -> Result<serde_json::Value, String> {
3123    blocking_res(move || {
3124        let path = std::path::Path::new(&file_path);
3125        let ext = path
3126            .extension()
3127            .and_then(|e| e.to_str())
3128            .unwrap_or("")
3129            .to_lowercase();
3130        match ext.as_str() {
3131            "als" => {
3132                let xml = read_als_xml_impl(&file_path)?;
3133                Ok(serde_json::json!({"type": "xml", "format": "Ableton Live Set", "content": xml, "path": file_path}))
3134            }
3135            "song" => {
3136                let xml = read_zip_xml(&file_path, &["song.xml", "Song/song.xml", "metainfo.xml"])?;
3137                Ok(serde_json::json!({"type": "xml", "format": "Studio One Song", "content": xml, "path": file_path}))
3138            }
3139            "dawproject" => {
3140                let xml = read_zip_xml(&file_path, &["project.xml", "metadata.xml"])?;
3141                Ok(serde_json::json!({"type": "xml", "format": "DAWproject", "content": xml, "path": file_path}))
3142            }
3143            "rpp" | "rpp-bak" => {
3144                let content = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
3145                Ok(serde_json::json!({"type": "text", "format": "REAPER Project", "content": content, "path": file_path}))
3146            }
3147            _ => read_binary_project(file_path, &ext),
3148        }
3149    })
3150    .await
3151}
3152
3153/// Read XML from a ZIP archive (Studio One, DAWproject).
3154fn read_zip_xml(file_path: &str, names: &[&str]) -> Result<String, String> {
3155    use std::io::Read;
3156    let file = std::fs::File::open(file_path).map_err(|e| e.to_string())?;
3157    let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Not a valid ZIP: {e}"))?;
3158    for name in names {
3159        if let Ok(mut entry) = archive.by_name(name) {
3160            let mut s = String::new();
3161            entry.read_to_string(&mut s).map_err(|e| e.to_string())?;
3162            if !s.is_empty() {
3163                return Ok(s);
3164            }
3165        }
3166    }
3167    // List all files and return the first XML found
3168    let mut xml_name = None;
3169    for i in 0..archive.len() {
3170        if let Ok(entry) = archive.by_index(i) {
3171            if entry.name().ends_with(".xml") {
3172                xml_name = Some(entry.name().to_string());
3173                break;
3174            }
3175        }
3176    }
3177    if let Some(name) = xml_name {
3178        let mut entry = archive.by_name(&name).map_err(|e| e.to_string())?;
3179        let mut s = String::new();
3180        entry.read_to_string(&mut s).map_err(|e| e.to_string())?;
3181        return Ok(s);
3182    }
3183    Err("No XML found in archive".into())
3184}
3185
3186/// Read any binary DAW project file and return a structured JSON tree.
3187fn read_binary_project(file_path: String, ext: &str) -> Result<serde_json::Value, String> {
3188    let format_name = match ext {
3189        "bwproject" => "Bitwig Studio Project (.bwproject)",
3190        "flp" => "FL Studio Project (.flp)",
3191        "logicx" => "Logic Pro Project (.logicx)",
3192        "cpr" => "Cubase Project (.cpr)",
3193        "npr" => "Nuendo Project (.npr)",
3194        "ptx" => "Pro Tools Session (.ptx)",
3195        "ptf" => "Pro Tools Session (.ptf)",
3196        "reason" => "Reason Song (.reason)",
3197        "band" => "GarageBand Project (.band)",
3198        _ => "Binary DAW Project",
3199    };
3200    let mut result = read_binary_project_inner(&file_path)?;
3201    if let Some(obj) = result.as_object_mut() {
3202        obj.insert(
3203            "_format".into(),
3204            serde_json::Value::String(format_name.into()),
3205        );
3206    }
3207    Ok(result)
3208}
3209
3210fn read_binary_project_inner(file_path: &str) -> Result<serde_json::Value, String> {
3211    let path = std::path::Path::new(file_path);
3212    // Handle macOS package directories (e.g. .bwproject, .logicx)
3213    let data = if path.is_dir() {
3214        // Read all files in the package and concatenate
3215        let mut buf = Vec::new();
3216        fn collect_dir(dir: &std::path::Path, buf: &mut Vec<u8>, limit: usize) {
3217            if buf.len() > limit {
3218                return;
3219            }
3220            if let Ok(entries) = std::fs::read_dir(dir) {
3221                for entry in entries.flatten() {
3222                    let p = entry.path();
3223                    if p.is_file() {
3224                        if let Ok(data) = std::fs::read(&p) {
3225                            buf.extend_from_slice(&data);
3226                            if buf.len() > limit {
3227                                return;
3228                            }
3229                        }
3230                    } else if p.is_dir() {
3231                        collect_dir(&p, buf, limit);
3232                    }
3233                }
3234            }
3235        }
3236        collect_dir(path, &mut buf, 50_000_000); // cap at 50MB
3237        buf
3238    } else {
3239        std::fs::read(file_path).map_err(|e| format!("Failed to read: {e}"))?
3240    };
3241
3242    let mut metadata = serde_json::Map::new();
3243    let mut strings_found = Vec::new();
3244    let mut plugins = Vec::new();
3245
3246    // Parse header metadata (key-value pairs encoded as printable strings)
3247    let mut i = 0;
3248    while i + 4 < data.len() && i < 10000 {
3249        if data[i] >= 0x20 && data[i] <= 0x7E {
3250            let start = i;
3251            while i < data.len() && data[i] >= 0x20 && data[i] <= 0x7E {
3252                i += 1;
3253            }
3254            if i - start >= 3 {
3255                let s = String::from_utf8_lossy(&data[start..i]).to_string();
3256                strings_found.push(s);
3257            }
3258        } else {
3259            i += 1;
3260        }
3261    }
3262
3263    let meta_keys = [
3264        "album",
3265        "application_version_name",
3266        "artist",
3267        "branch",
3268        "comment",
3269        "copyright",
3270        "creator",
3271        "genre",
3272        "orig_artist",
3273        "producer",
3274        "title",
3275        "version",
3276    ];
3277    let mut idx = 0;
3278    while idx + 1 < strings_found.len() {
3279        let key = &strings_found[idx];
3280        if meta_keys.contains(&key.as_str()) && idx + 1 < strings_found.len() {
3281            let val = &strings_found[idx + 1];
3282            if !val.is_empty() && !meta_keys.contains(&val.as_str()) {
3283                metadata.insert(key.clone(), serde_json::Value::String(val.clone()));
3284                idx += 2;
3285                continue;
3286            }
3287        }
3288        idx += 1;
3289    }
3290
3291    // Extract plugin paths from full binary
3292    let mut current = Vec::new();
3293    for &byte in &data {
3294        if (0x20..=0x7E).contains(&byte) {
3295            current.push(byte);
3296        } else {
3297            if current.len() >= 6 {
3298                let s = String::from_utf8_lossy(&current).to_string();
3299                if s.ends_with(".dll")
3300                    || s.ends_with(".vst3")
3301                    || s.ends_with(".component")
3302                    || s.ends_with(".clap")
3303                    || s.ends_with(".aaxplugin")
3304                {
3305                    plugins.push(s);
3306                }
3307            }
3308            current.clear();
3309        }
3310    }
3311    plugins.sort();
3312    plugins.dedup();
3313
3314    let mut tree = serde_json::Map::new();
3315    tree.insert(
3316        "_path".into(),
3317        serde_json::Value::String(file_path.to_string()),
3318    );
3319    tree.insert(
3320        "_size".into(),
3321        serde_json::Value::String(format_size(data.len() as u64)),
3322    );
3323    tree.insert("metadata".into(), serde_json::Value::Object(metadata));
3324    tree.insert(
3325        "plugins".into(),
3326        serde_json::Value::Array(plugins.into_iter().map(serde_json::Value::String).collect()),
3327    );
3328
3329    let mut fxb_count = 0usize;
3330    for window in data.windows(4) {
3331        if window == b".fxb" {
3332            fxb_count += 1;
3333        }
3334    }
3335    if fxb_count > 0 {
3336        tree.insert(
3337            "pluginStateCount".into(),
3338            serde_json::Value::Number(fxb_count.into()),
3339        );
3340    }
3341
3342    Ok(serde_json::Value::Object(tree))
3343}
3344
3345#[tauri::command]
3346async fn read_bwproject(file_path: String) -> Result<serde_json::Value, String> {
3347    blocking_res(move || read_binary_project(file_path, "bwproject")).await
3348}
3349
3350// ── MIDI metadata ──
3351
3352#[tauri::command]
3353async fn get_midi_info(file_path: String) -> Result<Option<midi::MidiInfo>, String> {
3354    blocking_res(move || Ok(midi::parse_midi(std::path::Path::new(&file_path)))).await
3355}
3356
3357// ── Export / Import commands ──
3358
3359fn plugins_to_export(plugins: &[PluginInfo]) -> Vec<ExportPlugin> {
3360    plugins
3361        .iter()
3362        .map(|p| ExportPlugin {
3363            name: p.name.clone(),
3364            plugin_type: p.plugin_type.clone(),
3365            version: p.version.clone(),
3366            manufacturer: p.manufacturer.clone(),
3367            manufacturer_url: p.manufacturer_url.clone(),
3368            path: p.path.clone(),
3369            size: p.size.clone(),
3370            size_bytes: p.size_bytes,
3371            modified: p.modified.clone(),
3372            architectures: p.architectures.clone(),
3373        })
3374        .collect()
3375}
3376
3377#[tauri::command]
3378async fn export_plugins_json(plugins: Vec<PluginInfo>, file_path: String) -> Result<(), String> {
3379    blocking_res(move || {
3380        #[cfg(not(test))]
3381        append_log(format!(
3382            "EXPORT — {} plugins → {}",
3383            plugins.len(),
3384            file_path
3385        ));
3386        let payload = ExportPayload {
3387            version: env!("CARGO_PKG_VERSION").into(),
3388            exported_at: chrono::Utc::now().to_rfc3339(),
3389            plugins: plugins_to_export(&plugins),
3390        };
3391        let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3392        std::fs::write(&file_path, json).map_err(|e| e.to_string())
3393    })
3394    .await
3395}
3396
3397#[tauri::command]
3398async fn export_plugins_csv(plugins: Vec<PluginInfo>, file_path: String) -> Result<(), String> {
3399    blocking_res(move || {
3400        #[cfg(not(test))]
3401        append_log(format!(
3402            "EXPORT — {} plugins → {}",
3403            plugins.len(),
3404            file_path
3405        ));
3406        let sep = detect_separator(&file_path);
3407        let mut out = format!(
3408            "Name{s}Type{s}Version{s}Manufacturer{s}Manufacturer URL{s}Path{s}Size{s}Modified\n",
3409            s = sep
3410        );
3411        for p in &plugins {
3412            out.push_str(&format!(
3413                "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3414                dsv_escape(&p.name, sep),
3415                dsv_escape(&p.plugin_type, sep),
3416                dsv_escape(&p.version, sep),
3417                dsv_escape(&p.manufacturer, sep),
3418                dsv_escape(p.manufacturer_url.as_deref().unwrap_or(""), sep),
3419                dsv_escape(&p.path, sep),
3420                dsv_escape(&p.size, sep),
3421                dsv_escape(&p.modified, sep),
3422            ));
3423        }
3424        std::fs::write(&file_path, out).map_err(|e| e.to_string())
3425    })
3426    .await
3427}
3428
3429#[cfg(test)]
3430fn csv_escape(s: &str) -> String {
3431    if s.contains(',') || s.contains('"') || s.contains('\n') {
3432        format!("\"{}\"", s.replace('"', "\"\""))
3433    } else {
3434        s.to_string()
3435    }
3436}
3437
3438fn dsv_escape(s: &str, sep: char) -> String {
3439    if s.contains(sep) || s.contains('"') || s.contains('\n') {
3440        format!("\"{}\"", s.replace('"', "\"\""))
3441    } else {
3442        s.to_string()
3443    }
3444}
3445
3446fn detect_separator(file_path: &str) -> char {
3447    if file_path.ends_with(".tsv") {
3448        '\t'
3449    } else {
3450        ','
3451    }
3452}
3453
3454// ── Audio export ──
3455
3456#[tauri::command]
3457async fn export_audio_json(
3458    samples: Vec<history::AudioSample>,
3459    file_path: String,
3460) -> Result<(), String> {
3461    blocking_res(move || {
3462        let payload = serde_json::json!({
3463            "version": env!("CARGO_PKG_VERSION"),
3464            "exported_at": chrono::Utc::now().to_rfc3339(),
3465            "samples": samples,
3466        });
3467        let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3468        std::fs::write(&file_path, json).map_err(|e| e.to_string())
3469    })
3470    .await
3471}
3472
3473#[tauri::command]
3474async fn export_audio_dsv(
3475    samples: Vec<history::AudioSample>,
3476    file_path: String,
3477) -> Result<(), String> {
3478    blocking_res(move || {
3479        let sep = detect_separator(&file_path);
3480        let mut out = format!(
3481            "Name{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
3482            s = sep
3483        );
3484        for s in &samples {
3485            out.push_str(&format!(
3486                "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3487                dsv_escape(&s.name, sep),
3488                dsv_escape(&s.format, sep),
3489                dsv_escape(&s.path, sep),
3490                dsv_escape(&s.directory, sep),
3491                dsv_escape(&s.size_formatted, sep),
3492                dsv_escape(&s.modified, sep),
3493            ));
3494        }
3495        std::fs::write(&file_path, out).map_err(|e| e.to_string())
3496    })
3497    .await
3498}
3499
3500// ── DAW export ──
3501
3502#[tauri::command]
3503async fn export_daw_json(
3504    projects: Vec<history::DawProject>,
3505    file_path: String,
3506) -> Result<(), String> {
3507    blocking_res(move || {
3508        let payload = serde_json::json!({
3509            "version": env!("CARGO_PKG_VERSION"),
3510            "exported_at": chrono::Utc::now().to_rfc3339(),
3511            "projects": projects,
3512        });
3513        let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
3514        std::fs::write(&file_path, json).map_err(|e| e.to_string())
3515    })
3516    .await
3517}
3518
3519#[tauri::command]
3520async fn export_daw_dsv(
3521    projects: Vec<history::DawProject>,
3522    file_path: String,
3523) -> Result<(), String> {
3524    blocking_res(move || {
3525        let sep = detect_separator(&file_path);
3526        let mut out = format!(
3527            "Name{s}DAW{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
3528            s = sep
3529        );
3530        for p in &projects {
3531            out.push_str(&format!(
3532                "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
3533                dsv_escape(&p.name, sep),
3534                dsv_escape(&p.daw, sep),
3535                dsv_escape(&p.format, sep),
3536                dsv_escape(&p.path, sep),
3537                dsv_escape(&p.directory, sep),
3538                dsv_escape(&p.size_formatted, sep),
3539                dsv_escape(&p.modified, sep),
3540            ));
3541        }
3542        std::fs::write(&file_path, out).map_err(|e| e.to_string())
3543    })
3544    .await
3545}
3546
3547#[tauri::command]
3548async fn import_plugins_json(file_path: String) -> Result<Vec<PluginInfo>, String> {
3549    blocking_res(move || {
3550        #[cfg(not(test))]
3551        append_log(format!("IMPORT — plugins ← {}", file_path));
3552        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
3553        let payload: ExportPayload = serde_json::from_str(&data).map_err(|e| e.to_string())?;
3554        Ok(payload
3555            .plugins
3556            .into_iter()
3557            .map(|p| PluginInfo {
3558                name: p.name,
3559                path: p.path,
3560                plugin_type: p.plugin_type,
3561                version: p.version,
3562                manufacturer: p.manufacturer,
3563                manufacturer_url: p.manufacturer_url,
3564                size: p.size,
3565                size_bytes: p.size_bytes,
3566                modified: p.modified,
3567                architectures: p.architectures,
3568            })
3569            .collect())
3570    })
3571    .await
3572}
3573
3574// ── Process stats ──
3575
3576use std::sync::Mutex;
3577use std::time::{Duration, Instant};
3578
3579/// Disk + DB file sizes + `table_counts` are expensive; the UI polls ~1 Hz.
3580struct SlowStatsSnapshot {
3581    at: Instant,
3582    dir_key: String,
3583    disk_total: u64,
3584    disk_free: u64,
3585    db_bytes: u64,
3586    prefs_bytes: u64,
3587    table_counts: serde_json::Value,
3588}
3589
3590static SLOW_STATS_CACHE: Mutex<Option<SlowStatsSnapshot>> = Mutex::new(None);
3591const SLOW_STATS_TTL: Duration = Duration::from_secs(4);
3592
3593fn compute_slow_stats(data_dir: &std::path::Path) -> (u64, u64, u64, u64, serde_json::Value) {
3594    let file_size = |name: &str| -> u64 {
3595        std::fs::metadata(data_dir.join(name))
3596            .map(|m| m.len())
3597            .unwrap_or(0)
3598    };
3599    let (disk_total, disk_free) = {
3600        use sysinfo::Disks;
3601        let disks = Disks::new_with_refreshed_list();
3602        let data_str = data_dir.to_string_lossy().to_string();
3603        let data_path = std::path::Path::new(&data_str);
3604        disks
3605            .iter()
3606            .filter(|d| data_path.starts_with(d.mount_point()))
3607            .max_by_key(|d| d.mount_point().as_os_str().len())
3608            .map(|d| (d.total_space(), d.available_space()))
3609            .unwrap_or((0, 0))
3610    };
3611    let db_bytes = file_size("audio_haxor.db")
3612        + file_size("audio_haxor.db-wal")
3613        + file_size("audio_haxor.db-shm");
3614    let prefs_bytes = file_size("preferences.toml");
3615    let table_counts = db::global().table_counts().unwrap_or_default();
3616    (disk_total, disk_free, db_bytes, prefs_bytes, table_counts)
3617}
3618
3619fn cached_slow_stats(data_dir: &std::path::Path) -> (u64, u64, u64, u64, serde_json::Value) {
3620    let now = Instant::now();
3621    let dir_key = data_dir.to_string_lossy().to_string();
3622    if let Ok(guard) = SLOW_STATS_CACHE.lock() {
3623        if let Some(s) = guard.as_ref() {
3624            if s.dir_key == dir_key && now.saturating_duration_since(s.at) < SLOW_STATS_TTL {
3625                return (
3626                    s.disk_total,
3627                    s.disk_free,
3628                    s.db_bytes,
3629                    s.prefs_bytes,
3630                    s.table_counts.clone(),
3631                );
3632            }
3633        }
3634    }
3635    let (disk_total, disk_free, db_bytes, prefs_bytes, table_counts) = compute_slow_stats(data_dir);
3636    if let Ok(mut guard) = SLOW_STATS_CACHE.lock() {
3637        *guard = Some(SlowStatsSnapshot {
3638            at: now,
3639            dir_key,
3640            disk_total,
3641            disk_free,
3642            db_bytes,
3643            prefs_bytes,
3644            table_counts: table_counts.clone(),
3645        });
3646    }
3647    (disk_total, disk_free, db_bytes, prefs_bytes, table_counts)
3648}
3649
3650fn dotted_extensions_to_upper_tags(exts: &[&str]) -> Vec<String> {
3651    exts.iter()
3652        .map(|e| e.strip_prefix('.').unwrap_or(e).to_ascii_uppercase())
3653        .collect()
3654}
3655
3656fn build_process_stats(app: AppHandle) -> serde_json::Value {
3657    let rss = get_rss_bytes();
3658    let virt = get_virtual_bytes();
3659    let threads = get_thread_count();
3660    let cpu_pct = get_cpu_percent();
3661    let rayon_threads = rayon::current_num_threads();
3662    let uptime_secs = get_uptime_secs();
3663    let pid = std::process::id();
3664    let open_fds = get_open_fd_count();
3665    let ncpus = num_cpus::get();
3666
3667    // Scanner states
3668    let scan_state = app.state::<ScanState>();
3669    let update_state = app.state::<UpdateState>();
3670    let audio_state = app.state::<AudioScanState>();
3671    let daw_state = app.state::<DawScanState>();
3672    let preset_state = app.state::<PresetScanState>();
3673    let pdf_state = app.state::<PdfScanState>();
3674    let midi_state = app.state::<MidiScanState>();
3675
3676    // Preferences for scanner config
3677    let prefs = history::load_preferences();
3678    let thread_mult = prefs
3679        .get("threadMultiplier")
3680        .and_then(|v| {
3681            v.as_str()
3682                .and_then(|s| s.parse::<usize>().ok())
3683                .or(v.as_u64().map(|n| n as usize))
3684        })
3685        .unwrap_or(4);
3686    let batch_size = prefs
3687        .get("batchSize")
3688        .and_then(|v| {
3689            v.as_str()
3690                .and_then(|s| s.parse::<usize>().ok())
3691                .or(v.as_u64().map(|n| n as usize))
3692        })
3693        .unwrap_or(100);
3694    let chan_buf = prefs
3695        .get("channelBuffer")
3696        .and_then(|v| {
3697            v.as_str()
3698                .and_then(|s| s.parse::<usize>().ok())
3699                .or(v.as_u64().map(|n| n as usize))
3700        })
3701        .unwrap_or(512);
3702    let flush_interval = prefs
3703        .get("flushInterval")
3704        .and_then(|v| {
3705            v.as_str()
3706                .and_then(|s| s.parse::<usize>().ok())
3707                .or(v.as_u64().map(|n| n as usize))
3708        })
3709        .unwrap_or(100);
3710    let page_size = prefs
3711        .get("pageSize")
3712        .and_then(|v| {
3713            v.as_str()
3714                .and_then(|s| s.parse::<usize>().ok())
3715                .or(v.as_u64().map(|n| n as usize))
3716        })
3717        .unwrap_or(200);
3718
3719    let sqlite_read_pool_pref = prefs
3720        .get("sqliteReadPoolExtra")
3721        .map(|v| {
3722            v.as_str()
3723                .map(std::string::ToString::to_string)
3724                .unwrap_or_else(|| v.to_string())
3725        })
3726        .unwrap_or_else(|| "auto".to_string());
3727
3728    let (sqlite_read_pool_extra, sqlite_read_pool_total) = if db::global_initialized() {
3729        let db = db::global();
3730        (
3731            db.sqlite_read_pool_extra_slots(),
3732            db.sqlite_read_pool_total_handles(),
3733        )
3734    } else {
3735        (0, 0)
3736    };
3737
3738    let data_dir = history::get_data_dir();
3739    let (disk_total, disk_free, db_bytes, prefs_bytes, db_table_counts) =
3740        cached_slow_stats(&data_dir);
3741
3742    // OS info
3743    let os_name = std::env::consts::OS;
3744    let os_arch = std::env::consts::ARCH;
3745    let hostname = gethostname();
3746
3747    // FD limits
3748    #[cfg(unix)]
3749    let (fd_soft, fd_hard) = {
3750        let mut rlim = libc::rlimit {
3751            rlim_cur: 0,
3752            rlim_max: 0,
3753        };
3754        if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) } == 0 {
3755            (rlim.rlim_cur, rlim.rlim_max)
3756        } else {
3757            (0, 0)
3758        }
3759    };
3760    #[cfg(not(unix))]
3761    let (fd_soft, fd_hard) = (0u64, 0u64);
3762
3763    // Supported formats: audio → `audio_extensions`; DAW/preset → scanners; xref → `xref::XREF_SUPPORTED_EXTENSIONS`
3764    let plugin_formats = ["VST2", "VST3", "AU", "CLAP", "AAX"];
3765    let daw_formats = dotted_extensions_to_upper_tags(crate::daw_scanner::DAW_EXTENSIONS);
3766    let preset_formats = dotted_extensions_to_upper_tags(crate::preset_scanner::PRESET_EXTENSIONS);
3767    let xref_formats = dotted_extensions_to_upper_tags(crate::xref::XREF_SUPPORTED_EXTENSIONS);
3768    let midi_formats = ["MID", "MIDI"];
3769    let pdf_formats = ["PDF"];
3770
3771    serde_json::json!({
3772        "pid": pid,
3773        "rssBytes": rss,
3774        "virtualBytes": virt,
3775        "threads": threads,
3776        "cpuPercent": cpu_pct,
3777        "rayonThreads": rayon_threads,
3778        "numCpus": ncpus,
3779        "uptimeSecs": uptime_secs,
3780        "openFds": open_fds,
3781        "fdSoftLimit": fd_soft,
3782        "fdHardLimit": fd_hard,
3783        "os": os_name,
3784        "arch": os_arch,
3785        "hostname": hostname,
3786        "appVersion": env!("CARGO_PKG_VERSION"),
3787        "tauriVersion": tauri::VERSION,
3788        "rustcTarget": option_env!("AUDIO_HAXOR_TARGET_TRIPLE").unwrap_or("unknown"),
3789        "buildProfile": if cfg!(debug_assertions) { "debug" } else { "release" },
3790        "diskTotalBytes": disk_total,
3791        "diskFreeBytes": disk_free,
3792        "app": {
3793            "audioFormats": crate::audio_extensions::audio_format_tags_for_app_info(),
3794            "pluginFormats": plugin_formats,
3795            "dawFormats": daw_formats,
3796            "presetFormats": preset_formats,
3797            "xrefFormats": xref_formats,
3798            "midiFormats": midi_formats,
3799            "pdfFormats": pdf_formats,
3800            "analysisEngines": ["BPM (autocorrelation)", "Key (Goertzel chromagram)", "LUFS (RMS dBFS)", "Fingerprint (spectral)"],
3801            "visualizers": ["FFT spectrum", "Waveform", "Spectrogram", "Stereo Lissajous", "Level meters", "Frequency bands"],
3802            "exportFormats": ["JSON", "TOML", "CSV", "TSV", "PDF"],
3803            "storageBackend": "SQLite (WAL mode)",
3804            "uiFramework": "Tauri v2 + vanilla JS",
3805            "searchEngine": "fzf-style fuzzy matching",
3806        },
3807        "scanner": {
3808            "pluginScanning": scan_state.scanning.load(Ordering::Relaxed),
3809            "pluginStopped": scan_state.stop_scan.load(Ordering::Relaxed),
3810            "updateChecking": update_state.checking.load(Ordering::Relaxed),
3811            "updateStopped": update_state.stop_updates.load(Ordering::Relaxed),
3812            "audioScanning": audio_state.scanning.load(Ordering::Relaxed),
3813            "audioStopped": audio_state.stop_scan.load(Ordering::Relaxed),
3814            "dawScanning": daw_state.scanning.load(Ordering::Relaxed),
3815            "dawStopped": daw_state.stop_scan.load(Ordering::Relaxed),
3816            "presetScanning": preset_state.scanning.load(Ordering::Relaxed),
3817            "presetStopped": preset_state.stop_scan.load(Ordering::Relaxed),
3818            "pdfScanning": pdf_state.scanning.load(Ordering::Relaxed),
3819            "pdfStopped": pdf_state.stop_scan.load(Ordering::Relaxed),
3820            "midiScanning": midi_state.scanning.load(Ordering::Relaxed),
3821            "midiStopped": midi_state.stop_scan.load(Ordering::Relaxed),
3822        },
3823        "config": {
3824            "threadMultiplier": thread_mult,
3825            "globalPoolSize": ncpus * thread_mult,
3826            "perScannerThreads": ncpus * 2,
3827            "batchSize": batch_size,
3828            "channelBuffer": chan_buf,
3829            "walkerChannelBuffer": 2048,
3830            "walkerBatchSize": 100,
3831            "flushInterval": flush_interval,
3832            "pageSize": page_size,
3833            "stackSize": "8 MB",
3834            "depthLimit": 50,
3835            "pluginChannelMin": 64,
3836            "pluginChannelMax": 8192,
3837        },
3838        "database": {
3839            "sizeBytes": db_bytes,
3840            "tables": db_table_counts,
3841            "sqliteReadPoolExtra": sqlite_read_pool_extra,
3842            "sqliteReadPoolTotal": sqlite_read_pool_total,
3843            "sqliteReadPoolExtraPref": sqlite_read_pool_pref,
3844        },
3845        "dataFiles": {
3846            "preferencesBytes": prefs_bytes,
3847        },
3848        "dataDir": data_dir.to_string_lossy(),
3849    })
3850}
3851
3852#[tauri::command]
3853async fn get_process_stats(app: AppHandle) -> serde_json::Value {
3854    let app = app.clone();
3855    blocking(move || build_process_stats(app))
3856        .await
3857        .unwrap_or_else(|_| serde_json::json!({}))
3858}
3859
3860#[tauri::command]
3861async fn list_data_files() -> Vec<serde_json::Value> {
3862    blocking(move || {
3863        let data_dir = history::get_data_dir();
3864        let mut files = Vec::new();
3865        if let Ok(entries) = std::fs::read_dir(&data_dir) {
3866            for entry in entries.flatten() {
3867                let path = entry.path();
3868                if !path.is_file() {
3869                    continue;
3870                }
3871                let name = path
3872                    .file_name()
3873                    .map(|n| n.to_string_lossy().to_string())
3874                    .unwrap_or_default();
3875                let meta = std::fs::metadata(&path).ok();
3876                let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
3877                let modified = meta
3878                    .and_then(|m| m.modified().ok())
3879                    .map(|t| {
3880                        let dt: chrono::DateTime<chrono::Utc> = t.into();
3881                        dt.format("%Y-%m-%d %H:%M:%S").to_string()
3882                    })
3883                    .unwrap_or_default();
3884                files.push(serde_json::json!({
3885                    "name": name,
3886                    "path": path.to_string_lossy(),
3887                    "size": size,
3888                    "sizeFormatted": format_size(size),
3889                    "modified": modified,
3890                }));
3891            }
3892        }
3893        files.sort_by(|a, b| {
3894            a["name"]
3895                .as_str()
3896                .unwrap_or("")
3897                .cmp(b["name"].as_str().unwrap_or(""))
3898        });
3899        files
3900    })
3901    .await
3902    .unwrap_or_default()
3903}
3904
3905#[tauri::command]
3906async fn delete_data_file(name: String) -> Result<(), String> {
3907    blocking_res(move || {
3908        let path = history::get_data_dir().join(&name);
3909        if !path.exists() {
3910            return Ok(());
3911        }
3912        std::fs::remove_file(&path).map_err(|e| e.to_string())
3913    })
3914    .await
3915}
3916
3917static APP_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
3918
3919fn get_uptime_secs() -> u64 {
3920    APP_START.get_or_init(Instant::now).elapsed().as_secs()
3921}
3922
3923// ── Cross-platform process stats via sysinfo ──
3924
3925fn get_process_info() -> (u64, u64, f32) {
3926    use std::sync::atomic::{AtomicBool, Ordering};
3927    use std::sync::{Mutex, OnceLock};
3928    use sysinfo::{Pid, System};
3929    static SYS: OnceLock<Mutex<System>> = OnceLock::new();
3930    static PRIMED: AtomicBool = AtomicBool::new(false);
3931    let sys_mutex = SYS.get_or_init(|| Mutex::new(System::new()));
3932    let mut sys = sys_mutex.lock().unwrap();
3933    let pid = Pid::from_u32(std::process::id());
3934    // First call: prime with an initial refresh so cpu_usage() has a baseline
3935    if !PRIMED.swap(true, Ordering::Relaxed) {
3936        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3937        std::thread::sleep(std::time::Duration::from_millis(200));
3938    }
3939    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3940    if let Some(proc_info) = sys.process(pid) {
3941        (
3942            proc_info.memory(),
3943            proc_info.virtual_memory(),
3944            proc_info.cpu_usage(),
3945        )
3946    } else {
3947        (0, 0, 0.0)
3948    }
3949}
3950
3951fn get_rss_bytes() -> u64 {
3952    get_process_info().0
3953}
3954
3955fn get_virtual_bytes() -> u64 {
3956    get_process_info().1
3957}
3958
3959fn get_thread_count() -> u32 {
3960    // Linux: `Process::tasks()` (per-thread PIDs). Never use `cpu_usage()` here (`f32`) — that
3961    // mismatch only surfaces on Linux targets. Other OSes use fallbacks below.
3962    #[cfg(target_os = "linux")]
3963    {
3964        use std::sync::{Mutex, OnceLock};
3965        use sysinfo::{Pid, System};
3966        static SYS: OnceLock<Mutex<System>> = OnceLock::new();
3967        let mut sys = SYS
3968            .get_or_init(|| Mutex::new(System::new()))
3969            .lock()
3970            .unwrap();
3971        let pid = Pid::from_u32(std::process::id());
3972        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
3973        if let Some(p) = sys.process(pid) {
3974            if let Some(tasks) = p.tasks() {
3975                return (tasks.len() as u32).saturating_add(1);
3976            }
3977        }
3978    }
3979    #[cfg(target_os = "macos")]
3980    {
3981        let pid = std::process::id();
3982        if let Ok(out) = std::process::Command::new("ps")
3983            .args(["-M", "-p", &pid.to_string()])
3984            .output()
3985        {
3986            return String::from_utf8_lossy(&out.stdout)
3987                .lines()
3988                .count()
3989                .saturating_sub(1) as u32;
3990        }
3991    }
3992    0
3993}
3994
3995/// Per-PID user+system CPU time in microseconds (same units as `libc::rusage` tv_sec/tv_usec combined).
3996/// Used so AudioEngine CPU% matches the header formula: `(Δuser + Δsys) / Δwall * 100`.
3997fn foreign_process_cpu_times_us(pid: u32) -> Option<(i64, i64)> {
3998    if pid == 0 {
3999        return None;
4000    }
4001    #[cfg(target_os = "linux")]
4002    {
4003        let path = format!("/proc/{pid}/stat");
4004        let line = std::fs::read_to_string(&path).ok()?;
4005        let idx = line.rfind(')')?;
4006        let rest = line[idx + 1..].trim_start();
4007        let fields: Vec<&str> = rest.split_whitespace().collect();
4008        if fields.len() < 13 {
4009            return None;
4010        }
4011        let utime_ticks: i64 = fields[11].parse().ok()?;
4012        let stime_ticks: i64 = fields[12].parse().ok()?;
4013        let clk = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as i64;
4014        if clk <= 0 {
4015            return None;
4016        }
4017        let user_us = utime_ticks * 1_000_000 / clk;
4018        let sys_us = stime_ticks * 1_000_000 / clk;
4019        return Some((user_us, sys_us));
4020    }
4021    #[cfg(target_os = "macos")]
4022    {
4023        // Must match `<sys/proc_info.h>` `struct proc_taskinfo` size for `proc_pidinfo`.
4024        #[repr(C)]
4025        struct ProcTaskInfo {
4026            virtual_size: u64,
4027            resident_size: u64,
4028            total_user: u64,
4029            total_system: u64,
4030            threads_user: u64,
4031            threads_system: u64,
4032            policy: u64,
4033            ssugg: u64,
4034            flags: u64,
4035        }
4036        #[link(name = "proc", kind = "dylib")]
4037        unsafe extern "C" {
4038            fn proc_pidinfo(
4039                pid: i32,
4040                flavor: i32,
4041                arg: u64,
4042                buffer: *mut ProcTaskInfo,
4043                buffersize: i32,
4044            ) -> i32;
4045        }
4046        const PROC_PIDTASKINFO: i32 = 4;
4047        let mut info: ProcTaskInfo = unsafe { std::mem::zeroed() };
4048        let n = unsafe {
4049            proc_pidinfo(
4050                pid as i32,
4051                PROC_PIDTASKINFO,
4052                0,
4053                &mut info,
4054                std::mem::size_of::<ProcTaskInfo>() as i32,
4055            )
4056        };
4057        if n <= 0 {
4058            return None;
4059        }
4060        // Nanoseconds → microseconds (same scale as `getrusage` path in `get_cpu_percent`).
4061        let user_us = (info.total_user / 1000) as i64;
4062        let sys_us = (info.total_system / 1000) as i64;
4063        return Some((user_us, sys_us));
4064    }
4065    #[cfg(target_os = "windows")]
4066    {
4067        use std::ffi::c_void;
4068        use std::mem::MaybeUninit;
4069        #[link(name = "kernel32")]
4070        unsafe extern "system" {
4071            fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
4072            fn CloseHandle(h: *mut c_void) -> i32;
4073            fn GetProcessTimes(
4074                h: *mut c_void,
4075                creation: *mut [u32; 2],
4076                exit: *mut [u32; 2],
4077                kernel: *mut [u32; 2],
4078                user: *mut [u32; 2],
4079            ) -> i32;
4080        }
4081        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
4082        unsafe {
4083            let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
4084            if h.is_null() {
4085                return None;
4086            }
4087            let mut creation = MaybeUninit::<[u32; 2]>::uninit();
4088            let mut exit = MaybeUninit::<[u32; 2]>::uninit();
4089            let mut kernel = MaybeUninit::<[u32; 2]>::uninit();
4090            let mut user = MaybeUninit::<[u32; 2]>::uninit();
4091            let ok = GetProcessTimes(
4092                h,
4093                creation.as_mut_ptr(),
4094                exit.as_mut_ptr(),
4095                kernel.as_mut_ptr(),
4096                user.as_mut_ptr(),
4097            );
4098            let _ = CloseHandle(h);
4099            if ok == 0 {
4100                return None;
4101            }
4102            let ft_to_us = |ft: [u32; 2]| -> i64 {
4103                let ticks = (ft[1] as i64) << 32 | ft[0] as i64;
4104                ticks / 10
4105            };
4106            let user_us = ft_to_us(user.assume_init());
4107            let sys_us = ft_to_us(kernel.assume_init());
4108            return Some((user_us, sys_us));
4109        }
4110    }
4111    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
4112    {
4113        let _ = pid;
4114        None
4115    }
4116}
4117
4118/// Same formula as [`get_cpu_percent`] (`getrusage` user+sys deltas vs wall clock), for another PID.
4119fn get_cpu_percent_like_rusage_for_pid(pid: u32) -> f64 {
4120    use std::sync::{Mutex, OnceLock};
4121    use std::time::Instant;
4122
4123    struct CpuSample {
4124        wall: Instant,
4125        user_us: i64,
4126        sys_us: i64,
4127    }
4128
4129    static PREV: OnceLock<Mutex<Option<(u32, CpuSample)>>> = OnceLock::new();
4130    let prev_lock = PREV.get_or_init(|| Mutex::new(None));
4131
4132    if pid == 0 {
4133        let mut prev = prev_lock.lock().unwrap();
4134        *prev = None;
4135        return 0.0;
4136    }
4137
4138    let Some((user_us, sys_us)) = foreign_process_cpu_times_us(pid) else {
4139        let mut prev = prev_lock.lock().unwrap();
4140        *prev = None;
4141        return 0.0;
4142    };
4143
4144    let now = Instant::now();
4145    let mut prev_guard = prev_lock.lock().unwrap();
4146    let pct = match *prev_guard {
4147        Some((prev_pid, ref p)) if prev_pid == pid => {
4148            let wall_us = now.duration_since(p.wall).as_micros() as f64;
4149            if wall_us > 0.0 {
4150                let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4151                (cpu_us / wall_us) * 100.0
4152            } else {
4153                0.0
4154            }
4155        }
4156        _ => 0.0,
4157    };
4158    *prev_guard = Some((
4159        pid,
4160        CpuSample {
4161            wall: now,
4162            user_us,
4163            sys_us,
4164        },
4165    ));
4166    pct
4167}
4168
4169fn get_cpu_percent() -> f64 {
4170    use std::sync::{Mutex, OnceLock};
4171    use std::time::Instant;
4172
4173    struct CpuSample {
4174        wall: Instant,
4175        user_us: i64,
4176        sys_us: i64,
4177    }
4178
4179    static PREV: OnceLock<Mutex<Option<CpuSample>>> = OnceLock::new();
4180    let prev_lock = PREV.get_or_init(|| Mutex::new(None));
4181
4182    #[cfg(any(target_os = "macos", target_os = "linux"))]
4183    {
4184        let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
4185        let ret = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) };
4186        if ret != 0 {
4187            return get_process_info().2 as f64;
4188        }
4189
4190        let now = Instant::now();
4191        let user_us = usage.ru_utime.tv_sec as i64 * 1_000_000 + usage.ru_utime.tv_usec as i64;
4192        let sys_us = usage.ru_stime.tv_sec as i64 * 1_000_000 + usage.ru_stime.tv_usec as i64;
4193
4194        let mut prev = prev_lock.lock().unwrap();
4195        let pct = if let Some(ref p) = *prev {
4196            let wall_us = now.duration_since(p.wall).as_micros() as f64;
4197            if wall_us > 0.0 {
4198                let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4199                (cpu_us / wall_us) * 100.0
4200            } else {
4201                0.0
4202            }
4203        } else {
4204            0.0
4205        };
4206        *prev = Some(CpuSample {
4207            wall: now,
4208            user_us,
4209            sys_us,
4210        });
4211        pct
4212    }
4213    #[cfg(target_os = "windows")]
4214    {
4215        use std::ffi::c_void;
4216        use std::mem::MaybeUninit;
4217        #[link(name = "kernel32")]
4218        unsafe extern "system" {
4219            fn GetCurrentProcess() -> *mut c_void;
4220            fn GetProcessTimes(
4221                h: *mut c_void,
4222                creation: *mut [u32; 2],
4223                exit: *mut [u32; 2],
4224                kernel: *mut [u32; 2],
4225                user: *mut [u32; 2],
4226            ) -> i32;
4227        }
4228        let mut creation = MaybeUninit::<[u32; 2]>::uninit();
4229        let mut exit = MaybeUninit::<[u32; 2]>::uninit();
4230        let mut kernel = MaybeUninit::<[u32; 2]>::uninit();
4231        let mut user = MaybeUninit::<[u32; 2]>::uninit();
4232        let ok = unsafe {
4233            GetProcessTimes(
4234                GetCurrentProcess(),
4235                creation.as_mut_ptr(),
4236                exit.as_mut_ptr(),
4237                kernel.as_mut_ptr(),
4238                user.as_mut_ptr(),
4239            )
4240        };
4241        if ok == 0 {
4242            return get_process_info().2 as f64;
4243        }
4244        let ft_to_us = |ft: [u32; 2]| -> i64 {
4245            let ticks = (ft[1] as i64) << 32 | ft[0] as i64; // 100ns ticks
4246            ticks / 10 // to microseconds
4247        };
4248        let now = Instant::now();
4249        let user_us = ft_to_us(unsafe { user.assume_init() });
4250        let sys_us = ft_to_us(unsafe { kernel.assume_init() });
4251
4252        let mut prev = prev_lock.lock().unwrap();
4253        let pct = if let Some(ref p) = *prev {
4254            let wall_us = now.duration_since(p.wall).as_micros() as f64;
4255            if wall_us > 0.0 {
4256                let cpu_us = ((user_us - p.user_us) + (sys_us - p.sys_us)) as f64;
4257                (cpu_us / wall_us) * 100.0
4258            } else {
4259                0.0
4260            }
4261        } else {
4262            0.0
4263        };
4264        *prev = Some(CpuSample {
4265            wall: now,
4266            user_us,
4267            sys_us,
4268        });
4269        pct
4270    }
4271    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
4272    {
4273        get_process_info().2 as f64
4274    }
4275}
4276
4277fn get_open_fd_count() -> u32 {
4278    #[cfg(any(target_os = "macos", target_os = "linux"))]
4279    {
4280        // /dev/fd on macOS, /proc/self/fd on Linux
4281        for dir in &["/dev/fd", "/proc/self/fd"] {
4282            if let Ok(entries) = std::fs::read_dir(dir) {
4283                return entries.count() as u32;
4284            }
4285        }
4286    }
4287    #[cfg(target_os = "windows")]
4288    {
4289        use std::ffi::c_void;
4290        #[link(name = "kernel32")]
4291        unsafe extern "system" {
4292            fn GetCurrentProcess() -> *mut c_void;
4293            fn GetProcessHandleCount(h_process: *mut c_void, p_count: *mut u32) -> i32;
4294        }
4295        unsafe {
4296            let mut count = 0u32;
4297            if GetProcessHandleCount(GetCurrentProcess(), &mut count) != 0 {
4298                return count;
4299            }
4300        }
4301    }
4302    0
4303}
4304
4305// ── AudioEngine subprocess stats (same probes as [`build_process_stats`] / header: sysinfo RSS/VIRT/CPU, FD count, threads) ──
4306
4307#[cfg(not(target_os = "linux"))]
4308fn thread_count_for_pid_non_sysinfo(pid: u32) -> u32 {
4309    if pid == 0 {
4310        return 0;
4311    }
4312    #[cfg(target_os = "macos")]
4313    {
4314        if let Ok(out) = std::process::Command::new("ps")
4315            .args(["-M", "-p", &pid.to_string()])
4316            .output()
4317        {
4318            return String::from_utf8_lossy(&out.stdout)
4319                .lines()
4320                .count()
4321                .saturating_sub(1) as u32;
4322        }
4323    }
4324    #[cfg(target_os = "windows")]
4325    {
4326        use std::os::windows::process::CommandExt;
4327        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
4328        if let Ok(out) = std::process::Command::new("powershell")
4329            .args([
4330                "-NoProfile",
4331                "-NonInteractive",
4332                "-Command",
4333                &format!("(Get-Process -Id {pid} -ErrorAction SilentlyContinue).Threads.Count"),
4334            ])
4335            .creation_flags(CREATE_NO_WINDOW)
4336            .output()
4337        {
4338            if out.status.success() {
4339                if let Ok(n) = String::from_utf8_lossy(&out.stdout).trim().parse::<u32>() {
4340                    return n;
4341                }
4342            }
4343        }
4344    }
4345    0
4346}
4347
4348fn open_fd_count_for_pid(pid: u32) -> u32 {
4349    if pid == 0 {
4350        return 0;
4351    }
4352    #[cfg(target_os = "linux")]
4353    {
4354        let path = format!("/proc/{pid}/fd");
4355        if let Ok(entries) = std::fs::read_dir(&path) {
4356            return entries.count() as u32;
4357        }
4358    }
4359    #[cfg(target_os = "macos")]
4360    {
4361        if let Ok(out) = std::process::Command::new("lsof")
4362            .args(["-w", "-p", &pid.to_string()])
4363            .output()
4364        {
4365            if !out.status.success() {
4366                return 0;
4367            }
4368            let stdout = String::from_utf8_lossy(&out.stdout);
4369            let lines: Vec<&str> = stdout
4370                .lines()
4371                .filter(|l| !l.trim().is_empty())
4372                .collect();
4373            if lines.is_empty() {
4374                return 0;
4375            }
4376            return lines.len().saturating_sub(1) as u32;
4377        }
4378    }
4379    #[cfg(target_os = "windows")]
4380    {
4381        use std::ffi::c_void;
4382        #[link(name = "kernel32")]
4383        unsafe extern "system" {
4384            fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
4385            fn CloseHandle(h: *mut c_void) -> i32;
4386            fn GetProcessHandleCount(hProcess: *mut c_void, lpdwHandleCount: *mut u32) -> i32;
4387        }
4388        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
4389        unsafe {
4390            let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
4391            if h.is_null() {
4392                return 0;
4393            }
4394            let mut count = 0u32;
4395            let ok = GetProcessHandleCount(h, &mut count);
4396            let _ = CloseHandle(h);
4397            if ok != 0 {
4398                return count;
4399            }
4400        }
4401    }
4402    0
4403}
4404
4405fn collect_audio_engine_process_metrics(pid: u32) -> (u64, u64, u64, u32) {
4406    use std::sync::atomic::{AtomicBool, Ordering};
4407    use std::sync::{Mutex, OnceLock};
4408    use sysinfo::{Pid, ProcessesToUpdate, System};
4409
4410    static SYS: OnceLock<Mutex<System>> = OnceLock::new();
4411    static PRIMED: AtomicBool = AtomicBool::new(false);
4412    static LAST_PID: Mutex<Option<u32>> = Mutex::new(None);
4413
4414    if pid == 0 {
4415        return (0, 0, 0, 0);
4416    }
4417
4418    let sys_mutex = SYS.get_or_init(|| Mutex::new(System::new()));
4419    let mut sys = sys_mutex.lock().unwrap();
4420    let spid = Pid::from_u32(pid);
4421
4422    {
4423        let mut last = LAST_PID.lock().unwrap();
4424        if *last != Some(pid) {
4425            PRIMED.store(false, Ordering::Relaxed);
4426            *last = Some(pid);
4427        }
4428    }
4429
4430    if !PRIMED.swap(true, Ordering::Relaxed) {
4431        sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true);
4432        std::thread::sleep(std::time::Duration::from_millis(200));
4433    }
4434    sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true);
4435
4436    let Some(proc_info) = sys.process(spid) else {
4437        return (0, 0, 0, 0);
4438    };
4439
4440    let rss = proc_info.memory();
4441    let virt = proc_info.virtual_memory();
4442    let run_time = proc_info.run_time();
4443
4444    #[cfg(target_os = "linux")]
4445    {
4446        let threads = proc_info
4447            .tasks()
4448            .map(|t| (t.len() as u32).saturating_add(1))
4449            .unwrap_or(0);
4450        (rss, virt, run_time, threads)
4451    }
4452
4453    #[cfg(not(target_os = "linux"))]
4454    {
4455        drop(sys);
4456        let threads = thread_count_for_pid_non_sysinfo(pid);
4457        (rss, virt, run_time, threads)
4458    }
4459}
4460
4461fn build_audio_engine_process_stats() -> serde_json::Value {
4462    let pid = audio_engine::audio_engine_child_pid();
4463    let ncpus = num_cpus::get() as u32;
4464    if pid == 0 {
4465        return serde_json::json!({
4466            "running": false,
4467            "pid": 0u32,
4468            "numCpus": ncpus,
4469            "rssBytes": 0u64,
4470            "virtualBytes": 0u64,
4471            "cpuPercent": 0.0,
4472            "threads": 0u32,
4473            "openFds": 0u32,
4474            "uptimeSecs": 0u64,
4475        });
4476    }
4477    let (rss, virt, run_time, threads) = collect_audio_engine_process_metrics(pid);
4478    let fds = open_fd_count_for_pid(pid);
4479    let cpu_pct = get_cpu_percent_like_rusage_for_pid(pid);
4480    serde_json::json!({
4481        "running": true,
4482        "pid": pid,
4483        "numCpus": ncpus,
4484        "rssBytes": rss,
4485        "virtualBytes": virt,
4486        "cpuPercent": cpu_pct,
4487        "threads": threads,
4488        "openFds": fds,
4489        "uptimeSecs": run_time,
4490    })
4491}
4492
4493#[tauri::command]
4494async fn get_audio_engine_process_stats() -> serde_json::Value {
4495    blocking(move || build_audio_engine_process_stats())
4496        .await
4497        .unwrap_or_else(|_| serde_json::json!({}))
4498}
4499
4500fn gethostname() -> String {
4501    sysinfo::System::host_name().unwrap_or_default()
4502}
4503
4504// ── PDF export/import ──
4505
4506#[tauri::command]
4507async fn export_pdfs_json(pdfs: Vec<PdfFile>, file_path: String) -> Result<(), String> {
4508    blocking_res(move || {
4509        let json = serde_json::to_string_pretty(&pdfs).map_err(|e| e.to_string())?;
4510        std::fs::write(&file_path, json).map_err(|e| e.to_string())
4511    })
4512    .await
4513}
4514
4515#[tauri::command]
4516async fn export_pdfs_dsv(pdfs: Vec<PdfFile>, file_path: String) -> Result<(), String> {
4517    blocking_res(move || {
4518        let sep = detect_separator(&file_path);
4519        let mut out = format!("Name{s}Path{s}Directory{s}Size{s}Modified\n", s = sep);
4520        for p in &pdfs {
4521            out.push_str(&format!(
4522                "{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
4523                dsv_escape(&p.name, sep),
4524                dsv_escape(&p.path, sep),
4525                dsv_escape(&p.directory, sep),
4526                dsv_escape(&p.size_formatted, sep),
4527                dsv_escape(&p.modified, sep),
4528            ));
4529        }
4530        std::fs::write(&file_path, out).map_err(|e| e.to_string())
4531    })
4532    .await
4533}
4534
4535// ── Preset export/import ──
4536
4537#[tauri::command]
4538async fn export_presets_json(presets: Vec<PresetFile>, file_path: String) -> Result<(), String> {
4539    blocking_res(move || {
4540        let json = serde_json::to_string_pretty(&presets).map_err(|e| e.to_string())?;
4541        std::fs::write(&file_path, json).map_err(|e| e.to_string())
4542    })
4543    .await
4544}
4545
4546#[tauri::command]
4547async fn export_presets_dsv(presets: Vec<PresetFile>, file_path: String) -> Result<(), String> {
4548    blocking_res(move || {
4549        let sep = detect_separator(&file_path);
4550        let mut out = format!(
4551            "Name{s}Format{s}Path{s}Directory{s}Size{s}Modified\n",
4552            s = sep
4553        );
4554        for p in &presets {
4555            out.push_str(&format!(
4556                "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}\n",
4557                dsv_escape(&p.name, sep),
4558                dsv_escape(&p.format, sep),
4559                dsv_escape(&p.path, sep),
4560                dsv_escape(&p.directory, sep),
4561                dsv_escape(&p.size_formatted, sep),
4562                dsv_escape(&p.modified, sep),
4563            ));
4564        }
4565        std::fs::write(&file_path, out).map_err(|e| e.to_string())
4566    })
4567    .await
4568}
4569
4570// ── TOML export (generic — works for all types via serde) ──
4571
4572#[tauri::command]
4573async fn export_toml(data: serde_json::Value, file_path: String) -> Result<(), String> {
4574    blocking_res(move || {
4575        let toml_str = toml::to_string_pretty(&data).map_err(|e| e.to_string())?;
4576        std::fs::write(&file_path, toml_str).map_err(|e| e.to_string())
4577    })
4578    .await
4579}
4580
4581#[tauri::command]
4582async fn import_toml(file_path: String) -> Result<serde_json::Value, String> {
4583    blocking_res(move || {
4584        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
4585        let val: toml::Value = toml::from_str(&data).map_err(|e| e.to_string())?;
4586        let json_str = serde_json::to_string(&val).map_err(|e| e.to_string())?;
4587        serde_json::from_str(&json_str).map_err(|e| e.to_string())
4588    })
4589    .await
4590}
4591
4592// ── PDF export (printpdf 0.9 — Op stream + PdfPage) ──
4593
4594fn export_pdf_impl(
4595    title: String,
4596    headers: Vec<String>,
4597    rows: Vec<Vec<String>>,
4598    file_path: String,
4599) -> Result<(), String> {
4600    #[cfg(not(test))]
4601    append_log(format!(
4602        "EXPORT PDF — \"{}\" | {} rows | {} columns → {}",
4603        title,
4604        rows.len(),
4605        headers.len(),
4606        file_path
4607    ));
4608    use printpdf::*;
4609
4610    let icon_bytes: &[u8] = include_bytes!("../icons/32x32.png");
4611
4612    let page_w_mm = Mm(297.0); // A4 landscape
4613    let page_h_mm = Mm(210.0);
4614    let page_w = page_w_mm.0;
4615    let page_h = page_h_mm.0;
4616    let margin_x = 10.0_f32;
4617    let margin_bottom = 12.0_f32;
4618    let row_height = 4.5_f32;
4619    let header_row_h = 7.0_f32;
4620    let col_count = headers.len();
4621    let usable_w = page_w - margin_x * 2.0;
4622
4623    const MAX_PDF_ROWS: usize = 10_000;
4624    let total_row_count = rows.len();
4625    let capped = total_row_count > MAX_PDF_ROWS;
4626    let export_rows = if capped {
4627        &rows[..MAX_PDF_ROWS]
4628    } else {
4629        &rows[..]
4630    };
4631
4632    let col_widths: Vec<f32> = if col_count > 0 {
4633        let sample_step = (export_rows.len() / 500).max(1);
4634        let mut col_maxes: Vec<usize> = headers.iter().map(|h| h.len()).collect();
4635        let mut col_sums: Vec<usize> = vec![0; col_count];
4636        let mut sample_count = 0_usize;
4637        for (idx, row) in export_rows.iter().enumerate() {
4638            if idx % sample_step != 0 {
4639                continue;
4640            }
4641            sample_count += 1;
4642            for (i, cell) in row.iter().enumerate() {
4643                if i < col_count {
4644                    let l = cell.len().min(120);
4645                    if l > col_maxes[i] {
4646                        col_maxes[i] = l;
4647                    }
4648                    col_sums[i] += l;
4649                }
4650            }
4651        }
4652        let effective: Vec<usize> = col_sums
4653            .iter()
4654            .enumerate()
4655            .map(|(i, &s)| {
4656                let avg = if sample_count > 0 {
4657                    s / sample_count
4658                } else {
4659                    6
4660                };
4661                let p90_approx = (avg as f32 * 1.3) as usize;
4662                p90_approx
4663                    .max(headers[i].len() * 2)
4664                    .max(6)
4665                    .min(col_maxes[i])
4666            })
4667            .collect();
4668        let total_len: usize = effective.iter().sum::<usize>().max(1);
4669        let min_col = 12.0_f32;
4670        let mut widths: Vec<f32> = effective
4671            .iter()
4672            .map(|&l| (l as f32 / total_len as f32 * usable_w).max(min_col))
4673            .collect();
4674        let sum: f32 = widths.iter().sum();
4675        let scale = usable_w / sum;
4676        for w in &mut widths {
4677            *w *= scale;
4678        }
4679        widths
4680    } else {
4681        vec![usable_w]
4682    };
4683    let version = env!("CARGO_PKG_VERSION");
4684
4685    fn rgb_color(r: f32, g: f32, b: f32) -> Color {
4686        Color::Rgb(Rgb::new(r, g, b, None))
4687    }
4688
4689    fn push_fill_rect(
4690        ops: &mut Vec<Op>,
4691        x: f32,
4692        y: f32,
4693        w: f32,
4694        h: f32,
4695        r: f32,
4696        g: f32,
4697        b: f32,
4698    ) {
4699        ops.push(Op::SetFillColor {
4700            col: rgb_color(r, g, b),
4701        });
4702        let lp = |x: f32, y: f32| LinePoint {
4703            p: Point::new(Mm(x), Mm(y)),
4704            bezier: false,
4705        };
4706        ops.push(Op::DrawPolygon {
4707            polygon: Polygon {
4708                rings: vec![PolygonRing {
4709                    points: vec![
4710                        lp(x, y),
4711                        lp(x + w, y),
4712                        lp(x + w, y + h),
4713                        lp(x, y + h),
4714                    ],
4715                }],
4716                mode: PaintMode::Fill,
4717                winding_order: WindingOrder::NonZero,
4718            },
4719        });
4720    }
4721
4722    fn push_stroke_line(
4723        ops: &mut Vec<Op>,
4724        x1: f32,
4725        y1: f32,
4726        x2: f32,
4727        y2: f32,
4728        r: f32,
4729        g: f32,
4730        b: f32,
4731        thick_pt: f32,
4732    ) {
4733        ops.push(Op::SetOutlineColor {
4734            col: rgb_color(r, g, b),
4735        });
4736        ops.push(Op::SetOutlineThickness {
4737            pt: Pt(thick_pt),
4738        });
4739        ops.push(Op::DrawLine {
4740            line: Line {
4741                points: vec![
4742                    LinePoint {
4743                        p: Point::new(Mm(x1), Mm(y1)),
4744                        bezier: false,
4745                    },
4746                    LinePoint {
4747                        p: Point::new(Mm(x2), Mm(y2)),
4748                        bezier: false,
4749                    },
4750                ],
4751                is_closed: false,
4752            },
4753        });
4754    }
4755
4756    fn push_text(
4757        ops: &mut Vec<Op>,
4758        text: String,
4759        font: BuiltinFont,
4760        size_pt: f32,
4761        x_mm: f32,
4762        y_mm: f32,
4763        color: Color,
4764    ) {
4765        ops.push(Op::StartTextSection);
4766        ops.push(Op::SetTextCursor {
4767            pos: Point::new(Mm(x_mm), Mm(y_mm)),
4768        });
4769        ops.push(Op::SetFont {
4770            font: PdfFontHandle::Builtin(font),
4771            size: Pt(size_pt),
4772        });
4773        ops.push(Op::SetLineHeight { lh: Pt(size_pt) });
4774        ops.push(Op::SetFillColor { col: color });
4775        ops.push(Op::ShowText {
4776            items: vec![TextItem::Text(text)],
4777        });
4778        ops.push(Op::EndTextSection);
4779    }
4780
4781    let mut doc = PdfDocument::new(title.as_str());
4782    let mut decode_warnings = Vec::new();
4783    let icon_info: Option<(XObjectId, f32, f32)> =
4784        match RawImage::decode_from_bytes(icon_bytes, &mut decode_warnings) {
4785            Ok(img) => {
4786                let iw = img.width as f32;
4787                let ih = img.height as f32;
4788                let id = doc.add_image(&img);
4789                Some((id, iw, ih))
4790            }
4791            Err(_) => None,
4792        };
4793
4794    let mut pages: Vec<PdfPage> = Vec::new();
4795    let mut ops: Vec<Op> = Vec::new();
4796
4797    let render_header = |ops: &mut Vec<Op>, y: &mut f32, page: usize| {
4798        push_fill_rect(ops, 0.0, page_h - 22.0, page_w, 22.0, 0.02, 0.02, 0.04);
4799
4800        let mut icon_offset = 0.0_f32;
4801        if let Some((ref id, iw, ih)) = icon_info {
4802            let icon_size = 6.0_f32;
4803            ops.push(Op::UseXobject {
4804                id: id.clone(),
4805                transform: XObjectTransform {
4806                    translate_x: Some(Mm(margin_x).into_pt()),
4807                    translate_y: Some(Mm(page_h - 19.0).into_pt()),
4808                    scale_x: Some(icon_size / iw),
4809                    scale_y: Some(icon_size / ih),
4810                    dpi: Some(300.0),
4811                    ..Default::default()
4812                },
4813            });
4814            icon_offset = icon_size + 2.0;
4815        }
4816
4817        push_text(
4818            ops,
4819            "AUDIO_HAXOR".to_string(),
4820            BuiltinFont::HelveticaBold,
4821            14.0,
4822            margin_x + icon_offset,
4823            page_h - 14.0,
4824            rgb_color(0.02, 0.85, 0.91),
4825        );
4826        push_text(
4827            ops,
4828            format!("v{}", version),
4829            BuiltinFont::Helvetica,
4830            8.0,
4831            margin_x + icon_offset + 58.0,
4832            page_h - 14.0,
4833            rgb_color(1.0, 1.0, 1.0),
4834        );
4835        push_text(
4836            ops,
4837            title.clone(),
4838            BuiltinFont::HelveticaBold,
4839            12.0,
4840            page_w - margin_x - 80.0,
4841            page_h - 14.0,
4842            rgb_color(1.0, 1.0, 1.0),
4843        );
4844
4845        push_stroke_line(
4846            ops,
4847            0.0,
4848            page_h - 22.0,
4849            page_w,
4850            page_h - 22.0,
4851            0.02,
4852            0.85,
4853            0.91,
4854            1.5,
4855        );
4856
4857        *y = page_h - 28.0;
4858
4859        if page == 1 {
4860            let sub = if capped {
4861                format!(
4862                    "Showing {} of {} items (capped)  |  Exported {}  |  by MenkeTechnologies",
4863                    export_rows.len(),
4864                    total_row_count,
4865                    chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
4866                )
4867            } else {
4868                format!(
4869                    "{} items  |  Exported {}  |  by MenkeTechnologies",
4870                    total_row_count,
4871                    chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
4872                )
4873            };
4874            push_text(
4875                ops,
4876                sub,
4877                BuiltinFont::HelveticaOblique,
4878                8.0,
4879                margin_x,
4880                *y,
4881                rgb_color(0.4, 0.4, 0.45),
4882            );
4883            *y -= 6.0;
4884        }
4885    };
4886
4887    let render_col_headers = |ops: &mut Vec<Op>, y: &mut f32| {
4888        push_fill_rect(
4889            ops,
4890            margin_x - 1.0,
4891            *y - 1.5,
4892            usable_w + 2.0,
4893            header_row_h,
4894            0.04,
4895            0.04,
4896            0.08,
4897        );
4898        push_stroke_line(
4899            ops,
4900            margin_x - 1.0,
4901            *y - 1.5,
4902            margin_x + usable_w + 1.0,
4903            *y - 1.5,
4904            0.02,
4905            0.85,
4906            0.91,
4907            0.5,
4908        );
4909
4910        let mut x = margin_x + 1.0;
4911        for (i, h) in headers.iter().enumerate() {
4912            push_text(
4913                ops,
4914                h.clone(),
4915                BuiltinFont::HelveticaBold,
4916                9.0,
4917                x,
4918                *y,
4919                rgb_color(0.02, 0.85, 0.91),
4920            );
4921            x += col_widths[i];
4922        }
4923        *y -= header_row_h;
4924    };
4925
4926    let render_footer = |ops: &mut Vec<Op>, page: usize| {
4927        let footer_y = 8.0;
4928        push_fill_rect(
4929            ops,
4930            0.0,
4931            0.0,
4932            page_w,
4933            footer_y + 4.0,
4934            0.02,
4935            0.02,
4936            0.04,
4937        );
4938        push_stroke_line(
4939            ops,
4940            margin_x,
4941            footer_y + 3.0,
4942            page_w - margin_x,
4943            footer_y + 3.0,
4944            0.02,
4945            0.85,
4946            0.91,
4947            0.5,
4948        );
4949        push_text(
4950            ops,
4951            format!("AUDIO_HAXOR v{} — {}", version, title),
4952            BuiltinFont::Helvetica,
4953            7.0,
4954            margin_x,
4955            footer_y,
4956            rgb_color(0.4, 0.4, 0.45),
4957        );
4958        push_text(
4959            ops,
4960            format!("Page {}", page),
4961            BuiltinFont::Helvetica,
4962            7.0,
4963            page_w - margin_x - 25.0,
4964            footer_y,
4965            rgb_color(0.4, 0.4, 0.45),
4966        );
4967    };
4968
4969    let mut y = 0.0_f32;
4970    let mut page_num = 1_usize;
4971    let mut row_idx = 0_usize;
4972
4973    render_header(&mut ops, &mut y, page_num);
4974    render_col_headers(&mut ops, &mut y);
4975    y -= 1.0;
4976
4977    for row in export_rows {
4978        if y < margin_bottom + 5.0 {
4979            render_footer(&mut ops, page_num);
4980            pages.push(PdfPage::new(page_w_mm, page_h_mm, std::mem::take(&mut ops)));
4981            page_num += 1;
4982            y = 0.0;
4983            render_header(&mut ops, &mut y, page_num);
4984            render_col_headers(&mut ops, &mut y);
4985            y -= 1.0;
4986            row_idx = 0;
4987        }
4988
4989        if row_idx == 0 {
4990            push_fill_rect(&mut ops, 0.0, 0.0, page_w, y + 2.0, 0.03, 0.03, 0.06);
4991        }
4992        if row_idx % 2 == 1 {
4993            push_fill_rect(
4994                &mut ops,
4995                margin_x - 1.0,
4996                y - 1.2,
4997                usable_w + 2.0,
4998                row_height,
4999                0.06,
5000                0.06,
5001                0.10,
5002            );
5003        } else {
5004            push_fill_rect(
5005                &mut ops,
5006                margin_x - 1.0,
5007                y - 1.2,
5008                usable_w + 2.0,
5009                row_height,
5010                0.04,
5011                0.04,
5012                0.08,
5013            );
5014        }
5015
5016        let mut x = margin_x + 0.5;
5017        for (i, cell) in row.iter().enumerate() {
5018            let w = if i < col_widths.len() {
5019                col_widths[i]
5020            } else {
5021                30.0
5022            };
5023            let max_chars = (w / 1.2) as usize;
5024            let cell_text = if cell.len() > max_chars && max_chars > 3 {
5025                format!("{}...", &cell[..max_chars - 3])
5026            } else {
5027                cell.clone()
5028            };
5029            push_text(
5030                &mut ops,
5031                cell_text,
5032                BuiltinFont::Helvetica,
5033                7.0,
5034                x,
5035                y,
5036                rgb_color(0.85, 0.85, 0.90),
5037            );
5038            x += w;
5039        }
5040
5041        y -= row_height;
5042        row_idx += 1;
5043    }
5044
5045    if capped {
5046        y -= 3.0;
5047        push_text(
5048            &mut ops,
5049            format!(
5050                "Export capped at {} of {} rows. Use CSV/JSON for the full dataset.",
5051                MAX_PDF_ROWS, total_row_count
5052            ),
5053            BuiltinFont::HelveticaBold,
5054            8.0,
5055            margin_x,
5056            y,
5057            rgb_color(0.83, 0.0, 0.77),
5058        );
5059    }
5060
5061    render_footer(&mut ops, page_num);
5062    pages.push(PdfPage::new(page_w_mm, page_h_mm, ops));
5063
5064    doc.with_pages(pages);
5065    let bytes = doc.save(&PdfSaveOptions::default(), &mut decode_warnings);
5066    std::fs::write(&file_path, bytes).map_err(|e| e.to_string())
5067}
5068
5069#[tauri::command]
5070async fn export_pdf(
5071    title: String,
5072    headers: Vec<String>,
5073    rows: Vec<Vec<String>>,
5074    file_path: String,
5075) -> Result<(), String> {
5076    blocking_res(move || export_pdf_impl(title, headers, rows, file_path)).await
5077}
5078
5079// ── File browser ──
5080
5081#[tauri::command]
5082async fn fs_list_dir(dir_path: String) -> Result<serde_json::Value, String> {
5083    blocking_res(move || {
5084        let path = std::path::Path::new(&dir_path);
5085        if !path.exists() {
5086            return Err(format!("Directory not found: {}", dir_path));
5087        }
5088        if !path.is_dir() {
5089            return Err(format!("Not a directory: {}", dir_path));
5090        }
5091
5092        let mut entries = Vec::new();
5093        let read = std::fs::read_dir(path).map_err(|e| e.to_string())?;
5094        for entry in read.flatten() {
5095            let ep = entry.path();
5096            let name = entry.file_name().to_string_lossy().to_string();
5097            if name.starts_with('.') {
5098                continue;
5099            }
5100            let is_dir = ep.is_dir();
5101            let meta = std::fs::metadata(&ep).ok();
5102            let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
5103            let modified = meta
5104                .and_then(|m| m.modified().ok())
5105                .map(|t| {
5106                    let dt: chrono::DateTime<chrono::Utc> = t.into();
5107                    dt.format("%Y-%m-%d %H:%M").to_string()
5108                })
5109                .unwrap_or_default();
5110            let ext = ep
5111                .extension()
5112                .map(|e| e.to_string_lossy().to_lowercase())
5113                .unwrap_or_default();
5114            entries.push(serde_json::json!({
5115                "name": name,
5116                "path": ep.to_string_lossy(),
5117                "isDir": is_dir,
5118                "size": size,
5119                "sizeFormatted": scanner::format_size(size),
5120                "modified": modified,
5121                "ext": ext,
5122            }));
5123        }
5124        entries.sort_by(|a, b| {
5125            let a_dir = a["isDir"].as_bool().unwrap_or(false);
5126            let b_dir = b["isDir"].as_bool().unwrap_or(false);
5127            b_dir.cmp(&a_dir).then_with(|| {
5128                a["name"]
5129                    .as_str()
5130                    .unwrap_or("")
5131                    .to_lowercase()
5132                    .cmp(&b["name"].as_str().unwrap_or("").to_lowercase())
5133            })
5134        });
5135        Ok(serde_json::json!({ "entries": entries, "path": dir_path }))
5136    })
5137    .await
5138}
5139
5140#[tauri::command]
5141async fn delete_file(file_path: String) -> Result<(), String> {
5142    blocking_res(move || {
5143        #[cfg(not(test))]
5144        append_log(format!("FILE DELETE — {}", file_path));
5145        let path = std::path::Path::new(&file_path);
5146        if !path.exists() {
5147            return Err("File not found".into());
5148        }
5149        if path.is_dir() {
5150            std::fs::remove_dir_all(path).map_err(|e| e.to_string())
5151        } else {
5152            std::fs::remove_file(path).map_err(|e| e.to_string())
5153        }
5154    })
5155    .await
5156}
5157
5158#[tauri::command]
5159async fn rename_file(old_path: String, new_path: String) -> Result<(), String> {
5160    blocking_res(move || {
5161        #[cfg(not(test))]
5162        append_log(format!("FILE RENAME — {} → {}", old_path, new_path));
5163        std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string())
5164    })
5165    .await
5166}
5167
5168#[tauri::command]
5169async fn write_text_file(file_path: String, contents: String) -> Result<(), String> {
5170    blocking_res(move || std::fs::write(&file_path, &contents).map_err(|e| e.to_string())).await
5171}
5172
5173#[tauri::command]
5174async fn read_text_file(file_path: String) -> Result<String, String> {
5175    blocking_res(move || std::fs::read_to_string(&file_path).map_err(|e| e.to_string())).await
5176}
5177
5178#[tauri::command]
5179async fn get_home_dir() -> Result<String, String> {
5180    blocking_res(|| {
5181        dirs::home_dir()
5182            .map(|p| p.to_string_lossy().to_string())
5183            .ok_or_else(|| "Could not determine home directory".into())
5184    })
5185    .await
5186}
5187
5188#[tauri::command]
5189async fn import_presets_json(file_path: String) -> Result<Vec<PresetFile>, String> {
5190    blocking_res(move || {
5191        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5192        if let Ok(arr) = serde_json::from_str::<Vec<PresetFile>>(&data) {
5193            return Ok(arr);
5194        }
5195        let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5196        if let Some(arr) = val.get("presets") {
5197            return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5198        }
5199        Err("Expected a JSON array of presets or { \"presets\": [...] }".into())
5200    })
5201    .await
5202}
5203
5204#[tauri::command]
5205async fn import_pdfs_json(file_path: String) -> Result<Vec<PdfFile>, String> {
5206    blocking_res(move || {
5207        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5208        if let Ok(arr) = serde_json::from_str::<Vec<PdfFile>>(&data) {
5209            return Ok(arr);
5210        }
5211        let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5212        if let Some(arr) = val.get("pdfs") {
5213            return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5214        }
5215        Err("Expected a JSON array of PDFs or { \"pdfs\": [...] }".into())
5216    })
5217    .await
5218}
5219
5220#[tauri::command]
5221async fn import_audio_json(file_path: String) -> Result<Vec<AudioSample>, String> {
5222    blocking_res(move || {
5223        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5224        if let Ok(arr) = serde_json::from_str::<Vec<AudioSample>>(&data) {
5225            return Ok(arr);
5226        }
5227        let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5228        if let Some(arr) = val.get("samples") {
5229            return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5230        }
5231        Err("Expected a JSON array of samples or { \"samples\": [...] }".into())
5232    })
5233    .await
5234}
5235
5236#[tauri::command]
5237async fn import_daw_json(file_path: String) -> Result<Vec<DawProject>, String> {
5238    blocking_res(move || {
5239        let data = std::fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
5240        if let Ok(arr) = serde_json::from_str::<Vec<DawProject>>(&data) {
5241            return Ok(arr);
5242        }
5243        let val: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
5244        if let Some(arr) = val.get("projects") {
5245            return serde_json::from_value(arr.clone()).map_err(|e| e.to_string());
5246        }
5247        Err("Expected a JSON array of projects or { \"projects\": [...] }".into())
5248    })
5249    .await
5250}
5251
5252// ── Tests ──
5253
5254#[cfg(test)]
5255mod tests {
5256    use super::*;
5257    use std::fs;
5258    use std::sync::Mutex;
5259
5260    /// Serialize tests that read/write `app.log` (parallel test runs would race otherwise).
5261    static APP_LOG_TEST_LOCK: Mutex<()> = Mutex::new(());
5262
5263    /// `history::set_test_data_dir_path` uses a process-global override; parallel tests would
5264    /// overwrite each other. Also `read_log` uses `spawn_blocking` — worker threads do not see
5265    /// thread-local test dirs, so only one test may set the global override at a time.
5266    static TEST_DATA_DIR_SERIAL: Mutex<()> = Mutex::new(());
5267
5268    fn app_log_lock() -> std::sync::MutexGuard<'static, ()> {
5269        APP_LOG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
5270    }
5271
5272    /// Run async `#[tauri::command]` handlers from sync `#[test]` (Tokio runtime).
5273    fn rt_block_on<F: std::future::Future>(f: F) -> F::Output {
5274        tokio::runtime::Runtime::new()
5275            .expect("tokio runtime for lib.rs tests")
5276            .block_on(f)
5277    }
5278
5279    /// Isolated temp data dir for tests that call `set_test_data_dir_path`; cleared on drop.
5280    /// Holds [`TEST_DATA_DIR_SERIAL`] so no other test can clobber the global override mid-case.
5281    struct TestDataDirGuard {
5282        path: std::path::PathBuf,
5283        _serial: std::sync::MutexGuard<'static, ()>,
5284    }
5285    impl Drop for TestDataDirGuard {
5286        fn drop(&mut self) {
5287            history::clear_test_data_dir_path();
5288            let _ = fs::remove_dir_all(&self.path);
5289        }
5290    }
5291
5292    fn test_data_dir() -> TestDataDirGuard {
5293        let serial = TEST_DATA_DIR_SERIAL
5294            .lock()
5295            .unwrap_or_else(|e| e.into_inner());
5296        let tmp = std::env::temp_dir().join(format!(
5297            "ah_data_test_{}_{}",
5298            std::process::id(),
5299            std::time::SystemTime::now()
5300                .duration_since(std::time::UNIX_EPOCH)
5301                .map(|d| d.as_nanos())
5302                .unwrap_or(0)
5303        ));
5304        let _ = fs::remove_dir_all(&tmp);
5305        fs::create_dir_all(&tmp).unwrap();
5306        history::set_test_data_dir_path(tmp.clone());
5307        TestDataDirGuard {
5308            path: tmp,
5309            _serial: serial,
5310        }
5311    }
5312
5313    fn make_plugin(name: &str, plugin_type: &str) -> PluginInfo {
5314        PluginInfo {
5315            name: name.into(),
5316            path: format!("/lib/{}.vst3", name),
5317            plugin_type: plugin_type.into(),
5318            version: "1.0.0".into(),
5319            manufacturer: "TestCo".into(),
5320            manufacturer_url: Some("https://testco.com".into()),
5321            size: "2.5 MB".into(),
5322            size_bytes: 2621440,
5323            modified: "2025-01-01".into(),
5324            architectures: vec!["ARM64".into(), "x86_64".into()],
5325        }
5326    }
5327
5328    #[test]
5329    fn test_csv_escape_plain() {
5330        assert_eq!(csv_escape("hello"), "hello");
5331    }
5332
5333    #[test]
5334    fn test_csv_escape_comma() {
5335        assert_eq!(csv_escape("a,b"), "\"a,b\"");
5336    }
5337
5338    #[test]
5339    fn test_csv_escape_quotes() {
5340        assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
5341    }
5342
5343    #[test]
5344    fn test_csv_escape_newline() {
5345        assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\"");
5346    }
5347
5348    #[test]
5349    fn test_csv_escape_empty() {
5350        assert_eq!(csv_escape(""), "");
5351    }
5352
5353    #[test]
5354    fn test_csv_escape_comma_and_quotes() {
5355        assert_eq!(csv_escape("a,\"b\""), "\"a,\"\"b\"\"\"");
5356    }
5357
5358    #[test]
5359    fn test_format_size_shared_tb() {
5360        assert_eq!(format_size(0), "0 B");
5361        assert_eq!(format_size(1024_u64.pow(4)), "1.0 TB");
5362        // Above max unit index: clamp to TB (e.g. 1 PiB → 1024.0 TB)
5363        assert_eq!(format_size(1024_u64.pow(5)), "1024.0 TB");
5364    }
5365
5366    #[test]
5367    fn test_format_size_fractional_kb() {
5368        assert_eq!(format_size(2048 + 512), "2.5 KB");
5369    }
5370
5371    #[test]
5372    fn test_format_size_single_byte_and_sub_kb() {
5373        assert_eq!(format_size(1), "1.0 B");
5374        assert_eq!(format_size(1023), "1023.0 B");
5375    }
5376
5377    #[test]
5378    fn test_format_size_mb_boundary() {
5379        assert_eq!(format_size(1024 * 1024), "1.0 MB");
5380        assert_eq!(format_size(1024 * 1024 + 512 * 1024), "1.5 MB");
5381    }
5382
5383    #[test]
5384    fn test_dsv_escape_tab_in_field() {
5385        assert_eq!(dsv_escape("a\tb", ','), "a\tb");
5386        assert_eq!(dsv_escape("a\tb", '\t'), "\"a\tb\"");
5387    }
5388
5389    #[test]
5390    fn test_dsv_escape_semicolon_field_when_sep_is_semicolon() {
5391        assert_eq!(dsv_escape("a;b", ';'), "\"a;b\"");
5392        assert_eq!(dsv_escape("plain", ';'), "plain");
5393    }
5394
5395    #[test]
5396    fn test_dsv_escape_quote_only() {
5397        assert_eq!(dsv_escape("\"", ','), "\"\"\"\"");
5398    }
5399
5400    #[test]
5401    fn test_dsv_escape_newline_requires_quoting() {
5402        assert_eq!(
5403            dsv_escape("a\nb", ','),
5404            "\"a\nb\"",
5405            "embedded newline must quote for CSV/DSV"
5406        );
5407        assert_eq!(dsv_escape("line1\nline2", '\t'), "\"line1\nline2\"");
5408    }
5409
5410    #[test]
5411    fn test_detect_separator() {
5412        assert_eq!(detect_separator("x.csv"), ',');
5413        assert_eq!(detect_separator("/path/to/out.tsv"), '\t');
5414        assert_eq!(detect_separator("nested/dir/report.csv"), ',');
5415        assert_eq!(detect_separator("sheet.tsv"), '\t');
5416    }
5417
5418    #[test]
5419    fn test_read_zip_xml_returns_named_entry() {
5420        use std::io::Write;
5421        let tmp = std::env::temp_dir().join("upum_test_lib_read_zip_named.zip");
5422        let _ = fs::remove_file(&tmp);
5423        let file = fs::File::create(&tmp).unwrap();
5424        let mut zip = zip::ZipWriter::new(file);
5425        zip.start_file::<_, ()>("notes.txt", Default::default())
5426            .unwrap();
5427        zip.write_all(b"noise").unwrap();
5428        zip.start_file::<_, ()>("project.xml", Default::default())
5429            .unwrap();
5430        zip.write_all(b"<Project>ok</Project>").unwrap();
5431        zip.finish().unwrap();
5432
5433        let xml = read_zip_xml(tmp.to_str().unwrap(), &["project.xml"]).unwrap();
5434        assert_eq!(xml, "<Project>ok</Project>");
5435        let _ = fs::remove_file(&tmp);
5436    }
5437
5438    #[test]
5439    fn test_read_zip_xml_fallback_scans_first_xml_member() {
5440        use std::io::Write;
5441        let tmp = std::env::temp_dir().join("upum_test_lib_read_zip_fallback.zip");
5442        let _ = fs::remove_file(&tmp);
5443        let file = fs::File::create(&tmp).unwrap();
5444        let mut zip = zip::ZipWriter::new(file);
5445        zip.start_file::<_, ()>("nested/session.xml", Default::default())
5446            .unwrap();
5447        zip.write_all(b"<Session/>").unwrap();
5448        zip.finish().unwrap();
5449
5450        let xml = read_zip_xml(tmp.to_str().unwrap(), &["project.xml"]).unwrap();
5451        assert_eq!(xml, "<Session/>");
5452        let _ = fs::remove_file(&tmp);
5453    }
5454
5455    #[test]
5456    fn test_read_zip_xml_invalid_file_errors() {
5457        let tmp = std::env::temp_dir().join("upum_test_lib_not_zip.bin");
5458        let _ = fs::remove_file(&tmp);
5459        fs::write(&tmp, b"plain text not zip").unwrap();
5460        let err = read_zip_xml(tmp.to_str().unwrap(), &["a.xml"]).unwrap_err();
5461        assert!(
5462            err.contains("Not a valid ZIP") || err.contains("zip"),
5463            "unexpected err: {err}"
5464        );
5465        let _ = fs::remove_file(&tmp);
5466    }
5467
5468    #[test]
5469    fn test_read_zip_xml_no_xml_member_errors() {
5470        use std::io::Write;
5471        let tmp = std::env::temp_dir().join("upum_test_lib_zip_no_xml.zip");
5472        let _ = fs::remove_file(&tmp);
5473        let file = fs::File::create(&tmp).unwrap();
5474        let mut zip = zip::ZipWriter::new(file);
5475        zip.start_file::<_, ()>("readme.txt", Default::default())
5476            .unwrap();
5477        zip.write_all(b"hello").unwrap();
5478        zip.finish().unwrap();
5479
5480        let err = read_zip_xml(tmp.to_str().unwrap(), &["missing.xml"]).unwrap_err();
5481        assert_eq!(err, "No XML found in archive");
5482        let _ = fs::remove_file(&tmp);
5483    }
5484
5485    #[test]
5486    fn test_read_binary_project_inner_missing_file_errors() {
5487        assert!(read_binary_project_inner("/nonexistent/audio_haxor_binary_probe.bin").is_err());
5488    }
5489
5490    #[test]
5491    fn test_read_binary_project_inner_extracts_printable_plugin_paths() {
5492        let tmp = std::env::temp_dir().join("upum_test_read_bin_inner.flp");
5493        let _ = fs::remove_file(&tmp);
5494        let mut blob = vec![0u8, 0x01, 0x02, 0x03];
5495        blob.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/PluginA.vst3");
5496        blob.push(0);
5497        blob.extend_from_slice(b"C:\\VSTPlugins\\PluginB.dll");
5498        blob.push(0);
5499        fs::write(&tmp, &blob).unwrap();
5500        let v = read_binary_project_inner(tmp.to_str().unwrap()).unwrap();
5501        let plugins: Vec<&str> = v["plugins"]
5502            .as_array()
5503            .unwrap()
5504            .iter()
5505            .filter_map(|x| x.as_str())
5506            .collect();
5507        assert!(
5508            plugins.contains(&"/Library/Audio/Plug-Ins/VST3/PluginA.vst3"),
5509            "plugins={plugins:?}"
5510        );
5511        assert!(
5512            plugins.contains(&"C:\\VSTPlugins\\PluginB.dll"),
5513            "plugins={plugins:?}"
5514        );
5515        let _ = fs::remove_file(&tmp);
5516    }
5517
5518    #[test]
5519    fn test_read_binary_project_adds_format_display_name() {
5520        let tmp = std::env::temp_dir().join("upum_test_read_bin_fmt.cpr");
5521        let _ = fs::remove_file(&tmp);
5522        fs::write(&tmp, b"x").unwrap();
5523        let v = read_binary_project(tmp.to_string_lossy().to_string(), "cpr").unwrap();
5524        assert_eq!(
5525            v.get("_format").and_then(|x| x.as_str()),
5526            Some("Cubase Project (.cpr)")
5527        );
5528        let _ = fs::remove_file(&tmp);
5529    }
5530
5531    #[test]
5532    fn test_plugins_to_export_empty() {
5533        let result = plugins_to_export(&[]);
5534        assert!(result.is_empty());
5535    }
5536
5537    #[test]
5538    fn test_plugins_to_export_preserves_fields() {
5539        let plugins = vec![make_plugin("Serum", "VST3")];
5540        let exported = plugins_to_export(&plugins);
5541        assert_eq!(exported.len(), 1);
5542        assert_eq!(exported[0].name, "Serum");
5543        assert_eq!(exported[0].plugin_type, "VST3");
5544        assert_eq!(exported[0].version, "1.0.0");
5545        assert_eq!(exported[0].manufacturer, "TestCo");
5546        assert_eq!(
5547            exported[0].manufacturer_url,
5548            Some("https://testco.com".into())
5549        );
5550    }
5551
5552    #[test]
5553    fn test_plugins_to_export_no_url() {
5554        let mut p = make_plugin("NoUrl", "AU");
5555        p.manufacturer_url = None;
5556        let exported = plugins_to_export(&[p]);
5557        assert_eq!(exported[0].manufacturer_url, None);
5558    }
5559
5560    #[test]
5561    fn test_export_import_json_roundtrip() {
5562        let tmp = std::env::temp_dir().join("upum_test_export_json.json");
5563        let _ = fs::remove_file(&tmp);
5564
5565        let plugins = vec![make_plugin("PluginA", "VST3"), make_plugin("PluginB", "AU")];
5566
5567        rt_block_on(export_plugins_json(
5568            plugins.clone(),
5569            tmp.to_string_lossy().to_string(),
5570        ))
5571        .unwrap();
5572        let imported = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5573
5574        assert_eq!(imported.len(), 2);
5575        assert_eq!(imported[0].name, "PluginA");
5576        assert_eq!(imported[0].plugin_type, "VST3");
5577        assert_eq!(imported[1].name, "PluginB");
5578        assert_eq!(imported[1].plugin_type, "AU");
5579        assert_eq!(imported[1].manufacturer, "TestCo");
5580
5581        let _ = fs::remove_file(&tmp);
5582    }
5583
5584    #[test]
5585    fn test_export_json_contains_metadata() {
5586        let tmp = std::env::temp_dir().join("upum_test_export_meta.json");
5587        let _ = fs::remove_file(&tmp);
5588
5589        let plugins = vec![make_plugin("Test", "VST2")];
5590        rt_block_on(export_plugins_json(plugins, tmp.to_string_lossy().to_string())).unwrap();
5591
5592        let content = fs::read_to_string(&tmp).unwrap();
5593        let payload: serde_json::Value = serde_json::from_str(&content).unwrap();
5594        assert_eq!(payload["version"], env!("CARGO_PKG_VERSION"));
5595        assert!(payload["exported_at"].as_str().unwrap().contains("T"));
5596        assert_eq!(payload["plugins"].as_array().unwrap().len(), 1);
5597
5598        let _ = fs::remove_file(&tmp);
5599    }
5600
5601    #[test]
5602    fn test_export_csv_format() {
5603        let tmp = std::env::temp_dir().join("upum_test_export.csv");
5604        let _ = fs::remove_file(&tmp);
5605
5606        let plugins = vec![make_plugin("Serum", "VST3")];
5607        rt_block_on(export_plugins_csv(plugins, tmp.to_string_lossy().to_string())).unwrap();
5608
5609        let content = fs::read_to_string(&tmp).unwrap();
5610        let lines: Vec<&str> = content.lines().collect();
5611        assert_eq!(
5612            lines[0],
5613            "Name,Type,Version,Manufacturer,Manufacturer URL,Path,Size,Modified"
5614        );
5615        assert!(lines[1].starts_with("Serum,VST3,1.0.0,TestCo,"));
5616
5617        let _ = fs::remove_file(&tmp);
5618    }
5619
5620    #[test]
5621    fn test_export_csv_escapes_commas() {
5622        let tmp = std::env::temp_dir().join("upum_test_export_escape.csv");
5623        let _ = fs::remove_file(&tmp);
5624
5625        let mut p = make_plugin("Plugin, With Comma", "VST3");
5626        p.manufacturer = "Company, Inc.".into();
5627        rt_block_on(export_plugins_csv(vec![p], tmp.to_string_lossy().to_string())).unwrap();
5628
5629        let content = fs::read_to_string(&tmp).unwrap();
5630        assert!(content.contains("\"Plugin, With Comma\""));
5631        assert!(content.contains("\"Company, Inc.\""));
5632
5633        let _ = fs::remove_file(&tmp);
5634    }
5635
5636    #[test]
5637    fn test_export_plugins_tsv_uses_tab_separator_and_header() {
5638        let tmp = std::env::temp_dir().join("upum_test_export_plugins.tsv");
5639        let _ = fs::remove_file(&tmp);
5640
5641        let plugins = vec![make_plugin("Serum", "VST3")];
5642        rt_block_on(export_plugins_csv(plugins, tmp.to_string_lossy().to_string())).unwrap();
5643
5644        let content = fs::read_to_string(&tmp).unwrap();
5645        let lines: Vec<&str> = content.lines().collect();
5646        assert_eq!(
5647            lines[0],
5648            "Name\tType\tVersion\tManufacturer\tManufacturer URL\tPath\tSize\tModified"
5649        );
5650        assert!(
5651            !lines[1].contains(','),
5652            "TSV data row should use tabs, not commas: {}",
5653            lines[1]
5654        );
5655        assert!(lines[1].contains('\t'));
5656
5657        let _ = fs::remove_file(&tmp);
5658    }
5659
5660    #[test]
5661    fn test_import_plugins_json_errors_on_malformed_json() {
5662        let tmp = std::env::temp_dir().join("upum_test_import_plugins_bad.json");
5663        let _ = fs::remove_file(&tmp);
5664        fs::write(&tmp, "{ not json").unwrap();
5665        let err = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap_err();
5666        assert!(!err.is_empty());
5667        let _ = fs::remove_file(&tmp);
5668    }
5669
5670    #[test]
5671    fn test_import_json_invalid_file() {
5672        let result = rt_block_on(import_plugins_json("/nonexistent/path.json".into()));
5673        assert!(result.is_err());
5674    }
5675
5676    #[test]
5677    fn test_import_json_invalid_format() {
5678        let tmp = std::env::temp_dir().join("upum_test_import_bad.json");
5679        fs::write(&tmp, "not valid json").unwrap();
5680
5681        let result = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string()));
5682        assert!(result.is_err());
5683
5684        let _ = fs::remove_file(&tmp);
5685    }
5686
5687    #[test]
5688    fn test_import_json_empty_plugins() {
5689        let tmp = std::env::temp_dir().join("upum_test_import_empty.json");
5690        let content = r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z","plugins":[]}"#;
5691        fs::write(&tmp, content).unwrap();
5692
5693        let result = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5694        assert!(result.is_empty());
5695
5696        let _ = fs::remove_file(&tmp);
5697    }
5698
5699    #[test]
5700    fn test_import_plugins_json_errors_when_plugins_is_not_array() {
5701        let tmp = std::env::temp_dir().join("upum_test_import_plugins_wrong_type.json");
5702        let _ = fs::remove_file(&tmp);
5703        fs::write(
5704            &tmp,
5705            r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z","plugins":"not-an-array"}"#,
5706        )
5707        .unwrap();
5708        let err = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap_err();
5709        assert!(!err.is_empty());
5710        let _ = fs::remove_file(&tmp);
5711    }
5712
5713    /// Forward-compatible imports: serde ignores unknown keys on plugin objects.
5714    #[test]
5715    fn test_import_plugins_json_extra_keys_on_plugin_ignored() {
5716        let tmp = std::env::temp_dir().join("upum_test_import_plugins_extra_keys.json");
5717        let _ = fs::remove_file(&tmp);
5718        let content = r#"{
5719        "version":"1.0",
5720        "exported_at":"2025-01-01T00:00:00Z",
5721        "plugins":[{
5722            "name":"Extra",
5723            "type":"VST3",
5724            "version":"1",
5725            "manufacturer":"M",
5726            "path":"/p.vst3",
5727            "size":"1 B",
5728            "sizeBytes":1,
5729            "modified":"t",
5730            "architectures":[],
5731            "futureProofField":true
5732        }]
5733    }"#;
5734        fs::write(&tmp, content).unwrap();
5735        let imported = rt_block_on(import_plugins_json(tmp.to_string_lossy().to_string())).unwrap();
5736        assert_eq!(imported.len(), 1);
5737        assert_eq!(imported[0].name, "Extra");
5738        assert_eq!(imported[0].plugin_type, "VST3");
5739        let _ = fs::remove_file(&tmp);
5740    }
5741
5742    #[test]
5743    fn test_export_csv_empty_plugins() {
5744        let tmp = std::env::temp_dir().join("upum_test_export_empty.csv");
5745        let _ = fs::remove_file(&tmp);
5746
5747        rt_block_on(export_plugins_csv(vec![], tmp.to_string_lossy().to_string())).unwrap();
5748        let content = fs::read_to_string(&tmp).unwrap();
5749        let lines: Vec<&str> = content.lines().collect();
5750        assert_eq!(lines.len(), 1); // header only
5751        assert!(lines[0].starts_with("Name,"));
5752
5753        let _ = fs::remove_file(&tmp);
5754    }
5755
5756    #[test]
5757    fn test_plugins_to_export_multiple() {
5758        let plugins = vec![
5759            make_plugin("A", "VST2"),
5760            make_plugin("B", "VST3"),
5761            make_plugin("C", "AU"),
5762        ];
5763        let exported = plugins_to_export(&plugins);
5764        assert_eq!(exported.len(), 3);
5765        assert_eq!(exported[0].name, "A");
5766        assert_eq!(exported[2].plugin_type, "AU");
5767    }
5768
5769    #[test]
5770    fn test_export_payload_serde() {
5771        let payload = ExportPayload {
5772            version: "1.0".into(),
5773            exported_at: "2025-01-01T00:00:00Z".into(),
5774            plugins: vec![ExportPlugin {
5775                name: "Test".into(),
5776                plugin_type: "VST3".into(),
5777                version: "2.0".into(),
5778                manufacturer: "Co".into(),
5779                manufacturer_url: None,
5780                path: "/test".into(),
5781                size: "1 MB".into(),
5782                size_bytes: 1048576,
5783                modified: "2025-01-01".into(),
5784                architectures: vec![],
5785            }],
5786        };
5787
5788        let json = serde_json::to_string(&payload).unwrap();
5789        let deserialized: ExportPayload = serde_json::from_str(&json).unwrap();
5790        assert_eq!(deserialized.version, "1.0");
5791        assert_eq!(deserialized.plugins.len(), 1);
5792        assert_eq!(deserialized.plugins[0].name, "Test");
5793        assert!(deserialized.plugins[0].manufacturer_url.is_none());
5794    }
5795
5796    #[test]
5797    fn test_export_plugin_skips_none_url_in_json() {
5798        let plugin = ExportPlugin {
5799            name: "Test".into(),
5800            plugin_type: "VST3".into(),
5801            version: "1.0".into(),
5802            manufacturer: "Co".into(),
5803            manufacturer_url: None,
5804            path: "/test".into(),
5805            size: "1 MB".into(),
5806            size_bytes: 0,
5807            modified: "2025-01-01".into(),
5808            architectures: vec![],
5809        };
5810        let json = serde_json::to_string(&plugin).unwrap();
5811        assert!(!json.contains("manufacturer_url"));
5812    }
5813
5814    #[test]
5815    fn test_export_plugin_includes_url_in_json() {
5816        let plugin = ExportPlugin {
5817            name: "Test".into(),
5818            plugin_type: "VST3".into(),
5819            version: "1.0".into(),
5820            manufacturer: "Co".into(),
5821            manufacturer_url: Some("https://co.com".into()),
5822            path: "/test".into(),
5823            size: "1 MB".into(),
5824            size_bytes: 0,
5825            modified: "2025-01-01".into(),
5826            architectures: vec![],
5827        };
5828        let json = serde_json::to_string(&plugin).unwrap();
5829        assert!(json.contains("manufacturer_url"));
5830        assert!(json.contains("https://co.com"));
5831    }
5832
5833    // ── Import/Export tests for all scan types ──
5834
5835    fn make_audio_sample(name: &str, format: &str) -> AudioSample {
5836        AudioSample {
5837            name: name.into(),
5838            path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5839            directory: "/tmp".into(),
5840            format: format.into(),
5841            size: 1024,
5842            size_formatted: "1.0 KB".into(),
5843            modified: "2025-01-01".into(),
5844            duration: None,
5845            channels: None,
5846            sample_rate: None,
5847            bits_per_sample: None,
5848        }
5849    }
5850
5851    fn make_daw_project(name: &str, format: &str, daw: &str) -> DawProject {
5852        DawProject {
5853            name: name.into(),
5854            path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5855            directory: "/tmp".into(),
5856            format: format.into(),
5857            daw: daw.into(),
5858            size: 2048,
5859            size_formatted: "2.0 KB".into(),
5860            modified: "2025-01-01".into(),
5861        }
5862    }
5863
5864    fn make_preset(name: &str, format: &str) -> PresetFile {
5865        PresetFile {
5866            name: name.into(),
5867            path: format!("/tmp/{}.{}", name, format.to_lowercase()),
5868            directory: "/tmp".into(),
5869            format: format.into(),
5870            size: 512,
5871            size_formatted: "512 B".into(),
5872            modified: "2025-01-01".into(),
5873        }
5874    }
5875
5876    #[test]
5877    fn test_import_audio_json_valid() {
5878        let tmp = std::env::temp_dir().join("upum_test_import_audio.json");
5879        let samples = vec![
5880            make_audio_sample("kick", "WAV"),
5881            make_audio_sample("snare", "FLAC"),
5882        ];
5883        let json = serde_json::to_string_pretty(&samples).unwrap();
5884        fs::write(&tmp, &json).unwrap();
5885
5886        let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
5887        assert!(result.is_ok());
5888        let imported = result.unwrap();
5889        assert_eq!(imported.len(), 2);
5890        assert_eq!(imported[0].name, "kick");
5891        assert_eq!(imported[1].format, "FLAC");
5892
5893        let _ = fs::remove_file(&tmp);
5894    }
5895
5896    #[test]
5897    fn test_import_audio_json_extra_field_on_sample_ignored() {
5898        let tmp = std::env::temp_dir().join("upum_test_import_audio_extra_field.json");
5899        let _ = fs::remove_file(&tmp);
5900        let content = r#"{
5901        "version":"1.0",
5902        "exported_at":"2025-01-01T00:00:00Z",
5903        "samples":[{
5904            "name":"Extra",
5905            "path":"/a.wav",
5906            "directory":"/d",
5907            "format":"WAV",
5908            "size":100,
5909            "sizeFormatted":"100 B",
5910            "modified":"t",
5911            "futureProof":true
5912        }]
5913    }"#;
5914        fs::write(&tmp, content).unwrap();
5915        let imported = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap();
5916        assert_eq!(imported.len(), 1);
5917        assert_eq!(imported[0].name, "Extra");
5918        assert_eq!(imported[0].format, "WAV");
5919        let _ = fs::remove_file(&tmp);
5920    }
5921
5922    #[test]
5923    fn test_import_audio_json_invalid_format() {
5924        let tmp = std::env::temp_dir().join("upum_test_import_audio_bad.json");
5925        fs::write(&tmp, r#"{"not": "an array"}"#).unwrap();
5926
5927        let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
5928        assert!(result.is_err());
5929
5930        let _ = fs::remove_file(&tmp);
5931    }
5932
5933    #[test]
5934    fn test_import_audio_json_nonexistent() {
5935        let result = rt_block_on(import_audio_json("/tmp/nonexistent_audio_file.json".into()));
5936        assert!(result.is_err());
5937    }
5938
5939    #[test]
5940    fn test_import_daw_json_valid() {
5941        let tmp = std::env::temp_dir().join("upum_test_import_daw.json");
5942        let projects = vec![
5943            make_daw_project("Song1", "ALS", "Ableton Live"),
5944            make_daw_project("Song2", "FLP", "FL Studio"),
5945        ];
5946        let json = serde_json::to_string_pretty(&projects).unwrap();
5947        fs::write(&tmp, &json).unwrap();
5948
5949        let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
5950        assert!(result.is_ok());
5951        let imported = result.unwrap();
5952        assert_eq!(imported.len(), 2);
5953        assert_eq!(imported[0].daw, "Ableton Live");
5954        assert_eq!(imported[1].format, "FLP");
5955
5956        let _ = fs::remove_file(&tmp);
5957    }
5958
5959    #[test]
5960    fn test_import_daw_json_extra_field_on_project_ignored() {
5961        let tmp = std::env::temp_dir().join("upum_test_import_daw_extra_field.json");
5962        let _ = fs::remove_file(&tmp);
5963        let content = r#"{
5964        "version":"1.0",
5965        "exported_at":"2025-01-01T00:00:00Z",
5966        "projects":[{
5967            "name":"Extra",
5968            "path":"/p.als",
5969            "directory":"/tmp",
5970            "format":"ALS",
5971            "daw":"Ableton Live",
5972            "size":2048,
5973            "sizeFormatted":"2.0 KB",
5974            "modified":"t",
5975            "futureProof":true
5976        }]
5977    }"#;
5978        fs::write(&tmp, content).unwrap();
5979        let imported = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap();
5980        assert_eq!(imported.len(), 1);
5981        assert_eq!(imported[0].name, "Extra");
5982        assert_eq!(imported[0].daw, "Ableton Live");
5983        let _ = fs::remove_file(&tmp);
5984    }
5985
5986    #[test]
5987    fn test_import_daw_json_invalid_format() {
5988        let tmp = std::env::temp_dir().join("upum_test_import_daw_bad.json");
5989        fs::write(&tmp, "not json at all").unwrap();
5990
5991        let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
5992        assert!(result.is_err());
5993
5994        let _ = fs::remove_file(&tmp);
5995    }
5996
5997    #[test]
5998    fn test_import_presets_json_valid() {
5999        let tmp = std::env::temp_dir().join("upum_test_import_presets.json");
6000        let presets = vec![make_preset("Lead", "FXP"), make_preset("Pad", "VSTPRESET")];
6001        let json = serde_json::to_string_pretty(&presets).unwrap();
6002        fs::write(&tmp, &json).unwrap();
6003
6004        let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6005        assert!(result.is_ok());
6006        let imported = result.unwrap();
6007        assert_eq!(imported.len(), 2);
6008        assert_eq!(imported[0].name, "Lead");
6009        assert_eq!(imported[1].format, "VSTPRESET");
6010
6011        let _ = fs::remove_file(&tmp);
6012    }
6013
6014    #[test]
6015    fn test_import_presets_json_extra_field_on_preset_ignored() {
6016        let tmp = std::env::temp_dir().join("upum_test_import_presets_extra_field.json");
6017        let _ = fs::remove_file(&tmp);
6018        let content = r#"{
6019        "version":"1.0",
6020        "exported_at":"2025-01-01T00:00:00Z",
6021        "presets":[{
6022            "name":"Extra",
6023            "path":"/p.fxp",
6024            "directory":"/d",
6025            "format":"FXP",
6026            "size":100,
6027            "sizeFormatted":"100 B",
6028            "modified":"t",
6029            "futureProof":true
6030        }]
6031    }"#;
6032        fs::write(&tmp, content).unwrap();
6033        let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6034        assert_eq!(imported.len(), 1);
6035        assert_eq!(imported[0].name, "Extra");
6036        assert_eq!(imported[0].format, "FXP");
6037        let _ = fs::remove_file(&tmp);
6038    }
6039
6040    #[test]
6041    fn test_import_presets_json_invalid_format() {
6042        let tmp = std::env::temp_dir().join("upum_test_import_presets_bad.json");
6043        fs::write(&tmp, r#"[{"wrong": "fields"}]"#).unwrap();
6044
6045        let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6046        assert!(result.is_err());
6047
6048        let _ = fs::remove_file(&tmp);
6049    }
6050
6051    #[test]
6052    fn test_export_import_presets_roundtrip() {
6053        let tmp = std::env::temp_dir().join("upum_test_preset_roundtrip.json");
6054        let presets = vec![
6055            make_preset("Bass", "FXB"),
6056            make_preset("Keys", "AUPRESET"),
6057            make_preset("Strings", "H2P"),
6058        ];
6059
6060        rt_block_on(export_presets_json(
6061            presets.clone(),
6062            tmp.to_string_lossy().to_string(),
6063        ))
6064        .unwrap();
6065        let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6066
6067        assert_eq!(imported.len(), 3);
6068        assert_eq!(imported[0].name, presets[0].name);
6069        assert_eq!(imported[1].format, presets[1].format);
6070        assert_eq!(imported[2].size, presets[2].size);
6071
6072        let _ = fs::remove_file(&tmp);
6073    }
6074
6075    #[test]
6076    fn test_export_import_audio_roundtrip() {
6077        let tmp = std::env::temp_dir().join("upum_test_audio_roundtrip.json");
6078        let samples = vec![
6079            make_audio_sample("hi-hat", "WAV"),
6080            make_audio_sample("pad", "FLAC"),
6081        ];
6082
6083        rt_block_on(export_audio_json(
6084            samples.clone(),
6085            tmp.to_string_lossy().to_string(),
6086        ))
6087        .unwrap();
6088        let imported = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap();
6089
6090        assert_eq!(imported.len(), 2);
6091        assert_eq!(imported[0].name, "hi-hat");
6092        assert_eq!(imported[1].format, "FLAC");
6093
6094        let _ = fs::remove_file(&tmp);
6095    }
6096
6097    #[test]
6098    fn test_export_import_daw_roundtrip() {
6099        let tmp = std::env::temp_dir().join("upum_test_daw_roundtrip.json");
6100        let projects = vec![
6101            make_daw_project("Track1", "LOGICX", "Logic Pro"),
6102            make_daw_project("Track2", "RPP", "REAPER"),
6103        ];
6104
6105        rt_block_on(export_daw_json(
6106            projects.clone(),
6107            tmp.to_string_lossy().to_string(),
6108        ))
6109        .unwrap();
6110        let imported = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap();
6111
6112        assert_eq!(imported.len(), 2);
6113        assert_eq!(imported[0].daw, "Logic Pro");
6114        assert_eq!(imported[1].format, "RPP");
6115
6116        let _ = fs::remove_file(&tmp);
6117    }
6118
6119    #[test]
6120    fn test_import_presets_json_nonexistent() {
6121        let result = rt_block_on(import_presets_json("/tmp/nonexistent_preset_file.json".into()));
6122        assert!(result.is_err());
6123    }
6124
6125    #[test]
6126    fn test_import_daw_json_nonexistent() {
6127        let result = rt_block_on(import_daw_json("/tmp/nonexistent_daw_file.json".into()));
6128        assert!(result.is_err());
6129    }
6130
6131    #[test]
6132    fn test_import_audio_json_empty_array() {
6133        let tmp = std::env::temp_dir().join("upum_test_import_audio_empty.json");
6134        fs::write(&tmp, "[]").unwrap();
6135
6136        let result = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string()));
6137        assert!(result.is_ok());
6138        assert_eq!(result.unwrap().len(), 0);
6139
6140        let _ = fs::remove_file(&tmp);
6141    }
6142
6143    #[test]
6144    fn test_import_presets_json_empty_array() {
6145        let tmp = std::env::temp_dir().join("upum_test_import_presets_empty.json");
6146        fs::write(&tmp, "[]").unwrap();
6147
6148        let result = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string()));
6149        assert!(result.is_ok());
6150        assert_eq!(result.unwrap().len(), 0);
6151
6152        let _ = fs::remove_file(&tmp);
6153    }
6154
6155    #[test]
6156    fn test_import_audio_json_errors_when_object_has_no_samples_key() {
6157        let tmp = std::env::temp_dir().join("upum_test_import_audio_no_samples.json");
6158        fs::write(
6159            &tmp,
6160            r#"{"version":"1.0","exported_at":"2025-01-01T00:00:00Z"}"#,
6161        )
6162        .unwrap();
6163        let err = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap_err();
6164        assert!(err.contains("samples"), "unexpected error: {err}");
6165        let _ = fs::remove_file(&tmp);
6166    }
6167
6168    #[test]
6169    fn test_import_daw_json_empty_array() {
6170        let tmp = std::env::temp_dir().join("upum_test_import_daw_empty.json");
6171        fs::write(&tmp, "[]").unwrap();
6172        let result = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string()));
6173        assert!(result.is_ok());
6174        assert_eq!(result.unwrap().len(), 0);
6175        let _ = fs::remove_file(&tmp);
6176    }
6177
6178    #[test]
6179    fn test_import_daw_json_errors_when_object_has_no_projects_key() {
6180        let tmp = std::env::temp_dir().join("upum_test_import_daw_no_projects.json");
6181        fs::write(&tmp, r#"{"version":"1.0","samples":[]}"#).unwrap();
6182        let err = rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).unwrap_err();
6183        assert!(err.contains("projects"), "unexpected error: {err}");
6184        let _ = fs::remove_file(&tmp);
6185    }
6186
6187    #[test]
6188    fn test_import_audio_json_errors_when_envelope_uses_projects_key() {
6189        let tmp = std::env::temp_dir().join("upum_test_import_audio_wrong_envelope.json");
6190        fs::write(&tmp, r#"{"projects":[]}"#).unwrap();
6191        let err = rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).unwrap_err();
6192        assert!(err.contains("samples"), "unexpected error: {err}");
6193        let _ = fs::remove_file(&tmp);
6194    }
6195
6196    #[test]
6197    fn test_import_presets_json_envelope_without_bare_array() {
6198        let tmp = std::env::temp_dir().join("upum_test_import_presets_envelope_only.json");
6199        let preset = make_preset("OnlyEnvelope", "FXP");
6200        let json = serde_json::json!({ "presets": [preset] });
6201        fs::write(&tmp, serde_json::to_string(&json).unwrap()).unwrap();
6202        let imported = rt_block_on(import_presets_json(tmp.to_string_lossy().to_string())).unwrap();
6203        assert_eq!(imported.len(), 1);
6204        assert_eq!(imported[0].name, "OnlyEnvelope");
6205        let _ = fs::remove_file(&tmp);
6206    }
6207
6208    #[test]
6209    fn test_import_audio_json_samples_not_array_returns_error() {
6210        let tmp = std::env::temp_dir().join("upum_test_import_audio_samples_bad_type.json");
6211        fs::write(&tmp, r#"{"samples":"nope"}"#).unwrap();
6212        assert!(rt_block_on(import_audio_json(tmp.to_string_lossy().to_string())).is_err());
6213        let _ = fs::remove_file(&tmp);
6214    }
6215
6216    #[test]
6217    fn test_import_daw_json_projects_not_array_returns_error() {
6218        let tmp = std::env::temp_dir().join("upum_test_import_daw_projects_bad_type.json");
6219        fs::write(&tmp, r#"{"projects":{}}"#).unwrap();
6220        assert!(rt_block_on(import_daw_json(tmp.to_string_lossy().to_string())).is_err());
6221        let _ = fs::remove_file(&tmp);
6222    }
6223
6224    // ── File browser tests ──
6225
6226    #[test]
6227    fn test_fs_list_dir_valid() {
6228        let tmp = std::env::temp_dir().join("upum_test_fs_list");
6229        let _ = fs::remove_dir_all(&tmp);
6230        fs::create_dir_all(&tmp).unwrap();
6231        fs::write(tmp.join("file1.txt"), "hello").unwrap();
6232        fs::write(tmp.join("file2.wav"), "audio").unwrap();
6233        fs::create_dir(tmp.join("subdir")).unwrap();
6234        fs::write(tmp.join(".hidden"), "skip").unwrap();
6235
6236        let result = rt_block_on(fs_list_dir(tmp.to_string_lossy().to_string())).unwrap();
6237        let entries = result["entries"].as_array().unwrap();
6238        // Should have 3 entries (subdir, file1.txt, file2.wav) — .hidden is skipped
6239        assert_eq!(entries.len(), 3);
6240        // Dirs first
6241        assert!(entries[0]["isDir"].as_bool().unwrap());
6242        assert_eq!(entries[0]["name"].as_str().unwrap(), "subdir");
6243        let _ = fs::remove_dir_all(&tmp);
6244    }
6245
6246    #[test]
6247    fn test_fs_list_dir_nonexistent() {
6248        let result = rt_block_on(fs_list_dir("/nonexistent/upum_dir_xyz".into()));
6249        assert!(result.is_err());
6250    }
6251
6252    #[test]
6253    fn test_fs_list_dir_not_a_dir() {
6254        let tmp = std::env::temp_dir().join("upum_test_fs_notdir.txt");
6255        fs::write(&tmp, "data").unwrap();
6256        let result = rt_block_on(fs_list_dir(tmp.to_string_lossy().to_string()));
6257        assert!(result.is_err());
6258        let _ = fs::remove_file(&tmp);
6259    }
6260
6261    #[test]
6262    fn test_delete_file_regular() {
6263        let tmp = std::env::temp_dir().join("upum_test_delete.txt");
6264        fs::write(&tmp, "delete me").unwrap();
6265        assert!(tmp.exists());
6266        rt_block_on(delete_file(tmp.to_string_lossy().to_string())).unwrap();
6267        assert!(!tmp.exists());
6268    }
6269
6270    #[test]
6271    fn test_delete_file_directory() {
6272        let tmp = std::env::temp_dir().join("upum_test_delete_dir");
6273        fs::create_dir_all(tmp.join("inner")).unwrap();
6274        fs::write(tmp.join("inner").join("file.txt"), "data").unwrap();
6275        rt_block_on(delete_file(tmp.to_string_lossy().to_string())).unwrap();
6276        assert!(!tmp.exists());
6277    }
6278
6279    #[test]
6280    fn test_delete_file_nonexistent() {
6281        let result = rt_block_on(delete_file("/nonexistent/upum_file_xyz.txt".into()));
6282        assert!(result.is_err());
6283    }
6284
6285    #[test]
6286    fn test_rename_file() {
6287        let tmp1 = std::env::temp_dir().join("upum_test_rename_old.txt");
6288        let tmp2 = std::env::temp_dir().join("upum_test_rename_new.txt");
6289        let _ = fs::remove_file(&tmp2);
6290        fs::write(&tmp1, "content").unwrap();
6291        rt_block_on(rename_file(
6292            tmp1.to_string_lossy().to_string(),
6293            tmp2.to_string_lossy().to_string(),
6294        ))
6295        .unwrap();
6296        assert!(!tmp1.exists());
6297        assert!(tmp2.exists());
6298        assert_eq!(fs::read_to_string(&tmp2).unwrap(), "content");
6299        let _ = fs::remove_file(&tmp2);
6300    }
6301
6302    #[test]
6303    fn test_get_home_dir() {
6304        let result = rt_block_on(get_home_dir());
6305        assert!(result.is_ok());
6306        let home = result.unwrap();
6307        assert!(!home.is_empty());
6308        assert!(std::path::Path::new(&home).exists());
6309    }
6310
6311    // ── Cache file tests ──
6312
6313    #[test]
6314    fn test_cache_file_roundtrip() {
6315        let _guard = test_data_dir();
6316        let db = db::Database::open().expect("open db for cache roundtrip");
6317        let data = serde_json::json!({"hello": "world", "count": 42});
6318        db.write_cache("test-cache-roundtrip.json", &data).unwrap();
6319        let result = db.read_cache("test-cache-roundtrip.json").unwrap();
6320        assert_eq!(result["hello"], "world");
6321        assert_eq!(result["count"], 42);
6322    }
6323
6324    #[test]
6325    fn test_cache_file_nonexistent() {
6326        let _guard = test_data_dir();
6327        let db = db::Database::open().expect("open db for cache read");
6328        let result = db.read_cache("nonexistent-cache-xyz.json").unwrap();
6329        // Falls back to waveform_cache table — result is valid JSON (may be empty or populated)
6330        assert!(result.is_object());
6331    }
6332
6333    #[test]
6334    fn test_append_and_read_log() {
6335        let _guard = app_log_lock();
6336        let _tmp = test_data_dir();
6337        rt_block_on(clear_log()).unwrap();
6338        let token = format!(
6339            "log-test-{}",
6340            std::time::SystemTime::now()
6341                .duration_since(std::time::UNIX_EPOCH)
6342                .map(|d| d.as_nanos())
6343                .unwrap_or(0)
6344        );
6345        append_log(format!("{token} entry1"));
6346        append_log(format!("{token} entry2"));
6347        let log = rt_block_on(read_log()).unwrap();
6348        assert!(
6349            log.contains(&format!("{token} entry1")),
6350            "missing first line in log (len {})",
6351            log.len()
6352        );
6353        assert!(
6354            log.contains(&format!("{token} entry2")),
6355            "missing second line in log (len {})",
6356            log.len()
6357        );
6358    }
6359
6360    #[test]
6361    fn test_clear_log() {
6362        let _guard = app_log_lock();
6363        let _tmp = test_data_dir();
6364        rt_block_on(clear_log()).unwrap();
6365        append_log("before clear".into());
6366        rt_block_on(clear_log()).unwrap();
6367        let log = rt_block_on(read_log()).unwrap();
6368        assert!(!log.contains("before clear"));
6369    }
6370
6371    #[test]
6372    fn test_read_log_missing_file_returns_empty() {
6373        let _guard = app_log_lock();
6374        let tmp = test_data_dir();
6375        let _ = fs::remove_file(tmp.path.join("app.log"));
6376        assert_eq!(rt_block_on(read_log()).unwrap(), "");
6377    }
6378
6379    #[test]
6380    fn test_log_entries_have_timestamp() {
6381        let _guard = app_log_lock();
6382        let _tmp = test_data_dir();
6383        rt_block_on(clear_log()).unwrap();
6384        append_log("timestamp-check".into());
6385        let log = rt_block_on(read_log()).unwrap();
6386        // Timestamp format: [YYYY-MM-DD HH:MM:SS]
6387        let re =
6388            regex::Regex::new(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] timestamp-check").unwrap();
6389        assert!(re.is_match(&log), "log entry missing timestamp: {}", log);
6390    }
6391
6392    #[test]
6393    fn test_log_appends_not_overwrites() {
6394        let _guard = app_log_lock();
6395        let _tmp = test_data_dir();
6396        rt_block_on(clear_log()).unwrap();
6397        append_log("first".into());
6398        append_log("second".into());
6399        append_log("third".into());
6400        let log = rt_block_on(read_log()).unwrap();
6401        let lines: Vec<&str> = log.lines().collect();
6402        assert!(
6403            lines.len() >= 3,
6404            "expected at least 3 lines, got {}",
6405            lines.len()
6406        );
6407        assert!(lines.iter().any(|l| l.contains("first")));
6408        assert!(lines.iter().any(|l| l.contains("second")));
6409        assert!(lines.iter().any(|l| l.contains("third")));
6410        // Verify order: first appears before second
6411        let first_pos = log.find("first").unwrap();
6412        let second_pos = log.find("second").unwrap();
6413        let third_pos = log.find("third").unwrap();
6414        assert!(
6415            first_pos < second_pos && second_pos < third_pos,
6416            "log entries out of order"
6417        );
6418    }
6419
6420    #[test]
6421    fn test_log_handles_special_characters() {
6422        let _guard = app_log_lock();
6423        let _tmp = test_data_dir();
6424        rt_block_on(clear_log()).unwrap();
6425        append_log("unicode: 日本語テスト 🎵 emoji".into());
6426        append_log("newlines: line1\nline2".into());
6427        append_log("path: /Users/test/my file (1).vst3".into());
6428        let log = rt_block_on(read_log()).unwrap();
6429        assert!(log.contains("日本語テスト"));
6430        assert!(log.contains("🎵"));
6431        assert!(log.contains("my file (1).vst3"));
6432    }
6433
6434    #[test]
6435    fn test_log_concurrent_appends() {
6436        let _guard = app_log_lock();
6437        let tmp = test_data_dir();
6438        rt_block_on(clear_log()).unwrap();
6439        let path = tmp.path.clone();
6440        let handles: Vec<_> = (0..10)
6441            .map(|i| {
6442                let path = path.clone();
6443                std::thread::spawn(move || {
6444                    history::set_test_data_dir_path(path);
6445                    append_log(format!("concurrent-{i}"));
6446                })
6447            })
6448            .collect();
6449        for h in handles {
6450            h.join().unwrap();
6451        }
6452        let log = rt_block_on(read_log()).unwrap();
6453        for i in 0..10 {
6454            assert!(
6455                log.contains(&format!("concurrent-{i}")),
6456                "missing concurrent-{i}"
6457            );
6458        }
6459    }
6460
6461    #[test]
6462    fn test_clear_log_then_append_works() {
6463        let _guard = app_log_lock();
6464        let _tmp = test_data_dir();
6465        rt_block_on(clear_log()).unwrap();
6466        append_log("before".into());
6467        rt_block_on(clear_log()).unwrap();
6468        append_log("after".into());
6469        let log = rt_block_on(read_log()).unwrap();
6470        assert!(!log.contains("before"), "cleared content should be gone");
6471        assert!(log.contains("after"), "new content should be present");
6472    }
6473
6474    // ── TOML export/import tests ──
6475
6476    #[test]
6477    fn test_export_import_toml_roundtrip() {
6478        let tmp = std::env::temp_dir().join("upum_test_export.toml");
6479        let data = serde_json::json!({
6480            "plugins": [{"name": "Test", "version": "1.0"}]
6481        });
6482        rt_block_on(export_toml(data.clone(), tmp.to_string_lossy().to_string())).unwrap();
6483        let imported = rt_block_on(import_toml(tmp.to_string_lossy().to_string())).unwrap();
6484        assert!(imported["plugins"].is_array());
6485        let _ = fs::remove_file(&tmp);
6486    }
6487
6488    #[test]
6489    fn test_import_toml_nonexistent() {
6490        let result = rt_block_on(import_toml("/nonexistent/file.toml".into()));
6491        assert!(result.is_err());
6492    }
6493
6494    #[test]
6495    fn test_import_toml_invalid() {
6496        let tmp = std::env::temp_dir().join("upum_test_invalid.toml");
6497        fs::write(&tmp, "this is not valid toml [[[").unwrap();
6498        let result = rt_block_on(import_toml(tmp.to_string_lossy().to_string()));
6499        assert!(result.is_err());
6500        let _ = fs::remove_file(&tmp);
6501    }
6502
6503    // ── Preset DSV export tests ──
6504
6505    #[test]
6506    fn test_export_presets_dsv_csv() {
6507        let tmp = std::env::temp_dir().join("upum_test_presets.csv");
6508        let presets = vec![PresetFile {
6509            name: "Lead".into(),
6510            path: "/presets/lead.fxp".into(),
6511            directory: "/presets".into(),
6512            format: "FXP".into(),
6513            size: 1024,
6514            size_formatted: "1.0 KB".into(),
6515            modified: "2024-01-01".into(),
6516        }];
6517        rt_block_on(export_presets_dsv(presets, tmp.to_string_lossy().to_string())).unwrap();
6518        let content = fs::read_to_string(&tmp).unwrap();
6519        assert!(content.contains("Lead"));
6520        assert!(content.contains("FXP"));
6521        assert!(content.contains(","));
6522        let _ = fs::remove_file(&tmp);
6523    }
6524
6525    #[test]
6526    fn test_export_presets_dsv_tsv() {
6527        let tmp = std::env::temp_dir().join("upum_test_presets.tsv");
6528        let presets = vec![PresetFile {
6529            name: "Bass".into(),
6530            path: "/presets/bass.fxp".into(),
6531            directory: "/presets".into(),
6532            format: "FXP".into(),
6533            size: 2048,
6534            size_formatted: "2.0 KB".into(),
6535            modified: "2024-02-01".into(),
6536        }];
6537        rt_block_on(export_presets_dsv(presets, tmp.to_string_lossy().to_string())).unwrap();
6538        let content = fs::read_to_string(&tmp).unwrap();
6539        assert!(content.contains("Bass"));
6540        assert!(content.contains("\t"));
6541        let _ = fs::remove_file(&tmp);
6542    }
6543
6544    // ── .band validation tests ──
6545
6546    #[test]
6547    fn test_band_validation_valid() {
6548        let tmp = std::env::temp_dir().join("upum_test_valid.band");
6549        fs::create_dir_all(tmp.join("Media")).unwrap();
6550        fs::write(tmp.join("projectData"), b"bplist00fake").unwrap();
6551        assert!(daw_scanner::is_package_ext(&tmp));
6552        let _ = fs::remove_dir_all(&tmp);
6553    }
6554
6555    #[test]
6556    fn test_band_validation_no_bplist() {
6557        let tmp = std::env::temp_dir().join("upum_test_nobplist.band");
6558        fs::create_dir_all(tmp.join("Media")).unwrap();
6559        fs::write(tmp.join("projectData"), b"not a plist").unwrap();
6560        // is_package_ext returns true (it's a .band dir) but the internal
6561        // validation in walk_for_daw would reject it
6562        assert!(daw_scanner::is_package_ext(&tmp));
6563        let _ = fs::remove_dir_all(&tmp);
6564    }
6565
6566    // ── open_daw_project tests ──
6567
6568    #[test]
6569    fn test_open_daw_project_nonexistent() {
6570        let rt = tokio::runtime::Runtime::new().unwrap();
6571        let result = rt.block_on(open_daw_project("/nonexistent/project.als".into()));
6572        assert!(result.is_err());
6573        assert!(result.unwrap_err().contains("not found"));
6574    }
6575
6576    #[test]
6577    fn bulk_format_size_non_empty() {
6578        for i in 0..12_000u32 {
6579            let b = i as u64 * 17 + (i as u64 % 1024);
6580            let s = format_size(b);
6581            assert!(!s.is_empty(), "format_size({b})");
6582        }
6583    }
6584
6585    #[test]
6586    fn test_format_size_one_gb() {
6587        assert_eq!(format_size(1024_u64.pow(3)), "1.0 GB");
6588    }
6589
6590    #[test]
6591    fn test_format_size_one_byte_below_one_gib_stays_in_mb_tier() {
6592        let b = 1024_u64.pow(3) - 1;
6593        let s = format_size(b);
6594        assert!(
6595            s.ends_with(" MB"),
6596            "just under 1 GiB should use MB unit, got {s}"
6597        );
6598    }
6599
6600    #[test]
6601    fn test_detect_separator_unknown_extension_defaults_csv() {
6602        assert_eq!(detect_separator("export.data"), ',');
6603        assert_eq!(detect_separator("/tmp/no_extension"), ',');
6604    }
6605
6606    #[test]
6607    fn test_export_pdf_writes_pdf_magic_bytes() {
6608        let tmp =
6609            std::env::temp_dir().join(format!("ah_export_pdf_test_{}.pdf", std::process::id()));
6610        let _ = fs::remove_file(&tmp);
6611        rt_block_on(export_pdf(
6612            "Unit test".into(),
6613            vec!["Col A".into(), "Col B".into()],
6614            vec![vec!["cell-a".into(), "cell-b".into()]],
6615            tmp.to_string_lossy().to_string(),
6616        ))
6617        .unwrap();
6618        let bytes = fs::read(&tmp).unwrap();
6619        assert!(
6620            bytes.starts_with(b"%PDF-"),
6621            "expected PDF header, got {:?}",
6622            &bytes[..bytes.len().min(16)]
6623        );
6624        let _ = fs::remove_file(&tmp);
6625    }
6626}
6627
6628// ── Database IPC commands ──
6629
6630#[tauri::command]
6631async fn db_query_audio(params: db::AudioQueryParams) -> Result<db::AudioQueryResult, String> {
6632    tokio::task::spawn_blocking(move || db::global().query_audio(&params))
6633        .await
6634        .map_err(|e| format!("db_query_audio task: {e}"))?
6635}
6636
6637#[tauri::command(rename_all = "snake_case")]
6638async fn db_query_plugins(
6639    search: Option<String>,
6640    type_filter: Option<String>,
6641    status_filter: Option<String>,
6642    sort_key: Option<String>,
6643    sort_asc: Option<bool>,
6644    search_regex: Option<bool>,
6645    offset: Option<u64>,
6646    limit: Option<u64>,
6647) -> Result<db::PluginQueryResult, String> {
6648    let search_regex = search_regex.unwrap_or(false);
6649    tokio::task::spawn_blocking(move || {
6650        db::global().query_plugins(
6651            search.as_deref(),
6652            type_filter.as_deref(),
6653            status_filter.as_deref(),
6654            &sort_key.unwrap_or("name".into()),
6655            sort_asc.unwrap_or(true),
6656            search_regex,
6657            offset.unwrap_or(0),
6658            limit.unwrap_or(200),
6659        )
6660    })
6661    .await
6662    .map_err(|e| format!("db_query_plugins task: {e}"))?
6663}
6664
6665#[tauri::command(rename_all = "snake_case")]
6666async fn db_query_daw(
6667    search: Option<String>,
6668    daw_filter: Option<String>,
6669    sort_key: Option<String>,
6670    sort_asc: Option<bool>,
6671    search_regex: Option<bool>,
6672    offset: Option<u64>,
6673    limit: Option<u64>,
6674) -> Result<db::DawQueryResult, String> {
6675    let search_regex = search_regex.unwrap_or(false);
6676    tokio::task::spawn_blocking(move || {
6677        db::global().query_daw(
6678            search.as_deref(),
6679            daw_filter.as_deref(),
6680            &sort_key.unwrap_or("name".into()),
6681            sort_asc.unwrap_or(true),
6682            search_regex,
6683            offset.unwrap_or(0),
6684            limit.unwrap_or(200),
6685        )
6686    })
6687    .await
6688    .map_err(|e| format!("db_query_daw task: {e}"))?
6689}
6690
6691#[tauri::command(rename_all = "snake_case")]
6692async fn db_query_presets(
6693    search: Option<String>,
6694    format_filter: Option<String>,
6695    sort_key: Option<String>,
6696    sort_asc: Option<bool>,
6697    search_regex: Option<bool>,
6698    offset: Option<u64>,
6699    limit: Option<u64>,
6700) -> Result<db::PresetQueryResult, String> {
6701    let search_regex = search_regex.unwrap_or(false);
6702    tokio::task::spawn_blocking(move || {
6703        db::global().query_presets(
6704            search.as_deref(),
6705            format_filter.as_deref(),
6706            &sort_key.unwrap_or("name".into()),
6707            sort_asc.unwrap_or(true),
6708            search_regex,
6709            offset.unwrap_or(0),
6710            limit.unwrap_or(200),
6711        )
6712    })
6713    .await
6714    .map_err(|e| format!("db_query_presets task: {e}"))?
6715}
6716
6717#[tauri::command]
6718async fn db_audio_stats(scan_id: Option<String>) -> Result<db::AudioStatsResult, String> {
6719    blocking_res(move || db::global().audio_stats(scan_id.as_deref())).await
6720}
6721
6722#[tauri::command]
6723async fn db_daw_stats(scan_id: Option<String>) -> Result<db::DawStatsResult, String> {
6724    blocking_res(move || db::global().daw_stats(scan_id.as_deref())).await
6725}
6726
6727#[tauri::command]
6728async fn db_preset_stats(scan_id: Option<String>) -> Result<db::PresetStatsResult, String> {
6729    blocking_res(move || db::global().preset_stats(scan_id.as_deref())).await
6730}
6731
6732#[tauri::command(rename_all = "snake_case")]
6733async fn db_query_pdfs(
6734    search: Option<String>,
6735    sort_key: Option<String>,
6736    sort_asc: Option<bool>,
6737    search_regex: Option<bool>,
6738    offset: Option<u64>,
6739    limit: Option<u64>,
6740) -> Result<db::PdfQueryResult, String> {
6741    let search_regex = search_regex.unwrap_or(false);
6742    tokio::task::spawn_blocking(move || {
6743        db::global().query_pdfs(
6744            search.as_deref(),
6745            &sort_key.unwrap_or("name".into()),
6746            sort_asc.unwrap_or(true),
6747            search_regex,
6748            offset.unwrap_or(0),
6749            limit.unwrap_or(200),
6750        )
6751    })
6752    .await
6753    .map_err(|e| format!("db_query_pdfs task: {e}"))?
6754}
6755
6756/// Single IPC round-trip for Cmd+K inventory preview (same limits as six separate `db_query_*` calls).
6757#[derive(Debug, Serialize)]
6758pub struct PalettePreviewResult {
6759    pub plugins: db::PluginQueryResult,
6760    pub audio: db::AudioQueryResult,
6761    pub daw: db::DawQueryResult,
6762    pub presets: db::PresetQueryResult,
6763    pub pdfs: db::PdfQueryResult,
6764    pub midi: db::MidiQueryResult,
6765}
6766
6767fn palette_preview_empty() -> PalettePreviewResult {
6768    PalettePreviewResult {
6769        plugins: db::PluginQueryResult {
6770            plugins: vec![],
6771            total_count: 0,
6772            total_count_capped: false,
6773            total_unfiltered: 0,
6774        },
6775        audio: db::AudioQueryResult {
6776            samples: vec![],
6777            total_count: 0,
6778            total_count_capped: false,
6779            total_unfiltered: 0,
6780        },
6781        daw: db::DawQueryResult {
6782            projects: vec![],
6783            total_count: 0,
6784            total_count_capped: false,
6785            total_unfiltered: 0,
6786        },
6787        presets: db::PresetQueryResult {
6788            presets: vec![],
6789            total_count: 0,
6790            total_count_capped: false,
6791            total_unfiltered: 0,
6792        },
6793        pdfs: db::PdfQueryResult {
6794            pdfs: vec![],
6795            total_count: 0,
6796            total_count_capped: false,
6797            total_unfiltered: 0,
6798        },
6799        midi: db::MidiQueryResult {
6800            midi_files: vec![],
6801            total_count: 0,
6802            total_count_capped: false,
6803            total_unfiltered: 0,
6804        },
6805    }
6806}
6807
6808#[tauri::command(rename_all = "snake_case")]
6809async fn db_query_palette_preview(search: String) -> Result<PalettePreviewResult, String> {
6810    let search = search.trim().to_string();
6811    if search.len() < 2 {
6812        return Ok(palette_preview_empty());
6813    }
6814    tokio::task::spawn_blocking(move || {
6815        let db = db::global();
6816        let plugins = db.query_plugins(Some(&search), None, None, "name", true, false, 0, 6)?;
6817        let audio = db.query_audio(&db::AudioQueryParams {
6818            scan_id: None,
6819            search: Some(search.clone()),
6820            search_regex: false,
6821            format_filter: None,
6822            sort_key: "name".into(),
6823            sort_asc: true,
6824            offset: 0,
6825            limit: 6,
6826        })?;
6827        let daw = db.query_daw(Some(&search), None, "name", true, false, 0, 6)?;
6828        let presets = db.query_presets(Some(&search), None, "name", true, false, 0, 6)?;
6829        let pdfs = db.query_pdfs(Some(&search), "name", true, false, 0, 6)?;
6830        let midi = db.query_midi(Some(&search), None, "name", true, false, 0, 6)?;
6831        Ok(PalettePreviewResult {
6832            plugins,
6833            audio,
6834            daw,
6835            presets,
6836            pdfs,
6837            midi,
6838        })
6839    })
6840    .await
6841    .map_err(|e| format!("db_query_palette_preview task: {e}"))?
6842}
6843
6844#[tauri::command]
6845async fn db_pdf_stats(scan_id: Option<String>) -> Result<db::PdfStatsResult, String> {
6846    blocking_res(move || db::global().pdf_stats(scan_id.as_deref())).await
6847}
6848
6849#[tauri::command(rename_all = "snake_case")]
6850async fn db_audio_filter_stats(
6851    search: Option<String>,
6852    format_filter: Option<String>,
6853    search_regex: Option<bool>,
6854) -> Result<db::FilterStatsResult, String> {
6855    let search_regex = search_regex.unwrap_or(false);
6856    tokio::task::spawn_blocking(move || {
6857        db::global().audio_filter_stats(
6858            search.as_deref(),
6859            format_filter.as_deref(),
6860            search_regex,
6861        )
6862    })
6863    .await
6864    .map_err(|e| format!("db_audio_filter_stats task: {e}"))?
6865}
6866
6867#[tauri::command(rename_all = "snake_case")]
6868async fn db_daw_filter_stats(
6869    search: Option<String>,
6870    daw_filter: Option<String>,
6871    search_regex: Option<bool>,
6872) -> Result<db::FilterStatsResult, String> {
6873    let search_regex = search_regex.unwrap_or(false);
6874    tokio::task::spawn_blocking(move || {
6875        db::global().daw_filter_stats(
6876            search.as_deref(),
6877            daw_filter.as_deref(),
6878            search_regex,
6879        )
6880    })
6881    .await
6882    .map_err(|e| format!("db_daw_filter_stats task: {e}"))?
6883}
6884
6885#[tauri::command(rename_all = "snake_case")]
6886async fn db_preset_filter_stats(
6887    search: Option<String>,
6888    format_filter: Option<String>,
6889    search_regex: Option<bool>,
6890) -> Result<db::FilterStatsResult, String> {
6891    let search_regex = search_regex.unwrap_or(false);
6892    tokio::task::spawn_blocking(move || {
6893        db::global().preset_filter_stats(
6894            search.as_deref(),
6895            format_filter.as_deref(),
6896            search_regex,
6897        )
6898    })
6899    .await
6900    .map_err(|e| format!("db_preset_filter_stats task: {e}"))?
6901}
6902
6903#[tauri::command(rename_all = "snake_case")]
6904async fn db_plugin_filter_stats(
6905    search: Option<String>,
6906    type_filter: Option<String>,
6907    search_regex: Option<bool>,
6908) -> Result<db::FilterStatsResult, String> {
6909    let search_regex = search_regex.unwrap_or(false);
6910    tokio::task::spawn_blocking(move || {
6911        db::global().plugin_filter_stats(
6912            search.as_deref(),
6913            type_filter.as_deref(),
6914            search_regex,
6915        )
6916    })
6917    .await
6918    .map_err(|e| format!("db_plugin_filter_stats task: {e}"))?
6919}
6920
6921#[tauri::command(rename_all = "snake_case")]
6922async fn db_pdf_filter_stats(
6923    search: Option<String>,
6924    search_regex: Option<bool>,
6925) -> Result<db::FilterStatsResult, String> {
6926    let search_regex = search_regex.unwrap_or(false);
6927    tokio::task::spawn_blocking(move || {
6928        db::global().pdf_filter_stats(search.as_deref(), search_regex)
6929    })
6930    .await
6931    .map_err(|e| format!("db_pdf_filter_stats task: {e}"))?
6932}
6933
6934/// Per-category row counts for the header strip — **library** scope (one row per `path`), not the
6935/// current in-progress `scan_id`. See [`db::Database::active_scan_inventory_counts`].
6936#[tauri::command]
6937async fn get_active_scan_inventory_counts() -> Result<serde_json::Value, String> {
6938    blocking_res(|| db::global().active_scan_inventory_counts()).await
6939}
6940
6941#[tauri::command]
6942async fn db_list_scans() -> Result<Vec<db::ScanInfo>, String> {
6943    blocking_res(|| db::global().list_scans()).await
6944}
6945
6946#[tauri::command]
6947async fn db_update_bpm(path: String, bpm: Option<f64>) -> Result<(), String> {
6948    blocking_res(move || db::global().update_bpm(&path, bpm)).await
6949}
6950
6951#[tauri::command]
6952async fn db_update_key(path: String, key: Option<String>) -> Result<(), String> {
6953    blocking_res(move || db::global().update_key(&path, key.as_deref())).await
6954}
6955
6956#[tauri::command]
6957async fn db_update_lufs(path: String, lufs: Option<f64>) -> Result<(), String> {
6958    blocking_res(move || db::global().update_lufs(&path, lufs)).await
6959}
6960
6961/// Persist BPM, key, and LUFS together (same transaction and `bpm_exhausted` rules as batch analysis).
6962#[tauri::command]
6963async fn db_update_analysis(
6964    path: String,
6965    bpm: Option<f64>,
6966    key: Option<String>,
6967    lufs: Option<f64>,
6968) -> Result<(), String> {
6969    let row = vec![(path, bpm, key, lufs)];
6970    blocking_res(move || db::global().batch_update_analysis(&row).map(|_| ())).await
6971}
6972
6973#[tauri::command]
6974async fn db_backfill_audio_meta(paths: Vec<String>) -> Result<serde_json::Value, String> {
6975    blocking_res(move || {
6976        let missing = db::global().paths_missing_audio_meta(&paths)?;
6977        if missing.is_empty() {
6978            return Ok(serde_json::json!({}));
6979        }
6980        let mut updated = serde_json::Map::new();
6981        for p in &missing {
6982            let am = audio_scanner::get_audio_metadata(p);
6983            if am.duration.is_some() || am.channels.is_some() {
6984                db::global().update_audio_meta(
6985                    p,
6986                    am.duration,
6987                    am.channels,
6988                    am.sample_rate,
6989                    am.bits_per_sample,
6990                )?;
6991                let mut obj = serde_json::Map::new();
6992                if let Some(d) = am.duration {
6993                    obj.insert("duration".into(), serde_json::json!(d));
6994                }
6995                if let Some(c) = am.channels {
6996                    obj.insert("channels".into(), serde_json::json!(c));
6997                }
6998                if let Some(sr) = am.sample_rate {
6999                    obj.insert("sampleRate".into(), serde_json::json!(sr));
7000                }
7001                if let Some(bps) = am.bits_per_sample {
7002                    obj.insert("bitsPerSample".into(), serde_json::json!(bps));
7003                }
7004                updated.insert(p.clone(), serde_json::Value::Object(obj));
7005            }
7006        }
7007        Ok(serde_json::Value::Object(updated))
7008    })
7009    .await
7010}
7011
7012#[tauri::command]
7013async fn db_get_analysis(path: String) -> Result<serde_json::Value, String> {
7014    blocking_res(move || db::global().get_analysis(&path)).await
7015}
7016
7017#[tauri::command]
7018async fn db_unanalyzed_paths(limit: Option<u64>) -> Result<Vec<String>, String> {
7019    blocking_res(move || db::global().unanalyzed_paths(limit.unwrap_or(100))).await
7020}
7021
7022#[tauri::command]
7023async fn db_audio_library_paths() -> Result<Vec<String>, String> {
7024    blocking_res(move || db::global().audio_library_paths()).await
7025}
7026
7027#[tauri::command]
7028async fn db_migrate_json() -> Result<usize, String> {
7029    blocking_res(|| db::global().migrate_from_json()).await
7030}
7031
7032#[tauri::command]
7033async fn db_cache_stats() -> Result<Vec<db::CacheStat>, String> {
7034    blocking_res(|| db::global().cache_stats()).await
7035}
7036
7037#[tauri::command]
7038async fn db_clear_caches() -> Result<(), String> {
7039    append_log("DB CLEAR — all caches (waveform, spectrogram, xref, fingerprint, kvr)".into());
7040    blocking_res(|| db::global().clear_all_caches()).await
7041}
7042
7043#[tauri::command]
7044async fn db_clear_cache_table(table: String) -> Result<(), String> {
7045    append_log(format!("DB CLEAR — cache table: {}", table));
7046    blocking_res(move || db::global().clear_cache_table(&table)).await
7047}
7048
7049fn resolve_ui_locale(locale: Option<String>) -> String {
7050    locale.unwrap_or_else(|| {
7051        history::load_preferences()
7052            .get("uiLocale")
7053            .and_then(|v| v.as_str().map(|s| s.to_string()))
7054            .unwrap_or_else(|| "en".to_string())
7055    })
7056}
7057
7058#[tauri::command]
7059async fn get_app_strings(
7060    locale: Option<String>,
7061) -> Result<std::collections::HashMap<String, String>, String> {
7062    let loc = resolve_ui_locale(locale);
7063    blocking_res(move || db::global().get_app_strings(&loc)).await
7064}
7065
7066#[tauri::command]
7067async fn get_toast_strings(
7068    locale: Option<String>,
7069) -> Result<std::collections::HashMap<String, String>, String> {
7070    get_app_strings(locale).await
7071}
7072
7073/// Rebuild the native menu bar from SQLite `app_i18n` for the current UI locale (after changing language in Settings).
7074#[tauri::command]
7075fn refresh_native_menu(app: AppHandle) -> Result<(), String> {
7076    let ui_locale = resolve_ui_locale(None);
7077    let strings = db::global().get_app_strings(&ui_locale).unwrap_or_default();
7078    let menu = native_menu::build_native_menu_bar(&app, &strings).map_err(|e| e.to_string())?;
7079    app.set_menu(menu).map_err(|e| e.to_string())?;
7080    let tray_state = app.state::<tray_menu::TrayState>();
7081    tray_menu::refresh_tray_popup_menu(&app, &tray_state, &strings)?;
7082    Ok(())
7083}
7084
7085// ── File watcher commands ──
7086
7087#[tauri::command]
7088fn start_file_watcher(app: AppHandle, dirs: Vec<String>) -> Result<(), String> {
7089    append_log(format!(
7090        "FILE WATCHER START — {} directories: {:?}",
7091        dirs.len(),
7092        dirs
7093    ));
7094    let state = app.state::<file_watcher::FileWatcherState>();
7095    file_watcher::start_watching(&app, &state, dirs)
7096}
7097
7098#[tauri::command]
7099fn stop_file_watcher(app: AppHandle) -> Result<(), String> {
7100    append_log("FILE WATCHER STOP".into());
7101    let state = app.state::<file_watcher::FileWatcherState>();
7102    file_watcher::stop_watching(&state);
7103    Ok(())
7104}
7105
7106#[tauri::command]
7107fn get_file_watcher_status(app: AppHandle) -> serde_json::Value {
7108    let state = app.state::<file_watcher::FileWatcherState>();
7109    serde_json::json!({
7110        "watching": file_watcher::is_watching(&state),
7111        "dirs": file_watcher::get_watched_dirs(&state),
7112    })
7113}
7114
7115// ── App setup ──
7116
7117#[cfg_attr(mobile, tauri::mobile_entry_point)]
7118pub fn run() {
7119    // Panic hook — write crash info to app.log before dying
7120    std::panic::set_hook(Box::new(|info| {
7121        let path = history::ensure_data_dir().join("app.log");
7122        let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
7123        let location = info
7124            .location()
7125            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
7126            .unwrap_or_default();
7127        let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
7128            s.to_string()
7129        } else if let Some(s) = info.payload().downcast_ref::<String>() {
7130            s.clone()
7131        } else {
7132            "unknown panic".into()
7133        };
7134        let backtrace = std::backtrace::Backtrace::force_capture();
7135        let msg = format!("[{timestamp}] PANIC at {location}: {payload}\n{backtrace}\n");
7136        eprintln!("{msg}");
7137        let _ = std::fs::OpenOptions::new()
7138            .create(true)
7139            .append(true)
7140            .open(&path)
7141            .and_then(|mut f| {
7142                use std::io::Write;
7143                f.write_all(msg.as_bytes())
7144            });
7145    }));
7146
7147    // Initialize app start time for uptime tracking
7148    APP_START.get_or_init(Instant::now);
7149
7150    // Register atexit handler: terminate the AudioEngine, then shutdown logging (Cmd+Q, SIGTERM, etc.)
7151    extern "C" fn on_exit() {
7152        let _ = audio_engine::shutdown_audio_engine_child();
7153        log_shutdown();
7154    }
7155    unsafe {
7156        libc::atexit(on_exit);
7157    }
7158
7159    // Load preferences once for all startup config
7160    let prefs = history::load_preferences();
7161    refresh_log_verbosity_from_prefs();
7162
7163    // Log startup with system info
7164    let rss = get_rss_bytes();
7165    let db_path = history::ensure_data_dir().join("audio_haxor.db");
7166    let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
7167    let rayon_threads = rayon::current_num_threads();
7168    let hostname = sysinfo::System::host_name().unwrap_or_default();
7169    // Raise file descriptor limit for intensive directory walking
7170    #[cfg(unix)]
7171    let fd_target: u64 = prefs
7172        .get("fdLimit")
7173        .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or(v.as_u64()))
7174        .unwrap_or(10240)
7175        .clamp(256, 65536);
7176    #[cfg(not(unix))]
7177    let fd_target: u64 = 0;
7178
7179    let batch_size = prefs
7180        .get("batchSize")
7181        .and_then(|v| v.as_str())
7182        .unwrap_or("100");
7183    let channel_buffer = prefs
7184        .get("channelBuffer")
7185        .and_then(|v| v.as_str())
7186        .unwrap_or("512");
7187    let flush_interval = prefs
7188        .get("flushInterval")
7189        .and_then(|v| v.as_str())
7190        .unwrap_or("100");
7191    let analysis_pause = prefs
7192        .get("analysisPause")
7193        .and_then(|v| v.as_str())
7194        .unwrap_or("100");
7195    let page_size = prefs
7196        .get("pageSize")
7197        .and_then(|v| v.as_str())
7198        .unwrap_or("200");
7199    let auto_scan = prefs
7200        .get("autoScan")
7201        .and_then(|v| v.as_str())
7202        .unwrap_or("off");
7203    let folder_watch = prefs
7204        .get("folderWatch")
7205        .and_then(|v| v.as_str())
7206        .unwrap_or("off");
7207    let log_verbosity = prefs
7208        .get("logVerbosity")
7209        .and_then(|v| v.as_str())
7210        .unwrap_or("normal");
7211
7212    append_log(format!(
7213        "APP START — v{} | {} {} | {} | {} cores | {} rayon threads | pid {} | RSS {} | DB {}",
7214        env!("CARGO_PKG_VERSION"),
7215        std::env::consts::OS,
7216        std::env::consts::ARCH,
7217        hostname,
7218        num_cpus::get(),
7219        rayon_threads,
7220        std::process::id(),
7221        format_size(rss),
7222        format_size(db_size),
7223    ));
7224    append_log(format!(
7225        "CONFIG — fd_limit: {} | batch_size: {} | channel_buffer: {} | flush_interval: {}ms | analysis_pause: {}ms | page_size: {} | auto_scan: {} | folder_watch: {} | log_verbosity: {}",
7226        fd_target, batch_size, channel_buffer, flush_interval, analysis_pause, page_size, auto_scan, folder_watch, log_verbosity,
7227    ));
7228
7229    #[cfg(unix)]
7230    {
7231        let mut rlim = libc::rlimit {
7232            rlim_cur: 0,
7233            rlim_max: 0,
7234        };
7235        unsafe {
7236            if libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) == 0 {
7237                let target = (rlim.rlim_max).min(fd_target);
7238                if rlim.rlim_cur < target {
7239                    rlim.rlim_cur = target;
7240                    libc::setrlimit(libc::RLIMIT_NOFILE, &rlim);
7241                }
7242            }
7243        }
7244    }
7245
7246    // Initialize rayon thread pool — multiplier read from config (default 4x).
7247    // Filesystem scanning is heavily I/O-bound: threads spend most time waiting
7248    // on disk reads, stat calls, and plist parsing. Oversubscription ensures
7249    // there are always runnable threads when others are blocked on I/O.
7250    let multiplier = prefs
7251        .get("threadMultiplier")
7252        .and_then(|v| {
7253            v.as_str()
7254                .or_else(|| v.as_u64().map(|_| ""))
7255                .and_then(|s| s.parse::<usize>().ok())
7256        })
7257        .or_else(|| {
7258            prefs
7259                .get("threadMultiplier")
7260                .and_then(|v| v.as_u64().map(|n| n as usize))
7261        })
7262        .unwrap_or(8)
7263        .clamp(1, 16);
7264    let pool_size = num_cpus::get() * multiplier;
7265    append_log(format!(
7266        "THREAD POOL — {}x multiplier | {} threads | 8MB stack",
7267        multiplier, pool_size,
7268    ));
7269    rayon::ThreadPoolBuilder::new()
7270        .num_threads(pool_size)
7271        .stack_size(8 * 1024 * 1024)
7272        .panic_handler(|panic_info| {
7273            let msg = format!("Rayon thread panicked: {:?}", panic_info);
7274            eprintln!("{msg}");
7275            append_log(msg);
7276        })
7277        .build_global()
7278        .ok();
7279
7280    // Initialize global SQLite database (open + migrate only — fast)
7281    db::init_global().expect("Failed to initialize database");
7282
7283    // Two-phase DB housekeeping (off main thread). `prune_old_scans` + `rebuild_*_libraries` can
7284    // hold a pooled handle for a long time; `setup()` + first IPC need `read_conn()` without waiting.
7285    // Light: `PRAGMA optimize` + prewarm soon after launch. Heavy: prune + `VACUUM` many seconds later.
7286    const STARTUP_DB_LIGHT_DELAY_MS: u64 = 750;
7287    const STARTUP_DB_HEAVY_DELAY_SECS: u64 = 12;
7288    std::thread::spawn(|| {
7289        std::thread::sleep(std::time::Duration::from_millis(STARTUP_DB_LIGHT_DELAY_MS));
7290        db::global().housekeep_light();
7291    });
7292    std::thread::spawn(|| {
7293        std::thread::sleep(std::time::Duration::from_secs(STARTUP_DB_HEAVY_DELAY_SECS));
7294        db::global().housekeep_heavy();
7295        if let Ok(counts) = db::global().table_counts() {
7296            let m = counts.as_object().unwrap();
7297            let get = |k: &str| m.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
7298            append_log(format!(
7299                "DB STATS — {} plugins | {} samples | {} DAW projects | {} presets | {} KVR cache | {} waveforms | {} spectrograms | {} xref | {} fingerprints",
7300                get("plugins"), get("audio_samples"), get("daw_projects"), get("presets"),
7301                get("kvr_cache"), get("waveform_cache"), get("spectrogram_cache"), get("xref_cache"), get("fingerprint_cache"),
7302            ));
7303        }
7304    });
7305
7306    tauri::Builder::default()
7307        .plugin(tauri_plugin_shell::init())
7308        .plugin(tauri_plugin_dialog::init())
7309        .plugin(tauri_plugin_drag::init())
7310        .manage(ScanState {
7311            scanning: AtomicBool::new(false),
7312            stop_scan: AtomicBool::new(false),
7313        })
7314        .manage(UpdateState {
7315            checking: AtomicBool::new(false),
7316            stop_updates: AtomicBool::new(false),
7317        })
7318        .manage(AudioScanState {
7319            scanning: AtomicBool::new(false),
7320            stop_scan: AtomicBool::new(false),
7321        })
7322        .manage(DawScanState {
7323            scanning: AtomicBool::new(false),
7324            stop_scan: AtomicBool::new(false),
7325        })
7326        .manage(PresetScanState {
7327            scanning: AtomicBool::new(false),
7328            stop_scan: AtomicBool::new(false),
7329        })
7330        .manage(MidiScanState {
7331            scanning: AtomicBool::new(false),
7332            stop_scan: AtomicBool::new(false),
7333        })
7334        .manage(PdfScanState {
7335            scanning: AtomicBool::new(false),
7336            stop_scan: AtomicBool::new(false),
7337        })
7338        .manage(WalkerStatus {
7339            plugin_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7340            audio_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7341            daw_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7342            preset_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7343            midi_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7344            pdf_dirs: Arc::new(std::sync::Mutex::new(Vec::new())),
7345            unified_scanning: AtomicBool::new(false),
7346        })
7347        .manage(file_watcher::FileWatcherState::new())
7348        .manage(tray_menu::TrayState::default())
7349        .invoke_handler(tauri::generate_handler![
7350            get_version,
7351            get_build_info,
7352            get_walker_status,
7353            scan_plugins,
7354            stop_scan,
7355            check_updates,
7356            stop_updates,
7357            resolve_kvr,
7358            history_get_scans,
7359            history_get_detail,
7360            history_delete,
7361            history_clear,
7362            history_diff,
7363            history_latest,
7364            kvr_cache_get,
7365            kvr_cache_update,
7366            scan_audio_samples,
7367            stop_audio_scan,
7368            get_audio_metadata,
7369            audio_history_save,
7370            audio_history_get_scans,
7371            audio_history_get_detail,
7372            audio_history_delete,
7373            audio_history_clear,
7374            audio_history_latest,
7375            audio_history_diff,
7376            scan_daw_projects,
7377            stop_daw_scan,
7378            daw_history_save,
7379            daw_history_get_scans,
7380            daw_history_get_detail,
7381            daw_history_delete,
7382            daw_history_clear,
7383            daw_history_latest,
7384            daw_history_diff,
7385            open_daw_folder,
7386            open_daw_project,
7387            extract_project_plugins,
7388            read_als_xml,
7389            estimate_bpm,
7390            detect_audio_key,
7391            measure_lufs,
7392            batch_analyze,
7393            read_cache_file,
7394            write_cache_file,
7395            audio_engine_invoke,
7396            audio_engine_restart,
7397            audio_engine_eof_watchdog_start,
7398            audio_engine_eof_watchdog_stop,
7399            get_audio_engine_process_stats,
7400            append_log,
7401            read_log,
7402            clear_log,
7403            list_data_files,
7404            delete_data_file,
7405            read_bwproject,
7406            read_project_file,
7407            compute_fingerprint,
7408            find_similar_samples,
7409            build_fingerprint_cache,
7410            find_content_duplicates,
7411            open_update_url,
7412            open_plugin_folder,
7413            open_audio_folder,
7414            export_plugins_json,
7415            export_plugins_csv,
7416            import_plugins_json,
7417            export_audio_json,
7418            export_audio_dsv,
7419            export_daw_json,
7420            export_daw_dsv,
7421            prefs_get_all,
7422            prefs_set,
7423            prefs_remove,
7424            prefs_save_all,
7425            scan_presets,
7426            stop_preset_scan,
7427            preset_history_save,
7428            preset_history_get_scans,
7429            preset_history_get_detail,
7430            preset_history_delete,
7431            preset_history_clear,
7432            preset_history_latest,
7433            preset_history_diff,
7434            open_preset_folder,
7435            scan_midi_files,
7436            stop_midi_scan,
7437            midi_history_save,
7438            midi_history_get_scans,
7439            midi_history_get_detail,
7440            midi_history_delete,
7441            midi_history_clear,
7442            midi_history_latest,
7443            midi_history_diff,
7444            db_query_midi,
7445            db_midi_filter_stats,
7446            scan_pdfs,
7447            stop_pdf_scan,
7448            scan_unified,
7449            get_unified_scan_run,
7450            prepare_unified_scan,
7451            stop_unified_scan,
7452            pdf_history_save,
7453            pdf_history_get_scans,
7454            pdf_history_get_detail,
7455            pdf_history_delete,
7456            pdf_history_clear,
7457            pdf_history_latest,
7458            pdf_history_diff,
7459            open_pdf_file,
7460            pdf_metadata_get,
7461            pdf_metadata_extract_abort,
7462            pdf_metadata_extract_batch,
7463            pdf_metadata_unindexed,
7464            open_file_default,
7465            export_presets_json,
7466            export_presets_dsv,
7467            export_pdfs_json,
7468            export_pdfs_dsv,
7469            import_pdfs_json,
7470            export_toml,
7471            import_toml,
7472            export_pdf,
7473            import_presets_json,
7474            import_audio_json,
7475            import_daw_json,
7476            open_with_app,
7477            fs_list_dir,
7478            delete_file,
7479            rename_file,
7480            write_text_file,
7481            read_text_file,
7482            get_home_dir,
7483            get_process_stats,
7484            open_prefs_file,
7485            get_prefs_path,
7486            db_query_audio,
7487            db_query_plugins,
7488            db_query_daw,
7489            db_query_presets,
7490            db_audio_stats,
7491            db_daw_stats,
7492            db_preset_stats,
7493            db_query_pdfs,
7494            db_query_palette_preview,
7495            db_pdf_stats,
7496            db_audio_filter_stats,
7497            db_daw_filter_stats,
7498            db_preset_filter_stats,
7499            db_plugin_filter_stats,
7500            db_pdf_filter_stats,
7501            get_active_scan_inventory_counts,
7502            db_list_scans,
7503            db_update_bpm,
7504            db_update_key,
7505            db_update_lufs,
7506            db_update_analysis,
7507            db_backfill_audio_meta,
7508            db_get_analysis,
7509            db_unanalyzed_paths,
7510            db_audio_library_paths,
7511            db_migrate_json,
7512            db_cache_stats,
7513            db_clear_caches,
7514            db_clear_cache_table,
7515            get_app_strings,
7516            get_toast_strings,
7517            refresh_native_menu,
7518            tray_menu::update_tray_now_playing,
7519            tray_menu::tray_popover_action,
7520            tray_menu::tray_popover_resize,
7521            tray_menu::tray_popover_get_state,
7522            tray_menu::tray_popover_get_ui_theme,
7523            tray_menu::show_main_window,
7524            tray_menu::tray_popover_hide,
7525            start_file_watcher,
7526            stop_file_watcher,
7527            get_file_watcher_status,
7528            get_midi_info,
7529        ])
7530        .setup(|app| {
7531            // Restore window size/position
7532            let prefs = history::load_preferences();
7533            if let Some(win_val) = prefs.get("window") {
7534                if let Some(win) = app.get_webview_window("main") {
7535                    if let Some(w) = win_val.get("width").and_then(|v| v.as_u64()) {
7536                        if let Some(h) = win_val.get("height").and_then(|v| v.as_u64()) {
7537                            let size = tauri::PhysicalSize::new(w as u32, h as u32);
7538                            let _ = win.set_size(tauri::Size::Physical(size));
7539                        }
7540                    }
7541                    if let Some(x) = win_val.get("x").and_then(|v| v.as_i64()) {
7542                        if let Some(y) = win_val.get("y").and_then(|v| v.as_i64()) {
7543                            let pos = tauri::PhysicalPosition::new(x as i32, y as i32);
7544                            let _ = win.set_position(tauri::Position::Physical(pos));
7545                        }
7546                    }
7547                }
7548            }
7549
7550            // Build menu bar
7551            let handle = app.handle();
7552            let ui_locale = prefs
7553                .get("uiLocale")
7554                .and_then(|v| v.as_str().map(|s| s.to_string()))
7555                .unwrap_or_else(|| "en".to_string());
7556            let strings = db::global().get_app_strings(&ui_locale).unwrap_or_default();
7557            let menu =
7558                native_menu::build_native_menu_bar(handle, &strings).map_err(|e| e.to_string())?;
7559            app.set_menu(menu).map_err(|e| e.to_string())?;
7560
7561            // Handle menu events — emit to frontend JS
7562            let handle2 = app.handle().clone();
7563            app.on_menu_event(move |_app, event| {
7564                let id = event.id().0.as_str();
7565                if let Some(win) = handle2.get_webview_window("main") {
7566                    let _ = win.emit("menu-action", id);
7567                }
7568            });
7569
7570            let tray = tray_menu::create_tray(app, &strings)?;
7571            {
7572                let state = app.state::<tray_menu::TrayState>();
7573                let mut guard = state
7574                    .inner
7575                    .lock()
7576                    .map_err(|_| "tray state mutex poisoned".to_string())?;
7577                guard.tray = Some(tray);
7578                guard.menu_strings = strings;
7579                guard.now_playing_menu_line = None;
7580            }
7581            /* Host-side poll thread — keeps tray title + popover elapsed live for audio-engine
7582             * playback even while the main window is unfocused (JS rAF + `setInterval` both stall
7583             * behind `isUiIdleHeavyCpu`, leaving the tray frozen). JS still pushes the track name. */
7584            tray_menu::start_tray_host_poll(app.handle().clone());
7585
7586            tray_popover_escape_macos::install(app.handle().clone());
7587
7588            /* Finder pre-warm: the first AppleEvent to Finder in a session loads Finder's scripting
7589             * support + Launch Services cache. On a cold machine (and ESPECIALLY when the user's
7590             * audio library lives on an SMB share), this cost can run multiple seconds on the first
7591             * Reveal in Finder click, spiking CPU and starving the audio-engine subprocess. Fire a
7592             * no-op `osascript` → Finder round-trip on a detached thread at startup so the cost is
7593             * paid before any audio is playing. The target (`name of application process "Finder"`)
7594             * is a purely local query — no filesystem access — and completes in a few ms once
7595             * Finder's scripting support is warm. */
7596            #[cfg(target_os = "macos")]
7597            std::thread::spawn(|| {
7598                /* `.stdout(Stdio::null()).stderr(Stdio::null())` suppresses osascript's reply
7599                 * (`tell Finder to get name` prints "Finder" to stdout, which otherwise leaks
7600                 * to the `pnpm tauri dev` terminal). We don't care about the return value —
7601                 * the whole point is to force Finder's AppleEvent scripting support + Launch
7602                 * Services to warm up before the user clicks Reveal in Finder. */
7603                let _ = std::process::Command::new("osascript")
7604                    .arg("-e")
7605                    .arg("tell application \"Finder\" to get name")
7606                    .stdout(std::process::Stdio::null())
7607                    .stderr(std::process::Stdio::null())
7608                    .spawn();
7609            });
7610
7611            Ok(())
7612        })
7613        .build(tauri::generate_context!())
7614        .expect("error while building tauri application")
7615        .run(|app, event| match event {
7616            tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
7617                let _ = audio_engine::shutdown_audio_engine_child();
7618                log_shutdown();
7619            }
7620            /* Click-outside-dismiss for the tray popover, Rust side. Two complementary handlers:
7621             *
7622             * 1. Popover loses key focus → DEFERRED hide (200 ms). Fires when the user clicks
7623             *    into another app or onto a different Space — the main window isn't gaining
7624             *    focus in that case (it may be on a different Space entirely), so handler #2
7625             *    below can't catch it. Requires `set_focus` on the popover at show time (see
7626             *    `toggle_tray_popover`) so the popover is actually key in the first place.
7627             *
7628             *    The 200 ms defer defuses a tray-icon-toggle race: clicking the tray icon
7629             *    while the popover is key makes the popover lose key status (menubar becomes
7630             *    the click target) → this blur handler fires → if we hid immediately, the
7631             *    subsequent tray-icon toggle would see `is_visible=false` and REOPEN the
7632             *    popover instead of leaving it closed. By deferring and re-checking
7633             *    `is_focused()` on a background thread, we skip the hide when the tray toggle
7634             *    has already handled the close path itself.
7635             *
7636             * 2. Any OTHER window gains focus → hide. Handles clicking into the main window
7637             *    (same Space) where the popover blur may not fire reliably. Non-deferred
7638             *    because there's no race with the tray icon toggle here. */
7639            tauri::RunEvent::WindowEvent {
7640                label,
7641                event: tauri::WindowEvent::Focused(false),
7642                ..
7643            } if label == "tray-popover" => {
7644                let app_handle = app.clone();
7645                std::thread::spawn(move || {
7646                    std::thread::sleep(std::time::Duration::from_millis(200));
7647                    let Some(popover) = app_handle.get_webview_window("tray-popover") else {
7648                        return;
7649                    };
7650                    /* Skip if the popover regained focus (user clicked back in) OR was already
7651                     * hidden by the tray-icon toggle path. */
7652                    if !popover.is_visible().unwrap_or(false) {
7653                        return;
7654                    }
7655                    if popover.is_focused().unwrap_or(false) {
7656                        return;
7657                    }
7658                    let _ = popover.hide();
7659                });
7660            }
7661            tauri::RunEvent::WindowEvent {
7662                label,
7663                event: tauri::WindowEvent::Focused(true),
7664                ..
7665            } if label != "tray-popover" => {
7666                if let Some(popover) = app.get_webview_window("tray-popover") {
7667                    if popover.is_visible().unwrap_or(false) {
7668                        let _ = popover.hide();
7669                    }
7670                }
7671            }
7672            _ => {}
7673        });
7674}
7675
7676#[cfg(test)]
7677mod log_verbosity_tests {
7678    use super::{app_log_verbose, log_verbosity_level, should_suppress_app_log_line, LOG_VERBOSITY_LEVEL};
7679    use std::sync::atomic::{AtomicUsize, Ordering};
7680
7681    #[test]
7682    fn quiet_filter_is_opt_in_prefix_list() {
7683        LOG_VERBOSITY_LEVEL.store(0, Ordering::Relaxed);
7684        assert!(!should_suppress_app_log_line("SCAN ERROR — daw | x"));
7685        assert!(!should_suppress_app_log_line("SCAN TCC DENIED — unified | x"));
7686        LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7687    }
7688
7689    #[test]
7690    fn app_log_verbose_skips_closure_when_not_verbose() {
7691        let calls = AtomicUsize::new(0);
7692        LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7693        app_log_verbose(|| {
7694            calls.fetch_add(1, Ordering::Relaxed);
7695            "should not run".to_string()
7696        });
7697        assert_eq!(calls.load(Ordering::Relaxed), 0);
7698        LOG_VERBOSITY_LEVEL.store(2, Ordering::Relaxed);
7699        app_log_verbose(|| {
7700            calls.fetch_add(1, Ordering::Relaxed);
7701            "should run".to_string()
7702        });
7703        assert_eq!(calls.load(Ordering::Relaxed), 1);
7704        LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7705    }
7706
7707    #[test]
7708    fn log_verbosity_level_tracks_atomic() {
7709        LOG_VERBOSITY_LEVEL.store(2, Ordering::Relaxed);
7710        assert_eq!(log_verbosity_level(), 2);
7711        LOG_VERBOSITY_LEVEL.store(1, Ordering::Relaxed);
7712    }
7713}
7714
7715fn log_shutdown() {
7716    use std::sync::atomic::{AtomicBool, Ordering};
7717    static LOGGED: AtomicBool = AtomicBool::new(false);
7718    if LOGGED.swap(true, Ordering::Relaxed) {
7719        return;
7720    } // only log once
7721    let uptime = APP_START.get().map(|s| s.elapsed().as_secs()).unwrap_or(0);
7722    append_log(format!(
7723        "APP SHUTDOWN — uptime {}m {}s",
7724        uptime / 60,
7725        uptime % 60
7726    ));
7727}