1use 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
38pub(crate) const DAW_EXTENSIONS: &[&str] = &[
41 ".als", ".logicx", ".flp", ".cpr", ".npr", ".bwproject", ".rpp", ".rpp-bak", ".ptx", ".ptf", ".song", ".reason", ".aup", ".aup3", ".band", ".ardour", ".dawproject", ];
59
60const PACKAGE_EXTENSIONS: &[&str] = &[".logicx", ".band"];
63
64const 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
80const 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 } 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#[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
137const 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
145pub 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
159fn is_valid_band_package(path: &Path) -> bool {
164 let pd = path.join("projectData");
165 if !pd.exists() {
166 return false;
167 }
168 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 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 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 let key = canon.unwrap_or_else(|| orig.clone());
341 if !visited.insert(key) {
342 return;
343 }
344 visited.insert(orig); }
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 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 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 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 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 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 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 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 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 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 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, None,
1119 None,
1120 );
1121 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, 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 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 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 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}