app_lib/
unified_walker.rs

1//! Unified filesystem walker — traverses a union of roots once and classifies
2//! files into audio/daw/preset/pdf buckets by extension.
3//!
4//! ## Why
5//! Running 4 separate walkers (audio, daw, preset, pdf) over overlapping roots
6//! (`~`, `/Applications`, `/Volumes/*`) re-walks the same directories 4x.
7//! On SMB shares, each `readdir`/`stat` is a network roundtrip — re-walks cost
8//! minutes. This module walks each subtree exactly once.
9//!
10//! ## How
11//! 1. Union the per-type root sets.
12//! 2. Walk each unique root in parallel via rayon, dedup-visited by canonical path.
13//! 3. For every file entry, classify by lowercase extension against each type's
14//!    extension set AND the type's root-membership predicate. A file at
15//!    `~/foo.wav` is audio if `~/foo.wav` sits under at least one `audio_roots`
16//!    entry (for typical setups, this is trivially true).
17//! 4. DAW packages (`.logicx`, `.band` directories) are detected at directory
18//!    level and treated as projects; their subtree is NOT descended.
19//! 5. Plugin bundles (`.vst3`, `.component`, etc.) are skipped entirely for DAW,
20//!    but their interiors ARE still walked for audio/preset/pdf content.
21//! 6. Symlinks: `readdir` does not mark symlink targets as files/dirs; each
22//!    symlink is `stat`ed and classified by its target (broken symlinks skipped).
23//!
24//! Per-type progress callbacks stream batches as they're discovered.
25
26use crate::audio_extensions::AUDIO_EXTENSIONS;
27use crate::bulk_stat::{read_dir_bulk, BulkEntry};
28use crate::history::{AudioSample, DawProject, PdfFile, PresetFile};
29use crate::scanner_skip_dirs::SCANNER_SKIP_DIRS as SKIP_DIRS;
30use rayon::prelude::*;
31use dashmap::DashSet;
32use std::collections::{HashMap, HashSet};
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
36use std::sync::{Arc, Mutex};
37
38/// Same path key string as visit deduplication: `canonicalize` when possible, else normalized original.
39fn directory_incremental_key(dir: &Path) -> String {
40    let orig = normalize_macos_path(dir.to_path_buf());
41    let canon = fs::canonicalize(dir).ok().map(normalize_macos_path);
42    let key = canon.unwrap_or(orig);
43    key.to_string_lossy().to_string()
44}
45
46fn dir_mtime_secs(dir: &Path) -> i64 {
47    fs::metadata(dir)
48        .and_then(|m| m.modified())
49        .ok()
50        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
51        .map(|d| d.as_secs() as i64)
52        .unwrap_or(0)
53}
54
55/// Snapshot + in-scan updates for incremental directory skipping (`scan_unified` and
56/// standalone per-type walkers). Persisted in SQLite under domain `"unified"` so all scan modes
57/// share one mtime map.
58pub struct IncrementalDirState {
59    mtimes: Arc<Mutex<HashMap<String, i64>>>,
60    pending: Arc<Mutex<Vec<(String, i64)>>>,
61}
62
63impl IncrementalDirState {
64    pub fn new(snapshot: HashMap<String, i64>) -> Self {
65        Self {
66            mtimes: Arc::new(Mutex::new(snapshot)),
67            pending: Arc::new(Mutex::new(Vec::new())),
68        }
69    }
70
71    /// Skip this directory (and its subtree) when stored mtime ≥ current — same rule as
72    /// `scan_unified`.
73    pub fn should_skip(&self, dir: &Path) -> bool {
74        let key = directory_incremental_key(dir);
75        let cur = dir_mtime_secs(dir);
76        if cur <= 0 {
77            return false;
78        }
79        let map = self.mtimes.lock().unwrap_or_else(|e| e.into_inner());
80        if let Some(&stored) = map.get(&key) {
81            if cur <= stored {
82                return true;
83            }
84        }
85        false
86    }
87
88    /// Call after successfully listing/processing a directory so the next run can skip it if
89    /// unchanged.
90    pub fn record_scanned_dir(&self, dir: &Path) {
91        let key = directory_incremental_key(dir);
92        let cur = dir_mtime_secs(dir);
93        if cur <= 0 {
94            return;
95        }
96        let mut map = self.mtimes.lock().unwrap_or_else(|e| e.into_inner());
97        map.insert(key.clone(), cur);
98        drop(map);
99        self.pending
100            .lock()
101            .unwrap_or_else(|e| e.into_inner())
102            .push((key, cur));
103    }
104
105    pub fn take_pending(&self) -> Vec<(String, i64)> {
106        let mut p = self.pending.lock().unwrap_or_else(|e| e.into_inner());
107        std::mem::take(&mut *p)
108    }
109}
110
111/// Format a UNIX timestamp (seconds since epoch) as "YYYY-MM-DD" in UTC.
112/// Returns empty string for invalid/zero timestamps.
113fn fmt_mtime_ymd(mtime_secs: i64) -> String {
114    if mtime_secs <= 0 {
115        return String::new();
116    }
117    chrono::DateTime::<chrono::Utc>::from_timestamp(mtime_secs, 0)
118        .map(|dt| dt.format("%Y-%m-%d").to_string())
119        .unwrap_or_default()
120}
121
122fn normalize_macos_path(p: PathBuf) -> PathBuf {
123    #[cfg(target_os = "macos")]
124    {
125        let s = p.to_string_lossy();
126        if s.starts_with("/System/Volumes/Data/") {
127            return PathBuf::from(&s["/System/Volumes/Data".len()..]);
128        }
129    }
130    p
131}
132
133// ── Network filesystem detection ─────────────────────────────────────────
134// Returns the filesystem type name (e.g. "smbfs", "nfs", "afpfs") if `path`
135// lives on a network mount, or None for local filesystems.  Uses statfs(2)
136// on macOS; always returns None on other platforms.
137#[cfg(target_os = "macos")]
138fn network_fs_type(path: &Path) -> Option<String> {
139    use std::ffi::CString;
140    let c_path = CString::new(path.as_os_str().to_string_lossy().as_bytes()).ok()?;
141    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
142    let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut stat) };
143    if rc != 0 {
144        return None;
145    }
146    let fstype = unsafe {
147        std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr())
148            .to_string_lossy()
149            .to_string()
150    };
151    // MNT_LOCAL is clear on network mounts (smbfs, nfs, afpfs, webdav).
152    const MNT_LOCAL: u32 = 0x00001000;
153    if stat.f_flags & MNT_LOCAL == 0 {
154        Some(fstype)
155    } else {
156        None
157    }
158}
159
160#[cfg(not(target_os = "macos"))]
161fn network_fs_type(_path: &Path) -> Option<String> {
162    None
163}
164
165/// Quick check: is this path likely on a network share?
166/// Network mounts can be anywhere (not just /Volumes/ or /mnt/), so fall back
167/// to statfs(2) which is authoritative on macOS.
168fn is_network_path(path: &Path) -> bool {
169    network_fs_type(path).is_some()
170}
171
172// Audio: `crate::audio_extensions::AUDIO_EXTENSIONS`. DAW/preset lists are local
173// to this walker (keep aligned with `daw_scanner` / `preset_scanner` / file watcher).
174const DAW_EXTENSIONS: &[&str] = &[
175    ".als",
176    ".logicx",
177    ".flp",
178    ".cpr",
179    ".npr",
180    ".bwproject",
181    ".rpp",
182    ".rpp-bak",
183    ".ptx",
184    ".ptf",
185    ".song",
186    ".reason",
187    ".aup",
188    ".aup3",
189    ".band",
190    ".ardour",
191    ".dawproject",
192];
193
194const DAW_PACKAGE_EXTENSIONS: &[&str] = &[".logicx", ".band"];
195
196const DAW_PLUGIN_BUNDLE_EXTENSIONS: &[&str] = &[
197    ".vst",
198    ".vst3",
199    ".component",
200    ".aaxplugin",
201    ".app",
202    ".framework",
203    ".bundle",
204    ".plugin",
205    ".dpm",
206    ".clap",
207];
208
209const DAW_BACKUP_DIRS: &[&str] = &["Backup", "Crash"];
210
211const PRESET_EXTENSIONS: &[&str] = &[
212    ".fxp",
213    ".fxb",
214    ".vstpreset",
215    ".aupreset",
216    ".adv",
217    ".adg",
218    ".nki",
219    ".nksn",
220    ".h2p",
221    ".syx",
222    ".tfx",
223    ".pjunoxl",
224    // .mid / .midi live in midi_files (separate walker/table) — NEVER here.
225];
226
227const PDF_EXTENSION: &str = ".pdf";
228
229fn format_size(bytes: u64) -> String {
230    crate::format_size(bytes)
231}
232
233fn daw_name_for_format(format: &str) -> &'static str {
234    match format {
235        "ALS" => "Ableton Live",
236        "LOGICX" => "Logic Pro",
237        "FLP" => "FL Studio",
238        "CPR" => "Cubase",
239        "NPR" => "Nuendo",
240        "BWPROJECT" => "Bitwig Studio",
241        "RPP" | "RPP-BAK" => "REAPER",
242        "PTX" | "PTF" => "Pro Tools",
243        "SONG" => "Studio One",
244        "REASON" => "Reason",
245        "AUP" | "AUP3" => "Audacity",
246        "BAND" => "GarageBand",
247        "ARDOUR" => "Ardour",
248        "DAWPROJECT" => "DAWproject",
249        _ => "Unknown",
250    }
251}
252
253fn is_valid_band_package(path: &Path) -> bool {
254    let pd = path.join("projectData");
255    if !pd.exists() {
256        return false;
257    }
258    if let Ok(mut f) = fs::File::open(&pd) {
259        use std::io::Read;
260        let mut magic = [0u8; 6];
261        if f.read_exact(&mut magic).is_err() || &magic != b"bplist" {
262            return false;
263        }
264    } else {
265        return false;
266    }
267    path.join("Media").is_dir()
268        || path.join("Output").is_dir()
269        || path.join("Freeze Files").is_dir()
270}
271
272fn get_directory_size(path: &Path) -> u64 {
273    get_directory_size_depth(path, 0)
274}
275
276fn get_directory_size_depth(path: &Path, depth: u32) -> u64 {
277    if depth > 10 {
278        return 0;
279    }
280    let mut total = 0u64;
281    if let Ok(entries) = fs::read_dir(path) {
282        for entry in entries.flatten() {
283            let p = entry.path();
284            // Match `daw_scanner` / `scanner`: `file_type().is_file()` skips symlink
285            // children; `path.is_dir()` + `metadata` follows symlinks for size.
286            if p.is_dir() {
287                total += get_directory_size_depth(&p, depth + 1);
288            } else if let Ok(meta) = fs::metadata(&p) {
289                total += meta.len();
290            }
291        }
292    }
293    total
294}
295
296/// Membership predicate — does `path` live under any root in `roots`?
297/// Empty roots list means "no files qualify for this type".
298fn under_any_root(path: &Path, roots: &[PathBuf]) -> bool {
299    if roots.is_empty() {
300        return false;
301    }
302    roots.iter().any(|r| path.starts_with(r))
303}
304
305/// Does the lowercased filename end with any of `exts`? `exts` must include the
306/// leading dot.
307fn ext_match(name_lower: &str, exts: &[&str]) -> Option<String> {
308    for e in exts {
309        if name_lower.ends_with(e) {
310            return Some(e.to_string());
311        }
312    }
313    None
314}
315
316/// Per-type scanning configuration. Empty roots disables the type (no output).
317#[derive(Debug, Clone, Default)]
318pub struct UnifiedSpec {
319    pub audio_roots: Vec<PathBuf>,
320    pub audio_exclude: HashSet<String>,
321    pub daw_roots: Vec<PathBuf>,
322    pub daw_exclude: HashSet<String>,
323    pub daw_include_backups: bool,
324    pub preset_roots: Vec<PathBuf>,
325    pub preset_exclude: HashSet<String>,
326    pub pdf_roots: Vec<PathBuf>,
327    pub pdf_exclude: HashSet<String>,
328}
329
330/// One classified batch sent to the on_batch callback.
331#[derive(Debug)]
332pub enum ClassifiedBatch {
333    Audio(Vec<AudioSample>),
334    Daw(Vec<DawProject>),
335    Preset(Vec<PresetFile>),
336    Pdf(Vec<PdfFile>),
337}
338
339/// Running totals across all types.
340#[derive(Debug, Clone, Copy, Default)]
341pub struct UnifiedCounts {
342    pub audio: usize,
343    pub daw: usize,
344    pub preset: usize,
345    pub pdf: usize,
346}
347
348/// Walk the union of all roots across types, emitting classified batches.
349///
350/// The callback is invoked from the main receiving thread (single-threaded),
351/// so it does not need to be Send/Sync. Batches are ~100 files each.
352pub fn walk_unified(
353    spec: &UnifiedSpec,
354    on_batch: &mut dyn FnMut(ClassifiedBatch, UnifiedCounts),
355    should_stop: &(dyn Fn() -> bool + Sync),
356    active_dirs: Vec<Arc<Mutex<Vec<String>>>>,
357    incremental: Option<Arc<IncrementalDirState>>,
358) {
359    let batch_size = 100;
360    let stop = Arc::new(AtomicBool::new(false));
361    // Fan-out sinks — each push goes to every provided Vec, so the walker's
362    // single traversal drives all N walker-status tiles simultaneously.
363    let active: Vec<Arc<Mutex<Vec<String>>>> = if active_dirs.is_empty() {
364        vec![Arc::new(Mutex::new(Vec::new()))]
365    } else {
366        active_dirs
367    };
368    let (tx, rx) = std::sync::mpsc::sync_channel::<ClassifiedBatch>(256);
369    let visited = Arc::new(DashSet::new());
370
371    // Union of all roots, deduped by canonicalized path.
372    let mut union: Vec<PathBuf> = Vec::new();
373    for list in [
374        &spec.audio_roots,
375        &spec.daw_roots,
376        &spec.preset_roots,
377        &spec.pdf_roots,
378    ] {
379        for r in list {
380            union.push(r.clone());
381        }
382    }
383    union.sort();
384    union.dedup();
385    let union: Vec<PathBuf> = union.into_iter().filter(|r| r.exists()).collect();
386
387    // Log root set with network mount annotations so the user can verify
388    // their SMB/NFS shares are included in the walk.
389    for r in &union {
390        if let Some(fs) = network_fs_type(r) {
391            let p = r.display().to_string();
392            crate::write_app_log_verbose(format!(
393                "SCAN ROOT — unified | {} [NETWORK: {}]",
394                p, fs,
395            ));
396        }
397    }
398
399    let audio_found = Arc::new(AtomicUsize::new(0));
400    let daw_found = Arc::new(AtomicUsize::new(0));
401    let preset_found = Arc::new(AtomicUsize::new(0));
402    let pdf_found = Arc::new(AtomicUsize::new(0));
403
404    let spec = Arc::new(spec.clone());
405    let stop2 = stop.clone();
406    let audio_f2 = audio_found.clone();
407    let daw_f2 = daw_found.clone();
408    let preset_f2 = preset_found.clone();
409    let pdf_f2 = pdf_found.clone();
410
411    let pool = rayon::ThreadPoolBuilder::new()
412        .num_threads(num_cpus::get().max(4))
413        .build()
414        .unwrap();
415
416    let tcc_denied: Arc<DashSet<PathBuf>> = Arc::new(DashSet::new());
417    let tcc_summary = tcc_denied.clone();
418    std::thread::spawn(move || {
419        pool.install(|| {
420            union.par_iter().for_each(|root| {
421                if stop2.load(Ordering::Relaxed) {
422                    return;
423                }
424                walk_dir_parallel(
425                    root, 0, &visited, &tx, &audio_f2, &daw_f2, &preset_f2, &pdf_f2, batch_size,
426                    &stop2, &spec, &active, &tcc_denied, incremental.clone(),
427                );
428            });
429        });
430        drop(pool);
431    });
432
433    loop {
434        if should_stop() {
435            stop.store(true, Ordering::Relaxed);
436            while rx.try_recv().is_ok() {}
437            break;
438        }
439        match rx.recv_timeout(std::time::Duration::from_millis(10)) {
440            Ok(batch) => {
441                let counts = UnifiedCounts {
442                    audio: audio_found.load(Ordering::Relaxed),
443                    daw: daw_found.load(Ordering::Relaxed),
444                    preset: preset_found.load(Ordering::Relaxed),
445                    pdf: pdf_found.load(Ordering::Relaxed),
446                };
447                on_batch(batch, counts);
448            }
449            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
450            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
451        }
452    }
453
454    if !tcc_summary.is_empty() {
455        let paths: Vec<_> = tcc_summary.iter().map(|p| p.key().display().to_string()).collect();
456        crate::write_app_log(format!(
457            "SCAN TCC SUMMARY — {} path(s) denied by macOS TCC: {} \
458             (grant access in System Settings → Privacy & Security → Files and Folders)",
459            paths.len(),
460            paths.join(", "),
461        ));
462    }
463}
464
465#[allow(clippy::too_many_arguments)]
466fn walk_dir_parallel(
467    dir: &Path,
468    depth: u32,
469    visited: &Arc<DashSet<PathBuf>>,
470    tx: &std::sync::mpsc::SyncSender<ClassifiedBatch>,
471    audio_found: &Arc<AtomicUsize>,
472    daw_found: &Arc<AtomicUsize>,
473    preset_found: &Arc<AtomicUsize>,
474    pdf_found: &Arc<AtomicUsize>,
475    batch_size: usize,
476    stop: &Arc<AtomicBool>,
477    spec: &Arc<UnifiedSpec>,
478    active_dirs: &[Arc<Mutex<Vec<String>>>],
479    tcc_denied: &Arc<DashSet<PathBuf>>,
480    incremental: Option<Arc<IncrementalDirState>>,
481) {
482    if depth > 30 || stop.load(Ordering::Relaxed) {
483        return;
484    }
485
486    // Skip paths under a previously TCC-denied ancestor — no syscalls needed.
487    if tcc_denied.iter().any(|d| dir.starts_with(d.key())) {
488        return;
489    }
490
491    {
492        // Canonicalize OUTSIDE the mutex — it's a syscall (network roundtrip
493        // on SMB) and must not block other workers while in flight.
494        let orig = normalize_macos_path(dir.to_path_buf());
495        let canon_result = fs::canonicalize(dir);
496        if is_network_path(dir) {
497            if let Err(ref e) = canon_result {
498                crate::write_app_log_verbose(format!(
499                    "SCAN NETWORK CANONICALIZE FAIL — unified | {} | {} (using original path as dedup key)",
500                    dir.display(), e,
501                ));
502            }
503        }
504        let canon = canon_result.ok().map(normalize_macos_path);
505        let key = canon.clone().unwrap_or_else(|| orig.clone());
506        if !visited.insert(key.clone()) {
507            if is_network_path(dir) {
508                crate::write_app_log_verbose(format!(
509                    "SCAN DEDUP SKIP — unified | orig={} | canon={} | key={}",
510                    orig.display(),
511                    canon.as_ref().map(|p| p.display().to_string())
512                        .unwrap_or_else(|| "<canonicalize failed>".into()),
513                    key.display(),
514                ));
515            }
516            return;
517        }
518        visited.insert(orig);
519    }
520
521    if let Some(ref inc) = incremental {
522        if inc.should_skip(dir) {
523            return;
524        }
525    }
526
527    let dir_str = dir.to_string_lossy().to_string();
528
529    // Log when entering a network share root (depth 0-2) so the user can
530    // verify their mounts are actually being traversed.
531    if depth <= 2 {
532        if let Some(fstype) = network_fs_type(dir) {
533            crate::write_app_log_verbose(format!(
534                "SCAN NETWORK ENTER — unified | {} | fs={} | depth={}",
535                dir.display(), fstype, depth,
536            ));
537        }
538    }
539    {
540        // Fan out the dir-status push to every sink (walker-status tiles).
541        for sink in active_dirs {
542            let mut ad = sink.lock().unwrap_or_else(|e| e.into_inner());
543            ad.push(dir_str.clone());
544            // Rolling window sized to fill a full-height walker tile (~200
545            // lines × 16px line-height ≈ 3200px, comfortably more than any
546            // realistic tile). Body is overflow-y:auto so any excess scrolls.
547            if ad.len() > 200 {
548                let excess = ad.len() - 200;
549                ad.drain(..excess);
550            }
551        }
552    }
553
554    // One bulk syscall (getattrlistbulk on macOS) returns name+type+size+mtime
555    // for every entry in the directory. Replaces readdir + file_type + stat
556    // per entry — critical for SMB where each syscall is a network roundtrip.
557    // Retry once on transient errors (common on SMB/NFS mounts).
558    let is_net = is_network_path(dir);
559    let entries: Vec<BulkEntry> = match read_dir_bulk(dir) {
560        Ok(e) => e,
561        Err(first_err) => {
562            // EPERM (os error 1) on macOS = TCC denial — retrying is futile,
563            // the user must grant access in System Settings → Privacy & Security.
564            // Log once per denied root, silently skip descendants.
565            if first_err.raw_os_error() == Some(1) {
566                if !tcc_denied.iter().any(|d| dir.starts_with(d.key())) {
567                    tcc_denied.insert(dir.to_path_buf());
568                    crate::write_app_log(format!(
569                        "SCAN TCC DENIED — unified | {} | {} \
570                         (grant Full Disk Access or Files and Folders permission)",
571                        dir.display(), first_err,
572                    ));
573                }
574                return;
575            }
576            // Local filesystem errors are not transient — don't retry.
577            if !is_net {
578                return;
579            }
580            // Network shares (SMB/NFS/AFP) return transient ETIMEDOUT / EIO /
581            // ENOENT on first access after idle, wake-from-sleep, or auto-mount.
582            // Retry up to 3 times with exponential backoff (50ms, 100ms, 200ms).
583            const MAX_RETRIES: u32 = 3;
584            const BASE_DELAY_MS: u64 = 50;
585            crate::write_app_log_verbose(format!(
586                "SCAN NETWORK RETRY — unified | {} | first error: {} | up to {} retries",
587                dir.display(), first_err, MAX_RETRIES,
588            ));
589            let mut last_err = first_err;
590            let mut recovered = None;
591            for attempt in 0..MAX_RETRIES {
592                let delay = BASE_DELAY_MS * (1 << attempt); // 50, 100, 200
593                std::thread::sleep(std::time::Duration::from_millis(delay));
594                match read_dir_bulk(dir) {
595                    Ok(e) => {
596                        crate::write_app_log_verbose(format!(
597                            "SCAN NETWORK RECOVERED — unified | {} | succeeded on retry {}",
598                            dir.display(), attempt + 1,
599                        ));
600                        recovered = Some(e);
601                        break;
602                    }
603                    Err(e) => {
604                        last_err = e;
605                    }
606                }
607            }
608            match recovered {
609                Some(e) => e,
610                None => {
611                    let fsinfo = network_fs_type(dir)
612                        .map(|fs| format!(" (fs={})", fs))
613                        .unwrap_or_default();
614                    crate::write_app_log(format!(
615                        "SCAN READDIR ERROR — unified | {}{} | {} retries exhausted | last: {}",
616                        dir.display(), fsinfo, MAX_RETRIES, last_err,
617                    ));
618                    return;
619                }
620            }
621        }
622    };
623
624    // Verbose: entry counts near roots only (avoids multi-million-line logs on deep trees).
625    if depth <= 2 {
626        let n = entries.len();
627        let d = dir.to_path_buf();
628        crate::app_log_verbose(move || {
629            format!(
630                "SCAN VERBOSE — unified | depth={} | dir={} | entries={}",
631                depth,
632                d.display(),
633                n
634            )
635        });
636    }
637
638    // Per-type batches collected in this directory before being flushed.
639    let mut audio_batch: Vec<AudioSample> = Vec::new();
640    let mut daw_batch: Vec<DawProject> = Vec::new();
641    let mut preset_batch: Vec<PresetFile> = Vec::new();
642    let mut pdf_batch: Vec<PdfFile> = Vec::new();
643    let mut subdirs: Vec<PathBuf> = Vec::new();
644
645    for entry in &entries {
646        let name_str = entry.name.as_str();
647        // `@` prefix = Synology NAS system dirs (@eaDir is in every media
648        // folder on a Synology share — alone it can double a scan's file
649        // count). Also handles @tmp, @syno*, @appstore, @docker, @database,
650        // @SynoDrive, @SynologyCloudSync, etc.
651        if name_str.starts_with('.') || name_str.starts_with('@') || SKIP_DIRS.contains(&name_str) {
652            continue;
653        }
654        if !spec.daw_include_backups && DAW_BACKUP_DIRS.contains(&name_str) {
655            // Backup/Crash dirs only matter for DAW. Audio/preset/pdf don't care
656            // — but these dirs typically contain autosaves of DAW projects, not
657            // user content, so skipping is safe for all types.
658            continue;
659        }
660
661        let path = entry.path.clone();
662        let name_lower = name_str.to_lowercase();
663
664        // Bulk `d_type` lists symlinks as neither file nor dir — follow target.
665        let (is_dir, is_file, size, mtime_secs) = if entry.is_symlink {
666            match fs::metadata(&path) {
667                Ok(m) => {
668                    let is_d = m.is_dir();
669                    let is_f = m.is_file();
670                    let sz = if is_f { m.len() } else { 0 };
671                    let mt = m
672                        .modified()
673                        .ok()
674                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
675                        .map(|d| d.as_secs() as i64)
676                        .unwrap_or(0);
677                    (is_d, is_f, sz, mt)
678                }
679                Err(_) => continue,
680            }
681        } else {
682            (entry.is_dir, entry.is_file, entry.size, entry.mtime_secs)
683        };
684
685        if is_dir {
686            // 1) Plugin bundle directory? DAW never enters; other types DO enter
687            //    since plugin bundles may contain presets/PDFs/audio.
688            let is_plugin_bundle = DAW_PLUGIN_BUNDLE_EXTENSIONS
689                .iter()
690                .any(|ext| name_lower.ends_with(ext));
691
692            // 2) DAW package directory? (.logicx, .band) → treat as DAW project,
693            //    don't descend. Other types ignore it (packages have no audio).
694            let is_daw_pkg = DAW_PACKAGE_EXTENSIONS
695                .iter()
696                .any(|ext| name_lower.ends_with(ext));
697
698            if is_daw_pkg && under_any_root(&path, &spec.daw_roots) {
699                if let Some(ext_with_dot) = ext_match(&name_lower, DAW_EXTENSIONS) {
700                    let format = ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase();
701                    if !(format == "BAND" && !is_valid_band_package(&path)) {
702                        let path_str = path.to_string_lossy().to_string();
703                        if !spec.daw_exclude.contains(&path_str) {
704                            // Package size still needs recursive directory
705                            // walk — that's inherent to the data model.
706                            let size = get_directory_size(&path);
707                            // Dir mtime comes free on macOS bulk path. On the
708                            // portable fallback (Linux/Windows) entry.mtime_secs
709                            // is 0 for dirs, so stat lazily here — only for
710                            // packages we actually emit, not every dir.
711                            let modified = if mtime_secs > 0 {
712                                fmt_mtime_ymd(mtime_secs)
713                            } else {
714                                fs::metadata(&path)
715                                    .ok()
716                                    .and_then(|m| m.modified().ok())
717                                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
718                                    .map(|d| fmt_mtime_ymd(d.as_secs() as i64))
719                                    .unwrap_or_default()
720                            };
721                            let project_name = path
722                                .file_stem()
723                                .map(|s| s.to_string_lossy().to_string())
724                                .unwrap_or_default();
725                            let daw = daw_name_for_format(&format).to_string();
726                            daw_batch.push(DawProject {
727                                name: project_name,
728                                path: path_str,
729                                directory: dir.to_string_lossy().to_string(),
730                                format,
731                                daw,
732                                size,
733                                size_formatted: format_size(size),
734                                modified,
735                            });
736                            daw_found.fetch_add(1, Ordering::Relaxed);
737                        }
738                    }
739                }
740            }
741
742            if is_plugin_bundle {
743                // Plugin bundles: DAW skips entirely. Others may still want to
744                // find presets/pdfs inside — descend for them.
745                if !spec.preset_roots.is_empty()
746                    || !spec.pdf_roots.is_empty()
747                    || !spec.audio_roots.is_empty()
748                {
749                    subdirs.push(path);
750                }
751                continue;
752            }
753
754            if is_daw_pkg {
755                // DAW package — never descend (contents are DAW-internal).
756                continue;
757            }
758
759            subdirs.push(path);
760            continue;
761        }
762
763        if !is_file {
764            continue;
765        }
766
767        // Files — classify by extension into each type's bucket.
768        let ext_with_dot = match name_lower.rfind('.') {
769            Some(i) => &name_lower[i..],
770            None => continue,
771        };
772        let modified = fmt_mtime_ymd(mtime_secs);
773
774        // Audio
775        if AUDIO_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.audio_roots) {
776            let path_str = path.to_string_lossy().to_string();
777            if !spec.audio_exclude.contains(&path_str) && size > 0 {
778                let sample_name = path
779                    .file_stem()
780                    .map(|s| s.to_string_lossy().to_string())
781                    .unwrap_or_default();
782                let am = crate::audio_scanner::get_audio_metadata(&path_str);
783                let (dur, ch, sr, bps) =
784                    (am.duration, am.channels, am.sample_rate, am.bits_per_sample);
785                audio_batch.push(AudioSample {
786                    name: sample_name,
787                    path: path_str.clone(),
788                    directory: dir.to_string_lossy().to_string(),
789                    format: ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase(),
790                    size,
791                    size_formatted: format_size(size),
792                    modified: modified.clone(),
793                    duration: dur,
794                    channels: ch,
795                    sample_rate: sr,
796                    bits_per_sample: bps,
797                });
798                audio_found.fetch_add(1, Ordering::Relaxed);
799            }
800        }
801
802        // DAW (single-file formats — packages handled above for directories)
803        if DAW_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.daw_roots) {
804            let path_str = path.to_string_lossy().to_string();
805            if !spec.daw_exclude.contains(&path_str) {
806                let format = ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase();
807                // .band is ONLY valid as a package — skip files with this ext.
808                // .ptx/.ptf must match Pro Tools session BOF (filters CUDA .ptx, etc.).
809                if format != "BAND"
810                    && ((format != "PTX" && format != "PTF")
811                        || crate::daw_scanner::is_valid_pro_tools_session_file(&path))
812                {
813                    let project_name = path
814                        .file_stem()
815                        .map(|s| s.to_string_lossy().to_string())
816                        .unwrap_or_default();
817                    let daw = daw_name_for_format(&format).to_string();
818                    daw_batch.push(DawProject {
819                        name: project_name,
820                        path: path_str,
821                        directory: dir.to_string_lossy().to_string(),
822                        format,
823                        daw,
824                        size,
825                        size_formatted: format_size(size),
826                        modified: modified.clone(),
827                    });
828                    daw_found.fetch_add(1, Ordering::Relaxed);
829                }
830            }
831        }
832
833        // Preset (and midi — same bucket)
834        if PRESET_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.preset_roots) {
835            let path_str = path.to_string_lossy().to_string();
836            if !spec.preset_exclude.contains(&path_str) {
837                let preset_name = path
838                    .file_stem()
839                    .map(|s| s.to_string_lossy().to_string())
840                    .unwrap_or_default();
841                preset_batch.push(PresetFile {
842                    name: preset_name,
843                    path: path_str,
844                    directory: dir.to_string_lossy().to_string(),
845                    format: ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase(),
846                    size,
847                    size_formatted: format_size(size),
848                    modified: modified.clone(),
849                });
850                preset_found.fetch_add(1, Ordering::Relaxed);
851            }
852        }
853
854        // PDF
855        if ext_with_dot == PDF_EXTENSION && under_any_root(&path, &spec.pdf_roots) {
856            let path_str = path.to_string_lossy().to_string();
857            if !spec.pdf_exclude.contains(&path_str) {
858                let pdf_name = path
859                    .file_stem()
860                    .map(|s| s.to_string_lossy().to_string())
861                    .unwrap_or_default();
862                pdf_batch.push(PdfFile {
863                    name: pdf_name,
864                    path: path_str,
865                    directory: dir.to_string_lossy().to_string(),
866                    size,
867                    size_formatted: format_size(size),
868                    modified,
869                });
870                pdf_found.fetch_add(1, Ordering::Relaxed);
871            }
872        }
873
874        // Flush any batch that's reached batch_size.
875        if audio_batch.len() >= batch_size {
876            let _ = tx.send(ClassifiedBatch::Audio(std::mem::take(&mut audio_batch)));
877        }
878        if daw_batch.len() >= batch_size {
879            let _ = tx.send(ClassifiedBatch::Daw(std::mem::take(&mut daw_batch)));
880        }
881        if preset_batch.len() >= batch_size {
882            let _ = tx.send(ClassifiedBatch::Preset(std::mem::take(&mut preset_batch)));
883        }
884        if pdf_batch.len() >= batch_size {
885            let _ = tx.send(ClassifiedBatch::Pdf(std::mem::take(&mut pdf_batch)));
886        }
887    }
888
889    // Flush any partial batches at end of directory.
890    if !audio_batch.is_empty() {
891        let _ = tx.send(ClassifiedBatch::Audio(audio_batch));
892    }
893    if !daw_batch.is_empty() {
894        let _ = tx.send(ClassifiedBatch::Daw(daw_batch));
895    }
896    if !preset_batch.is_empty() {
897        let _ = tx.send(ClassifiedBatch::Preset(preset_batch));
898    }
899    if !pdf_batch.is_empty() {
900        let _ = tx.send(ClassifiedBatch::Pdf(pdf_batch));
901    }
902
903    subdirs.par_iter().for_each(|subdir| {
904        walk_dir_parallel(
905            subdir,
906            depth + 1,
907            visited,
908            tx,
909            audio_found,
910            daw_found,
911            preset_found,
912            pdf_found,
913            batch_size,
914            stop,
915            spec,
916            active_dirs,
917            tcc_denied,
918            incremental.clone(),
919        );
920    });
921
922    if let Some(ref inc) = incremental {
923        inc.record_scanned_dir(dir);
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use std::collections::HashMap;
931    use std::fs::{self, File};
932    use std::io::Write;
933    use std::path::Path;
934    use std::sync::Arc;
935
936    fn try_symlink(target: &Path, link: &Path) -> bool {
937        #[cfg(unix)]
938        {
939            std::os::unix::fs::symlink(target, link).is_ok()
940        }
941        #[cfg(windows)]
942        {
943            std::os::windows::fs::symlink_file(target, link).is_ok()
944        }
945        #[cfg(not(any(unix, windows)))]
946        {
947            let _ = (target, link);
948            false
949        }
950    }
951
952    struct TestDir {
953        path: PathBuf,
954    }
955    impl TestDir {
956        fn new(name: &str) -> Self {
957            let path = std::env::temp_dir().join(format!(
958                "upum_uw_{}_{}",
959                name,
960                std::time::SystemTime::now()
961                    .duration_since(std::time::UNIX_EPOCH)
962                    .unwrap()
963                    .as_nanos()
964            ));
965            let _ = fs::remove_dir_all(&path);
966            fs::create_dir_all(&path).unwrap();
967            Self { path }
968        }
969    }
970    impl Drop for TestDir {
971        fn drop(&mut self) {
972            let _ = fs::remove_dir_all(&self.path);
973        }
974    }
975
976    fn touch(p: &Path, content: &[u8]) {
977        if let Some(parent) = p.parent() {
978            fs::create_dir_all(parent).unwrap();
979        }
980        let mut f = File::create(p).unwrap();
981        f.write_all(content).unwrap();
982    }
983
984    #[test]
985    fn test_under_any_root() {
986        let roots = vec![PathBuf::from("/a/b"), PathBuf::from("/x/y")];
987        assert!(under_any_root(Path::new("/a/b/c.wav"), &roots));
988        assert!(under_any_root(Path::new("/x/y/z/1.pdf"), &roots));
989        assert!(!under_any_root(Path::new("/a/other.wav"), &roots));
990        assert!(!under_any_root(Path::new("/q.wav"), &roots));
991        assert!(!under_any_root(Path::new("/a/b/c.wav"), &[]));
992    }
993
994    #[test]
995    fn test_ext_match() {
996        assert_eq!(
997            ext_match("song.wav", AUDIO_EXTENSIONS),
998            Some(".wav".to_string())
999        );
1000        assert_eq!(
1001            ext_match("mix.flp", DAW_EXTENSIONS),
1002            Some(".flp".to_string())
1003        );
1004        assert_eq!(ext_match("readme.txt", AUDIO_EXTENSIONS), None);
1005    }
1006
1007    #[test]
1008    fn test_walk_unified_classifies_by_extension() {
1009        let tmp = TestDir::new("classify");
1010        let root = tmp.path.clone();
1011        touch(&root.join("a.wav"), b"RIFF");
1012        touch(&root.join("b.pdf"), b"%PDF-1.4");
1013        touch(&root.join("c.fxp"), b"CcnK");
1014        touch(&root.join("d.als"), b"<?xml");
1015        touch(&root.join("e.mid"), b"MThd");
1016        touch(&root.join("skip.txt"), b"junk");
1017
1018        let spec = UnifiedSpec {
1019            audio_roots: vec![root.clone()],
1020            daw_roots: vec![root.clone()],
1021            preset_roots: vec![root.clone()],
1022            pdf_roots: vec![root.clone()],
1023            ..Default::default()
1024        };
1025
1026        let mut audio = Vec::new();
1027        let mut daw = Vec::new();
1028        let mut preset = Vec::new();
1029        let mut pdf = Vec::new();
1030
1031        walk_unified(
1032            &spec,
1033            &mut |batch, _counts| match batch {
1034                ClassifiedBatch::Audio(b) => audio.extend(b),
1035                ClassifiedBatch::Daw(b) => daw.extend(b),
1036                ClassifiedBatch::Preset(b) => preset.extend(b),
1037                ClassifiedBatch::Pdf(b) => pdf.extend(b),
1038            },
1039            &|| false,
1040            Vec::new(),
1041            None,
1042        );
1043
1044        assert_eq!(audio.len(), 1, "expected 1 audio file");
1045        assert_eq!(audio[0].format, "WAV");
1046        assert_eq!(pdf.len(), 1, "expected 1 pdf");
1047        assert_eq!(daw.len(), 1, "expected 1 daw");
1048        assert_eq!(daw[0].format, "ALS");
1049        assert_eq!(
1050            preset.len(),
1051            1,
1052            "expected 1 preset (fxp) — .mid routes to MIDI bucket"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_walk_unified_follows_symlink_to_als() {
1058        let tmp = TestDir::new("symlink_als");
1059        let root = tmp.path.clone();
1060        touch(&root.join("real.als"), b"<?xml");
1061        let link = root.join("link.als");
1062        if !try_symlink(&root.join("real.als"), &link) {
1063            return;
1064        }
1065
1066        let spec = UnifiedSpec {
1067            daw_roots: vec![root.clone()],
1068            ..Default::default()
1069        };
1070
1071        let mut daw = Vec::new();
1072        walk_unified(
1073            &spec,
1074            &mut |batch, _counts| {
1075                if let ClassifiedBatch::Daw(b) = batch {
1076                    daw.extend(b);
1077                }
1078            },
1079            &|| false,
1080            Vec::new(),
1081            None,
1082        );
1083
1084        assert_eq!(daw.len(), 2, "real file + symlink path both classify as DAW");
1085        assert!(daw.iter().any(|d| d.path.ends_with("link.als")));
1086        assert!(daw.iter().any(|d| d.path.ends_with("real.als")));
1087    }
1088
1089    #[test]
1090    fn test_walk_unified_respects_per_type_roots() {
1091        // Only audio is configured — files of other types exist but shouldn't
1092        // appear in output.
1093        let tmp = TestDir::new("pertype");
1094        let root = tmp.path.clone();
1095        touch(&root.join("a.wav"), b"RIFF");
1096        touch(&root.join("b.pdf"), b"%PDF-1.4");
1097        touch(&root.join("c.fxp"), b"CcnK");
1098
1099        let spec = UnifiedSpec {
1100            audio_roots: vec![root.clone()],
1101            ..Default::default()
1102        };
1103
1104        let mut audio = 0usize;
1105        let mut other = 0usize;
1106        walk_unified(
1107            &spec,
1108            &mut |batch, _| match batch {
1109                ClassifiedBatch::Audio(b) => audio += b.len(),
1110                _ => other += 1,
1111            },
1112            &|| false,
1113            Vec::new(),
1114            None,
1115        );
1116        assert_eq!(audio, 1);
1117        assert_eq!(other, 0, "no batches for unconfigured types");
1118    }
1119
1120    #[test]
1121    fn test_walk_unified_skips_hidden_and_skip_dirs() {
1122        let tmp = TestDir::new("skipdirs");
1123        let root = tmp.path.clone();
1124        touch(&root.join("keep.wav"), b"RIFF");
1125        touch(&root.join(".hidden.wav"), b"RIFF");
1126        touch(&root.join("node_modules/dep.wav"), b"RIFF");
1127        touch(&root.join("bower_components/legacy.wav"), b"RIFF");
1128        touch(&root.join("target/debug.wav"), b"RIFF");
1129        touch(&root.join("htmlcov/cov.wav"), b"RIFF");
1130        touch(&root.join("coverage/lcov.wav"), b"RIFF");
1131        touch(&root.join("Caches/thing.wav"), b"RIFF");
1132        touch(&root.join("DerivedData/build.wav"), b"RIFF");
1133        // Synology system dirs — @-prefixed (dir guard) and #snapshot (list).
1134        touch(&root.join("@eaDir/thumb.wav"), b"RIFF");
1135        touch(&root.join("@tmp/work.wav"), b"RIFF");
1136        touch(&root.join("@SynoDrive/sync.wav"), b"RIFF");
1137        touch(&root.join("#snapshot/old.wav"), b"RIFF");
1138        touch(&root.join("#recycle/trash.wav"), b"RIFF");
1139
1140        let spec = UnifiedSpec {
1141            audio_roots: vec![root.clone()],
1142            ..Default::default()
1143        };
1144
1145        let mut names: Vec<String> = Vec::new();
1146        walk_unified(
1147            &spec,
1148            &mut |batch, _| {
1149                if let ClassifiedBatch::Audio(b) = batch {
1150                    names.extend(b.into_iter().map(|s| s.name));
1151                }
1152            },
1153            &|| false,
1154            Vec::new(),
1155            None,
1156        );
1157        assert_eq!(names, vec!["keep".to_string()]);
1158    }
1159
1160    #[test]
1161    fn test_walk_unified_path_not_under_root_excluded() {
1162        // File exists in the traversal (because some OTHER type's root contains
1163        // it) but the audio_root does not — it must not appear as audio.
1164        let tmp = TestDir::new("rootcheck");
1165        let root = tmp.path.clone();
1166        let audio_dir = root.join("samples");
1167        let pdf_dir = root.join("docs");
1168        touch(&audio_dir.join("a.wav"), b"RIFF");
1169        touch(&pdf_dir.join("cross.wav"), b"RIFF"); // outside audio_roots
1170        touch(&pdf_dir.join("doc.pdf"), b"%PDF");
1171
1172        let spec = UnifiedSpec {
1173            audio_roots: vec![audio_dir.clone()],
1174            pdf_roots: vec![pdf_dir.clone()],
1175            ..Default::default()
1176        };
1177
1178        let mut audio = Vec::new();
1179        let mut pdf = Vec::new();
1180        walk_unified(
1181            &spec,
1182            &mut |batch, _| match batch {
1183                ClassifiedBatch::Audio(b) => audio.extend(b),
1184                ClassifiedBatch::Pdf(b) => pdf.extend(b),
1185                _ => {}
1186            },
1187            &|| false,
1188            Vec::new(),
1189            None,
1190        );
1191        assert_eq!(audio.len(), 1, "only audio under audio_roots counts");
1192        assert_eq!(audio[0].name, "a");
1193        assert_eq!(pdf.len(), 1);
1194    }
1195
1196    #[test]
1197    fn test_walk_unified_stop_flag() {
1198        let tmp = TestDir::new("stopflag");
1199        let root = tmp.path.clone();
1200        for i in 0..200 {
1201            touch(&root.join(format!("f{}.wav", i)), b"RIFF");
1202        }
1203        let spec = UnifiedSpec {
1204            audio_roots: vec![root.clone()],
1205            ..Default::default()
1206        };
1207        walk_unified(&spec, &mut |_, _| {}, &|| true, Vec::new(), None);
1208        // No assertion — just ensure stop=true returns promptly (test timeout
1209        // would catch a hang).
1210    }
1211
1212    #[test]
1213    fn test_walk_unified_excludes_specific_paths() {
1214        let tmp = TestDir::new("exclude");
1215        let root = tmp.path.clone();
1216        touch(&root.join("keep.wav"), b"RIFF");
1217        touch(&root.join("skip.wav"), b"RIFF");
1218        let skip_path = root.join("skip.wav").to_string_lossy().to_string();
1219        let mut excl = HashSet::new();
1220        excl.insert(skip_path);
1221        let spec = UnifiedSpec {
1222            audio_roots: vec![root.clone()],
1223            audio_exclude: excl,
1224            ..Default::default()
1225        };
1226        let mut names = Vec::new();
1227        walk_unified(
1228            &spec,
1229            &mut |batch, _| {
1230                if let ClassifiedBatch::Audio(b) = batch {
1231                    names.extend(b.into_iter().map(|s| s.name));
1232                }
1233            },
1234            &|| false,
1235            Vec::new(),
1236            None,
1237        );
1238        assert_eq!(names, vec!["keep".to_string()]);
1239    }
1240
1241    #[test]
1242    fn test_walk_unified_empty_spec() {
1243        let tmp = TestDir::new("empty");
1244        let root = tmp.path.clone();
1245        touch(&root.join("a.wav"), b"RIFF");
1246        let spec = UnifiedSpec::default();
1247        let mut batches = 0;
1248        walk_unified(
1249            &spec,
1250            &mut |_, _| {
1251                batches += 1;
1252            },
1253            &|| false,
1254            Vec::new(),
1255            None,
1256        );
1257        assert_eq!(batches, 0, "empty spec produces no output");
1258    }
1259
1260    #[test]
1261    fn test_incremental_skips_unchanged_directory_tree() {
1262        let tmp = TestDir::new("incskip");
1263        let root = tmp.path.clone();
1264        touch(&root.join("a.wav"), b"RIFF");
1265        let key = directory_incremental_key(&root);
1266        let m = dir_mtime_secs(&root);
1267        let mut snap = HashMap::new();
1268        snap.insert(key, m);
1269        let spec = UnifiedSpec {
1270            audio_roots: vec![root.clone()],
1271            ..Default::default()
1272        };
1273        let mut count = 0usize;
1274        walk_unified(
1275            &spec,
1276            &mut |batch, _| {
1277                if let ClassifiedBatch::Audio(b) = batch {
1278                    count += b.len();
1279                }
1280            },
1281            &|| false,
1282            Vec::new(),
1283            Some(Arc::new(IncrementalDirState::new(snap))),
1284        );
1285        assert_eq!(
1286            count, 0,
1287            "snapshot matching current dir mtime skips subtree"
1288        );
1289    }
1290
1291    /// Display names for DAW `format` codes must stay aligned with `daw_scanner` / UI.
1292    #[test]
1293    fn daw_name_for_format_maps_all_known_codes() {
1294        assert_eq!(daw_name_for_format("ALS"), "Ableton Live");
1295        assert_eq!(daw_name_for_format("LOGICX"), "Logic Pro");
1296        assert_eq!(daw_name_for_format("FLP"), "FL Studio");
1297        assert_eq!(daw_name_for_format("CPR"), "Cubase");
1298        assert_eq!(daw_name_for_format("NPR"), "Nuendo");
1299        assert_eq!(daw_name_for_format("BWPROJECT"), "Bitwig Studio");
1300        assert_eq!(daw_name_for_format("RPP"), "REAPER");
1301        assert_eq!(daw_name_for_format("RPP-BAK"), "REAPER");
1302        assert_eq!(daw_name_for_format("PTX"), "Pro Tools");
1303        assert_eq!(daw_name_for_format("PTF"), "Pro Tools");
1304        assert_eq!(daw_name_for_format("SONG"), "Studio One");
1305        assert_eq!(daw_name_for_format("REASON"), "Reason");
1306        assert_eq!(daw_name_for_format("AUP"), "Audacity");
1307        assert_eq!(daw_name_for_format("AUP3"), "Audacity");
1308        assert_eq!(daw_name_for_format("BAND"), "GarageBand");
1309        assert_eq!(daw_name_for_format("ARDOUR"), "Ardour");
1310        assert_eq!(daw_name_for_format("DAWPROJECT"), "DAWproject");
1311        assert_eq!(daw_name_for_format("___unknown___"), "Unknown");
1312        assert_eq!(daw_name_for_format(""), "Unknown");
1313    }
1314
1315    #[test]
1316    fn normalize_macos_path_strips_system_data_volume_prefix() {
1317        let p = PathBuf::from("/System/Volumes/Data/Users/foo/bar");
1318        let n = normalize_macos_path(p);
1319        #[cfg(target_os = "macos")]
1320        {
1321            assert_eq!(n, PathBuf::from("/Users/foo/bar"));
1322        }
1323        #[cfg(not(target_os = "macos"))]
1324        {
1325            assert_eq!(n, PathBuf::from("/System/Volumes/Data/Users/foo/bar"));
1326        }
1327    }
1328
1329    #[test]
1330    fn normalize_macos_path_noop_when_not_data_volume() {
1331        let p = PathBuf::from("/home/user/Projects");
1332        assert_eq!(
1333            normalize_macos_path(p),
1334            PathBuf::from("/home/user/Projects")
1335        );
1336    }
1337}