app_lib/
daw_scanner.rs

1//! DAW project file scanner supporting 14+ DAW formats.
2//!
3//! Discovers Ableton, Logic, FL Studio, REAPER, Cubase, Pro Tools,
4//! Bitwig, Studio One, Reason, Audacity, GarageBand, Ardour, and
5//! DAWproject files. Handles macOS package directories (.logicx, .band)
6//! and validates GarageBand bundles by internal structure.
7//!
8//! `.ptx`/`.ptf` are validated by the Pro Tools session BOF signature (PRONOM
9//! fmt/1727 / fmt/1951; Library of Congress FDD fdd000639) so unrelated files
10//! that reuse `.ptx` (e.g. NVIDIA CUDA assembly) are not listed as DAW projects.
11//!
12//! `readdir(3)` reports symlinks as neither file nor directory; the walker
13//! `stat`s symlink targets and classifies them (broken symlinks are skipped).
14
15use crate::history::DawProject;
16use crate::scanner_skip_dirs::SCANNER_SKIP_DIRS as SKIP_DIRS;
17use crate::unified_walker::IncrementalDirState;
18use rayon::prelude::*;
19use dashmap::DashSet;
20use std::collections::HashSet;
21use std::fs;
22use std::io::Read;
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex};
26
27fn normalize_macos_path(p: PathBuf) -> PathBuf {
28    #[cfg(target_os = "macos")]
29    {
30        let s = p.to_string_lossy();
31        if s.starts_with("/System/Volumes/Data/") {
32            return PathBuf::from(&s["/System/Volumes/Data".len()..]);
33        }
34    }
35    p
36}
37
38/// File extensions for DAW project files.
39/// Includes both single-file formats and macOS bundle/package formats.
40pub(crate) const DAW_EXTENSIONS: &[&str] = &[
41    ".als",        // Ableton Live Set
42    ".logicx",     // Logic Pro X (macOS package)
43    ".flp",        // FL Studio
44    ".cpr",        // Cubase Project
45    ".npr",        // Nuendo Project
46    ".bwproject",  // Bitwig Studio
47    ".rpp",        // REAPER
48    ".rpp-bak",    // REAPER backup
49    ".ptx",        // Pro Tools (v10+)
50    ".ptf",        // Pro Tools (legacy)
51    ".song",       // Studio One
52    ".reason",     // Reason
53    ".aup",        // Audacity (legacy)
54    ".aup3",       // Audacity 3
55    ".band",       // GarageBand (macOS package)
56    ".ardour",     // Ardour
57    ".dawproject", // DAWproject (open standard)
58];
59
60/// Extensions that are macOS packages (directories with these extensions should
61/// be treated as files, not recursed into).
62const PACKAGE_EXTENSIONS: &[&str] = &[".logicx", ".band"];
63
64/// Plugin bundle extensions — directories with these extensions should never
65/// be recursed into by the DAW scanner. They contain plugin code and presets,
66/// not DAW projects.
67const PLUGIN_BUNDLE_EXTENSIONS: &[&str] = &[
68    ".vst",
69    ".vst3",
70    ".component",
71    ".aaxplugin",
72    ".app",
73    ".framework",
74    ".bundle",
75    ".plugin",
76    ".dpm",
77    ".clap",
78];
79
80/// Additional directories to skip when not including Ableton backups/crashes.
81/// Ableton stores auto-saved backup Live Sets in a "Backup" folder
82/// and crash recovery sets in a "Crash" folder inside each project directory.
83const BACKUP_DIRS: &[&str] = &["Backup", "Crash"];
84
85pub fn format_size(bytes: u64) -> String {
86    crate::format_size(bytes)
87}
88
89fn get_directory_size(path: &Path) -> u64 {
90    get_directory_size_depth(path, 0)
91}
92
93fn get_directory_size_depth(path: &Path, depth: u32) -> u64 {
94    if depth > 10 {
95        return 0;
96    } // cap recursion to limit FD usage
97    let mut total = 0u64;
98    if let Ok(entries) = fs::read_dir(path) {
99        for entry in entries.flatten() {
100            let p = entry.path();
101            if p.is_dir() {
102                total += get_directory_size_depth(&p, depth + 1);
103            } else if let Ok(meta) = fs::metadata(&p) {
104                total += meta.len();
105            }
106        }
107    }
108    total
109}
110
111pub fn ext_matches(path: &Path) -> Option<String> {
112    let name = path.file_name()?.to_string_lossy().to_lowercase();
113    for ext in DAW_EXTENSIONS {
114        if name.ends_with(ext) {
115            return Some(ext[1..].to_uppercase());
116        }
117    }
118    None
119}
120
121/// `Path::extension()` lowercased, no dot — used by the file watcher to match [`DAW_EXTENSIONS`].
122#[inline]
123pub(crate) fn is_daw_extension_lowercase(ext: &str) -> bool {
124    DAW_EXTENSIONS
125        .iter()
126        .any(|e| e.strip_prefix('.') == Some(ext))
127}
128
129pub fn is_package_ext(path: &Path) -> bool {
130    let name = path
131        .file_name()
132        .map(|n| n.to_string_lossy().to_lowercase())
133        .unwrap_or_default();
134    PACKAGE_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
135}
136
137/// BOF bytes shared by Pro Tools `.ptx` (v10+) and `.ptf`/`.pts` session files.
138/// See UK National Archives PRONOM fmt/1727 / fmt/1951; LOC FDD fdd000639.
139const PRO_TOOLS_SESSION_MAGIC: &[u8] = &[
140    0x03,
141    b'0', b'0', b'1', b'0', b'1', b'1', b'1', b'1',
142    b'0', b'0', b'1', b'0', b'1', b'0', b'1', b'1',
143];
144
145/// Returns true if `path` is a regular file whose first bytes match the Pro
146/// Tools session format (same check for `.ptx` and `.ptf`).
147pub fn is_valid_pro_tools_session_file(path: &Path) -> bool {
148    let mut file = match fs::File::open(path) {
149        Ok(f) => f,
150        Err(_) => return false,
151    };
152    let mut header = [0u8; PRO_TOOLS_SESSION_MAGIC.len()];
153    if file.read_exact(&mut header).is_err() {
154        return false;
155    }
156    header == *PRO_TOOLS_SESSION_MAGIC
157}
158
159/// Validate that a .band directory is actually a GarageBand project.
160/// Checks for `projectData` binary plist (must start with "bplist") AND
161/// requires at least one other GarageBand-specific marker to eliminate
162/// false positives from other macOS bundles that happen to use .band extension.
163fn is_valid_band_package(path: &Path) -> bool {
164    let pd = path.join("projectData");
165    if !pd.exists() {
166        return false;
167    }
168    // Verify projectData is a binary plist (starts with "bplist")
169    if let Ok(mut f) = fs::File::open(&pd) {
170        use std::io::Read;
171        let mut magic = [0u8; 6];
172        if f.read_exact(&mut magic).is_err() || &magic != b"bplist" {
173            return false;
174        }
175    } else {
176        return false;
177    }
178    // Require at least one GarageBand-specific subdirectory
179    path.join("Media").is_dir()
180        || path.join("Output").is_dir()
181        || path.join("Freeze Files").is_dir()
182}
183
184pub fn daw_name_for_format(format: &str) -> &'static str {
185    match format {
186        "ALS" => "Ableton Live",
187        "LOGICX" => "Logic Pro",
188        "FLP" => "FL Studio",
189        "CPR" => "Cubase",
190        "NPR" => "Nuendo",
191        "BWPROJECT" => "Bitwig Studio",
192        "RPP" | "RPP-BAK" => "REAPER",
193        "PTX" | "PTF" => "Pro Tools",
194        "SONG" => "Studio One",
195        "REASON" => "Reason",
196        "AUP" | "AUP3" => "Audacity",
197        "BAND" => "GarageBand",
198        "ARDOUR" => "Ardour",
199        "DAWPROJECT" => "DAWproject",
200        _ => "Unknown",
201    }
202}
203
204pub fn get_daw_roots() -> Vec<PathBuf> {
205    let home = dirs::home_dir().unwrap_or_default();
206    let mut roots = Vec::new();
207
208    #[cfg(target_os = "macos")]
209    {
210        roots.push(home.clone());
211        roots.push(PathBuf::from("/Applications"));
212        if let Ok(vols) = fs::read_dir("/Volumes") {
213            for entry in vols.flatten() {
214                let path = entry.path();
215                if path.is_dir() || path.is_symlink() {
216                    roots.push(path);
217                }
218            }
219        }
220    }
221
222    #[cfg(target_os = "windows")]
223    {
224        roots.push(home.clone());
225        roots.push(PathBuf::from(
226            std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into()),
227        ));
228        roots.push(PathBuf::from(
229            std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into()),
230        ));
231        for c in b'C'..=b'Z' {
232            let drive = format!("{}:\\", c as char);
233            if Path::new(&drive).exists() {
234                roots.push(PathBuf::from(drive));
235            }
236        }
237    }
238
239    #[cfg(target_os = "linux")]
240    {
241        roots.push(home.clone());
242        roots.push(PathBuf::from("/usr/share"));
243        roots.push(PathBuf::from("/usr/local/share"));
244    }
245
246    roots.sort();
247    roots.dedup();
248    roots.into_iter().filter(|r| r.exists()).collect()
249}
250
251pub fn walk_for_daw(
252    roots: &[PathBuf],
253    on_batch: &mut dyn FnMut(&[DawProject], usize),
254    should_stop: &(dyn Fn() -> bool + Sync),
255    exclude: Option<HashSet<String>>,
256    include_backups: bool,
257    active_dirs: Option<Arc<Mutex<Vec<String>>>>,
258    incremental: Option<Arc<IncrementalDirState>>,
259) {
260    let batch_size = 100;
261    let stop = Arc::new(AtomicBool::new(false));
262    let found = Arc::new(AtomicUsize::new(0));
263    let active = active_dirs.unwrap_or_else(|| Arc::new(Mutex::new(Vec::new())));
264    let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<DawProject>>(256);
265    let visited = Arc::new(DashSet::new());
266    let exclude = Arc::new(exclude.unwrap_or_default());
267
268    let roots_owned: Vec<PathBuf> = roots.to_vec();
269    let stop2 = stop.clone();
270    let found2 = found.clone();
271    let incremental = incremental.clone();
272    let pool = rayon::ThreadPoolBuilder::new()
273        .num_threads(num_cpus::get().max(4))
274        .build()
275        .unwrap();
276    std::thread::spawn(move || {
277        pool.install(|| {
278            roots_owned.par_iter().for_each(|root| {
279                if stop2.load(Ordering::Relaxed) {
280                    return;
281                }
282                walk_dir_parallel(
283                    root,
284                    0,
285                    &visited,
286                    &tx,
287                    &found2,
288                    batch_size,
289                    &stop2,
290                    &exclude,
291                    include_backups,
292                    &active,
293                    incremental.clone(),
294                );
295            });
296        });
297        drop(pool);
298    });
299
300    // Stream results to callback as they arrive, checking stop frequently
301    let mut total_found = 0usize;
302    loop {
303        if should_stop() {
304            stop.store(true, Ordering::Relaxed);
305            while rx.try_recv().is_ok() {}
306            break;
307        }
308        let projects = match rx.recv_timeout(std::time::Duration::from_millis(10)) {
309            Ok(p) => p,
310            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
311            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
312        };
313        total_found += projects.len();
314        on_batch(&projects, total_found);
315    }
316}
317
318#[allow(clippy::too_many_arguments)]
319fn walk_dir_parallel(
320    dir: &Path,
321    depth: u32,
322    visited: &Arc<DashSet<PathBuf>>,
323    tx: &std::sync::mpsc::SyncSender<Vec<DawProject>>,
324    found: &Arc<AtomicUsize>,
325    batch_size: usize,
326    stop: &Arc<AtomicBool>,
327    exclude: &Arc<HashSet<String>>,
328    include_backups: bool,
329    active_dirs: &Arc<Mutex<Vec<String>>>,
330    incremental: Option<Arc<IncrementalDirState>>,
331) {
332    if depth > 30 || stop.load(Ordering::Relaxed) {
333        return;
334    }
335
336    {
337        let orig = normalize_macos_path(dir.to_path_buf());
338        let canon = fs::canonicalize(dir).ok().map(normalize_macos_path);
339        // Dedup on canonical path (resolves symlinks), fall back to original
340        let key = canon.unwrap_or_else(|| orig.clone());
341        if !visited.insert(key) {
342            return;
343        }
344        visited.insert(orig); // also mark original to prevent re-entry via different route
345    }
346
347    if let Some(ref inc) = incremental {
348        if inc.should_skip(dir) {
349            return;
350        }
351    }
352
353    let dir_str = dir.to_string_lossy().to_string();
354    {
355        let mut ad = active_dirs.lock().unwrap_or_else(|e| e.into_inner());
356        ad.push(dir_str.clone());
357        if ad.len() > 200 {
358            let excess = ad.len() - 200;
359            ad.drain(..excess);
360        }
361    }
362
363    let entries: Vec<_> = match fs::read_dir(dir) {
364        Ok(e) => e.flatten().collect(),
365        Err(_e) => {
366            return;
367        }
368    };
369
370    let mut files_and_packages = Vec::new();
371    let mut subdirs = Vec::new();
372
373    for entry in &entries {
374        let name = entry.file_name();
375        let name_str = name.to_string_lossy();
376        // `@` prefix = Synology NAS system dirs (@eaDir, @tmp, @syno*, etc.).
377        if name_str.starts_with('.')
378            || name_str.starts_with('@')
379            || SKIP_DIRS.contains(&name_str.as_ref())
380            || exclude.contains(name_str.as_ref())
381        {
382            continue;
383        }
384        if !include_backups && BACKUP_DIRS.contains(&name_str.as_ref()) {
385            continue;
386        }
387        // Cached d_type from readdir — no extra stat() syscall per entry.
388        let ft = match entry.file_type() {
389            Ok(f) => f,
390            Err(_) => continue,
391        };
392        let path = entry.path();
393        if ft.is_dir() {
394            // Skip plugin bundles entirely — they contain presets, not DAW projects
395            let name_lower = name_str.to_lowercase();
396            if PLUGIN_BUNDLE_EXTENSIONS
397                .iter()
398                .any(|ext| name_lower.ends_with(ext))
399            {
400                continue;
401            }
402            if is_package_ext(&path) {
403                files_and_packages.push((path, dir.to_path_buf(), true));
404            } else {
405                subdirs.push(path);
406            }
407        } else if ft.is_file() {
408            files_and_packages.push((path, dir.to_path_buf(), false));
409        } else if ft.is_symlink() {
410            // Symlinks are neither is_dir nor is_file in d_type — follow target.
411            match fs::metadata(&path) {
412                Ok(m) if m.is_dir() => {
413                    let name_lower = name_str.to_lowercase();
414                    if PLUGIN_BUNDLE_EXTENSIONS
415                        .iter()
416                        .any(|ext| name_lower.ends_with(ext))
417                    {
418                        continue;
419                    }
420                    if is_package_ext(&path) {
421                        files_and_packages.push((path, dir.to_path_buf(), true));
422                    } else {
423                        subdirs.push(path);
424                    }
425                }
426                Ok(m) if m.is_file() => {
427                    files_and_packages.push((path, dir.to_path_buf(), false));
428                }
429                _ => {}
430            }
431        }
432    }
433
434    let mut batch = Vec::new();
435    for (path, parent, is_pkg) in files_and_packages {
436        if exclude.contains(&path.to_string_lossy().to_string()) {
437            continue;
438        }
439        if let Some(format) = ext_matches(&path) {
440            // .band is ONLY valid as a GarageBand package directory, never as a plain file
441            if format == "BAND" && (!is_pkg || !is_valid_band_package(&path)) {
442                continue;
443            }
444            if (format == "PTX" || format == "PTF") && !is_valid_pro_tools_session_file(&path) {
445                continue;
446            }
447            let (size, modified) = if is_pkg {
448                let sz = get_directory_size(&path);
449                let mod_str = fs::metadata(&path)
450                    .ok()
451                    .and_then(|m| m.modified().ok())
452                    .map(|t| {
453                        let dt: chrono::DateTime<chrono::Utc> = t.into();
454                        dt.format("%Y-%m-%d").to_string()
455                    })
456                    .unwrap_or_default();
457                (sz, mod_str)
458            } else if let Ok(meta) = fs::metadata(&path) {
459                let mod_str = meta
460                    .modified()
461                    .ok()
462                    .map(|t| {
463                        let dt: chrono::DateTime<chrono::Utc> = t.into();
464                        dt.format("%Y-%m-%d").to_string()
465                    })
466                    .unwrap_or_default();
467                (meta.len(), mod_str)
468            } else {
469                continue;
470            };
471
472            let project_name = path
473                .file_stem()
474                .map(|s| s.to_string_lossy().to_string())
475                .unwrap_or_default();
476            let daw = daw_name_for_format(&format).to_string();
477
478            batch.push(DawProject {
479                name: project_name,
480                path: path.to_string_lossy().to_string(),
481                directory: parent.to_string_lossy().to_string(),
482                format,
483                daw,
484                size,
485                size_formatted: format_size(size),
486                modified,
487            });
488            found.fetch_add(1, Ordering::Relaxed);
489
490            if batch.len() >= batch_size {
491                let _ = tx.send(batch);
492                batch = Vec::new();
493            }
494        }
495    }
496    if !batch.is_empty() {
497        let _ = tx.send(batch);
498    }
499
500    subdirs.par_iter().for_each(|subdir| {
501        walk_dir_parallel(
502            subdir,
503            depth + 1,
504            visited,
505            tx,
506            found,
507            batch_size,
508            stop,
509            exclude,
510            include_backups,
511            active_dirs,
512            incremental.clone(),
513        );
514    });
515
516    if let Some(ref inc) = incremental {
517        inc.record_scanned_dir(dir);
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::collections::HashSet;
525    use std::fs;
526    use std::path::Path;
527    use std::slice::from_ref;
528
529    #[test]
530    fn test_format_size() {
531        assert_eq!(format_size(0), "0 B");
532        assert_eq!(format_size(500), "500.0 B");
533        assert_eq!(format_size(1024), "1.0 KB");
534        assert_eq!(format_size(1_048_576), "1.0 MB");
535        assert_eq!(format_size(1_073_741_824), "1.0 GB");
536    }
537
538    #[test]
539    fn test_daw_extensions_complete() {
540        for ext in &[
541            ".als",
542            ".logicx",
543            ".flp",
544            ".cpr",
545            ".npr",
546            ".bwproject",
547            ".rpp",
548            ".rpp-bak",
549            ".ptx",
550            ".ptf",
551            ".song",
552            ".reason",
553            ".aup",
554            ".aup3",
555            ".band",
556            ".ardour",
557            ".dawproject",
558        ] {
559            assert!(
560                DAW_EXTENSIONS.contains(ext),
561                "DAW_EXTENSIONS should contain {}",
562                ext
563            );
564        }
565    }
566
567    #[test]
568    fn test_ext_matches() {
569        assert_eq!(ext_matches(Path::new("song.als")), Some("ALS".into()));
570        assert_eq!(ext_matches(Path::new("song.flp")), Some("FLP".into()));
571        assert_eq!(
572            ext_matches(Path::new("project.logicx")),
573            Some("LOGICX".into())
574        );
575        assert_eq!(ext_matches(Path::new("not_a_daw.txt")), None);
576    }
577
578    #[test]
579    fn test_ext_matches_ardour_and_dawproject() {
580        assert_eq!(
581            ext_matches(Path::new("session.ardour")),
582            Some("ARDOUR".into())
583        );
584        assert_eq!(
585            ext_matches(Path::new("openstd.dawproject")),
586            Some("DAWPROJECT".into())
587        );
588        assert_eq!(daw_name_for_format("ARDOUR"), "Ardour");
589        assert_eq!(daw_name_for_format("DAWPROJECT"), "DAWproject");
590    }
591
592    #[test]
593    fn test_is_package_ext() {
594        assert!(is_package_ext(Path::new("MySong.logicx")));
595        assert!(is_package_ext(Path::new("MySong.band")));
596        assert!(!is_package_ext(Path::new("MySong.als")));
597        assert!(!is_package_ext(Path::new("MySong.flp")));
598    }
599
600    #[test]
601    fn test_is_package_ext_dawproject_is_file_not_bundle() {
602        assert!(
603            !is_package_ext(Path::new("project.dawproject")),
604            ".dawproject is a zip file, not a macOS package dir"
605        );
606    }
607
608    #[test]
609    fn test_ext_matches_reaper_backup_suffix_before_plain_rpp() {
610        assert_eq!(
611            ext_matches(Path::new("Mixdown.rpp-bak")),
612            Some("RPP-BAK".into())
613        );
614        assert_eq!(ext_matches(Path::new("Mixdown.rpp")), Some("RPP".into()));
615    }
616
617    #[test]
618    fn test_ext_matches_case_insensitive_file_name() {
619        assert_eq!(ext_matches(Path::new("LIVE.SET.ALS")), Some("ALS".into()));
620    }
621
622    #[test]
623    fn test_ext_matches_audacity_aup3_suffix() {
624        assert_eq!(ext_matches(Path::new("podcast.aup3")), Some("AUP3".into()));
625    }
626
627    #[test]
628    fn test_ext_matches_aup3_not_matched_as_aup_prefix() {
629        // `.aup` appears before `.aup3` in `DAW_EXTENSIONS`; `ends_with` must still pick the longer suffix.
630        assert_eq!(ext_matches(Path::new("session.aup3")), Some("AUP3".into()));
631        assert_eq!(ext_matches(Path::new("legacy.aup")), Some("AUP".into()));
632    }
633
634    #[test]
635    fn test_ext_matches_pro_tools_ptf_vs_ptx() {
636        assert_eq!(ext_matches(Path::new("session.ptf")), Some("PTF".into()));
637        assert_eq!(ext_matches(Path::new("session.ptx")), Some("PTX".into()));
638    }
639
640    #[test]
641    fn test_is_valid_pro_tools_session_file_accepts_pronom_magic() {
642        let tmp = std::env::temp_dir().join("upum_test_pt_session_magic_ok");
643        let _ = fs::remove_file(&tmp);
644        let mut payload = PRO_TOOLS_SESSION_MAGIC.to_vec();
645        payload.extend_from_slice(&[0u8; 32]);
646        fs::write(&tmp, &payload).unwrap();
647        assert!(is_valid_pro_tools_session_file(&tmp));
648        let _ = fs::remove_file(&tmp);
649    }
650
651    #[test]
652    fn test_is_valid_pro_tools_session_file_rejects_cuda_style_ptx() {
653        let tmp = std::env::temp_dir().join("upum_test_cuda_ptx_fake.ptx");
654        let _ = fs::remove_file(&tmp);
655        fs::write(&tmp, b".version 7.0\n.target sm_75\n").unwrap();
656        assert!(!is_valid_pro_tools_session_file(&tmp));
657        let _ = fs::remove_file(&tmp);
658    }
659
660    #[test]
661    fn test_is_valid_pro_tools_session_file_rejects_too_short() {
662        let tmp = std::env::temp_dir().join("upum_test_pt_short.ptx");
663        let _ = fs::remove_file(&tmp);
664        fs::write(&tmp, &PRO_TOOLS_SESSION_MAGIC[..8]).unwrap();
665        assert!(!is_valid_pro_tools_session_file(&tmp));
666        let _ = fs::remove_file(&tmp);
667    }
668
669    #[test]
670    fn test_walk_for_daw_skips_non_protools_ptx() {
671        let tmp = std::env::temp_dir().join("upum_test_daw_skip_cuda_ptx");
672        let _ = fs::remove_dir_all(&tmp);
673        fs::create_dir_all(&tmp).unwrap();
674        fs::write(tmp.join("real.als"), b"ableton").unwrap();
675        fs::write(tmp.join("kernel.ptx"), b".version 7.0\n").unwrap();
676
677        let mut found = Vec::new();
678        walk_for_daw(
679            from_ref(&tmp),
680            &mut |batch, _count| {
681                found.extend_from_slice(batch);
682            },
683            &|| false,
684            None,
685            false,
686            None,
687            None,
688        );
689        assert_eq!(found.len(), 1);
690        assert!(found[0].path.ends_with("real.als"));
691        let _ = fs::remove_dir_all(&tmp);
692    }
693
694    fn try_symlink(target: &Path, link: &Path) -> bool {
695        #[cfg(unix)]
696        {
697            std::os::unix::fs::symlink(target, link).is_ok()
698        }
699        #[cfg(windows)]
700        {
701            std::os::windows::fs::symlink_file(target, link).is_ok()
702        }
703        #[cfg(not(any(unix, windows)))]
704        {
705            let _ = (target, link);
706            false
707        }
708    }
709
710    #[test]
711    fn test_walk_for_daw_follows_symlink_to_file() {
712        let tmp = std::env::temp_dir().join("upum_test_daw_symlink_als");
713        let _ = fs::remove_dir_all(&tmp);
714        fs::create_dir_all(&tmp).unwrap();
715        let real = tmp.join("real.als");
716        fs::write(&real, b"ableton").unwrap();
717        let link = tmp.join("link.als");
718        if !try_symlink(&real, &link) {
719            return;
720        }
721
722        let mut found = Vec::new();
723        walk_for_daw(
724            from_ref(&tmp),
725            &mut |batch, _count| {
726                found.extend_from_slice(batch);
727            },
728            &|| false,
729            None,
730            false,
731            None,
732            None,
733        );
734        assert_eq!(found.len(), 2, "real file + symlink path both appear");
735        assert!(found.iter().any(|d| d.path.ends_with("link.als")));
736        assert!(found.iter().any(|d| d.path.ends_with("real.als")));
737        let _ = fs::remove_dir_all(&tmp);
738    }
739
740    #[test]
741    fn test_daw_name_for_format_all_known_tokens() {
742        assert_eq!(daw_name_for_format("ALS"), "Ableton Live");
743        assert_eq!(daw_name_for_format("LOGICX"), "Logic Pro");
744        assert_eq!(daw_name_for_format("FLP"), "FL Studio");
745        assert_eq!(daw_name_for_format("CPR"), "Cubase");
746        assert_eq!(daw_name_for_format("NPR"), "Nuendo");
747        assert_eq!(daw_name_for_format("BWPROJECT"), "Bitwig Studio");
748        assert_eq!(daw_name_for_format("RPP"), "REAPER");
749        assert_eq!(daw_name_for_format("RPP-BAK"), "REAPER");
750        assert_eq!(daw_name_for_format("PTX"), "Pro Tools");
751        assert_eq!(daw_name_for_format("PTF"), "Pro Tools");
752        assert_eq!(daw_name_for_format("SONG"), "Studio One");
753        assert_eq!(daw_name_for_format("REASON"), "Reason");
754        assert_eq!(daw_name_for_format("AUP"), "Audacity");
755        assert_eq!(daw_name_for_format("AUP3"), "Audacity");
756        assert_eq!(daw_name_for_format("BAND"), "GarageBand");
757        assert_eq!(daw_name_for_format("ARDOUR"), "Ardour");
758        assert_eq!(daw_name_for_format("DAWPROJECT"), "DAWproject");
759        assert_eq!(daw_name_for_format("XYZ"), "Unknown");
760    }
761
762    #[test]
763    fn test_get_daw_roots_not_empty() {
764        let roots = get_daw_roots();
765        assert!(
766            roots.iter().any(|r| r.exists()),
767            "get_daw_roots should return at least one existing path"
768        );
769    }
770
771    #[test]
772    fn test_walk_for_daw_empty_dir() {
773        let tmp = std::env::temp_dir().join("upum_test_daw_walk_empty");
774        let _ = fs::remove_dir_all(&tmp);
775        fs::create_dir_all(&tmp).unwrap();
776
777        let mut total = 0usize;
778        walk_for_daw(
779            from_ref(&tmp),
780            &mut |_batch, count| {
781                total = count;
782            },
783            &|| false,
784            None,
785            false,
786            None,
787            None,
788        );
789        assert_eq!(total, 0);
790        let _ = fs::remove_dir_all(&tmp);
791    }
792
793    #[test]
794    fn test_walk_for_daw_finds_files() {
795        let tmp = std::env::temp_dir().join("upum_test_daw_walk_finds");
796        let _ = fs::remove_dir_all(&tmp);
797        fs::create_dir_all(&tmp).unwrap();
798        fs::write(tmp.join("mysong.als"), b"fake ableton data").unwrap();
799        fs::write(tmp.join("test.txt"), b"not a daw project").unwrap();
800
801        let mut found = Vec::new();
802        walk_for_daw(
803            from_ref(&tmp),
804            &mut |batch, _count| {
805                found.extend_from_slice(batch);
806            },
807            &|| false,
808            None,
809            false,
810            None,
811            None,
812        );
813        assert_eq!(found.len(), 1);
814        assert!(found[0].path.contains("mysong.als"));
815        assert_eq!(found[0].format, "ALS");
816        assert_eq!(found[0].daw, "Ableton Live");
817        let _ = fs::remove_dir_all(&tmp);
818    }
819
820    #[test]
821    fn test_walk_for_daw_exclude_full_path_skips_file() {
822        let tmp = std::env::temp_dir().join("upum_test_daw_walk_exclude_path");
823        let _ = fs::remove_dir_all(&tmp);
824        fs::create_dir_all(&tmp).unwrap();
825        fs::write(tmp.join("keep.als"), b"fake").unwrap();
826        fs::write(tmp.join("skip.als"), b"fake").unwrap();
827        let mut ex = HashSet::new();
828        ex.insert(tmp.join("skip.als").to_string_lossy().into_owned());
829
830        let mut found = Vec::new();
831        walk_for_daw(
832            from_ref(&tmp),
833            &mut |batch, _count| {
834                found.extend_from_slice(batch);
835            },
836            &|| false,
837            Some(ex),
838            false,
839            None,
840            None,
841        );
842        assert_eq!(found.len(), 1);
843        assert!(found[0].path.contains("keep.als"));
844        let _ = fs::remove_dir_all(&tmp);
845    }
846
847    #[test]
848    fn test_walk_for_daw_finds_multiple_formats() {
849        let tmp = std::env::temp_dir().join("upum_test_daw_walk_multi");
850        let _ = fs::remove_dir_all(&tmp);
851        fs::create_dir_all(&tmp).unwrap();
852        fs::write(tmp.join("song1.als"), b"ableton").unwrap();
853        fs::write(tmp.join("song2.flp"), b"fl studio").unwrap();
854        fs::write(tmp.join("song3.rpp"), b"reaper").unwrap();
855
856        let mut found = Vec::new();
857        walk_for_daw(
858            from_ref(&tmp),
859            &mut |batch, _count| {
860                found.extend_from_slice(batch);
861            },
862            &|| false,
863            None,
864            false,
865            None,
866            None,
867        );
868        assert_eq!(found.len(), 3);
869        let formats: Vec<&str> = found.iter().map(|d| d.format.as_str()).collect();
870        assert!(formats.contains(&"ALS"));
871        assert!(formats.contains(&"FLP"));
872        assert!(formats.contains(&"RPP"));
873        let _ = fs::remove_dir_all(&tmp);
874    }
875
876    #[test]
877    fn test_walk_for_daw_stop() {
878        let tmp = std::env::temp_dir().join("upum_test_daw_walk_stop");
879        let _ = fs::remove_dir_all(&tmp);
880        fs::create_dir_all(&tmp).unwrap();
881        fs::write(tmp.join("test.als"), b"fake data").unwrap();
882
883        let mut found = Vec::new();
884        walk_for_daw(
885            from_ref(&tmp),
886            &mut |batch, _count| {
887                found.extend_from_slice(batch);
888            },
889            &|| true,
890            None,
891            false,
892            None,
893            None,
894        );
895        assert_eq!(found.len(), 0);
896        let _ = fs::remove_dir_all(&tmp);
897    }
898
899    #[test]
900    fn test_walk_for_daw_skips_dotdirs() {
901        let tmp = std::env::temp_dir().join("upum_test_daw_walk_dotdirs");
902        let _ = fs::remove_dir_all(&tmp);
903        fs::create_dir_all(tmp.join(".hidden")).unwrap();
904        fs::create_dir_all(tmp.join("visible")).unwrap();
905        fs::write(tmp.join(".hidden").join("test.als"), b"hidden").unwrap();
906        fs::write(tmp.join("visible").join("test.als"), b"visible").unwrap();
907
908        let mut found = Vec::new();
909        walk_for_daw(
910            from_ref(&tmp),
911            &mut |batch, _count| {
912                found.extend_from_slice(batch);
913            },
914            &|| false,
915            None,
916            false,
917            None,
918            None,
919        );
920        assert_eq!(found.len(), 1);
921        assert!(found[0].path.contains("visible"));
922        let _ = fs::remove_dir_all(&tmp);
923    }
924
925    #[test]
926    fn test_walk_for_daw_package_dirs() {
927        let tmp = std::env::temp_dir().join("upum_test_daw_walk_pkg");
928        let _ = fs::remove_dir_all(&tmp);
929
930        // Create a .logicx package directory
931        let logicx = tmp.join("MySong.logicx");
932        fs::create_dir_all(&logicx).unwrap();
933        fs::write(logicx.join("projectdata"), b"logic data").unwrap();
934
935        // Create a .band package directory (must have bplist projectData + Media dir)
936        let band = tmp.join("MyBand.band");
937        fs::create_dir_all(band.join("Media")).unwrap();
938        fs::write(band.join("projectData"), b"bplist00fake").unwrap();
939
940        let mut found = Vec::new();
941        walk_for_daw(
942            from_ref(&tmp),
943            &mut |batch, _count| {
944                found.extend_from_slice(batch);
945            },
946            &|| false,
947            None,
948            false,
949            None,
950            None,
951        );
952        assert_eq!(found.len(), 2);
953        let formats: Vec<&str> = found.iter().map(|d| d.format.as_str()).collect();
954        assert!(formats.contains(&"LOGICX"));
955        assert!(formats.contains(&"BAND"));
956        let _ = fs::remove_dir_all(&tmp);
957    }
958
959    #[test]
960    fn test_walk_for_daw_batching() {
961        let tmp = std::env::temp_dir().join("upum_test_daw_walk_batching");
962        let _ = fs::remove_dir_all(&tmp);
963        fs::create_dir_all(&tmp).unwrap();
964
965        for i in 0..120 {
966            fs::write(tmp.join(format!("song_{}.als", i)), b"data").unwrap();
967        }
968
969        let mut batch_call_count = 0usize;
970        walk_for_daw(
971            from_ref(&tmp),
972            &mut |_batch, _count| {
973                batch_call_count += 1;
974            },
975            &|| false,
976            None,
977            false,
978            None,
979            None,
980        );
981        assert!(
982            batch_call_count >= 2,
983            "Expected on_batch called at least twice for 120 files with batch_size=100, got {}",
984            batch_call_count
985        );
986        let _ = fs::remove_dir_all(&tmp);
987    }
988
989    #[test]
990    fn test_walk_for_daw_deduplicates_symlinks() {
991        let tmp = std::env::temp_dir().join("upum_test_daw_walk_symlinks");
992        let _ = fs::remove_dir_all(&tmp);
993        let subdir = tmp.join("originals");
994        fs::create_dir_all(&subdir).unwrap();
995        fs::write(subdir.join("test.als"), b"data").unwrap();
996
997        #[cfg(unix)]
998        {
999            let link = tmp.join("linked");
1000            std::os::unix::fs::symlink(&subdir, &link).unwrap();
1001
1002            let mut found = Vec::new();
1003            walk_for_daw(
1004                from_ref(&tmp),
1005                &mut |batch, _count| {
1006                    found.extend_from_slice(batch);
1007                },
1008                &|| false,
1009                None,
1010                false,
1011                None,
1012                None,
1013            );
1014            let count = found.iter().filter(|d| d.name == "test").count();
1015            assert_eq!(
1016                count, 1,
1017                "test.als should be found exactly once despite symlink, found {}",
1018                count
1019            );
1020        }
1021        let _ = fs::remove_dir_all(&tmp);
1022    }
1023
1024    #[test]
1025    fn test_walk_for_daw_deduplicates_overlapping_roots() {
1026        // Parent and child dir as separate roots — files in child should be found once
1027        let tmp = std::env::temp_dir().join("upum_test_daw_overlap");
1028        let _ = fs::remove_dir_all(&tmp);
1029        let child = tmp.join("sub");
1030        fs::create_dir_all(&child).unwrap();
1031        fs::write(child.join("overlap.rpp"), b"data").unwrap();
1032        fs::write(tmp.join("top.als"), b"data").unwrap();
1033
1034        let mut found = Vec::new();
1035        walk_for_daw(
1036            &[tmp.clone(), child.clone()],
1037            &mut |batch, _| found.extend_from_slice(batch),
1038            &|| false,
1039            None,
1040            false,
1041            None,
1042            None,
1043        );
1044        let overlap_count = found.iter().filter(|d| d.name == "overlap").count();
1045        assert_eq!(
1046            overlap_count, 1,
1047            "overlap.rpp should be found once despite overlapping roots, got {}",
1048            overlap_count
1049        );
1050        assert!(
1051            found.iter().any(|d| d.name == "top"),
1052            "top.als should be found"
1053        );
1054        let _ = fs::remove_dir_all(&tmp);
1055    }
1056
1057    #[test]
1058    fn test_walk_for_daw_consistent_counts() {
1059        // Run the same scan twice — should get identical results
1060        let tmp = std::env::temp_dir().join("upum_test_daw_consistent");
1061        let _ = fs::remove_dir_all(&tmp);
1062        for i in 0..5 {
1063            let d = tmp.join(format!("dir{}", i));
1064            fs::create_dir_all(&d).unwrap();
1065            fs::write(d.join(format!("project{}.als", i)), b"data").unwrap();
1066            fs::write(d.join(format!("project{}.rpp", i)), b"data").unwrap();
1067        }
1068
1069        let mut count1 = 0;
1070        walk_for_daw(
1071            &[tmp.clone()],
1072            &mut |batch, _| count1 += batch.len(),
1073            &|| false,
1074            None,
1075            false,
1076            None,
1077            None,
1078        );
1079        let mut count2 = 0;
1080        walk_for_daw(
1081            &[tmp.clone()],
1082            &mut |batch, _| count2 += batch.len(),
1083            &|| false,
1084            None,
1085            false,
1086            None,
1087            None,
1088        );
1089
1090        assert_eq!(
1091            count1, count2,
1092            "two scans of same dirs should find same count: {} vs {}",
1093            count1, count2
1094        );
1095        assert_eq!(count1, 10, "should find 10 projects");
1096        let _ = fs::remove_dir_all(&tmp);
1097    }
1098
1099    #[test]
1100    fn test_walk_for_daw_skips_backup_dirs() {
1101        let tmp = std::env::temp_dir().join("upum_test_daw_backup");
1102        let _ = fs::remove_dir_all(&tmp);
1103        fs::create_dir_all(tmp.join("Backup")).unwrap();
1104        fs::create_dir_all(tmp.join("Crash")).unwrap();
1105        fs::write(tmp.join("main.als"), b"data").unwrap();
1106        fs::write(tmp.join("Backup").join("backup.als"), b"data").unwrap();
1107        fs::write(tmp.join("Crash").join("crash.als"), b"data").unwrap();
1108
1109        let mut found = Vec::new();
1110        walk_for_daw(
1111            from_ref(&tmp),
1112            &mut |batch, _| {
1113                found.extend_from_slice(batch);
1114            },
1115            &|| false,
1116            None,
1117            false, // include_backups = false
1118            None,
1119            None,
1120        );
1121        // Should only find main.als, not backup or crash
1122        assert_eq!(
1123            found.len(),
1124            1,
1125            "Expected 1 (main.als), found {}: {:?}",
1126            found.len(),
1127            found.iter().map(|f| &f.name).collect::<Vec<_>>()
1128        );
1129        assert_eq!(found[0].name, "main");
1130        let _ = fs::remove_dir_all(&tmp);
1131    }
1132
1133    #[test]
1134    fn test_walk_for_daw_includes_backup_when_enabled() {
1135        let tmp = std::env::temp_dir().join("upum_test_daw_backup_on");
1136        let _ = fs::remove_dir_all(&tmp);
1137        fs::create_dir_all(tmp.join("Backup")).unwrap();
1138        fs::write(tmp.join("main.als"), b"data").unwrap();
1139        fs::write(tmp.join("Backup").join("backup.als"), b"data").unwrap();
1140
1141        let mut found = Vec::new();
1142        walk_for_daw(
1143            from_ref(&tmp),
1144            &mut |batch, _| {
1145                found.extend_from_slice(batch);
1146            },
1147            &|| false,
1148            None,
1149            true, // include_backups = true
1150            None,
1151            None,
1152        );
1153        assert_eq!(found.len(), 2);
1154        let _ = fs::remove_dir_all(&tmp);
1155    }
1156
1157    #[test]
1158    fn test_walk_for_daw_skips_plugin_bundles() {
1159        let tmp = std::env::temp_dir().join("upum_test_daw_plugin_skip");
1160        let _ = fs::remove_dir_all(&tmp);
1161        // Create a .vst3 directory with .als inside — should NOT be found
1162        fs::create_dir_all(tmp.join("Plugin.vst3").join("Contents")).unwrap();
1163        fs::write(
1164            tmp.join("Plugin.vst3").join("Contents").join("fake.als"),
1165            b"data",
1166        )
1167        .unwrap();
1168        // Create a real .als outside
1169        fs::write(tmp.join("real.als"), b"data").unwrap();
1170
1171        let mut found = Vec::new();
1172        walk_for_daw(
1173            from_ref(&tmp),
1174            &mut |batch, _| {
1175                found.extend_from_slice(batch);
1176            },
1177            &|| false,
1178            None,
1179            false,
1180            None,
1181            None,
1182        );
1183        assert_eq!(found.len(), 1);
1184        assert_eq!(found[0].name, "real");
1185        let _ = fs::remove_dir_all(&tmp);
1186    }
1187
1188    #[test]
1189    fn test_band_plain_file_rejected() {
1190        let tmp = std::env::temp_dir().join("upum_test_band_file");
1191        let _ = fs::remove_dir_all(&tmp);
1192        fs::create_dir_all(&tmp).unwrap();
1193        // .band as a plain file should be rejected
1194        fs::write(tmp.join("preset.band"), b"not a garageband project").unwrap();
1195
1196        let mut found = Vec::new();
1197        walk_for_daw(
1198            from_ref(&tmp),
1199            &mut |batch, _| {
1200                found.extend_from_slice(batch);
1201            },
1202            &|| false,
1203            None,
1204            false,
1205            None,
1206            None,
1207        );
1208        assert_eq!(found.len(), 0, "Plain .band file should be rejected");
1209        let _ = fs::remove_dir_all(&tmp);
1210    }
1211}