1use crate::unified_walker::IncrementalDirState;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13#[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
217fn 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
266fn detect_architectures(plugin_path: &Path) -> Vec<String> {
269 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 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 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 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 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 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 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 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"); let _ = fs::write(tmp.join("sub").join("b.txt"), "world!"); 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 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 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 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 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 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 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 let dirs = vec![tmp.to_string_lossy().to_string()];
734 let result = discover_plugins(&dirs, None);
735 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 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 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 let mut header = vec![0u8; 8];
780 header[0..4].copy_from_slice(&0xFEEDFACFu32.to_le_bytes()); header[4..8].copy_from_slice(&0x0100000Cu32.to_le_bytes()); 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()); 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()); header[4..8].copy_from_slice(&7u32.to_le_bytes()); 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 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()); header[8..12].copy_from_slice(&0x01000007u32.to_be_bytes());
864 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 #[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 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 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 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 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 assert!(size > 0, "should count at least the shallow file");
1028 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}