1use crate::api::PluginRef;
6use crate::client::{PluginFormat, PluginHost};
7use crate::protocol::Descriptor;
8use crate::resolve::infer_category;
9use ferraus_core::{Error, ErrorKind, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
17pub struct ScannedPlugin {
18 pub id: String,
19 pub name: String,
20 #[serde(default)]
21 pub vendor: String,
22 #[serde(default)]
23 pub version: String,
24 #[serde(default)]
25 pub description: String,
26 pub format: PluginFormat,
27 pub path: PathBuf,
29 #[serde(default)]
30 pub features: Vec<String>,
31 pub kind: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub category: Option<String>,
35 #[serde(default)]
37 pub source: String,
38}
39
40impl ScannedPlugin {
41 pub fn is_instrument(&self) -> bool {
42 self.kind == "instrument"
43 }
44
45 pub fn is_effect(&self) -> bool {
46 self.kind == "audio-effect" || self.kind == "note-effect"
47 }
48
49 pub fn plugin_ref(&self) -> PluginRef {
50 PluginRef {
51 format: self.format,
52 path: self.path.clone(),
53 plugin_id: if self.id.is_empty() { None } else { Some(self.id.clone()) },
54 name: self.name.clone(),
55 vendor: self.vendor.clone(),
56 version: self.version.clone(),
57 }
58 }
59
60 fn from_descriptor(d: Descriptor, format: PluginFormat, path: &Path, source: &str) -> Self {
61 let category = infer_category(&format!("{} {}", d.name, d.features.join(" "))).map(|s| s.to_string());
62 ScannedPlugin {
63 id: d.id,
64 name: d.name,
65 vendor: d.vendor,
66 version: d.version,
67 description: d.description,
68 format,
69 path: path.to_path_buf(),
70 features: d.features,
71 kind: d.kind,
72 category,
73 source: source.into(),
74 }
75 }
76}
77
78#[derive(Clone, Debug, Serialize, Deserialize, Default)]
79pub struct CacheEntry {
80 pub mtime: u64,
81 pub format: Option<PluginFormat>,
82 #[serde(default)]
83 pub plugins: Vec<ScannedPlugin>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86}
87
88#[derive(Clone, Debug, Serialize, Deserialize, Default)]
90pub struct ScanCache {
91 pub version: u32,
92 pub entries: BTreeMap<String, CacheEntry>,
93}
94
95pub const CACHE_VERSION: u32 = 1;
96
97impl ScanCache {
98 pub fn load(path: &Path) -> ScanCache {
99 std::fs::read(path)
100 .ok()
101 .and_then(|b| serde_json::from_slice::<ScanCache>(&b).ok())
102 .filter(|c| c.version == CACHE_VERSION)
103 .unwrap_or(ScanCache { version: CACHE_VERSION, entries: BTreeMap::new() })
104 }
105
106 pub fn save(&self, path: &Path) -> Result<()> {
107 if let Some(p) = path.parent() {
108 std::fs::create_dir_all(p)?;
109 }
110 let tmp = path.with_extension("json.part");
111 std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
112 std::fs::rename(&tmp, path)?;
113 Ok(())
114 }
115
116 pub fn plugins(&self) -> Vec<ScannedPlugin> {
118 self.entries
119 .iter()
120 .filter(|(p, _)| Path::new(p).exists())
121 .flat_map(|(_, e)| e.plugins.iter().cloned())
122 .collect()
123 }
124}
125
126pub fn default_cache_path() -> PathBuf {
128 ferraus_core::paths::cache_dir().join("plugins").join("scan.json")
129}
130
131fn home() -> PathBuf {
132 std::env::var_os("HOME")
133 .or_else(|| std::env::var_os("USERPROFILE"))
134 .map(PathBuf::from)
135 .unwrap_or_else(|| PathBuf::from("."))
136}
137
138pub fn user_plugin_dir(format: PluginFormat) -> PathBuf {
141 if let Some(b) = std::env::var_os("FERRAUS_PLUGIN_DIR").filter(|v| !v.is_empty()) {
142 return PathBuf::from(b).join(format.as_str());
143 }
144 let h = home();
145 match (format, std::env::consts::OS) {
146 (PluginFormat::Clap, "macos") => h.join("Library/Audio/Plug-Ins/CLAP"),
147 (PluginFormat::Vst3, "macos") => h.join("Library/Audio/Plug-Ins/VST3"),
148 (PluginFormat::Clap, "windows") => local_app_data().join("Programs/Common/CLAP"),
149 (PluginFormat::Vst3, "windows") => local_app_data().join("Programs/Common/VST3"),
150 (PluginFormat::Clap, _) => h.join(".clap"),
151 (PluginFormat::Vst3, _) => h.join(".vst3"),
152 }
153}
154
155fn local_app_data() -> PathBuf {
156 std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData/Local"))
157}
158
159pub fn standard_dirs(format: PluginFormat) -> Vec<PathBuf> {
163 if std::env::var("FERRAUS_PLUGIN_STANDARD_DIRS").map(|v| v == "0").unwrap_or(false) {
164 return vec![user_plugin_dir(format)];
165 }
166 let h = home();
167 let common = std::env::var_os("COMMONPROGRAMFILES")
168 .map(PathBuf::from)
169 .unwrap_or_else(|| PathBuf::from("C:/Program Files/Common Files"));
170 let mut dirs: Vec<PathBuf> = match (format, std::env::consts::OS) {
171 (PluginFormat::Clap, "macos") => {
172 vec![h.join("Library/Audio/Plug-Ins/CLAP"), PathBuf::from("/Library/Audio/Plug-Ins/CLAP")]
173 }
174 (PluginFormat::Vst3, "macos") => {
175 vec![h.join("Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Library/Audio/Plug-Ins/VST3")]
176 }
177 (PluginFormat::Clap, "windows") => {
178 vec![common.join("CLAP"), local_app_data().join("Programs/Common/CLAP")]
179 }
180 (PluginFormat::Vst3, "windows") => {
181 vec![common.join("VST3"), local_app_data().join("Programs/Common/VST3")]
182 }
183 (PluginFormat::Clap, _) => vec![h.join(".clap"), PathBuf::from("/usr/lib/clap")],
184 (PluginFormat::Vst3, _) => {
185 vec![h.join(".vst3"), PathBuf::from("/usr/lib/vst3"), PathBuf::from("/usr/local/lib/vst3")]
186 }
187 };
188 if format == PluginFormat::Clap {
189 if let Some(p) = std::env::var_os("CLAP_PATH") {
190 dirs.extend(std::env::split_paths(&p));
191 }
192 }
193 let user = user_plugin_dir(format);
194 if !dirs.contains(&user) {
195 dirs.push(user);
196 }
197 dirs
198}
199
200fn is_bundle_ext(p: &Path, formats: &[PluginFormat]) -> Option<PluginFormat> {
201 let f = PluginFormat::from_path(p)?;
202 formats.contains(&f).then_some(f)
203}
204
205pub fn find_bundles(dir: &Path, formats: &[PluginFormat]) -> Vec<(PluginFormat, PathBuf)> {
207 let mut out = Vec::new();
208 if let Some(f) = is_bundle_ext(dir, formats) {
209 if dir.exists() {
210 out.push((f, dir.to_path_buf()));
211 }
212 return out;
213 }
214 walk(dir, formats, 0, &mut out);
215 out
216}
217
218fn walk(dir: &Path, formats: &[PluginFormat], depth: usize, out: &mut Vec<(PluginFormat, PathBuf)>) {
219 if depth > 6 {
220 return;
221 }
222 let Ok(rd) = std::fs::read_dir(dir) else {
223 return;
224 };
225 let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
226 entries.sort();
227 for p in entries {
228 if let Some(f) = is_bundle_ext(&p, formats) {
229 out.push((f, p));
230 continue;
231 }
232 let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
233 if matches!(ext.as_str(), "component" | "app" | "lv2" | "vst" | "aaxplugin" | "clap" | "vst3") {
234 continue;
235 }
236 if p.is_dir() {
237 walk(&p, formats, depth + 1, out);
238 }
239 }
240}
241
242fn mtime_of(p: &Path) -> u64 {
243 std::fs::metadata(p)
244 .and_then(|m| m.modified())
245 .ok()
246 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
247 .map(|d| d.as_secs())
248 .unwrap_or(0)
249}
250
251pub fn bundle_mtime(p: &Path) -> u64 {
253 let mut m = mtime_of(p);
254 if p.is_dir() {
255 fn rec(d: &Path, depth: usize, m: &mut u64) {
256 if depth > 4 {
257 return;
258 }
259 if let Ok(rd) = std::fs::read_dir(d) {
260 for e in rd.flatten() {
261 let p = e.path();
262 *m = (*m).max(mtime_of(&p));
263 if p.is_dir() {
264 rec(&p, depth + 1, m);
265 }
266 }
267 }
268 }
269 rec(p, 0, &mut m);
270 }
271 m
272}
273
274fn json5_lite(text: &str) -> String {
276 let b = text.as_bytes();
277 let mut out = String::with_capacity(text.len());
278 let mut i = 0;
279 let mut in_str = false;
280 while i < b.len() {
281 let c = b[i] as char;
282 if in_str {
283 out.push(c);
284 if c == '\\' && i + 1 < b.len() {
285 out.push(b[i + 1] as char);
286 i += 2;
287 continue;
288 }
289 if c == '"' {
290 in_str = false;
291 }
292 i += 1;
293 continue;
294 }
295 if c == '"' {
296 in_str = true;
297 out.push(c);
298 i += 1;
299 } else if c == '/' && i + 1 < b.len() && b[i + 1] == b'/' {
300 while i < b.len() && b[i] != b'\n' {
301 i += 1;
302 }
303 } else if c == '/' && i + 1 < b.len() && b[i + 1] == b'*' {
304 i += 2;
305 while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
306 i += 1;
307 }
308 i += 2;
309 } else if c == ',' {
310 let mut j = i + 1;
311 while j < b.len() && (b[j] as char).is_whitespace() {
312 j += 1;
313 }
314 if j < b.len() && (b[j] == b'}' || b[j] == b']') {
315 i += 1;
316 } else {
317 out.push(c);
318 i += 1;
319 }
320 } else {
321 let len = utf8_len(b[i]);
323 out.push_str(&text[i..(i + len).min(text.len())]);
324 i += len;
325 }
326 }
327 out
328}
329
330fn utf8_len(b: u8) -> usize {
331 match b {
332 0x00..=0x7F => 1,
333 0xC0..=0xDF => 2,
334 0xE0..=0xEF => 3,
335 _ => 4,
336 }
337}
338
339pub fn read_vst3_moduleinfo(bundle: &Path) -> Option<Vec<ScannedPlugin>> {
341 let candidates = [
342 bundle.join("Contents").join("Resources").join("moduleinfo.json"),
343 bundle.join("Contents").join("moduleinfo.json"),
344 ];
345 let file = candidates.iter().find(|p| p.is_file())?;
346 let text = std::fs::read_to_string(file).ok()?;
347 let v: serde_json::Value = serde_json::from_str(&json5_lite(&text)).ok()?;
348 let module_version = v.get("Version").and_then(|x| x.as_str()).unwrap_or("").to_string();
349 let factory_vendor = v.pointer("/Factory Info/Vendor").and_then(|x| x.as_str()).unwrap_or("").to_string();
350 let url = v.pointer("/Factory Info/URL").and_then(|x| x.as_str()).unwrap_or("").to_string();
351 let mut out = Vec::new();
352 for c in v.get("Classes").and_then(|c| c.as_array()).into_iter().flatten() {
353 let cat = c.get("Category").and_then(|x| x.as_str()).unwrap_or("");
354 if cat != "Audio Module Class" {
355 continue;
356 }
357 let subs: Vec<String> = match c.get("Sub Categories") {
358 Some(serde_json::Value::Array(a)) => {
359 a.iter().filter_map(|s| s.as_str()).map(|s| s.to_ascii_lowercase()).collect()
360 }
361 Some(serde_json::Value::String(s)) => s.split('|').map(|s| s.trim().to_ascii_lowercase()).collect(),
362 _ => Vec::new(),
363 };
364 let mut features = subs.clone();
365 if subs.iter().any(|s| s == "instrument") {
366 features.push("instrument".into());
367 } else if subs.iter().any(|s| s == "fx") {
368 features.push("audio-effect".into());
369 }
370 let d = Descriptor {
371 id: c.get("CID").and_then(|x| x.as_str()).unwrap_or("").to_string(),
372 name: c.get("Name").and_then(|x| x.as_str()).unwrap_or("").to_string(),
373 vendor: c
374 .get("Vendor")
375 .and_then(|x| x.as_str())
376 .map(|s| s.to_string())
377 .filter(|s| !s.is_empty())
378 .unwrap_or_else(|| factory_vendor.clone()),
379 version: c
380 .get("Version")
381 .and_then(|x| x.as_str())
382 .map(|s| s.to_string())
383 .unwrap_or_else(|| module_version.clone()),
384 description: String::new(),
385 url: url.clone(),
386 kind: Descriptor::classify(&features),
387 features,
388 };
389 out.push(ScannedPlugin::from_descriptor(d, PluginFormat::Vst3, bundle, "moduleinfo"));
390 }
391 Some(out)
392}
393
394pub fn probe_bundle(format: PluginFormat, path: &Path, watchdog: Duration) -> Result<Vec<ScannedPlugin>> {
398 if format == PluginFormat::Vst3 {
399 if let Some(p) = read_vst3_moduleinfo(path) {
400 return Ok(p);
401 }
402 }
403 let label = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
404 let probed = PluginHost::spawn(format, &label, path, watchdog).and_then(|mut h| {
405 let r = h.probe(path);
406 h.shutdown();
407 r
408 });
409 match (probed, format) {
410 (Ok(ds), _) => Ok(ds.into_iter().map(|d| ScannedPlugin::from_descriptor(d, format, path, "probe")).collect()),
411 (Err(e), PluginFormat::Vst3) if e.code != "plugin_crashed" && e.code != "plugin_timeout" => {
412 let d = Descriptor { id: String::new(), name: label.clone(), kind: "unknown".into(), ..Default::default() };
414 let mut p = ScannedPlugin::from_descriptor(d, format, path, "bundle");
415 p.description = format!("not probed: {}", e.message);
416 Ok(vec![p])
417 }
418 (Err(e), _) => Err(e),
419 }
420}
421
422#[derive(Clone, Debug)]
424pub struct ScanOptions {
425 pub extra: Vec<PathBuf>,
426 pub formats: Vec<PluginFormat>,
427 pub rescan: bool,
428 pub standard: bool,
430 pub watchdog: Duration,
431 pub cache_path: PathBuf,
432}
433
434impl Default for ScanOptions {
435 fn default() -> Self {
436 ScanOptions {
437 extra: Vec::new(),
438 formats: vec![PluginFormat::Clap, PluginFormat::Vst3],
439 rescan: false,
440 standard: true,
441 watchdog: Duration::from_secs(30),
442 cache_path: default_cache_path(),
443 }
444 }
445}
446
447#[derive(Clone, Debug, Serialize)]
448pub struct ScanError {
449 pub path: PathBuf,
450 pub format: PluginFormat,
451 pub error: String,
452}
453
454#[derive(Clone, Debug, Serialize, Default)]
455pub struct ScanReport {
456 pub plugins: Vec<ScannedPlugin>,
457 pub errors: Vec<ScanError>,
458 pub dirs: Vec<PathBuf>,
459 pub bundles: usize,
460 pub probed: usize,
461 pub cached: usize,
462}
463
464pub fn scan_dirs(opts: &ScanOptions) -> Vec<PathBuf> {
466 let mut dirs: Vec<PathBuf> = Vec::new();
467 if opts.standard {
468 for f in &opts.formats {
469 dirs.extend(standard_dirs(*f));
470 }
471 }
472 dirs.extend(opts.extra.iter().map(|p| ferraus_core::ctx::absolutize(p)));
474 let mut seen = Vec::new();
475 dirs.retain(|d| {
476 if seen.contains(d) {
477 false
478 } else {
479 seen.push(d.clone());
480 true
481 }
482 });
483 dirs
484}
485
486pub fn scan(opts: &ScanOptions) -> Result<ScanReport> {
489 let mut cache = ScanCache::load(&opts.cache_path);
490 let dirs = scan_dirs(opts);
491 let mut bundles: Vec<(PluginFormat, PathBuf)> = Vec::new();
492 for d in &dirs {
493 for b in find_bundles(d, &opts.formats) {
494 if !bundles.contains(&b) {
495 bundles.push(b);
496 }
497 }
498 }
499 let mut report = ScanReport { dirs: dirs.clone(), bundles: bundles.len(), ..Default::default() };
500 let mut todo: Vec<(PluginFormat, PathBuf, u64)> = Vec::new();
501 for (f, p) in &bundles {
502 let key = p.to_string_lossy().into_owned();
503 let m = bundle_mtime(p);
504 match cache.entries.get(&key) {
505 Some(e) if !opts.rescan && e.mtime == m && e.format == Some(*f) => {
506 report.cached += 1;
507 }
508 _ => todo.push((*f, p.clone(), m)),
509 }
510 }
511 let results: Vec<(PluginFormat, PathBuf, u64, Result<Vec<ScannedPlugin>>)> = std::thread::scope(|s| {
513 let mut out = Vec::new();
514 for chunk in todo.chunks(4) {
515 let handles: Vec<_> = chunk
516 .iter()
517 .map(|(f, p, m)| {
518 let (f, p, m) = (*f, p.clone(), *m);
519 let wd = opts.watchdog;
520 s.spawn(move || {
521 let r = probe_bundle(f, &p, wd);
522 (f, p, m, r)
523 })
524 })
525 .collect();
526 for h in handles {
527 if let Ok(r) = h.join() {
528 out.push(r);
529 }
530 }
531 }
532 out
533 });
534 for (f, p, m, r) in results {
535 report.probed += 1;
536 let key = p.to_string_lossy().into_owned();
537 let entry = match r {
538 Ok(plugins) => CacheEntry { mtime: m, format: Some(f), plugins, error: None },
539 Err(e) => CacheEntry { mtime: m, format: Some(f), plugins: Vec::new(), error: Some(e.message.clone()) },
540 };
541 cache.entries.insert(key, entry);
542 }
543 cache.entries.retain(|p, _| Path::new(p).exists());
545 for (f, p) in &bundles {
546 if let Some(e) = cache.entries.get(&p.to_string_lossy().into_owned()) {
547 report.plugins.extend(e.plugins.iter().cloned());
548 if let Some(err) = &e.error {
549 report.errors.push(ScanError { path: p.clone(), format: *f, error: err.clone() });
550 }
551 }
552 }
553 cache.save(&opts.cache_path)?;
554 Ok(report)
555}
556
557fn norm(s: &str) -> String {
558 s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
559}
560
561fn unquote(s: &str) -> &str {
562 let s = s.trim();
563 s.strip_prefix('"')
564 .and_then(|x| x.strip_suffix('"'))
565 .or_else(|| s.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')))
566 .unwrap_or(s)
567}
568
569pub fn match_plugins<'a>(plugins: &'a [ScannedPlugin], query: &str) -> Vec<&'a ScannedPlugin> {
571 let q = unquote(query);
572 let nq = norm(q);
573 let stages: [&dyn Fn(&ScannedPlugin) -> bool; 4] = [
574 &|p| p.id == q,
575 &|p| p.name.eq_ignore_ascii_case(q) || p.id.eq_ignore_ascii_case(q),
576 &|p| !nq.is_empty() && (norm(&p.name) == nq || norm(&p.id) == nq),
577 &|p| !nq.is_empty() && (norm(&p.name).contains(&nq)),
578 ];
579 for test in stages {
580 let hits: Vec<&ScannedPlugin> = plugins.iter().filter(|p| test(p)).collect();
581 if hits.is_empty() {
582 continue;
583 }
584 let mut hits = hits;
586 hits.sort_by_key(|p| (p.format != PluginFormat::Clap, p.path.clone()));
587 let first = hits[0];
588 let same = hits.iter().all(|p| norm(&p.name) == norm(&first.name));
589 if same {
590 return vec![first];
591 }
592 return hits;
593 }
594 Vec::new()
595}
596
597pub fn resolve_plugin(query: &str, sound_paths: &[PathBuf], watchdog: Duration) -> Result<PluginRef> {
599 resolve_plugin_with(query, sound_paths, watchdog, &default_cache_path())
600}
601
602pub fn resolve_plugin_with(
603 query: &str,
604 sound_paths: &[PathBuf],
605 watchdog: Duration,
606 cache_path: &Path,
607) -> Result<PluginRef> {
608 let q = unquote(query.trim().strip_prefix("plugin:").unwrap_or(query.trim()));
609 if q.is_empty() {
610 return Err(Error::usage("empty plugin name"));
611 }
612 let (path_part, id_part) = match q.rsplit_once('#') {
614 Some((p, id)) if Path::new(p).exists() => (p, Some(id)),
615 _ => (q, None),
616 };
617 let p = Path::new(path_part);
618 if p.exists() && PluginFormat::from_path(p).is_some() {
619 let format = PluginFormat::from_path(p).unwrap();
620 let mut opts = ScanOptions {
621 extra: vec![p.to_path_buf()],
622 formats: vec![format],
623 standard: false,
624 watchdog,
625 cache_path: cache_path.to_path_buf(),
626 ..Default::default()
627 };
628 opts.rescan = false;
629 let report = scan(&opts)?;
630 if let Some(e) = report.errors.first() {
631 return Err(Error::new(
632 ErrorKind::Plugin,
633 "plugin_probe_failed",
634 format!("cannot read plugin bundle {}: {}", p.display(), e.error),
635 ));
636 }
637 let chosen: Vec<&ScannedPlugin> = match id_part {
638 Some(id) => report.plugins.iter().filter(|x| x.id == id).collect(),
639 None => report.plugins.iter().collect(),
640 };
641 return match chosen.len() {
642 1 => Ok(chosen[0].plugin_ref()),
643 0 if id_part.is_some() => Err(Error::new(
644 ErrorKind::Plugin,
645 "plugin_not_found",
646 format!("{} has no plugin with id '{}'", p.display(), id_part.unwrap()),
647 )),
648 0 => Err(Error::new(ErrorKind::Plugin, "plugin_not_found", format!("{} contains no plugins", p.display()))),
649 _ => Err(ambiguous(q, &chosen, true)),
650 };
651 }
652 let mut pool: Vec<ScannedPlugin> = Vec::new();
654 if !sound_paths.is_empty() {
655 let opts = ScanOptions {
656 extra: sound_paths.to_vec(),
657 standard: false,
658 watchdog,
659 cache_path: cache_path.to_path_buf(),
660 ..Default::default()
661 };
662 pool.extend(scan(&opts)?.plugins);
663 }
664 let mut cache = ScanCache::load(cache_path);
665 if cache.entries.is_empty() {
666 let opts = ScanOptions { standard: true, watchdog, cache_path: cache_path.to_path_buf(), ..Default::default() };
667 let _ = scan(&opts);
668 cache = ScanCache::load(cache_path);
669 }
670 for p in cache.plugins() {
671 if !pool.contains(&p) {
672 pool.push(p);
673 }
674 }
675 let hits = match_plugins(&pool, q);
676 match hits.len() {
677 1 => Ok(hits[0].plugin_ref()),
678 0 => Err(Error::new(
679 ErrorKind::Plugin,
680 "plugin_not_found",
681 format!("no plugin named '{q}' was found (searched the scan cache and --sound-path folders)"),
682 )
683 .with_hint("ferraus plugin scan --paths <folder-with-plugins>")),
684 _ => Err(ambiguous(q, &hits, false)),
685 }
686}
687
688fn ambiguous(q: &str, hits: &[&ScannedPlugin], by_path: bool) -> Error {
689 let list: Vec<String> = hits
690 .iter()
691 .take(10)
692 .map(|p| {
693 if by_path {
694 format!("{} ({}#{})", p.name, p.path.display(), p.id)
695 } else {
696 format!("{} [{} {}] id {}", p.name, p.format, p.vendor, p.id)
697 }
698 })
699 .collect();
700 let first = hits[0];
701 let hint = if by_path {
702 format!("ferraus plugin info \"{}#{}\"", first.path.display(), first.id)
703 } else {
704 format!("ferraus plugin info \"{}\"", first.id)
705 };
706 Error::new(ErrorKind::Usage, "plugin_ambiguous", format!("'{q}' matches several plugins: {}", list.join("; ")))
707 .with_hint(hint)
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[test]
715 fn moduleinfo_is_parsed_with_comments_and_trailing_commas() {
716 let dir = ferraus_core::testing::TempDir::new("vst3mi");
717 let b = dir.path("Fake Synth.vst3");
718 std::fs::create_dir_all(b.join("Contents/Resources")).unwrap();
719 std::fs::write(
720 b.join("Contents/Resources/moduleinfo.json"),
721 r#"{
722 // generated
723 "Name": "Fake Synth", "Version": "1.2.0",
724 "Factory Info": { "Vendor": "Acme", "URL": "https://acme.test", },
725 "Classes": [
726 { "CID": "ABCDEF0123456789ABCDEF0123456789", "Category": "Audio Module Class",
727 "Name": "Fake Synth", "Sub Categories": ["Instrument", "Synth"], },
728 { "CID": "00", "Category": "Component Controller Class", "Name": "Fake Synth Controller" },
729 ],
730}"#,
731 )
732 .unwrap();
733 let p = read_vst3_moduleinfo(&b).unwrap();
734 assert_eq!(p.len(), 1);
735 assert_eq!(p[0].name, "Fake Synth");
736 assert_eq!(p[0].vendor, "Acme");
737 assert_eq!(p[0].kind, "instrument");
738 assert_eq!(p[0].category.as_deref(), Some("synth"));
739 let found = find_bundles(&dir.0, &[PluginFormat::Vst3]);
740 assert_eq!(found.len(), 1);
741 }
742}