app_lib/
scanner.rs

1//! Plugin filesystem scanner for VST2, VST3, Audio Unit, and CLAP plugins.
2//!
3//! Discovers plugins from platform-specific directories, extracts version
4//! and manufacturer info from macOS Info.plist bundles, and detects binary
5//! architectures by reading Mach-O/PE headers directly.
6
7use crate::unified_walker::IncrementalDirState;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Information about a discovered audio plugin.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PluginInfo {
16    pub name: String,
17    pub path: String,
18    #[serde(rename = "type")]
19    pub plugin_type: String,
20    pub version: String,
21    pub manufacturer: String,
22    #[serde(rename = "manufacturerUrl")]
23    pub manufacturer_url: Option<String>,
24    pub size: String,
25    #[serde(rename = "sizeBytes", default)]
26    pub size_bytes: u64,
27    pub modified: String,
28    #[serde(rename = "architectures", default)]
29    pub architectures: Vec<String>,
30}
31
32pub fn get_vst_directories() -> Vec<String> {
33    let mut dirs_list: Vec<PathBuf> = Vec::new();
34
35    #[cfg(target_os = "macos")]
36    {
37        let home = dirs::home_dir().unwrap_or_default();
38        dirs_list.extend([
39            PathBuf::from("/Library/Audio/Plug-Ins/VST"),
40            PathBuf::from("/Library/Audio/Plug-Ins/VST3"),
41            PathBuf::from("/Library/Audio/Plug-Ins/Components"),
42            PathBuf::from("/Library/Audio/Plug-Ins/CLAP"),
43            home.join("Library/Audio/Plug-Ins/VST"),
44            home.join("Library/Audio/Plug-Ins/VST3"),
45            home.join("Library/Audio/Plug-Ins/Components"),
46            home.join("Library/Audio/Plug-Ins/CLAP"),
47        ]);
48    }
49
50    #[cfg(target_os = "windows")]
51    {
52        let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
53        let pf86 =
54            std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into());
55        dirs_list.extend([
56            PathBuf::from(&pf).join("Common Files").join("VST3"),
57            PathBuf::from(&pf).join("Common Files").join("CLAP"),
58            PathBuf::from(&pf).join("VSTPlugins"),
59            PathBuf::from(&pf).join("Steinberg").join("VSTPlugins"),
60            PathBuf::from(&pf86).join("Common Files").join("VST3"),
61            PathBuf::from(&pf86).join("Common Files").join("CLAP"),
62            PathBuf::from(&pf86).join("VSTPlugins"),
63            PathBuf::from(&pf86).join("Steinberg").join("VSTPlugins"),
64        ]);
65    }
66
67    #[cfg(target_os = "linux")]
68    {
69        let home = dirs::home_dir().unwrap_or_default();
70        dirs_list.extend([
71            PathBuf::from("/usr/lib/vst"),
72            PathBuf::from("/usr/lib/vst3"),
73            PathBuf::from("/usr/lib/clap"),
74            PathBuf::from("/usr/local/lib/vst"),
75            PathBuf::from("/usr/local/lib/vst3"),
76            PathBuf::from("/usr/local/lib/clap"),
77            home.join(".vst"),
78            home.join(".vst3"),
79            home.join(".clap"),
80        ]);
81    }
82
83    #[cfg(not(any(
84        target_os = "macos",
85        target_os = "linux",
86        target_os = "windows"
87    )))]
88    {
89        let home = dirs::home_dir().unwrap_or_default();
90        dirs_list.extend([
91            PathBuf::from("/usr/lib/vst"),
92            PathBuf::from("/usr/lib/vst3"),
93            PathBuf::from("/usr/lib/clap"),
94            PathBuf::from("/usr/local/lib/vst"),
95            PathBuf::from("/usr/local/lib/vst3"),
96            PathBuf::from("/usr/local/lib/clap"),
97            home.join(".vst"),
98            home.join(".vst3"),
99            home.join(".clap"),
100        ]);
101    }
102
103    dirs_list
104        .into_iter()
105        .filter(|d| d.exists())
106        .map(|d| d.to_string_lossy().to_string())
107        .collect()
108}
109
110pub fn get_plugin_type(ext: &str) -> &str {
111    match ext {
112        ".vst" => "VST2",
113        ".vst3" => "VST3",
114        ".component" => "AU",
115        ".clap" => "CLAP",
116        ".dll" => "VST2",
117        _ => "Unknown",
118    }
119}
120
121fn get_directory_size(dir: &Path) -> u64 {
122    get_directory_size_depth(dir, 0)
123}
124
125fn get_directory_size_depth(dir: &Path, depth: u32) -> u64 {
126    if depth > 10 {
127        return 0;
128    }
129    let mut size = 0u64;
130    if let Ok(entries) = fs::read_dir(dir) {
131        for entry in entries.flatten() {
132            let path = entry.path();
133            if path.is_dir() {
134                size += get_directory_size_depth(&path, depth + 1);
135            } else if let Ok(meta) = fs::metadata(&path) {
136                size += meta.len();
137            }
138        }
139    }
140    size
141}
142
143pub fn format_size(bytes: u64) -> String {
144    crate::format_size(bytes)
145}
146
147#[cfg(target_os = "macos")]
148fn read_plist_info(plugin_path: &Path) -> (Option<String>, Option<String>, Option<String>) {
149    let plist_path = plugin_path.join("Contents").join("Info.plist");
150    if !plist_path.exists() {
151        return (None, None, None);
152    }
153
154    let plist_val = match plist::Value::from_file(&plist_path) {
155        Ok(v) => v,
156        Err(_) => return (None, None, None),
157    };
158
159    let dict = match plist_val.as_dictionary() {
160        Some(d) => d,
161        None => return (None, None, None),
162    };
163
164    let version = dict
165        .get("CFBundleShortVersionString")
166        .and_then(|v| v.as_string())
167        .map(|s| s.to_string());
168
169    let mut manufacturer: Option<String> = None;
170    let mut manufacturer_url: Option<String> = None;
171
172    if let Some(bundle_id) = dict.get("CFBundleIdentifier").and_then(|v| v.as_string()) {
173        let parts: Vec<&str> = bundle_id.split('.').collect();
174        if parts.len() >= 2 {
175            let domain = parts[1];
176            let mut mfg = domain.to_string();
177            if let Some(first) = mfg.get_mut(0..1) {
178                first.make_ascii_uppercase();
179            }
180            manufacturer = Some(mfg);
181
182            let lower = domain.to_lowercase();
183            if lower != "apple" && lower.len() > 1 {
184                manufacturer_url = Some(format!("https://www.{}.com", lower));
185            }
186        }
187    }
188
189    if manufacturer_url.is_none() {
190        if let Some(copyright) = dict
191            .get("NSHumanReadableCopyright")
192            .and_then(|v| v.as_string())
193        {
194            if let Some(m) = crate::kvr::URL_RE.find(copyright) {
195                manufacturer_url = Some(m.as_str().to_string());
196            }
197        }
198    }
199
200    (version, manufacturer, manufacturer_url)
201}
202
203#[cfg(not(target_os = "macos"))]
204fn read_plist_info(_plugin_path: &Path) -> (Option<String>, Option<String>, Option<String>) {
205    (None, None, None)
206}
207
208fn json_pick_str(v: &Value, keys: &[&str]) -> Option<String> {
209    for k in keys {
210        if let Some(s) = v.get(*k).and_then(|x| x.as_str()) {
211            return Some(s.to_string());
212        }
213    }
214    None
215}
216
217/// VST3 bundles ship `moduleinfo.json` (macOS, Windows, Linux). Fills version / vendor when
218/// [`read_plist_info`] does not apply (non-macOS or missing plist).
219fn read_vst3_moduleinfo(plugin_path: &Path) -> (Option<String>, Option<String>, Option<String>) {
220    let candidates = [
221        plugin_path.join("Contents").join("moduleinfo.json"),
222        plugin_path.join("Contents").join("Resources").join("moduleinfo.json"),
223    ];
224    for path in candidates {
225        let Ok(s) = fs::read_to_string(&path) else {
226            continue;
227        };
228        let Ok(v) = serde_json::from_str::<Value>(&s) else {
229            continue;
230        };
231        let root = v.get("JSON").unwrap_or(&v);
232        let version = json_pick_str(root, &["Version", "version"]);
233        let manufacturer = json_pick_str(
234            root,
235            &[
236                "Vendor",
237                "vendor",
238                "Manufacturer",
239                "manufacturer",
240                "Company",
241                "company",
242            ],
243        );
244        let manufacturer_url = json_pick_str(
245            root,
246            &["URL", "url", "Homepage", "homepage", "VendorURL", "vendorURL"],
247        );
248        if version.is_some() || manufacturer.is_some() || manufacturer_url.is_some() {
249            return (version, manufacturer, manufacturer_url);
250        }
251    }
252    (None, None, None)
253}
254
255fn read_bundle_metadata(plugin_path: &Path) -> (Option<String>, Option<String>, Option<String>) {
256    #[cfg(target_os = "macos")]
257    {
258        let p = read_plist_info(plugin_path);
259        if p.0.is_some() || p.1.is_some() || p.2.is_some() {
260            return p;
261        }
262    }
263    read_vst3_moduleinfo(plugin_path)
264}
265
266/// Detect binary architectures for a plugin bundle.
267/// Reads Mach-O headers directly — no subprocess spawning for speed.
268fn detect_architectures(plugin_path: &Path) -> Vec<String> {
269    // Find the main binary inside the bundle
270    let contents_macos = plugin_path.join("Contents").join("MacOS");
271    let binary = if contents_macos.is_dir() {
272        fs::read_dir(&contents_macos).ok().and_then(|entries| {
273            entries
274                .flatten()
275                .find(|e| e.path().is_file())
276                .map(|e| e.path())
277        })
278    } else if plugin_path.is_file() {
279        Some(plugin_path.to_path_buf())
280    } else {
281        None
282    };
283
284    let binary = match binary {
285        Some(b) => b,
286        None => return Vec::new(),
287    };
288
289    // Read first 4KB — enough for all headers
290    let mut buf = [0u8; 4096];
291    let n = match fs::File::open(&binary).and_then(|mut f| {
292        use std::io::Read;
293        f.read(&mut buf)
294    }) {
295        Ok(n) => n,
296        Err(_) => return Vec::new(),
297    };
298    if n < 8 {
299        return Vec::new();
300    }
301
302    let magic = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
303
304    // Fat (universal) binary — parse arch list from fat header
305    if magic == 0xCAFEBABE || magic == 0xBEBAFECA {
306        let is_be = magic == 0xCAFEBABE;
307        let nfat = if is_be {
308            u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]])
309        } else {
310            u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]])
311        } as usize;
312        let mut archs = Vec::new();
313        for i in 0..nfat.min(10) {
314            let off = 8 + i * 20;
315            if off + 4 > n {
316                break;
317            }
318            let cpu = if is_be {
319                u32::from_be_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
320            } else {
321                u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
322            };
323            archs.push(match cpu {
324                0x0100000C => "ARM64".to_string(),
325                0x01000007 => "x86_64".to_string(),
326                7 => "i386".to_string(),
327                _ => format!("cpu:{}", cpu),
328            });
329        }
330        return archs;
331    }
332
333    // Thin Mach-O 64-bit
334    let mh_magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
335    if mh_magic == 0xFEEDFACF {
336        let cpu = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
337        return vec![match cpu {
338            0x0100000C => "ARM64".to_string(),
339            0x01000007 => "x86_64".to_string(),
340            _ => format!("cpu:{}", cpu),
341        }];
342    }
343    // Thin Mach-O 32-bit
344    if mh_magic == 0xFEEDFACE {
345        let cpu = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
346        return vec![match cpu {
347            7 => "i386".to_string(),
348            _ => format!("cpu:{}", cpu),
349        }];
350    }
351
352    // PE (Windows DLL)
353    if buf[0] == b'M' && buf[1] == b'Z' && n >= 64 {
354        let pe_off = u32::from_le_bytes([buf[60], buf[61], buf[62], buf[63]]) as usize;
355        if pe_off + 6 <= n && buf[pe_off] == b'P' && buf[pe_off + 1] == b'E' {
356            let machine = u16::from_le_bytes([buf[pe_off + 4], buf[pe_off + 5]]);
357            return vec![match machine {
358                0x8664 => "x86_64".to_string(),
359                0x014c => "i386".to_string(),
360                0xAA64 => "ARM64".to_string(),
361                _ => format!("pe:{}", machine),
362            }];
363        }
364    }
365
366    Vec::new()
367}
368
369pub fn get_plugin_info(file_path: &Path) -> Option<PluginInfo> {
370    let ext = file_path
371        .extension()
372        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
373        .unwrap_or_default();
374
375    let name = file_path
376        .file_stem()
377        .map(|s| s.to_string_lossy().to_string())
378        .unwrap_or_default();
379
380    let meta = fs::metadata(file_path).ok()?;
381
382    let (version, manufacturer, manufacturer_url) = read_bundle_metadata(file_path);
383
384    let size = if meta.is_dir() {
385        get_directory_size(file_path)
386    } else {
387        meta.len()
388    };
389
390    let modified = meta
391        .modified()
392        .ok()
393        .map(|t| {
394            let datetime: chrono::DateTime<chrono::Utc> = t.into();
395            datetime.format("%Y-%m-%d").to_string()
396        })
397        .unwrap_or_default();
398
399    let architectures = detect_architectures(file_path);
400
401    Some(PluginInfo {
402        name,
403        path: file_path.to_string_lossy().to_string(),
404        plugin_type: get_plugin_type(&ext).to_string(),
405        version: version.unwrap_or_else(|| "Unknown".into()),
406        manufacturer: manufacturer.unwrap_or_else(|| "Unknown".into()),
407        manufacturer_url,
408        size: format_size(size),
409        size_bytes: size,
410        modified,
411        architectures,
412    })
413}
414
415pub fn discover_plugins(
416    directories: &[String],
417    incremental: Option<&IncrementalDirState>,
418) -> Vec<PathBuf> {
419    let valid_extensions = [".vst", ".vst3", ".component", ".clap", ".dll"];
420    let mut plugin_paths = Vec::new();
421
422    for dir in directories {
423        let root = Path::new(dir);
424        if let Some(inc) = incremental {
425            if inc.should_skip(root) {
426                continue;
427            }
428        }
429        if let Ok(entries) = fs::read_dir(root) {
430            for entry in entries.flatten() {
431                let path = entry.path();
432                let ext = path
433                    .extension()
434                    .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
435                    .unwrap_or_default();
436                if valid_extensions.contains(&ext.as_str()) {
437                    // CLAP plugins are bundle directories; ignore stray files named *.clap
438                    if ext == ".clap" && !path.is_dir() {
439                        continue;
440                    }
441                    plugin_paths.push(path);
442                }
443            }
444            if let Some(inc) = incremental {
445                inc.record_scanned_dir(root);
446            }
447        }
448    }
449
450    plugin_paths
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use std::fs;
457
458    #[test]
459    fn test_get_plugin_type() {
460        assert_eq!(get_plugin_type(".vst"), "VST2");
461        assert_eq!(get_plugin_type(".vst3"), "VST3");
462        assert_eq!(get_plugin_type(".component"), "AU");
463        assert_eq!(get_plugin_type(".dll"), "VST2");
464        assert_eq!(get_plugin_type(".exe"), "Unknown");
465        assert_eq!(get_plugin_type(".clap"), "CLAP");
466        assert_eq!(get_plugin_type(".aaxplugin"), "Unknown");
467    }
468
469    #[test]
470    fn test_format_size() {
471        assert_eq!(format_size(0), "0 B");
472        assert_eq!(format_size(512), "512.0 B");
473        assert_eq!(format_size(1024), "1.0 KB");
474        assert_eq!(format_size(1536), "1.5 KB");
475        assert_eq!(format_size(1048576), "1.0 MB");
476        assert_eq!(format_size(1073741824), "1.0 GB");
477    }
478
479    #[test]
480    fn test_discover_plugins_empty_dir() {
481        let tmp = std::env::temp_dir().join("upum_test_discover_empty");
482        let _ = fs::create_dir_all(&tmp);
483        let dirs = vec![tmp.to_string_lossy().to_string()];
484        let result = discover_plugins(&dirs, None);
485        assert!(result.is_empty());
486        let _ = fs::remove_dir_all(&tmp);
487    }
488
489    #[test]
490    fn test_discover_plugins_incremental_second_pass_skips_roots() {
491        use crate::unified_walker::IncrementalDirState;
492        use std::collections::HashMap;
493
494        let tmp = std::env::temp_dir().join("upum_test_discover_inc_second");
495        let _ = fs::remove_dir_all(&tmp);
496        fs::create_dir_all(&tmp).unwrap();
497        let vst = tmp.join("A.vst");
498        fs::create_dir_all(&vst).unwrap();
499        let dirs = vec![tmp.to_string_lossy().to_string()];
500        let inc = IncrementalDirState::new(HashMap::new());
501        let first = discover_plugins(&dirs, Some(&inc));
502        assert_eq!(first.len(), 1);
503        let second = discover_plugins(&dirs, Some(&inc));
504        assert!(
505            second.is_empty(),
506            "shared incremental state would skip plugin roots after the first enumeration"
507        );
508        let _ = fs::remove_dir_all(&tmp);
509    }
510
511    #[test]
512    fn test_discover_plugins_finds_vst() {
513        let tmp = std::env::temp_dir().join("upum_test_discover_vst");
514        let _ = fs::remove_dir_all(&tmp);
515        let _ = fs::create_dir_all(&tmp);
516
517        // Create fake plugin bundles (directories with plugin extensions)
518        let vst2 = tmp.join("TestPlugin.vst");
519        let vst3 = tmp.join("TestPlugin.vst3");
520        let au = tmp.join("TestPlugin.component");
521        let txt = tmp.join("readme.txt");
522        let _ = fs::create_dir_all(&vst2);
523        let _ = fs::create_dir_all(&vst3);
524        let _ = fs::create_dir_all(&au);
525        let _ = fs::write(&txt, "not a plugin");
526
527        let dirs = vec![tmp.to_string_lossy().to_string()];
528        let mut result = discover_plugins(&dirs, None);
529        result.sort();
530
531        assert_eq!(result.len(), 3);
532        assert!(result.iter().any(|p| p.extension().unwrap() == "vst"));
533        assert!(result.iter().any(|p| p.extension().unwrap() == "vst3"));
534        assert!(result.iter().any(|p| p.extension().unwrap() == "component"));
535
536        let _ = fs::remove_dir_all(&tmp);
537    }
538
539    #[test]
540    fn test_discover_plugins_uppercase_extension_normalized() {
541        let tmp = std::env::temp_dir().join("upum_test_discover_upper");
542        let _ = fs::remove_dir_all(&tmp);
543        fs::create_dir_all(&tmp).unwrap();
544        let plug = tmp.join("UpperCase.VST3");
545        fs::create_dir_all(&plug).unwrap();
546
547        let result = discover_plugins(&[tmp.to_string_lossy().to_string()], None);
548        assert_eq!(result.len(), 1);
549        assert_eq!(result[0].extension().and_then(|e| e.to_str()), Some("VST3"));
550
551        let _ = fs::remove_dir_all(&tmp);
552    }
553
554    #[test]
555    fn test_discover_plugins_nonexistent_dir() {
556        let dirs = vec!["/nonexistent/path/that/does/not/exist".to_string()];
557        let result = discover_plugins(&dirs, None);
558        assert!(result.is_empty());
559    }
560
561    #[test]
562    fn test_discover_plugins_only_top_level_entries() {
563        let tmp = std::env::temp_dir().join("upum_test_discover_nonrecursive");
564        let _ = fs::remove_dir_all(&tmp);
565        let _ = fs::create_dir_all(&tmp);
566        let nested = tmp.join("nested");
567        let _ = fs::create_dir_all(&nested.join("Deep.vst3"));
568        let top = tmp.join("Shallow.vst3");
569        let _ = fs::create_dir_all(&top);
570        let dirs = vec![tmp.to_string_lossy().to_string()];
571        let mut result = discover_plugins(&dirs, None);
572        result.sort();
573        assert_eq!(result.len(), 1);
574        assert!(result[0].ends_with("Shallow.vst3"));
575        let _ = fs::remove_dir_all(&tmp);
576    }
577
578    #[test]
579    fn test_get_directory_size() {
580        let tmp = std::env::temp_dir().join("upum_test_dir_size");
581        let _ = fs::remove_dir_all(&tmp);
582        let _ = fs::create_dir_all(tmp.join("sub"));
583        let _ = fs::write(tmp.join("a.txt"), "hello"); // 5 bytes
584        let _ = fs::write(tmp.join("sub").join("b.txt"), "world!"); // 6 bytes
585        assert_eq!(get_directory_size(&tmp), 11);
586        let _ = fs::remove_dir_all(&tmp);
587    }
588
589    #[test]
590    fn test_get_vst_directories_returns_existing_only() {
591        let dirs = get_vst_directories();
592        for d in &dirs {
593            assert!(Path::new(d).exists(), "Directory {} should exist", d);
594        }
595    }
596
597    #[test]
598    fn test_format_size_edge_cases() {
599        assert_eq!(format_size(1), "1.0 B");
600        assert_eq!(format_size(1023), "1023.0 B");
601        assert_eq!(format_size(1024), "1.0 KB");
602        // Large value: 5 GB
603        assert_eq!(format_size(5 * 1024 * 1024 * 1024), "5.0 GB");
604    }
605
606    #[test]
607    fn test_get_plugin_info_on_real_dir() {
608        let tmp = std::env::temp_dir().join("upum_test_plugin_info");
609        let _ = fs::remove_dir_all(&tmp);
610        fs::create_dir_all(&tmp).unwrap();
611
612        let plugin_dir = tmp.join("FakePlugin.vst3");
613        fs::create_dir_all(&plugin_dir).unwrap();
614
615        let info = get_plugin_info(&plugin_dir);
616        assert!(info.is_some());
617        let info = info.unwrap();
618        assert_eq!(info.name, "FakePlugin");
619        assert_eq!(info.plugin_type, "VST3");
620        assert!(info.path.contains("FakePlugin.vst3"));
621        let _ = fs::remove_dir_all(&tmp);
622    }
623
624    #[test]
625    fn test_format_size_exact_boundaries() {
626        assert_eq!(format_size(0), "0 B");
627        assert_eq!(format_size(1), "1.0 B");
628        assert_eq!(format_size(1024), "1.0 KB");
629        assert_eq!(format_size(1048576), "1.0 MB");
630        assert_eq!(format_size(1073741824), "1.0 GB");
631    }
632
633    #[test]
634    fn test_get_plugin_info_returns_none_for_nonexistent() {
635        let path = Path::new("/nonexistent/path/that/does/not/exist/Plugin.vst3");
636        let result = get_plugin_info(path);
637        assert!(
638            result.is_none(),
639            "get_plugin_info should return None for nonexistent path"
640        );
641    }
642
643    #[test]
644    fn test_get_plugin_info_file_not_dir() {
645        let tmp = std::env::temp_dir().join("upum_test_plugin_info_file");
646        let _ = fs::remove_dir_all(&tmp);
647        fs::create_dir_all(&tmp).unwrap();
648
649        // Create a regular file with .vst3 extension (not a directory bundle)
650        let plugin_file = tmp.join("FakeFile.vst3");
651        fs::write(&plugin_file, b"not a real plugin").unwrap();
652
653        let info = get_plugin_info(&plugin_file);
654        assert!(
655            info.is_some(),
656            "Should return Some even for a file with .vst3 ext"
657        );
658        let info = info.unwrap();
659        assert_eq!(info.name, "FakeFile");
660        assert_eq!(info.plugin_type, "VST3");
661        // Size should reflect the file size (not 0)
662        assert_ne!(info.size, "0 B");
663        let _ = fs::remove_dir_all(&tmp);
664    }
665
666    #[test]
667    fn test_discover_plugins_multiple_dirs() {
668        let tmp1 = std::env::temp_dir().join("upum_test_discover_multi_1");
669        let tmp2 = std::env::temp_dir().join("upum_test_discover_multi_2");
670        let _ = fs::remove_dir_all(&tmp1);
671        let _ = fs::remove_dir_all(&tmp2);
672        fs::create_dir_all(&tmp1).unwrap();
673        fs::create_dir_all(&tmp2).unwrap();
674
675        fs::create_dir_all(tmp1.join("PlugA.vst3")).unwrap();
676        fs::create_dir_all(tmp1.join("PlugB.vst")).unwrap();
677        fs::create_dir_all(tmp2.join("PlugC.component")).unwrap();
678
679        let dirs = vec![
680            tmp1.to_string_lossy().to_string(),
681            tmp2.to_string_lossy().to_string(),
682        ];
683        let result = discover_plugins(&dirs, None);
684        assert_eq!(result.len(), 3, "Should find all plugins across both dirs");
685
686        let _ = fs::remove_dir_all(&tmp1);
687        let _ = fs::remove_dir_all(&tmp2);
688    }
689
690    #[test]
691    fn test_discover_plugins_mixed_extensions() {
692        let tmp = std::env::temp_dir().join("upum_test_discover_mixed");
693        let _ = fs::remove_dir_all(&tmp);
694        fs::create_dir_all(&tmp).unwrap();
695
696        // Valid plugin extensions
697        fs::create_dir_all(tmp.join("A.vst")).unwrap();
698        fs::create_dir_all(tmp.join("B.vst3")).unwrap();
699        fs::create_dir_all(tmp.join("C.component")).unwrap();
700        fs::create_dir_all(tmp.join("E.clap")).unwrap();
701        fs::write(tmp.join("D.dll"), b"fake dll").unwrap();
702
703        // Invalid extensions
704        fs::write(tmp.join("readme.txt"), b"text").unwrap();
705        fs::create_dir_all(tmp.join("Something.app")).unwrap();
706
707        let dirs = vec![tmp.to_string_lossy().to_string()];
708        let result = discover_plugins(&dirs, None);
709
710        assert_eq!(
711            result.len(),
712            5,
713            "Should find exactly 5 valid plugins (.vst, .vst3, .component, .clap, .dll), found: {:?}",
714            result
715        );
716
717        let _ = fs::remove_dir_all(&tmp);
718    }
719
720    #[test]
721    fn test_discover_plugins_ignores_subdirs() {
722        let tmp = std::env::temp_dir().join("upum_test_discover_subdirs");
723        let _ = fs::remove_dir_all(&tmp);
724        fs::create_dir_all(&tmp).unwrap();
725
726        // Create a subdir, and put a .vst3 inside it (nested, not top-level)
727        let subdir = tmp.join("subdir");
728        fs::create_dir_all(&subdir).unwrap();
729        let nested_plugin = subdir.join("Nested.vst3");
730        fs::create_dir_all(&nested_plugin).unwrap();
731
732        // discover_plugins should only scan one level deep from the given directories
733        let dirs = vec![tmp.to_string_lossy().to_string()];
734        let result = discover_plugins(&dirs, None);
735        // "subdir" has no plugin extension, and Nested.vst3 is inside subdir, not at top level of tmp
736        assert!(
737            result.is_empty(),
738            "Should not find plugins nested inside subdirs, found: {:?}",
739            result
740        );
741        let _ = fs::remove_dir_all(&tmp);
742    }
743
744    #[test]
745    fn test_get_vst_directories_returns_existing() {
746        let dirs = super::get_vst_directories();
747        // All returned directories should exist
748        for dir in &dirs {
749            assert!(
750                std::path::Path::new(dir).exists(),
751                "Directory should exist: {}",
752                dir
753            );
754        }
755    }
756
757    #[test]
758    fn test_detect_architectures_nonexistent() {
759        let archs = super::detect_architectures(Path::new("/nonexistent/plugin.vst3"));
760        assert!(archs.is_empty());
761    }
762
763    #[test]
764    fn test_detect_architectures_empty_dir() {
765        let tmp = std::env::temp_dir().join("upum_test_empty_plugin.vst3");
766        let _ = fs::create_dir_all(&tmp);
767        let archs = super::detect_architectures(&tmp);
768        // No Contents/MacOS dir, should return empty
769        assert!(archs.is_empty());
770        let _ = fs::remove_dir_all(&tmp);
771    }
772
773    #[test]
774    fn test_detect_architectures_macho_thin() {
775        let tmp = std::env::temp_dir().join("upum_test_macho_plugin.vst3");
776        let macos = tmp.join("Contents").join("MacOS");
777        let _ = fs::create_dir_all(&macos);
778        // Write a minimal Mach-O 64-bit ARM64 header
779        let mut header = vec![0u8; 8];
780        header[0..4].copy_from_slice(&0xFEEDFACFu32.to_le_bytes()); // MH_MAGIC_64
781        header[4..8].copy_from_slice(&0x0100000Cu32.to_le_bytes()); // CPU_TYPE_ARM64
782        fs::write(macos.join("binary"), &header).unwrap();
783
784        let archs = super::detect_architectures(&tmp);
785        assert_eq!(archs, vec!["ARM64"]);
786        let _ = fs::remove_dir_all(&tmp);
787    }
788
789    #[test]
790    fn test_detect_architectures_macho_x86() {
791        let tmp = std::env::temp_dir().join("upum_test_x86_plugin.vst3");
792        let macos = tmp.join("Contents").join("MacOS");
793        let _ = fs::create_dir_all(&macos);
794        let mut header = vec![0u8; 8];
795        header[0..4].copy_from_slice(&0xFEEDFACFu32.to_le_bytes());
796        header[4..8].copy_from_slice(&0x01000007u32.to_le_bytes()); // CPU_TYPE_X86_64
797        fs::write(macos.join("binary"), &header).unwrap();
798
799        let archs = super::detect_architectures(&tmp);
800        assert_eq!(archs, vec!["x86_64"]);
801        let _ = fs::remove_dir_all(&tmp);
802    }
803
804    #[test]
805    fn test_detect_architectures_macho_thin_i386_32bit() {
806        let tmp = std::env::temp_dir().join("upum_test_macho_i386.vst3");
807        let macos = tmp.join("Contents").join("MacOS");
808        let _ = fs::create_dir_all(&macos);
809        let mut header = vec![0u8; 8];
810        header[0..4].copy_from_slice(&0xFEEDFACEu32.to_le_bytes()); // MH_MAGIC 32-bit
811        header[4..8].copy_from_slice(&7u32.to_le_bytes()); // CPU_TYPE_I386
812        fs::write(macos.join("binary"), &header).unwrap();
813
814        assert_eq!(super::detect_architectures(&tmp), vec!["i386"]);
815        let _ = fs::remove_dir_all(&tmp);
816    }
817
818    #[test]
819    fn test_detect_architectures_macho64_unknown_cpu_type_label() {
820        let tmp = std::env::temp_dir().join("upum_test_macho_unknown64.vst3");
821        let macos = tmp.join("Contents").join("MacOS");
822        let _ = fs::create_dir_all(&macos);
823        let mut header = vec![0u8; 8];
824        header[0..4].copy_from_slice(&0xFEEDFACFu32.to_le_bytes());
825        header[4..8].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
826        fs::write(macos.join("binary"), &header).unwrap();
827
828        assert_eq!(
829            super::detect_architectures(&tmp),
830            vec![format!("cpu:{}", 0xDEADBEEFu32)]
831        );
832        let _ = fs::remove_dir_all(&tmp);
833    }
834
835    #[test]
836    fn test_detect_architectures_pe_unknown_machine_label() {
837        let tmp = std::env::temp_dir().join("upum_test_pe_unknown.dll");
838        let _ = fs::remove_file(&tmp);
839        let pe_off = 0x40usize;
840        let mut buf = vec![0u8; 0x80];
841        buf[0] = b'M';
842        buf[1] = b'Z';
843        buf[0x3C..0x40].copy_from_slice(&(pe_off as u32).to_le_bytes());
844        buf[pe_off] = b'P';
845        buf[pe_off + 1] = b'E';
846        buf[pe_off + 4..pe_off + 6].copy_from_slice(&0xFFFFu16.to_le_bytes());
847        fs::write(&tmp, &buf).unwrap();
848
849        assert_eq!(super::detect_architectures(&tmp), vec!["pe:65535"]);
850        let _ = fs::remove_file(&tmp);
851    }
852
853    #[test]
854    fn test_detect_architectures_fat_binary() {
855        let tmp = std::env::temp_dir().join("upum_test_fat_plugin.vst3");
856        let macos = tmp.join("Contents").join("MacOS");
857        let _ = fs::create_dir_all(&macos);
858        // Fat binary: magic CAFEBABE, 2 archs
859        let mut header = vec![0u8; 48];
860        header[0..4].copy_from_slice(&0xCAFEBABEu32.to_be_bytes());
861        header[4..8].copy_from_slice(&2u32.to_be_bytes()); // nfat_arch = 2
862                                                           // Arch 1: x86_64
863        header[8..12].copy_from_slice(&0x01000007u32.to_be_bytes());
864        // Arch 2: ARM64 (at offset 28)
865        header[28..32].copy_from_slice(&0x0100000Cu32.to_be_bytes());
866        fs::write(macos.join("binary"), &header).unwrap();
867
868        let archs = super::detect_architectures(&tmp);
869        assert!(archs.contains(&"x86_64".to_string()));
870        assert!(archs.contains(&"ARM64".to_string()));
871        let _ = fs::remove_dir_all(&tmp);
872    }
873
874    /// Universal binary with reversed fat magic `0xBEBAFECA` (first 4 bytes on disk: `BE BA FE CA` when
875    /// read as big-endian u32). Parser uses LE for `nfat` and CPU slots on this path.
876    #[test]
877    fn test_detect_architectures_fat_binary_little_endian_magic() {
878        let tmp = std::env::temp_dir().join("upum_test_fat_le_plugin.vst3");
879        let macos = tmp.join("Contents").join("MacOS");
880        let _ = fs::create_dir_all(&macos);
881        let mut header = vec![0u8; 48];
882        header[0..4].copy_from_slice(&0xBEBAFECAu32.to_be_bytes());
883        header[4..8].copy_from_slice(&2u32.to_le_bytes());
884        header[8..12].copy_from_slice(&0x01000007u32.to_le_bytes());
885        header[28..32].copy_from_slice(&0x0100000Cu32.to_le_bytes());
886        fs::write(macos.join("binary"), &header).unwrap();
887
888        let archs = super::detect_architectures(&tmp);
889        assert!(archs.contains(&"x86_64".to_string()));
890        assert!(archs.contains(&"ARM64".to_string()));
891        let _ = fs::remove_dir_all(&tmp);
892    }
893
894    #[test]
895    fn test_detect_architectures_pe_x64_dll_file() {
896        let tmp = std::env::temp_dir().join("upum_test_pe_amd64.dll");
897        let _ = fs::remove_file(&tmp);
898        let pe_off = 0x80usize;
899        let mut buf = vec![0u8; 0x100];
900        buf[0] = b'M';
901        buf[1] = b'Z';
902        buf[0x3C..0x40].copy_from_slice(&(pe_off as u32).to_le_bytes());
903        buf[pe_off] = b'P';
904        buf[pe_off + 1] = b'E';
905        buf[pe_off + 4..pe_off + 6].copy_from_slice(&0x8664u16.to_le_bytes());
906        fs::write(&tmp, &buf).unwrap();
907
908        let archs = super::detect_architectures(&tmp);
909        assert_eq!(archs, vec!["x86_64"]);
910        let _ = fs::remove_file(&tmp);
911    }
912
913    #[test]
914    fn test_detect_architectures_pe_arm64_ec_file() {
915        let tmp = std::env::temp_dir().join("upum_test_pe_arm64.dll");
916        let _ = fs::remove_file(&tmp);
917        let pe_off = 0x40usize;
918        let mut buf = vec![0u8; 0x80];
919        buf[0] = b'M';
920        buf[1] = b'Z';
921        buf[0x3C..0x40].copy_from_slice(&(pe_off as u32).to_le_bytes());
922        buf[pe_off] = b'P';
923        buf[pe_off + 1] = b'E';
924        buf[pe_off + 4..pe_off + 6].copy_from_slice(&0xAA64u16.to_le_bytes());
925        fs::write(&tmp, &buf).unwrap();
926
927        assert_eq!(super::detect_architectures(&tmp), vec!["ARM64"]);
928        let _ = fs::remove_file(&tmp);
929    }
930
931    #[test]
932    fn test_detect_architectures_pe_i386_machine() {
933        let tmp = std::env::temp_dir().join("upum_test_pe_i386.dll");
934        let _ = fs::remove_file(&tmp);
935        let pe_off = 0x40usize;
936        let mut buf = vec![0u8; 0x80];
937        buf[0] = b'M';
938        buf[1] = b'Z';
939        buf[0x3C..0x40].copy_from_slice(&(pe_off as u32).to_le_bytes());
940        buf[pe_off] = b'P';
941        buf[pe_off + 1] = b'E';
942        buf[pe_off + 4..pe_off + 6].copy_from_slice(&0x014cu16.to_le_bytes());
943        fs::write(&tmp, &buf).unwrap();
944
945        assert_eq!(super::detect_architectures(&tmp), vec!["i386"]);
946        let _ = fs::remove_file(&tmp);
947    }
948
949    #[test]
950    fn test_get_plugin_info_nonexistent() {
951        let info = super::get_plugin_info(Path::new("/nonexistent/plugin.vst3"));
952        assert!(info.is_none());
953    }
954
955    #[test]
956    fn test_plugin_info_serialization() {
957        let info = PluginInfo {
958            name: "TestPlugin".into(),
959            path: "/test/plugin.vst3".into(),
960            plugin_type: "VST3".into(),
961            version: "1.0.0".into(),
962            manufacturer: "TestCo".into(),
963            manufacturer_url: Some("https://test.com".into()),
964            size: "1.0 MB".into(),
965            size_bytes: 1048576,
966            modified: "2024-01-01".into(),
967            architectures: vec!["ARM64".into(), "x86_64".into()],
968        };
969        let json = serde_json::to_string(&info).unwrap();
970        assert!(json.contains("TestPlugin"));
971        assert!(json.contains("ARM64"));
972        assert!(json.contains("architectures"));
973
974        // Deserialize back
975        let back: PluginInfo = serde_json::from_str(&json).unwrap();
976        assert_eq!(back.name, "TestPlugin");
977        assert_eq!(back.architectures.len(), 2);
978    }
979
980    #[test]
981    fn test_get_plugin_type_unknown_ext() {
982        assert_eq!(get_plugin_type(".xyz"), "Unknown");
983        assert_eq!(get_plugin_type(""), "Unknown");
984        assert_eq!(get_plugin_type(".so"), "Unknown");
985        assert_eq!(get_plugin_type(".app"), "Unknown");
986    }
987
988    #[test]
989    fn test_format_size_1_byte_1023_bytes_1_gb() {
990        assert_eq!(format_size(1), "1.0 B");
991        assert_eq!(format_size(1023), "1023.0 B");
992        assert_eq!(format_size(1_073_741_824), "1.0 GB");
993    }
994
995    #[test]
996    fn test_plugin_info_missing_architectures_deserialize() {
997        // Old JSON without architectures field should deserialize with empty vec
998        let json = r#"{"name":"Old","path":"/old","type":"VST3","version":"1.0","manufacturer":"Co","size":"1 MB","modified":"2024"}"#;
999        let info: PluginInfo = serde_json::from_str(json).unwrap();
1000        assert_eq!(info.architectures.len(), 0);
1001    }
1002
1003    #[test]
1004    fn test_plugin_info_missing_size_bytes_deserializes_to_zero() {
1005        let json = r#"{"name":"N","path":"/p","type":"VST3","version":"1","manufacturer":"M","size":"1 MB","modified":"2024"}"#;
1006        let info: PluginInfo = serde_json::from_str(json).unwrap();
1007        assert_eq!(info.size_bytes, 0);
1008    }
1009
1010    #[test]
1011    fn test_get_directory_size_depth_limit() {
1012        // Create nested dirs deeper than 10 levels
1013        let tmp = std::env::temp_dir().join("upum_test_depth_limit");
1014        let _ = fs::remove_dir_all(&tmp);
1015        let mut deep = tmp.clone();
1016        for i in 0..15 {
1017            deep = deep.join(format!("d{}", i));
1018        }
1019        fs::create_dir_all(&deep).unwrap();
1020        fs::write(deep.join("deep.txt"), b"deep file").unwrap();
1021        // Also put a file at level 5
1022        let shallow = tmp.join("d0/d1/d2/d3/d4");
1023        fs::write(shallow.join("shallow.txt"), b"shallow file").unwrap();
1024
1025        let size = get_directory_size(&tmp);
1026        // Should count shallow.txt but NOT deep.txt (beyond depth 10)
1027        assert!(size > 0, "should count at least the shallow file");
1028        // deep.txt is at depth 15, which exceeds limit of 10
1029        assert!(
1030            size < 100,
1031            "should not count the deeply nested file, got {}",
1032            size
1033        );
1034
1035        let _ = fs::remove_dir_all(&tmp);
1036    }
1037}