Skip to main content

ferraus_plugins/
scan.rs

1//! Plugin discovery: standard per-OS folders plus extra folders, CLAP probing in a host
2//! subprocess (a crashing plugin cannot kill the scan), VST3 metadata from
3//! `moduleinfo.json`, and a cache at `<cache>/plugins/scan.json` keyed by path and mtime.
4
5use 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/// One plugin found by a scan.
16#[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    /// The bundle (`.clap` / `.vst3` file or directory).
28    pub path: PathBuf,
29    #[serde(default)]
30    pub features: Vec<String>,
31    /// `instrument`, `audio-effect`, `note-effect`, `analyzer`, `other`, or `unknown`.
32    pub kind: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub category: Option<String>,
35    /// Where the metadata came from: `probe`, `moduleinfo`, or `bundle` (name only).
36    #[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/// `<cache>/plugins/scan.json`.
89#[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    /// Every cached plugin whose bundle still exists.
117    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
126/// Default cache location.
127pub 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
138/// The per-user folder `sounds install` places plugins in. `FERRAUS_PLUGIN_DIR` overrides
139/// the base (`<base>/clap`, `<base>/vst3`).
140pub 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
159/// The standard per-OS plugin folders from the PRD (plus `CLAP_PATH` for CLAP and the
160/// ferraus per-user install folder). `FERRAUS_PLUGIN_STANDARD_DIRS=0` limits this to the
161/// ferraus install folder (hermetic tests and sandboxes).
162pub 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
205/// Every plugin bundle under `dir` (recursive; bundles themselves are not descended into).
206pub 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
251/// Change stamp of a bundle: the newest mtime of the bundle and the files inside it.
252pub 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
274/// Strip `//` and `/* */` comments and trailing commas (moduleinfo.json is JSON5-ish).
275fn 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            // copy a full UTF-8 sequence
322            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
339/// Plugins declared by a VST3 bundle's `moduleinfo.json`, when it has one.
340pub 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
394/// Probe one bundle. CLAP bundles are probed in a `ferraus-plughost` subprocess; VST3
395/// bundles are read from `moduleinfo.json`, else probed by `ferraus-plughost-vst3` when
396/// available, else listed by bundle name.
397pub 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            // No moduleinfo.json and no usable VST3 host: still list the bundle by name.
413            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/// What to scan.
423#[derive(Clone, Debug)]
424pub struct ScanOptions {
425    pub extra: Vec<PathBuf>,
426    pub formats: Vec<PluginFormat>,
427    pub rescan: bool,
428    /// Include the standard per-OS folders.
429    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
464/// The folders a scan will visit.
465pub 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    // absolute, so cached entries stay valid from any working directory
473    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
486/// Scan, probing only bundles that are new or changed since the cached scan (or all of
487/// them with `rescan`), and update the cache.
488pub 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    // Probe in parallel, a few host processes at a time.
512    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    // forget bundles that were removed from disk
544    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
569/// Pick plugins matching `query` by id, name, or unique prefix/substring.
570pub 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        // the same plugin in CLAP and VST3: prefer CLAP
585        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
597/// Resolve a plugin name, id, or path to a [`PluginRef`] (see [`PluginRef::resolve`]).
598pub 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    // explicit path, optionally `path#plugin.id`
613    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    // by name or id: sound paths first (scanned now, cached by mtime), then the cache
653    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}