Skip to main content

maolan_plugin_host/
scan.rs

1//! Plugin scanner: loads a plugin library, validates it, and dumps metadata to JSON.
2
3use crate::clap::{
4    CLAP_EXT_AUDIO_PORTS, CLAP_EXT_PARAMS, CLAP_VERSION, ClapAudioPortInfo, ClapHost,
5    ClapParamInfo, ClapPluginAudioPorts, ClapPluginEntry, ClapPluginFactory, ClapPluginParams,
6};
7use serde::{Deserialize, Serialize};
8use std::ffi::{CStr, CString, c_char, c_void};
9use std::path::{Path, PathBuf};
10use std::ptr;
11
12/// Metadata for a single parameter.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ParamMetadata {
15    pub id: u32,
16    pub name: String,
17    pub module: String,
18    pub min_value: f64,
19    pub max_value: f64,
20    pub default_value: f64,
21    pub flags: u32,
22}
23
24/// Metadata for an audio port.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AudioPortMetadata {
27    pub id: u32,
28    pub name: String,
29    pub channel_count: u32,
30    pub flags: u32,
31}
32
33/// Metadata for a single plugin inside a library.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PluginMetadata {
36    pub id: String,
37    pub name: String,
38    pub vendor: String,
39    pub version: String,
40    pub description: String,
41    pub params: Vec<ParamMetadata>,
42    pub audio_inputs: Vec<AudioPortMetadata>,
43    pub audio_outputs: Vec<AudioPortMetadata>,
44}
45
46/// Full scan result for a plugin library.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ScanResult {
49    pub format: String,
50    pub path: String,
51    pub plugins: Vec<PluginMetadata>,
52    pub error: Option<String>,
53}
54
55// ─── CLAP system-scan types (match engine message types) ───
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ClapPluginInfo {
59    pub name: String,
60    pub path: String,
61    pub capabilities: Option<ClapPluginCapabilities>,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ClapPluginCapabilities {
66    pub has_gui: bool,
67    pub gui_apis: Vec<String>,
68    pub supports_embedded: bool,
69    pub supports_floating: bool,
70    pub has_params: bool,
71    pub has_state: bool,
72    pub audio_inputs: usize,
73    pub audio_outputs: usize,
74    pub midi_inputs: usize,
75    pub midi_outputs: usize,
76}
77
78unsafe extern "C" fn dummy_get_extension(_: *const ClapHost, _: *const c_char) -> *const c_void {
79    ptr::null()
80}
81unsafe extern "C" fn dummy_request_restart(_: *const ClapHost) {}
82unsafe extern "C" fn dummy_request_process(_: *const ClapHost) {}
83unsafe extern "C" fn dummy_request_callback(_: *const ClapHost) {}
84
85fn cstr_to_string(ptr: *const c_char) -> String {
86    if ptr.is_null() {
87        return String::new();
88    }
89    unsafe { CStr::from_ptr(ptr) }
90        .to_string_lossy()
91        .into_owned()
92}
93
94/// Check if a plugin's CLAP version is compatible with the host.
95fn clap_version_is_compatible(plugin_version: &crate::clap::ClapVersion) -> bool {
96    plugin_version.major == crate::clap::CLAP_VERSION.major
97        && plugin_version.minor <= crate::clap::CLAP_VERSION.minor
98}
99
100/// Scan a CLAP plugin library and return metadata for every plugin it contains.
101pub fn scan_clap_plugin(plugin_path: &str) -> ScanResult {
102    let path = Path::new(plugin_path);
103    if !path.exists() {
104        return ScanResult {
105            format: "clap".to_string(),
106            path: plugin_path.to_string(),
107            plugins: Vec::new(),
108            error: Some(format!("path does not exist: {plugin_path}")),
109        };
110    }
111
112    let library = match unsafe { libloading::Library::new(path) } {
113        Ok(lib) => lib,
114        Err(e) => {
115            return ScanResult {
116                format: "clap".to_string(),
117                path: plugin_path.to_string(),
118                plugins: Vec::new(),
119                error: Some(format!("failed to load library: {e}")),
120            };
121        }
122    };
123
124    let entry: libloading::Symbol<*const ClapPluginEntry> =
125        match unsafe { library.get(b"clap_entry\0") } {
126            Ok(sym) => sym,
127            Err(e) => {
128                return ScanResult {
129                    format: "clap".to_string(),
130                    path: plugin_path.to_string(),
131                    plugins: Vec::new(),
132                    error: Some(format!("clap_entry not found: {e}")),
133                };
134            }
135        };
136
137    let entry = unsafe { &**entry };
138
139    if let Some(init) = entry.init {
140        let path_c = match CString::new(plugin_path) {
141            Ok(s) => s,
142            Err(_) => {
143                return ScanResult {
144                    format: "clap".to_string(),
145                    path: plugin_path.to_string(),
146                    plugins: Vec::new(),
147                    error: Some("plugin path contains null bytes".to_string()),
148                };
149            }
150        };
151        if !unsafe { init(path_c.as_ptr()) } {
152            return ScanResult {
153                format: "clap".to_string(),
154                path: plugin_path.to_string(),
155                plugins: Vec::new(),
156                error: Some("clap_entry.init() failed".to_string()),
157            };
158        }
159    }
160
161    let factory = if let Some(get_factory) = entry.get_factory {
162        let factory_id = CString::new("clap.plugin-factory").unwrap();
163        let factory_ptr = unsafe { get_factory(factory_id.as_ptr()) };
164        if factory_ptr.is_null() {
165            return ScanResult {
166                format: "clap".to_string(),
167                path: plugin_path.to_string(),
168                plugins: Vec::new(),
169                error: Some("clap.plugin-factory not found".to_string()),
170            };
171        }
172        unsafe { &*(factory_ptr as *const ClapPluginFactory) }
173    } else {
174        return ScanResult {
175            format: "clap".to_string(),
176            path: plugin_path.to_string(),
177            plugins: Vec::new(),
178            error: Some("clap_entry.get_factory is null".to_string()),
179        };
180    };
181
182    let count = factory
183        .get_plugin_count
184        .map(|f| unsafe { f(factory) })
185        .unwrap_or(0);
186
187    let mut host = ClapHost {
188        clap_version: CLAP_VERSION,
189        host_data: ptr::null_mut(),
190        name: c"maolan-plugin-host".as_ptr(),
191        vendor: c"Maolan".as_ptr(),
192        url: c"https://maolan.github.io".as_ptr(),
193        version: c"0.1.0".as_ptr(),
194        get_extension: Some(dummy_get_extension),
195        request_restart: Some(dummy_request_restart),
196        request_process: Some(dummy_request_process),
197        request_callback: Some(dummy_request_callback),
198    };
199    host.host_data = (&mut host as *mut ClapHost).cast::<c_void>();
200
201    let mut plugins = Vec::with_capacity(count as usize);
202
203    for i in 0..count {
204        let desc = factory
205            .get_plugin_descriptor
206            .map(|f| unsafe { f(factory, i) })
207            .unwrap_or(ptr::null());
208        if desc.is_null() {
209            continue;
210        }
211        let desc = unsafe { &*desc };
212
213        if !clap_version_is_compatible(&desc.clap_version) {
214            continue;
215        }
216
217        let plugin_id = cstr_to_string(desc.id);
218        let plugin_id_c = match CString::new(&*plugin_id) {
219            Ok(s) => s,
220            Err(_) => continue,
221        };
222
223        let plugin = factory
224            .create_plugin
225            .map(|f| unsafe { f(factory, &host, plugin_id_c.as_ptr()) })
226            .unwrap_or(ptr::null());
227        if plugin.is_null() {
228            continue;
229        }
230
231        let init_ok = unsafe { (*plugin).init }
232            .map(|f| unsafe { f(plugin) })
233            .unwrap_or(false);
234        if !init_ok {
235            unsafe {
236                if let Some(destroy) = (*plugin).destroy {
237                    destroy(plugin);
238                }
239            }
240            continue;
241        }
242
243        let mut params = Vec::new();
244        let mut audio_inputs = Vec::new();
245        let mut audio_outputs = Vec::new();
246
247        // Query params extension.
248        unsafe {
249            let ext = (*plugin)
250                .get_extension
251                .map(|f| f(plugin, CLAP_EXT_PARAMS.as_ptr()));
252            if let Some(ptr) = ext
253                && !ptr.is_null()
254            {
255                let p = &*(ptr as *const ClapPluginParams);
256                let count = p.count.map(|f| f(plugin)).unwrap_or(0);
257                for pi in 0..count {
258                    let mut info = ClapParamInfo {
259                        id: 0,
260                        flags: 0,
261                        cookie: ptr::null_mut(),
262                        name: [0; 256],
263                        module: [0; 1024],
264                        min_value: 0.0,
265                        max_value: 0.0,
266                        default_value: 0.0,
267                    };
268                    if p.get_info
269                        .map(|f| f(plugin, pi, &mut info))
270                        .unwrap_or(false)
271                    {
272                        let name = CStr::from_ptr(info.name.as_ptr())
273                            .to_string_lossy()
274                            .into_owned();
275                        let module = CStr::from_ptr(info.module.as_ptr())
276                            .to_string_lossy()
277                            .into_owned();
278                        params.push(ParamMetadata {
279                            id: info.id,
280                            name,
281                            module,
282                            min_value: info.min_value,
283                            max_value: info.max_value,
284                            default_value: info.default_value,
285                            flags: info.flags,
286                        });
287                    }
288                }
289            }
290        }
291
292        // Query audio-ports extension.
293        unsafe {
294            let ext = (*plugin)
295                .get_extension
296                .map(|f| f(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr()));
297            if let Some(ptr) = ext
298                && !ptr.is_null()
299            {
300                let ap = &*(ptr as *const ClapPluginAudioPorts);
301                let in_count = ap.count.map(|f| f(plugin, true)).unwrap_or(0);
302                let out_count = ap.count.map(|f| f(plugin, false)).unwrap_or(0);
303                for pi in 0..in_count {
304                    let mut info = ClapAudioPortInfo {
305                        id: 0,
306                        name: [0; 256],
307                        flags: 0,
308                        channel_count: 0,
309                        port_type: ptr::null(),
310                        in_place_pair: 0,
311                    };
312                    if ap
313                        .get
314                        .map(|f| f(plugin, pi, true, &mut info))
315                        .unwrap_or(false)
316                    {
317                        let name = CStr::from_ptr(info.name.as_ptr())
318                            .to_string_lossy()
319                            .into_owned();
320                        audio_inputs.push(AudioPortMetadata {
321                            id: info.id,
322                            name,
323                            channel_count: info.channel_count,
324                            flags: info.flags,
325                        });
326                    }
327                }
328                for pi in 0..out_count {
329                    let mut info = ClapAudioPortInfo {
330                        id: 0,
331                        name: [0; 256],
332                        flags: 0,
333                        channel_count: 0,
334                        port_type: ptr::null(),
335                        in_place_pair: 0,
336                    };
337                    if ap
338                        .get
339                        .map(|f| f(plugin, pi, false, &mut info))
340                        .unwrap_or(false)
341                    {
342                        let name = CStr::from_ptr(info.name.as_ptr())
343                            .to_string_lossy()
344                            .into_owned();
345                        audio_outputs.push(AudioPortMetadata {
346                            id: info.id,
347                            name,
348                            channel_count: info.channel_count,
349                            flags: info.flags,
350                        });
351                    }
352                }
353            }
354        }
355
356        plugins.push(PluginMetadata {
357            id: plugin_id,
358            name: cstr_to_string(desc.name),
359            vendor: cstr_to_string(desc.vendor),
360            version: cstr_to_string(desc.version),
361            description: cstr_to_string(desc.description),
362            params,
363            audio_inputs,
364            audio_outputs,
365        });
366
367        unsafe {
368            if let Some(destroy) = (*plugin).destroy {
369                destroy(plugin);
370            }
371        }
372    }
373
374    if let Some(deinit) = entry.deinit {
375        unsafe { deinit() };
376    }
377
378    ScanResult {
379        format: "clap".to_string(),
380        path: plugin_path.to_string(),
381        plugins,
382        error: None,
383    }
384}
385
386// ─── CLAP system scanning ───
387
388#[cfg(any(
389    target_os = "macos",
390    target_os = "linux",
391    target_os = "freebsd",
392    target_os = "openbsd"
393))]
394fn default_clap_search_roots() -> Vec<PathBuf> {
395    let mut roots = Vec::new();
396    #[cfg(target_os = "macos")]
397    {
398        crate::paths::push_macos_audio_plugin_roots(&mut roots, "CLAP");
399    }
400    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))]
401    {
402        crate::paths::push_unix_plugin_roots(&mut roots, "clap");
403    }
404    roots
405}
406
407#[cfg(not(any(
408    target_os = "macos",
409    target_os = "linux",
410    target_os = "freebsd",
411    target_os = "openbsd"
412)))]
413fn default_clap_search_roots() -> Vec<PathBuf> {
414    Vec::new()
415}
416
417fn is_supported_clap_binary(path: &Path) -> bool {
418    path.extension()
419        .is_some_and(|ext| ext.eq_ignore_ascii_case("clap"))
420}
421
422/// Scan a single CLAP bundle and return plugin info entries.
423fn scan_clap_bundle(path: &Path, scan_capabilities: bool) -> Vec<ClapPluginInfo> {
424    use crate::clap::{
425        ClapPluginAudioPorts, ClapPluginEntry, ClapPluginFactory, ClapPluginGui,
426        ClapPluginNotePorts, ClapPluginParams,
427    };
428
429    let path_str = path.to_string_lossy().to_string();
430    let factory_id = c"clap.plugin-factory";
431    let mut host = ClapHost {
432        clap_version: CLAP_VERSION,
433        host_data: ptr::null_mut(),
434        name: c"Maolan".as_ptr(),
435        vendor: c"Maolan".as_ptr(),
436        url: c"https://example.invalid".as_ptr(),
437        version: c"0.0.1".as_ptr(),
438        get_extension: Some(dummy_get_extension),
439        request_restart: Some(dummy_request_restart),
440        request_process: Some(dummy_request_process),
441        request_callback: Some(dummy_request_callback),
442    };
443    host.host_data = (&mut host as *mut ClapHost).cast::<c_void>();
444
445    let lib = match unsafe { libloading::Library::new(path) } {
446        Ok(l) => l,
447        Err(_) => {
448            return vec![ClapPluginInfo {
449                name: path
450                    .file_stem()
451                    .map(|s| s.to_string_lossy().to_string())
452                    .unwrap_or_else(|| path_str.clone()),
453                path: path_str,
454                capabilities: None,
455            }];
456        }
457    };
458
459    let entry: libloading::Symbol<*const ClapPluginEntry> =
460        match unsafe { lib.get(b"clap_entry\0") } {
461            Ok(sym) => sym,
462            Err(_) => {
463                return vec![ClapPluginInfo {
464                    name: path
465                        .file_stem()
466                        .map(|s| s.to_string_lossy().to_string())
467                        .unwrap_or_else(|| path_str.clone()),
468                    path: path_str,
469                    capabilities: None,
470                }];
471            }
472        };
473    let entry = unsafe { &**entry };
474
475    if let Some(init) = entry.init {
476        let path_c = match CString::new(&*path_str) {
477            Ok(s) => s,
478            Err(_) => {
479                return vec![ClapPluginInfo {
480                    name: path_str.clone(),
481                    path: path_str,
482                    capabilities: None,
483                }];
484            }
485        };
486        if !unsafe { init(path_c.as_ptr()) } {
487            return vec![ClapPluginInfo {
488                name: path_str.clone(),
489                path: path_str,
490                capabilities: None,
491            }];
492        }
493    }
494
495    let factory = if let Some(get_factory) = entry.get_factory {
496        let factory_ptr = unsafe { get_factory(factory_id.as_ptr()) };
497        if factory_ptr.is_null() {
498            return vec![ClapPluginInfo {
499                name: path_str.clone(),
500                path: path_str,
501                capabilities: None,
502            }];
503        }
504        unsafe { &*(factory_ptr as *const ClapPluginFactory) }
505    } else {
506        return vec![ClapPluginInfo {
507            name: path_str.clone(),
508            path: path_str,
509            capabilities: None,
510        }];
511    };
512
513    let count = factory
514        .get_plugin_count
515        .map(|f| unsafe { f(factory) })
516        .unwrap_or(0);
517
518    let mut out = Vec::with_capacity(count as usize);
519
520    for i in 0..count {
521        let desc = factory
522            .get_plugin_descriptor
523            .map(|f| unsafe { f(factory, i) })
524            .unwrap_or(ptr::null());
525        if desc.is_null() {
526            continue;
527        }
528        let desc = unsafe { &*desc };
529        if !clap_version_is_compatible(&desc.clap_version) {
530            continue;
531        }
532        let name = cstr_to_string(desc.name);
533        let plugin_id = cstr_to_string(desc.id);
534        let plugin_id_c = match CString::new(&*plugin_id) {
535            Ok(s) => s,
536            Err(_) => continue,
537        };
538
539        let plugin = factory
540            .create_plugin
541            .map(|f| unsafe { f(factory, &host, plugin_id_c.as_ptr()) })
542            .unwrap_or(ptr::null());
543        if plugin.is_null() {
544            continue;
545        }
546        let init_ok = unsafe { (*plugin).init }
547            .map(|f| unsafe { f(plugin) })
548            .unwrap_or(false);
549        if !init_ok {
550            unsafe {
551                if let Some(destroy) = (*plugin).destroy {
552                    destroy(plugin);
553                }
554            }
555            continue;
556        }
557
558        let mut capabilities = None;
559        if scan_capabilities {
560            let mut caps = ClapPluginCapabilities {
561                has_gui: false,
562                gui_apis: Vec::new(),
563                supports_embedded: false,
564                supports_floating: false,
565                has_params: false,
566                has_state: false,
567                audio_inputs: 0,
568                audio_outputs: 0,
569                midi_inputs: 0,
570                midi_outputs: 0,
571            };
572
573            unsafe {
574                let ext = (*plugin)
575                    .get_extension
576                    .map(|f| f(plugin, c"clap.gui".as_ptr()));
577                if let Some(ptr) = ext
578                    && !ptr.is_null()
579                {
580                    let gui = &*(ptr as *const ClapPluginGui);
581                    caps.has_gui = gui
582                        .is_api_supported
583                        .map(|f| f(plugin, c"x11".as_ptr(), true))
584                        .unwrap_or(false)
585                        || gui
586                            .is_api_supported
587                            .map(|f| f(plugin, c"win32".as_ptr(), true))
588                            .unwrap_or(false)
589                        || gui
590                            .is_api_supported
591                            .map(|f| f(plugin, c"cocoa".as_ptr(), true))
592                            .unwrap_or(false);
593                    if caps.has_gui {
594                        caps.gui_apis = vec!["x11".to_string()];
595                        caps.supports_embedded = true;
596                        caps.supports_floating = gui
597                            .is_api_supported
598                            .map(|f| f(plugin, ptr::null(), false))
599                            .unwrap_or(false);
600                    }
601                }
602            }
603
604            unsafe {
605                let ext = (*plugin)
606                    .get_extension
607                    .map(|f| f(plugin, CLAP_EXT_PARAMS.as_ptr()));
608                if let Some(ptr) = ext
609                    && !ptr.is_null()
610                {
611                    let p = &*(ptr as *const ClapPluginParams);
612                    caps.has_params = p.count.map(|f| f(plugin)).unwrap_or(0) > 0;
613                }
614            }
615
616            unsafe {
617                let ext = (*plugin)
618                    .get_extension
619                    .map(|f| f(plugin, c"clap.state".as_ptr()));
620                if let Some(ptr) = ext
621                    && !ptr.is_null()
622                {
623                    caps.has_state = true;
624                }
625            }
626
627            unsafe {
628                let ext = (*plugin)
629                    .get_extension
630                    .map(|f| f(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr()));
631                if let Some(ptr) = ext
632                    && !ptr.is_null()
633                {
634                    let ap = &*(ptr as *const ClapPluginAudioPorts);
635                    caps.audio_inputs = ap.count.map(|f| f(plugin, true)).unwrap_or(0) as usize;
636                    caps.audio_outputs = ap.count.map(|f| f(plugin, false)).unwrap_or(0) as usize;
637                }
638            }
639
640            unsafe {
641                let ext = (*plugin)
642                    .get_extension
643                    .map(|f| f(plugin, c"clap.note-ports".as_ptr()));
644                if let Some(ptr) = ext
645                    && !ptr.is_null()
646                {
647                    let np = &*(ptr as *const ClapPluginNotePorts);
648                    caps.midi_inputs = np.count.map(|f| f(plugin, true)).unwrap_or(0) as usize;
649                    caps.midi_outputs = np.count.map(|f| f(plugin, false)).unwrap_or(0) as usize;
650                }
651            }
652
653            capabilities = Some(caps);
654        }
655
656        unsafe {
657            if let Some(destroy) = (*plugin).destroy {
658                destroy(plugin);
659            }
660        }
661
662        out.push(ClapPluginInfo {
663            name,
664            path: format!("{}::{}", path_str, plugin_id),
665            capabilities,
666        });
667    }
668
669    if let Some(deinit) = entry.deinit {
670        unsafe { deinit() };
671    }
672
673    if out.is_empty() {
674        out.push(ClapPluginInfo {
675            name: path
676                .file_stem()
677                .map(|s| s.to_string_lossy().to_string())
678                .unwrap_or_else(|| path_str.clone()),
679            path: path_str,
680            capabilities: None,
681        });
682    }
683
684    out
685}
686
687fn collect_clap_plugins(root: &Path, out: &mut Vec<ClapPluginInfo>, scan_capabilities: bool) {
688    let Ok(entries) = std::fs::read_dir(root) else {
689        return;
690    };
691    for entry in entries.flatten() {
692        let path = entry.path();
693        let Ok(ft) = entry.file_type() else {
694            continue;
695        };
696        if ft.is_dir() {
697            if path
698                .file_name()
699                .and_then(|name| name.to_str())
700                .is_some_and(|name| {
701                    matches!(
702                        name,
703                        "deps" | "build" | "incremental" | ".fingerprint" | "examples"
704                    )
705                })
706            {
707                continue;
708            }
709            collect_clap_plugins(&path, out, scan_capabilities);
710            continue;
711        }
712
713        if is_supported_clap_binary(&path) {
714            let infos = scan_clap_bundle(&path, scan_capabilities);
715            if infos.is_empty() {
716                let name = path
717                    .file_stem()
718                    .map(|s| s.to_string_lossy().to_string())
719                    .unwrap_or_else(|| path.to_string_lossy().to_string());
720                out.push(ClapPluginInfo {
721                    name,
722                    path: path.to_string_lossy().to_string(),
723                    capabilities: None,
724                });
725            } else {
726                out.extend(infos);
727            }
728        }
729    }
730}
731
732/// Scan all CLAP plugins on the system.
733pub fn scan_clap_plugins(scan_capabilities: bool) -> Vec<ClapPluginInfo> {
734    let mut roots = default_clap_search_roots();
735
736    if let Ok(extra) = std::env::var("CLAP_PATH") {
737        for p in std::env::split_paths(&extra) {
738            if !p.as_os_str().is_empty() {
739                roots.push(p);
740            }
741        }
742    }
743
744    let mut out = Vec::new();
745    for root in roots {
746        collect_clap_plugins(&root, &mut out, scan_capabilities);
747    }
748
749    out.sort_by_key(|a| a.name.to_lowercase());
750    out.dedup_by(|a, b| {
751        a.name.eq_ignore_ascii_case(&b.name) && a.path.eq_ignore_ascii_case(&b.path)
752    });
753    out
754}
755
756// ─── VST3 system scanning ───
757
758pub fn scan_vst3_plugins() -> Vec<crate::vst3::Vst3PluginInfo> {
759    crate::vst3::host::Vst3Host::new().list_plugins()
760}
761
762// ─── LV2 system scanning ───
763
764#[cfg(unix)]
765pub fn scan_lv2_plugins() -> Vec<crate::lv2::Lv2PluginInfo> {
766    crate::lv2::Lv2Host::new(48_000.0).list_plugins()
767}
768
769// ─── Unified scan runner ───
770
771/// Run a scan and print JSON to stdout or write to `output_path`.
772///
773/// * `format`: `"clap"`, `"vst3"`, or `"lv2"`
774/// * `plugin_path`: specific file/directory to scan, or `"--system"` for system-wide scan
775/// * `output_path`: optional file to write JSON to
776pub fn run_scan(format: &str, plugin_path: &str, output_path: Option<&str>) -> i32 {
777    let json = match format {
778        "clap" => {
779            if plugin_path == "--system" {
780                match serde_json::to_string_pretty(&scan_clap_plugins(true)) {
781                    Ok(j) => j,
782                    Err(e) => {
783                        eprintln!("Failed to serialize scan result: {e}");
784                        return 1;
785                    }
786                }
787            } else {
788                match serde_json::to_string_pretty(&scan_clap_plugin(plugin_path)) {
789                    Ok(j) => j,
790                    Err(e) => {
791                        eprintln!("Failed to serialize scan result: {e}");
792                        return 1;
793                    }
794                }
795            }
796        }
797        "vst3" => {
798            if plugin_path != "--system" {
799                eprintln!("VST3 single-file scan not yet supported; use --system");
800                return 1;
801            }
802            match serde_json::to_string_pretty(&scan_vst3_plugins()) {
803                Ok(j) => j,
804                Err(e) => {
805                    eprintln!("Failed to serialize scan result: {e}");
806                    return 1;
807                }
808            }
809        }
810        #[cfg(unix)]
811        "lv2" => {
812            if plugin_path != "--system" {
813                eprintln!("LV2 single-file scan not yet supported; use --system");
814                return 1;
815            }
816            match serde_json::to_string_pretty(&scan_lv2_plugins()) {
817                Ok(j) => j,
818                Err(e) => {
819                    eprintln!("Failed to serialize scan result: {e}");
820                    return 1;
821                }
822            }
823        }
824        _ => {
825            eprintln!("Scan format '{}' not supported", format);
826            return 1;
827        }
828    };
829
830    if let Some(path) = output_path {
831        match std::fs::write(path, &json) {
832            Ok(()) => {
833                println!("{path}");
834                0
835            }
836            Err(e) => {
837                eprintln!("Failed to write {path}: {e}");
838                1
839            }
840        }
841    } else {
842        println!("{json}");
843        0
844    }
845}