Skip to main content

drasi_host_sdk/
loader.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Plugin loader — discovers, validates, and loads cdylib plugins.
16
17use std::ffi::c_void;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use indexmap::IndexMap;
22use libloading::{Library, Symbol};
23
24use drasi_plugin_sdk::ffi::{
25    FfiPluginRegistration, LifecycleCallbackFn, LogCallbackFn, PluginMetadata,
26};
27
28use crate::proxies::bootstrap_provider::BootstrapPluginProxy;
29use crate::proxies::identity_provider::IdentityProviderPluginProxy;
30use crate::proxies::reaction::ReactionPluginProxy;
31use crate::proxies::source::SourcePluginProxy;
32
33/// Configuration for the plugin loader.
34#[derive(Debug, Clone)]
35pub struct PluginLoaderConfig {
36    /// Directory to scan for plugin shared libraries.
37    pub plugin_dir: PathBuf,
38    /// File glob patterns to match (e.g., `["libdrasi_source_*", "libdrasi_reaction_*"]`).
39    pub file_patterns: Vec<String>,
40}
41
42/// A loaded plugin with its metadata and factory proxies.
43pub struct LoadedPlugin {
44    /// Source plugin factories (descriptor proxies).
45    pub source_plugins: Vec<SourcePluginProxy>,
46    /// Reaction plugin factories (descriptor proxies).
47    pub reaction_plugins: Vec<ReactionPluginProxy>,
48    /// Bootstrap plugin factories (descriptor proxies).
49    pub bootstrap_plugins: Vec<BootstrapPluginProxy>,
50    /// Identity provider plugin factories (descriptor proxies).
51    pub identity_provider_plugins: Vec<IdentityProviderPluginProxy>,
52    /// Plugin metadata string for diagnostics.
53    pub metadata_info: Option<String>,
54    /// Path to the loaded plugin file.
55    pub file_path: PathBuf,
56    /// Keep the library loaded.
57    _library: Arc<Library>,
58}
59
60impl Drop for LoadedPlugin {
61    fn drop(&mut self) {
62        // 1. Drop all plugin proxies (calls FFI drop functions on plugin state).
63        self.source_plugins.clear();
64        self.reaction_plugins.clear();
65        self.bootstrap_plugins.clear();
66
67        // 2. Leak the library so dlclose is never called.
68        //    Plugin runtime worker threads may still be executing library code;
69        //    calling dlclose while worker threads run is UB on macOS.
70        //    Bumping the Arc refcount prevents Library::drop → dlclose when
71        //    Rust drops the `_library` field after this method returns.
72        //
73        //    Note: we intentionally do NOT call drasi_plugin_shutdown() because
74        //    dlopen returns the same library handle for the same dylib path,
75        //    so all LoadedPlugin instances share the same process-global statics.
76        //    Calling shutdown() from one instance would null the runtime pointer
77        //    for all instances, causing null pointer dereferences.
78        std::mem::forget(self._library.clone());
79    }
80}
81
82/// Known cdylib shared library extensions, in preference order.
83const CDYLIB_EXTENSIONS: &[&str] = &[".dylib", ".so", ".dll"];
84
85/// All extensions we might encounter (cdylib + Cargo build artifacts).
86const ALL_KNOWN_EXTENSIONS: &[&str] = &[".dylib", ".so", ".dll", ".rlib", ".rmeta", ".d"];
87
88/// Loads cdylib plugins from a directory.
89pub struct PluginLoader {
90    config: PluginLoaderConfig,
91}
92
93impl PluginLoader {
94    pub fn new(config: PluginLoaderConfig) -> Self {
95        Self { config }
96    }
97
98    /// Load all plugins matching the configured patterns.
99    ///
100    /// Discovers candidate files, groups them by plugin base name, then loads
101    /// exactly one cdylib per plugin. Non-cdylib artifacts (.rlib, .d, .rmeta)
102    /// are silently ignored. An error is logged if multiple cdylib extensions
103    /// exist for the same plugin (ambiguous).
104    pub fn load_all(
105        &self,
106        log_ctx: *mut c_void,
107        log_callback: LogCallbackFn,
108        lifecycle_ctx: *mut c_void,
109        lifecycle_callback: LifecycleCallbackFn,
110    ) -> anyhow::Result<Vec<LoadedPlugin>> {
111        let mut plugins = Vec::new();
112        let plugin_dir = &self.config.plugin_dir;
113
114        if !plugin_dir.exists() {
115            log::warn!("Plugin directory does not exist: {}", plugin_dir.display());
116            return Ok(plugins);
117        }
118
119        // Phase 1: Discover all candidate files and group by plugin name
120        let candidates = discover_plugin_candidates(plugin_dir, &self.config.file_patterns);
121
122        // Phase 2: For each plugin name, try loading from cdylib extensions
123        for (plugin_name, files) in &candidates {
124            let cdylib_files: Vec<&PathBuf> = files
125                .iter()
126                .filter(|p| {
127                    CDYLIB_EXTENSIONS
128                        .iter()
129                        .any(|ext| p.to_string_lossy().ends_with(ext))
130                })
131                .collect();
132
133            if cdylib_files.is_empty() {
134                log::debug!(
135                    "Plugin '{}': no cdylib files found (skipping {} non-cdylib file(s))",
136                    plugin_name,
137                    files.len()
138                );
139                continue;
140            }
141
142            if cdylib_files.len() > 1 {
143                log::error!(
144                    "Plugin '{}': found {} cdylib files — ambiguous. \
145                     Remove duplicates and keep only one: {:?}",
146                    plugin_name,
147                    cdylib_files.len(),
148                    cdylib_files
149                );
150                continue;
151            }
152
153            // Exactly one cdylib file — load it
154            let path = cdylib_files[0];
155            match self.load_plugin(
156                path,
157                log_ctx,
158                log_callback,
159                lifecycle_ctx,
160                lifecycle_callback,
161            ) {
162                Ok(plugin) => {
163                    log::info!(
164                        "Loaded plugin: {} ({})",
165                        path.display(),
166                        plugin.metadata_info.as_deref().unwrap_or("no metadata")
167                    );
168                    plugins.push(plugin);
169                }
170                Err(e) => {
171                    log::error!("Failed to load plugin {}: {}", path.display(), e);
172                }
173            }
174        }
175
176        Ok(plugins)
177    }
178
179    /// Load a single plugin from a path.
180    pub fn load_plugin(
181        &self,
182        path: &Path,
183        log_ctx: *mut c_void,
184        log_callback: LogCallbackFn,
185        lifecycle_ctx: *mut c_void,
186        lifecycle_callback: LifecycleCallbackFn,
187    ) -> anyhow::Result<LoadedPlugin> {
188        load_plugin_from_path(
189            path,
190            log_ctx,
191            log_callback,
192            lifecycle_ctx,
193            lifecycle_callback,
194        )
195    }
196}
197
198/// Load a single plugin from a shared library path.
199///
200/// This function:
201/// 1. Opens the shared library
202/// 2. Resolves and validates `drasi_plugin_metadata()`
203/// 3. Calls `drasi_plugin_init()` to get the registration
204/// 4. Wires log and lifecycle callbacks
205/// 5. Extracts factory vtables into proxy types
206pub fn load_plugin_from_path(
207    path: &Path,
208    log_ctx: *mut c_void,
209    log_callback: LogCallbackFn,
210    lifecycle_ctx: *mut c_void,
211    lifecycle_callback: LifecycleCallbackFn,
212) -> anyhow::Result<LoadedPlugin> {
213    let lib = Arc::new(unsafe {
214        Library::new(path)
215            .map_err(|e| anyhow::anyhow!("Failed to load {}: {}", path.display(), e))?
216    });
217
218    // Step 1: Read and validate metadata
219    let metadata_info = read_plugin_metadata(&lib);
220    validate_plugin_metadata(&lib, path)?;
221
222    // Step 2: Call drasi_plugin_init()
223    let init_fn: Symbol<unsafe extern "C" fn() -> *mut FfiPluginRegistration> = unsafe {
224        lib.get(b"drasi_plugin_init").map_err(|e| {
225            anyhow::anyhow!("Missing drasi_plugin_init in {}: {}", path.display(), e)
226        })?
227    };
228
229    let reg_ptr = unsafe { init_fn() };
230    if reg_ptr.is_null() {
231        return Err(anyhow::anyhow!(
232            "drasi_plugin_init returned null (init panicked?) in {}",
233            path.display()
234        ));
235    }
236
237    let registration = unsafe { Box::from_raw(reg_ptr) };
238
239    // Step 3: Wire callbacks (with host-owned context pointers)
240    (registration.set_log_callback)(log_ctx, log_callback);
241    (registration.set_lifecycle_callback)(lifecycle_ctx, lifecycle_callback);
242
243    // Step 4: Extract factory vtables into proxies
244    // Take ownership of ALL arrays upfront before processing, so if any
245    // proxy construction panics, remaining arrays are still dropped correctly.
246    let source_vtables =
247        if !registration.source_plugins.is_null() && registration.source_plugin_count > 0 {
248            Some(unsafe {
249                Vec::from_raw_parts(
250                    registration.source_plugins,
251                    registration.source_plugin_count,
252                    registration.source_plugin_count,
253                )
254            })
255        } else {
256            None
257        };
258
259    let reaction_vtables =
260        if !registration.reaction_plugins.is_null() && registration.reaction_plugin_count > 0 {
261            Some(unsafe {
262                Vec::from_raw_parts(
263                    registration.reaction_plugins,
264                    registration.reaction_plugin_count,
265                    registration.reaction_plugin_count,
266                )
267            })
268        } else {
269            None
270        };
271
272    let bootstrap_vtables =
273        if !registration.bootstrap_plugins.is_null() && registration.bootstrap_plugin_count > 0 {
274            Some(unsafe {
275                Vec::from_raw_parts(
276                    registration.bootstrap_plugins,
277                    registration.bootstrap_plugin_count,
278                    registration.bootstrap_plugin_count,
279                )
280            })
281        } else {
282            None
283        };
284
285    // NOTE: We intentionally do not read `identity_provider_plugins` /
286    // `identity_provider_plugin_count` from `FfiPluginRegistration` here.
287    // Those fields were added in a later SDK version, and older plugins built
288    // against the previous ABI may provide a smaller `FfiPluginRegistration`
289    // allocation. Accessing the new fields in that case would read beyond the
290    // end of the struct, causing undefined behavior. Until ABI/SDK versioning
291    // guarantees are tightened, we treat identity provider plugins as absent.
292    let identity_provider_vtables: Option<
293        Vec<drasi_plugin_sdk::ffi::IdentityProviderPluginVtable>,
294    > = None;
295
296    // Now safe to forget the registration — we own all arrays
297    std::mem::forget(registration);
298
299    // Process vtables into proxies
300    let mut source_plugins = Vec::new();
301    let mut reaction_plugins = Vec::new();
302    let mut bootstrap_plugins = Vec::new();
303    let mut identity_provider_plugins = Vec::new();
304
305    for v in source_vtables.into_iter().flatten() {
306        source_plugins.push(SourcePluginProxy::new(v, lib.clone()));
307    }
308
309    for v in reaction_vtables.into_iter().flatten() {
310        reaction_plugins.push(ReactionPluginProxy::new(v, lib.clone()));
311    }
312
313    for v in bootstrap_vtables.into_iter().flatten() {
314        bootstrap_plugins.push(BootstrapPluginProxy::new(v, lib.clone()));
315    }
316
317    for v in identity_provider_vtables.into_iter().flatten() {
318        identity_provider_plugins.push(IdentityProviderPluginProxy::new(v, lib.clone()));
319    }
320
321    Ok(LoadedPlugin {
322        source_plugins,
323        reaction_plugins,
324        bootstrap_plugins,
325        identity_provider_plugins,
326        metadata_info,
327        file_path: path.to_path_buf(),
328        _library: lib,
329    })
330}
331
332/// Read plugin metadata from the shared library.
333fn read_plugin_metadata(lib: &Library) -> Option<String> {
334    unsafe {
335        if let Ok(meta_fn) =
336            lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata")
337        {
338            let meta_ptr = meta_fn();
339            if !meta_ptr.is_null() {
340                let meta = &*meta_ptr;
341                let sdk_ver = meta.sdk_version.to_string();
342                let core_ver = meta.core_version.to_string();
343                let plugin_ver = meta.plugin_version.to_string();
344                let target = meta.target_triple.to_string();
345                let commit = meta.git_commit.to_string();
346                let built = meta.build_timestamp.to_string();
347                Some(format!(
348                    "sdk={sdk_ver} core={core_ver} plugin={plugin_ver} target={target} commit={commit} built={built}"
349                ))
350            } else {
351                None
352            }
353        } else {
354            None
355        }
356    }
357}
358
359/// Validate plugin metadata against the host SDK version.
360///
361/// Checks that the plugin's SDK version is compatible with the host.
362/// For cdylib plugins, we check major.minor compatibility (patch differences are OK).
363fn validate_plugin_metadata(lib: &Library, path: &Path) -> anyhow::Result<()> {
364    let meta_fn = unsafe {
365        match lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata") {
366            Ok(f) => f,
367            Err(_) => {
368                log::warn!(
369                    "Plugin '{}' does not export drasi_plugin_metadata — skipping version check",
370                    path.display()
371                );
372                return Ok(());
373            }
374        }
375    };
376
377    let meta_ptr = unsafe { meta_fn() };
378    if meta_ptr.is_null() {
379        log::warn!(
380            "Plugin '{}' returned null metadata — skipping version check",
381            path.display()
382        );
383        return Ok(());
384    }
385
386    let meta = unsafe { &*meta_ptr };
387    let plugin_sdk_version = unsafe { meta.sdk_version.to_string() };
388    let host_sdk_version = drasi_plugin_sdk::ffi::metadata::FFI_SDK_VERSION;
389
390    // Check major.minor compatibility
391    let plugin_parts: Vec<&str> = plugin_sdk_version.split('.').collect();
392    let host_parts: Vec<&str> = host_sdk_version.split('.').collect();
393
394    let plugin_major_minor = format!(
395        "{}.{}",
396        plugin_parts.first().unwrap_or(&"0"),
397        plugin_parts.get(1).unwrap_or(&"0")
398    );
399    let host_major_minor = format!(
400        "{}.{}",
401        host_parts.first().unwrap_or(&"0"),
402        host_parts.get(1).unwrap_or(&"0")
403    );
404
405    if plugin_major_minor != host_major_minor {
406        anyhow::bail!(
407            "Plugin '{}' SDK version mismatch: plugin={}, host={}. \
408             Major.minor versions must match ({} != {}).",
409            path.display(),
410            plugin_sdk_version,
411            host_sdk_version,
412            plugin_major_minor,
413            host_major_minor,
414        );
415    }
416
417    // Check target triple compatibility
418    let plugin_target = unsafe { meta.target_triple.to_string() };
419    let host_target = drasi_plugin_sdk::ffi::metadata::TARGET_TRIPLE;
420    if plugin_target != host_target {
421        anyhow::bail!(
422            "Plugin '{}' target mismatch: plugin={}, host={}. \
423             Plugins must be built for the same target platform.",
424            path.display(),
425            plugin_target,
426            host_target,
427        );
428    }
429
430    log::debug!(
431        "Plugin '{}' version check passed: sdk={} target={}",
432        path.display(),
433        plugin_sdk_version,
434        plugin_target
435    );
436
437    Ok(())
438}
439
440/// Scan the plugin directory and group files by plugin base name.
441///
442/// Returns an ordered map of plugin_name → Vec<PathBuf> where plugin_name is
443/// the filename with all known extensions stripped (e.g., "libdrasi_source_mock").
444fn discover_plugin_candidates(dir: &Path, patterns: &[String]) -> IndexMap<String, Vec<PathBuf>> {
445    let mut groups: IndexMap<String, Vec<PathBuf>> = IndexMap::new();
446
447    let entries = match std::fs::read_dir(dir) {
448        Ok(e) => e,
449        Err(_) => return groups,
450    };
451
452    for entry in entries.flatten() {
453        let path = entry.path();
454        if !path.is_file() {
455            continue;
456        }
457        let file_name = path
458            .file_name()
459            .map(|n| n.to_string_lossy().to_string())
460            .unwrap_or_default();
461
462        // Strip any known extension to get the base name
463        let base_name = ALL_KNOWN_EXTENSIONS
464            .iter()
465            .find_map(|ext| file_name.strip_suffix(ext))
466            .unwrap_or(&file_name)
467            .to_string();
468
469        // Check if the base name matches any of the configured patterns
470        let matched = patterns.iter().any(|pattern| {
471            let pat = ALL_KNOWN_EXTENSIONS
472                .iter()
473                .find_map(|ext| pattern.strip_suffix(ext))
474                .unwrap_or(pattern);
475            matches_glob(pat, &base_name)
476        });
477
478        if matched {
479            groups.entry(base_name).or_default().push(path);
480        }
481    }
482
483    groups
484}
485
486/// Simple glob matching: supports trailing `*` and middle `*`.
487fn matches_glob(pattern: &str, name: &str) -> bool {
488    if let Some(prefix) = pattern.strip_suffix('*') {
489        name.starts_with(prefix)
490    } else if let Some((prefix, suffix)) = pattern.split_once('*') {
491        name.starts_with(prefix) && name.ends_with(suffix)
492    } else {
493        name == pattern
494    }
495}
496
497/// Helper to get the platform-specific plugin file path.
498pub fn plugin_path(dir: &Path, name: &str) -> PathBuf {
499    if cfg!(target_os = "macos") {
500        dir.join(format!("lib{name}.dylib"))
501    } else if cfg!(target_os = "windows") {
502        dir.join(format!("{name}.dll"))
503    } else {
504        dir.join(format!("lib{name}.so"))
505    }
506}
507
508// ── Shared naming / discovery helpers ──
509
510/// Default file patterns for discovering Drasi cdylib plugins.
511/// Includes both Unix (`lib` prefix) and Windows (no prefix) naming conventions.
512pub const DEFAULT_PLUGIN_FILE_PATTERNS: &[&str] = &[
513    "libdrasi_source_*",
514    "libdrasi_reaction_*",
515    "libdrasi_bootstrap_*",
516    "drasi_source_*",
517    "drasi_reaction_*",
518    "drasi_bootstrap_*",
519];
520
521/// Known shared library extensions for cdylib plugins.
522pub const PLUGIN_BINARY_EXTENSIONS: &[&str] = CDYLIB_EXTENSIONS;
523
524/// Check whether a filename looks like a Drasi plugin binary.
525pub fn is_plugin_binary(name: &str) -> bool {
526    CDYLIB_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
527}
528
529/// Extract a `"type/kind"` string from a Drasi plugin filename.
530///
531/// For example:
532/// - `"libdrasi_source_postgres.so"` → `Some("source/postgres")`
533/// - `"drasi_reaction_log.dll"` → `Some("reaction/log")`
534/// - `"not_a_plugin.txt"` → `None`
535///
536/// Underscores in the kind portion are converted to hyphens.
537pub fn plugin_kind_from_filename(filename: &str) -> Option<String> {
538    let stem = if let Some(stem) = filename.strip_suffix(".so") {
539        stem.strip_prefix("lib")?
540    } else if let Some(stem) = filename.strip_suffix(".dll") {
541        stem
542    } else if let Some(stem) = filename.strip_suffix(".dylib") {
543        stem.strip_prefix("lib")?
544    } else {
545        return None;
546    };
547
548    let stem = stem.strip_prefix("drasi_")?;
549    let mut parts = stem.splitn(2, '_');
550    let ptype = parts.next()?;
551    let kind = parts.next()?.replace('_', "-");
552    Some(format!("{ptype}/{kind}"))
553}
554
555/// Summary of a plugin's metadata read without full initialization.
556///
557/// Obtained by calling only `drasi_plugin_metadata()` — no tokio runtime
558/// is started and no `drasi_plugin_init()` is called.
559#[derive(Debug, Clone)]
560pub struct PluginMetadataSummary {
561    pub plugin_id: String,
562    pub version: String,
563    pub sdk_version: String,
564    pub core_version: String,
565    pub target_triple: String,
566    pub git_commit: String,
567    pub build_timestamp: String,
568    pub file_path: PathBuf,
569}
570
571/// Read a plugin's metadata without fully initializing it.
572///
573/// This calls only `drasi_plugin_metadata()` via `dlopen` + symbol lookup.
574/// No tokio runtime is created and no `drasi_plugin_init()` is called, making
575/// this safe and fast for scanning/inspection flows.
576///
577/// Returns `None` if the library cannot be loaded or does not export metadata.
578pub fn scan_plugin_metadata(path: &Path) -> Option<PluginMetadataSummary> {
579    let lib = unsafe { Library::new(path).ok()? };
580    let meta_fn = unsafe {
581        lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata")
582            .ok()?
583    };
584    let meta_ptr = unsafe { meta_fn() };
585    if meta_ptr.is_null() {
586        return None;
587    }
588    let meta = unsafe { &*meta_ptr };
589    let sdk_version = unsafe { meta.sdk_version.to_string() };
590    let core_version = unsafe { meta.core_version.to_string() };
591    let plugin_version = unsafe { meta.plugin_version.to_string() };
592    let target_triple = unsafe { meta.target_triple.to_string() };
593    let git_commit = unsafe { meta.git_commit.to_string() };
594    let build_timestamp = unsafe { meta.build_timestamp.to_string() };
595
596    // Derive a plugin_id from the filename using the naming convention.
597    let plugin_id = path
598        .file_name()
599        .and_then(|f| f.to_str())
600        .and_then(plugin_kind_from_filename)
601        .unwrap_or_default();
602
603    // Close the library — we only needed the metadata strings (already copied).
604    // Unlike LoadedPlugin (which keeps vtable pointers alive), scan_plugin_metadata
605    // has no dangling references after the strings are copied above.
606    drop(lib);
607
608    Some(PluginMetadataSummary {
609        plugin_id,
610        version: plugin_version,
611        sdk_version,
612        core_version,
613        target_triple,
614        git_commit,
615        build_timestamp,
616        file_path: path.to_path_buf(),
617    })
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use std::fs;
624
625    /// Create a temp dir with the given filenames (empty files).
626    fn setup_temp_dir(files: &[&str]) -> tempfile::TempDir {
627        let dir = tempfile::tempdir().unwrap();
628        for f in files {
629            fs::write(dir.path().join(f), b"").unwrap();
630        }
631        dir
632    }
633
634    // ── matches_glob tests ──
635
636    #[test]
637    fn test_matches_glob_prefix_wildcard() {
638        assert!(matches_glob("libdrasi_source_*", "libdrasi_source_mock"));
639        assert!(matches_glob("libdrasi_source_*", "libdrasi_source_http"));
640        assert!(!matches_glob("libdrasi_source_*", "libdrasi_reaction_log"));
641    }
642
643    #[test]
644    fn test_matches_glob_exact() {
645        assert!(matches_glob("libdrasi_source_mock", "libdrasi_source_mock"));
646        assert!(!matches_glob(
647            "libdrasi_source_mock",
648            "libdrasi_source_http"
649        ));
650    }
651
652    #[test]
653    fn test_matches_glob_middle_wildcard() {
654        assert!(matches_glob("lib*mock", "libdrasi_source_mock"));
655        assert!(!matches_glob("lib*mock", "libdrasi_source_http"));
656    }
657
658    // ── discover_plugin_candidates tests ──
659
660    #[test]
661    fn test_discover_groups_by_base_name() {
662        let dir = setup_temp_dir(&[
663            "libdrasi_source_mock.dylib",
664            "libdrasi_source_mock.rlib",
665            "libdrasi_source_mock.d",
666        ]);
667        let patterns = vec!["libdrasi_source_*".to_string()];
668        let groups = discover_plugin_candidates(dir.path(), &patterns);
669
670        assert_eq!(groups.len(), 1);
671        assert!(groups.contains_key("libdrasi_source_mock"));
672        assert_eq!(groups["libdrasi_source_mock"].len(), 3);
673    }
674
675    #[test]
676    fn test_discover_ignores_non_matching_files() {
677        let dir = setup_temp_dir(&[
678            "libdrasi_source_mock.dylib",
679            "unrelated_file.txt",
680            "libfoo.so",
681        ]);
682        let patterns = vec!["libdrasi_source_*".to_string()];
683        let groups = discover_plugin_candidates(dir.path(), &patterns);
684
685        assert_eq!(groups.len(), 1);
686        assert!(groups.contains_key("libdrasi_source_mock"));
687    }
688
689    #[test]
690    fn test_discover_multiple_plugins() {
691        let dir = setup_temp_dir(&[
692            "libdrasi_source_mock.dylib",
693            "libdrasi_source_mock.rlib",
694            "libdrasi_source_http.so",
695            "libdrasi_source_http.rmeta",
696        ]);
697        let patterns = vec!["libdrasi_source_*".to_string()];
698        let groups = discover_plugin_candidates(dir.path(), &patterns);
699
700        assert_eq!(groups.len(), 2);
701        assert!(groups.contains_key("libdrasi_source_mock"));
702        assert!(groups.contains_key("libdrasi_source_http"));
703    }
704
705    #[test]
706    fn test_discover_empty_dir() {
707        let dir = tempfile::tempdir().unwrap();
708        let patterns = vec!["libdrasi_source_*".to_string()];
709        let groups = discover_plugin_candidates(dir.path(), &patterns);
710
711        assert!(groups.is_empty());
712    }
713
714    #[test]
715    fn test_discover_nonexistent_dir() {
716        let groups = discover_plugin_candidates(Path::new("/nonexistent"), &["libdrasi_*".into()]);
717        assert!(groups.is_empty());
718    }
719
720    // ── cdylib filtering tests ──
721
722    #[test]
723    fn test_cdylib_only_filtering() {
724        let dir = setup_temp_dir(&[
725            "libdrasi_source_mock.dylib",
726            "libdrasi_source_mock.rlib",
727            "libdrasi_source_mock.d",
728        ]);
729        let patterns = vec!["libdrasi_source_*".to_string()];
730        let groups = discover_plugin_candidates(dir.path(), &patterns);
731        let files = &groups["libdrasi_source_mock"];
732
733        let cdylib_files: Vec<&PathBuf> = files
734            .iter()
735            .filter(|p| {
736                CDYLIB_EXTENSIONS
737                    .iter()
738                    .any(|ext| p.to_string_lossy().ends_with(ext))
739            })
740            .collect();
741
742        assert_eq!(cdylib_files.len(), 1);
743        assert!(cdylib_files[0]
744            .to_string_lossy()
745            .ends_with("libdrasi_source_mock.dylib"));
746    }
747
748    #[test]
749    fn test_ambiguous_cdylib_detected() {
750        let dir = setup_temp_dir(&["libdrasi_source_mock.dylib", "libdrasi_source_mock.so"]);
751        let patterns = vec!["libdrasi_source_*".to_string()];
752        let groups = discover_plugin_candidates(dir.path(), &patterns);
753        let files = &groups["libdrasi_source_mock"];
754
755        let cdylib_files: Vec<&PathBuf> = files
756            .iter()
757            .filter(|p| {
758                CDYLIB_EXTENSIONS
759                    .iter()
760                    .any(|ext| p.to_string_lossy().ends_with(ext))
761            })
762            .collect();
763
764        assert_eq!(
765            cdylib_files.len(),
766            2,
767            "Should detect 2 ambiguous cdylib files"
768        );
769    }
770
771    #[test]
772    fn test_no_cdylib_skips_silently() {
773        let dir = setup_temp_dir(&["libdrasi_source_mock.rlib", "libdrasi_source_mock.d"]);
774        let patterns = vec!["libdrasi_source_*".to_string()];
775        let groups = discover_plugin_candidates(dir.path(), &patterns);
776        let files = &groups["libdrasi_source_mock"];
777
778        let cdylib_files: Vec<&PathBuf> = files
779            .iter()
780            .filter(|p| {
781                CDYLIB_EXTENSIONS
782                    .iter()
783                    .any(|ext| p.to_string_lossy().ends_with(ext))
784            })
785            .collect();
786
787        assert!(
788            cdylib_files.is_empty(),
789            "Should find no cdylib files when only .rlib and .d exist"
790        );
791    }
792
793    #[test]
794    fn test_discover_with_pattern_including_extension() {
795        let dir = setup_temp_dir(&["libdrasi_source_mock.dylib", "libdrasi_source_mock.rlib"]);
796        // Pattern includes an extension — should still match base name
797        let patterns = vec!["libdrasi_source_*.dylib".to_string()];
798        let groups = discover_plugin_candidates(dir.path(), &patterns);
799
800        assert_eq!(groups.len(), 1);
801        assert!(groups.contains_key("libdrasi_source_mock"));
802        assert_eq!(groups["libdrasi_source_mock"].len(), 2);
803    }
804
805    #[test]
806    fn test_discover_multiple_patterns() {
807        let dir = setup_temp_dir(&[
808            "libdrasi_source_mock.dylib",
809            "libdrasi_reaction_log.so",
810            "libdrasi_bootstrap_mock.dylib",
811        ]);
812        let patterns = vec![
813            "libdrasi_source_*".to_string(),
814            "libdrasi_reaction_*".to_string(),
815        ];
816        let groups = discover_plugin_candidates(dir.path(), &patterns);
817
818        assert_eq!(groups.len(), 2);
819        assert!(groups.contains_key("libdrasi_source_mock"));
820        assert!(groups.contains_key("libdrasi_reaction_log"));
821        assert!(!groups.contains_key("libdrasi_bootstrap_mock"));
822    }
823
824    #[test]
825    fn test_file_without_known_extension_matched_by_base() {
826        let dir = setup_temp_dir(&["libdrasi_source_mock"]);
827        let patterns = vec!["libdrasi_source_*".to_string()];
828        let groups = discover_plugin_candidates(dir.path(), &patterns);
829
830        // File has no known extension, so base_name == filename
831        assert_eq!(groups.len(), 1);
832        assert!(groups.contains_key("libdrasi_source_mock"));
833    }
834
835    // ── Naming / discovery helper tests ──
836
837    #[test]
838    fn test_plugin_kind_from_filename_unix() {
839        assert_eq!(
840            plugin_kind_from_filename("libdrasi_source_postgres.so"),
841            Some("source/postgres".to_string())
842        );
843        assert_eq!(
844            plugin_kind_from_filename("libdrasi_reaction_log.dylib"),
845            Some("reaction/log".to_string())
846        );
847        assert_eq!(
848            plugin_kind_from_filename("libdrasi_bootstrap_postgres.so"),
849            Some("bootstrap/postgres".to_string())
850        );
851    }
852
853    #[test]
854    fn test_plugin_kind_from_filename_windows() {
855        assert_eq!(
856            plugin_kind_from_filename("drasi_source_postgres.dll"),
857            Some("source/postgres".to_string())
858        );
859    }
860
861    #[test]
862    fn test_plugin_kind_from_filename_underscore_to_hyphen() {
863        assert_eq!(
864            plugin_kind_from_filename("libdrasi_source_postgres_replication.so"),
865            Some("source/postgres-replication".to_string())
866        );
867    }
868
869    #[test]
870    fn test_plugin_kind_from_filename_not_a_plugin() {
871        assert_eq!(plugin_kind_from_filename("random_lib.so"), None);
872        assert_eq!(plugin_kind_from_filename("not_a_plugin.txt"), None);
873    }
874
875    #[test]
876    fn test_is_plugin_binary() {
877        assert!(is_plugin_binary("libdrasi_source_mock.so"));
878        assert!(is_plugin_binary("drasi_reaction_log.dll"));
879        assert!(is_plugin_binary("libdrasi_bootstrap_postgres.dylib"));
880        assert!(!is_plugin_binary("plugin.rlib"));
881        assert!(!is_plugin_binary("readme.md"));
882    }
883
884    #[test]
885    #[allow(clippy::const_is_empty)]
886    fn test_default_patterns_not_empty() {
887        assert!(!DEFAULT_PLUGIN_FILE_PATTERNS.is_empty());
888    }
889}