Skip to main content

oximedia_plugin/
registry.rs

1//! Central plugin registry with priority ordering and capability caching.
2//!
3//! The [`PluginRegistry`] is the main entry point for managing plugins.
4//! It handles registration, discovery, and codec lookup across all
5//! loaded plugins.
6//!
7//! # Priority
8//!
9//! Each plugin can be assigned a numeric priority (higher = preferred).
10//! When multiple plugins provide the same codec, the one with the highest
11//! priority wins.  Plugins with equal priority are ranked by registration
12//! order (first-registered wins among equals).
13//!
14//! # Capability Cache
15//!
16//! The registry maintains an internal cache of codec → plugin index
17//! mappings to avoid O(n) scans on every lookup.  The cache is invalidated
18//! atomically on every `register` or `unregister` operation.
19
20use crate::error::{PluginError, PluginResult};
21use crate::traits::{CodecPlugin, CodecPluginInfo, PluginCapability, PLUGIN_API_VERSION};
22use oximedia_codec::{CodecResult, EncoderConfig, VideoDecoder, VideoEncoder};
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, RwLock};
26
27// ── PluginEntry ───────────────────────────────────────────────────────────────
28
29/// An entry in the plugin registry, bundling the plugin with its priority.
30struct PluginEntry {
31    plugin: Arc<dyn CodecPlugin>,
32    /// Numeric priority: higher values are preferred over lower values.
33    /// When two plugins have the same priority, the one registered first wins.
34    priority: i32,
35}
36
37// ── CapabilityCache ───────────────────────────────────────────────────────────
38
39/// Cached mapping from codec name to the index of the best plugin for that codec.
40///
41/// Two separate caches for decode and encode allow plugins that only support
42/// one direction to serve correctly without confusion.
43#[derive(Default)]
44struct CapabilityCache {
45    /// codec_name → index into the sorted plugins Vec for decode.
46    decoder_index: HashMap<String, usize>,
47    /// codec_name → index into the sorted plugins Vec for encode.
48    encoder_index: HashMap<String, usize>,
49}
50
51impl CapabilityCache {
52    fn new() -> Self {
53        Self {
54            decoder_index: HashMap::new(),
55            encoder_index: HashMap::new(),
56        }
57    }
58
59    fn invalidate(&mut self) {
60        self.decoder_index.clear();
61        self.encoder_index.clear();
62    }
63
64    /// Rebuild the cache from the current (already priority-sorted) plugin list.
65    fn rebuild(&mut self, plugins: &[PluginEntry]) {
66        self.invalidate();
67        for (idx, entry) in plugins.iter().enumerate() {
68            for cap in entry.plugin.capabilities() {
69                if cap.can_decode {
70                    self.decoder_index
71                        .entry(cap.codec_name.clone())
72                        .or_insert(idx);
73                }
74                if cap.can_encode {
75                    self.encoder_index
76                        .entry(cap.codec_name.clone())
77                        .or_insert(idx);
78                }
79            }
80        }
81    }
82}
83
84// ── PluginRegistry ────────────────────────────────────────────────────────────
85
86/// Central registry for all loaded codec plugins.
87///
88/// The registry maintains a list of registered plugins ordered by priority
89/// (descending) and provides methods to discover codecs, create
90/// decoders/encoders, and manage the plugin lifecycle.
91///
92/// # Thread Safety
93///
94/// The registry uses interior mutability (`RwLock`) so it can be
95/// shared across threads. Multiple readers can query the registry
96/// concurrently; writes (registration) acquire an exclusive lock.
97///
98/// # Priority
99///
100/// Higher `priority` values take precedence when multiple plugins provide
101/// the same codec.  Use [`register_with_priority`](Self::register_with_priority)
102/// to supply a custom priority (default is `0`).
103///
104/// # Example
105///
106/// ```rust
107/// use oximedia_plugin::{PluginRegistry, StaticPlugin, CodecPluginInfo, PluginCapability};
108/// use std::sync::Arc;
109/// use std::collections::HashMap;
110///
111/// let registry = PluginRegistry::new();
112///
113/// let info = CodecPluginInfo {
114///     name: "example".to_string(),
115///     version: "0.1.0".to_string(),
116///     author: "Test".to_string(),
117///     description: "Example plugin".to_string(),
118///     api_version: oximedia_plugin::PLUGIN_API_VERSION,
119///     license: "MIT".to_string(),
120///     patent_encumbered: false,
121/// };
122///
123/// let plugin = StaticPlugin::new(info)
124///     .add_capability(PluginCapability {
125///         codec_name: "test".to_string(),
126///         can_decode: true,
127///         can_encode: false,
128///         pixel_formats: vec![],
129///         properties: HashMap::new(),
130///     });
131///
132/// registry.register(Arc::new(plugin)).expect("should register");
133/// assert!(registry.has_codec("test"));
134/// ```
135pub struct PluginRegistry {
136    /// Plugins sorted by descending priority.
137    plugins: RwLock<Vec<PluginEntry>>,
138    /// Capability lookup cache — invalidated on every mutation.
139    cache: RwLock<CapabilityCache>,
140    search_paths: Vec<PathBuf>,
141}
142
143impl PluginRegistry {
144    /// Create a new empty plugin registry with default search paths.
145    #[must_use]
146    pub fn new() -> Self {
147        Self {
148            plugins: RwLock::new(Vec::new()),
149            cache: RwLock::new(CapabilityCache::new()),
150            search_paths: Self::default_search_paths(),
151        }
152    }
153
154    /// Create a registry with no search paths (for testing).
155    #[must_use]
156    pub fn empty() -> Self {
157        Self {
158            plugins: RwLock::new(Vec::new()),
159            cache: RwLock::new(CapabilityCache::new()),
160            search_paths: Vec::new(),
161        }
162    }
163
164    /// Add a directory to search for plugins.
165    pub fn add_search_path(&mut self, path: PathBuf) {
166        if !self.search_paths.contains(&path) {
167            self.search_paths.push(path);
168        }
169    }
170
171    /// Get the list of search paths.
172    pub fn search_paths(&self) -> &[PathBuf] {
173        &self.search_paths
174    }
175
176    /// Compute default search paths for plugin discovery.
177    ///
178    /// The following paths are checked (in order):
179    /// 1. `$OXIMEDIA_PLUGIN_PATH` (colon-separated on Unix, semicolon on Windows)
180    /// 2. `~/.oximedia/plugins/`
181    /// 3. `/usr/lib/oximedia/plugins/` (Unix only)
182    /// 4. `/usr/local/lib/oximedia/plugins/` (Unix only)
183    #[must_use]
184    pub fn default_search_paths() -> Vec<PathBuf> {
185        let mut paths = Vec::new();
186
187        // Check environment variable
188        if let Ok(env_paths) = std::env::var("OXIMEDIA_PLUGIN_PATH") {
189            let separator = if cfg!(windows) { ';' } else { ':' };
190            for p in env_paths.split(separator) {
191                let path = PathBuf::from(p);
192                if !paths.contains(&path) {
193                    paths.push(path);
194                }
195            }
196        }
197
198        // User home directory
199        if let Some(home) = home_dir() {
200            let user_plugins = home.join(".oximedia").join("plugins");
201            if !paths.contains(&user_plugins) {
202                paths.push(user_plugins);
203            }
204        }
205
206        // System paths (Unix)
207        #[cfg(unix)]
208        {
209            let sys_paths = [
210                PathBuf::from("/usr/lib/oximedia/plugins"),
211                PathBuf::from("/usr/local/lib/oximedia/plugins"),
212            ];
213            for p in sys_paths {
214                if !paths.contains(&p) {
215                    paths.push(p);
216                }
217            }
218        }
219
220        paths
221    }
222
223    /// Register a static plugin instance with default priority (0).
224    ///
225    /// The plugin is validated (API version check, duplicate name check)
226    /// before being added to the registry.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`PluginError::ApiIncompatible`] if the API version does
231    /// not match, or [`PluginError::AlreadyRegistered`] if a plugin
232    /// with the same name is already loaded.
233    pub fn register(&self, plugin: Arc<dyn CodecPlugin>) -> PluginResult<()> {
234        self.register_with_priority(plugin, 0)
235    }
236
237    /// Register a plugin with an explicit priority.
238    ///
239    /// Plugins with a higher `priority` value are preferred when multiple
240    /// plugins provide the same codec.  Negative priorities are allowed.
241    ///
242    /// # Errors
243    ///
244    /// Same as [`register`](Self::register).
245    pub fn register_with_priority(
246        &self,
247        plugin: Arc<dyn CodecPlugin>,
248        priority: i32,
249    ) -> PluginResult<()> {
250        let info = plugin.info();
251
252        // Validate API version
253        if info.api_version != PLUGIN_API_VERSION {
254            return Err(PluginError::ApiIncompatible(format!(
255                "Plugin '{}' has API v{}, host expects v{PLUGIN_API_VERSION}",
256                info.name, info.api_version
257            )));
258        }
259
260        let mut plugins = self
261            .plugins
262            .write()
263            .map_err(|e| PluginError::InitFailed(format!("Lock poisoned: {e}")))?;
264
265        // Check for duplicates
266        for existing in plugins.iter() {
267            if existing.plugin.info().name == info.name {
268                return Err(PluginError::AlreadyRegistered(info.name));
269            }
270        }
271
272        tracing::info!(
273            "Registered plugin: {} v{} (priority={}, {} codec(s))",
274            info.name,
275            info.version,
276            priority,
277            plugin.capabilities().len()
278        );
279
280        if info.patent_encumbered {
281            tracing::warn!(
282                "Plugin '{}' contains patent-encumbered codecs. Use at your own risk.",
283                info.name
284            );
285        }
286
287        plugins.push(PluginEntry { plugin, priority });
288
289        // Re-sort by descending priority (stable sort preserves FIFO for equal priorities).
290        plugins.sort_by(|a, b| b.priority.cmp(&a.priority));
291
292        // Invalidate and rebuild the cache.
293        drop(plugins);
294        self.rebuild_cache()?;
295        Ok(())
296    }
297
298    /// Unregister a plugin by name.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`PluginError::NotFound`] if no plugin with that name is registered.
303    pub fn unregister(&self, name: &str) -> PluginResult<()> {
304        let mut plugins = self
305            .plugins
306            .write()
307            .map_err(|e| PluginError::InitFailed(format!("Lock poisoned: {e}")))?;
308
309        let before = plugins.len();
310        plugins.retain(|e| e.plugin.info().name != name);
311
312        if plugins.len() == before {
313            return Err(PluginError::NotFound(name.to_string()));
314        }
315
316        drop(plugins);
317        self.rebuild_cache()?;
318        Ok(())
319    }
320
321    /// Load a plugin from a shared library file.
322    ///
323    /// Requires the `dynamic-loading` feature.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`PluginError::DynamicLoadingDisabled`] if the feature
328    /// is not enabled, or propagates loading errors.
329    ///
330    /// # Safety
331    ///
332    /// Loading a shared library executes arbitrary code. Only load
333    /// plugins from trusted sources.
334    #[cfg(feature = "dynamic-loading")]
335    pub fn load_plugin(&self, path: &Path) -> PluginResult<()> {
336        let loaded = crate::loader::LoadedPlugin::load(path)?;
337        self.register(loaded.into_plugin())
338    }
339
340    /// Load a plugin from a shared library file.
341    ///
342    /// This is a stub that returns an error when the `dynamic-loading`
343    /// feature is not enabled.
344    #[cfg(not(feature = "dynamic-loading"))]
345    pub fn load_plugin(&self, _path: &Path) -> PluginResult<()> {
346        Err(PluginError::DynamicLoadingDisabled)
347    }
348
349    /// Discover and load all plugins from search paths.
350    ///
351    /// Scans each search path for `plugin.json` manifest files,
352    /// validates them, and loads the corresponding shared libraries.
353    ///
354    /// Returns information about all successfully loaded plugins.
355    /// Errors for individual plugins are logged but do not prevent
356    /// other plugins from loading.
357    ///
358    /// Requires the `dynamic-loading` feature.
359    #[cfg(feature = "dynamic-loading")]
360    pub fn discover_plugins(&self) -> PluginResult<Vec<CodecPluginInfo>> {
361        let mut loaded = Vec::new();
362
363        for search_path in &self.search_paths {
364            if !search_path.is_dir() {
365                tracing::debug!(
366                    "Plugin search path does not exist: {}",
367                    search_path.display()
368                );
369                continue;
370            }
371
372            let entries = std::fs::read_dir(search_path)?;
373            for entry in entries {
374                let entry = entry?;
375                let path = entry.path();
376
377                // Look for plugin.json in subdirectories
378                let manifest_path = if path.is_dir() {
379                    path.join("plugin.json")
380                } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
381                    path.clone()
382                } else {
383                    continue;
384                };
385
386                if !manifest_path.exists() {
387                    continue;
388                }
389
390                match self.load_from_manifest(&manifest_path) {
391                    Ok(info) => loaded.push(info),
392                    Err(e) => {
393                        tracing::warn!(
394                            "Failed to load plugin from {}: {e}",
395                            manifest_path.display()
396                        );
397                    }
398                }
399            }
400        }
401
402        Ok(loaded)
403    }
404
405    /// Discover plugins stub when dynamic loading is disabled.
406    #[cfg(not(feature = "dynamic-loading"))]
407    pub fn discover_plugins(&self) -> PluginResult<Vec<CodecPluginInfo>> {
408        Err(PluginError::DynamicLoadingDisabled)
409    }
410
411    /// Load a plugin from its manifest file.
412    #[cfg(feature = "dynamic-loading")]
413    fn load_from_manifest(&self, manifest_path: &Path) -> PluginResult<CodecPluginInfo> {
414        let manifest = crate::manifest::PluginManifest::from_file(manifest_path)?;
415        manifest.validate()?;
416
417        let lib_path = manifest.library_path(manifest_path).ok_or_else(|| {
418            PluginError::LoadFailed("Cannot determine library path from manifest".to_string())
419        })?;
420
421        self.load_plugin(&lib_path)?;
422
423        // Return the info from the last registered plugin
424        let plugins = self
425            .plugins
426            .read()
427            .map_err(|e| PluginError::InitFailed(format!("Lock poisoned: {e}")))?;
428
429        plugins.last().map(|e| e.plugin.info()).ok_or_else(|| {
430            PluginError::InitFailed("Plugin was not added after loading".to_string())
431        })
432    }
433
434    /// List all registered plugins (in priority order, highest first).
435    pub fn list_plugins(&self) -> Vec<CodecPluginInfo> {
436        let plugins = match self.plugins.read() {
437            Ok(p) => p,
438            Err(_) => return Vec::new(),
439        };
440        plugins.iter().map(|e| e.plugin.info()).collect()
441    }
442
443    /// List all available codecs across all plugins.
444    pub fn list_codecs(&self) -> Vec<PluginCapability> {
445        let plugins = match self.plugins.read() {
446            Ok(p) => p,
447            Err(_) => return Vec::new(),
448        };
449        plugins
450            .iter()
451            .flat_map(|e| e.plugin.capabilities())
452            .collect()
453    }
454
455    /// Find and create a decoder for a given codec name.
456    ///
457    /// Searches all registered plugins (in priority order) for one that
458    /// can decode the requested codec, and creates a new decoder instance.
459    ///
460    /// Uses the capability cache for O(1) plugin lookup.
461    ///
462    /// # Errors
463    ///
464    /// Returns error if no plugin supports decoding the given codec.
465    pub fn find_decoder(&self, codec_name: &str) -> CodecResult<Box<dyn VideoDecoder>> {
466        // Try the fast cache path first.
467        if let Some(plugin) = self.cached_decoder_plugin(codec_name) {
468            return plugin.create_decoder(codec_name);
469        }
470
471        // Fall back to a linear scan (cache miss or rebuild needed).
472        let plugins = self
473            .plugins
474            .read()
475            .map_err(|e| oximedia_codec::CodecError::Internal(format!("Lock poisoned: {e}")))?;
476
477        for entry in plugins.iter() {
478            if entry.plugin.can_decode(codec_name) {
479                return entry.plugin.create_decoder(codec_name);
480            }
481        }
482
483        Err(oximedia_codec::CodecError::UnsupportedFeature(format!(
484            "No plugin provides decoder for '{codec_name}'"
485        )))
486    }
487
488    /// Find and create an encoder for a given codec name.
489    ///
490    /// Searches all registered plugins (in priority order) for one that
491    /// can encode the requested codec, and creates a new encoder instance.
492    ///
493    /// Uses the capability cache for O(1) plugin lookup.
494    ///
495    /// # Errors
496    ///
497    /// Returns error if no plugin supports encoding the given codec.
498    pub fn find_encoder(
499        &self,
500        codec_name: &str,
501        config: EncoderConfig,
502    ) -> CodecResult<Box<dyn VideoEncoder>> {
503        // Try the fast cache path first.
504        if let Some(plugin) = self.cached_encoder_plugin(codec_name) {
505            return plugin.create_encoder(codec_name, config);
506        }
507
508        // Fall back to a linear scan.
509        let plugins = self
510            .plugins
511            .read()
512            .map_err(|e| oximedia_codec::CodecError::Internal(format!("Lock poisoned: {e}")))?;
513
514        for entry in plugins.iter() {
515            if entry.plugin.can_encode(codec_name) {
516                return entry.plugin.create_encoder(codec_name, config);
517            }
518        }
519
520        Err(oximedia_codec::CodecError::UnsupportedFeature(format!(
521            "No plugin provides encoder for '{codec_name}'"
522        )))
523    }
524
525    /// Check if any plugin provides a given codec (decode or encode).
526    pub fn has_codec(&self, codec_name: &str) -> bool {
527        // Check cache first.
528        if let Ok(cache) = self.cache.read() {
529            if cache.decoder_index.contains_key(codec_name)
530                || cache.encoder_index.contains_key(codec_name)
531            {
532                return true;
533            }
534        }
535        let plugins = match self.plugins.read() {
536            Ok(p) => p,
537            Err(_) => return false,
538        };
539        plugins.iter().any(|e| e.plugin.supports_codec(codec_name))
540    }
541
542    /// Check if any plugin can decode a given codec.
543    pub fn has_decoder(&self, codec_name: &str) -> bool {
544        if let Ok(cache) = self.cache.read() {
545            if cache.decoder_index.contains_key(codec_name) {
546                return true;
547            }
548        }
549        let plugins = match self.plugins.read() {
550            Ok(p) => p,
551            Err(_) => return false,
552        };
553        plugins.iter().any(|e| e.plugin.can_decode(codec_name))
554    }
555
556    /// Check if any plugin can encode a given codec.
557    pub fn has_encoder(&self, codec_name: &str) -> bool {
558        if let Ok(cache) = self.cache.read() {
559            if cache.encoder_index.contains_key(codec_name) {
560                return true;
561            }
562        }
563        let plugins = match self.plugins.read() {
564            Ok(p) => p,
565            Err(_) => return false,
566        };
567        plugins.iter().any(|e| e.plugin.can_encode(codec_name))
568    }
569
570    /// Get the number of registered plugins.
571    pub fn plugin_count(&self) -> usize {
572        match self.plugins.read() {
573            Ok(p) => p.len(),
574            Err(_) => 0,
575        }
576    }
577
578    /// Unload all registered plugins and clear the cache.
579    pub fn clear(&self) {
580        if let Ok(mut plugins) = self.plugins.write() {
581            plugins.clear();
582        }
583        if let Ok(mut cache) = self.cache.write() {
584            cache.invalidate();
585        }
586    }
587
588    /// Find the plugin that provides a given codec (respects priority ordering).
589    pub fn find_plugin_for_codec(&self, codec_name: &str) -> Option<CodecPluginInfo> {
590        // Check cache for decoder first, then encoder.
591        if let Some(plugin) = self.cached_decoder_plugin(codec_name) {
592            return Some(plugin.info());
593        }
594        if let Some(plugin) = self.cached_encoder_plugin(codec_name) {
595            return Some(plugin.info());
596        }
597        let plugins = self.plugins.read().ok()?;
598        plugins
599            .iter()
600            .find(|e| e.plugin.supports_codec(codec_name))
601            .map(|e| e.plugin.info())
602    }
603
604    /// Get the priority of a registered plugin by name.
605    ///
606    /// Returns `None` if no plugin with that name is registered.
607    pub fn plugin_priority(&self, name: &str) -> Option<i32> {
608        let plugins = self.plugins.read().ok()?;
609        plugins
610            .iter()
611            .find(|e| e.plugin.info().name == name)
612            .map(|e| e.priority)
613    }
614
615    // ── Internal helpers ─────────────────────────────────────────────────────
616
617    /// Rebuild the capability cache from the current sorted plugin list.
618    fn rebuild_cache(&self) -> PluginResult<()> {
619        let plugins = self
620            .plugins
621            .read()
622            .map_err(|e| PluginError::InitFailed(format!("Lock poisoned: {e}")))?;
623        let mut cache = self
624            .cache
625            .write()
626            .map_err(|e| PluginError::InitFailed(format!("Cache lock poisoned: {e}")))?;
627        cache.rebuild(&plugins);
628        Ok(())
629    }
630
631    /// Return the plugin for the best decoder of `codec_name` using the cache.
632    fn cached_decoder_plugin(&self, codec_name: &str) -> Option<Arc<dyn CodecPlugin>> {
633        let cache = self.cache.read().ok()?;
634        let idx = *cache.decoder_index.get(codec_name)?;
635        drop(cache);
636        let plugins = self.plugins.read().ok()?;
637        plugins.get(idx).map(|e| Arc::clone(&e.plugin))
638    }
639
640    /// Return the plugin for the best encoder of `codec_name` using the cache.
641    fn cached_encoder_plugin(&self, codec_name: &str) -> Option<Arc<dyn CodecPlugin>> {
642        let cache = self.cache.read().ok()?;
643        let idx = *cache.encoder_index.get(codec_name)?;
644        drop(cache);
645        let plugins = self.plugins.read().ok()?;
646        plugins.get(idx).map(|e| Arc::clone(&e.plugin))
647    }
648}
649
650impl Default for PluginRegistry {
651    fn default() -> Self {
652        Self::new()
653    }
654}
655
656/// Get the user's home directory in a cross-platform way.
657fn home_dir() -> Option<PathBuf> {
658    #[cfg(unix)]
659    {
660        std::env::var("HOME").ok().map(PathBuf::from)
661    }
662    #[cfg(windows)]
663    {
664        std::env::var("USERPROFILE").ok().map(PathBuf::from)
665    }
666    #[cfg(not(any(unix, windows)))]
667    {
668        None
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::static_plugin::StaticPlugin;
676    use std::collections::HashMap;
677
678    fn make_test_plugin(name: &str, codecs: &[(&str, bool, bool)]) -> Arc<dyn CodecPlugin> {
679        let info = CodecPluginInfo {
680            name: name.to_string(),
681            version: "1.0.0".to_string(),
682            author: "Test".to_string(),
683            description: format!("Test plugin: {name}"),
684            api_version: PLUGIN_API_VERSION,
685            license: "MIT".to_string(),
686            patent_encumbered: false,
687        };
688
689        let mut plugin = StaticPlugin::new(info);
690        for (codec_name, decode, encode) in codecs {
691            plugin = plugin.add_capability(PluginCapability {
692                codec_name: (*codec_name).to_string(),
693                can_decode: *decode,
694                can_encode: *encode,
695                pixel_formats: vec!["yuv420p".to_string()],
696                properties: HashMap::new(),
697            });
698        }
699        Arc::new(plugin)
700    }
701
702    #[test]
703    fn test_registry_new() {
704        let registry = PluginRegistry::empty();
705        assert_eq!(registry.plugin_count(), 0);
706        assert!(registry.list_plugins().is_empty());
707        assert!(registry.list_codecs().is_empty());
708    }
709
710    #[test]
711    fn test_register_plugin() {
712        let registry = PluginRegistry::empty();
713        let plugin = make_test_plugin("test-1", &[("h264", true, true)]);
714        registry.register(plugin).expect("should register");
715        assert_eq!(registry.plugin_count(), 1);
716    }
717
718    #[test]
719    fn test_register_duplicate_rejected() {
720        let registry = PluginRegistry::empty();
721        let p1 = make_test_plugin("same-name", &[("h264", true, false)]);
722        let p2 = make_test_plugin("same-name", &[("h265", true, false)]);
723        registry.register(p1).expect("first should succeed");
724        let err = registry.register(p2).expect_err("second should fail");
725        assert!(err.to_string().contains("already registered"));
726    }
727
728    #[test]
729    fn test_register_wrong_api_version() {
730        let registry = PluginRegistry::empty();
731        let info = CodecPluginInfo {
732            name: "bad-api".to_string(),
733            version: "1.0.0".to_string(),
734            author: "Test".to_string(),
735            description: "Bad API plugin".to_string(),
736            api_version: 999,
737            license: "MIT".to_string(),
738            patent_encumbered: false,
739        };
740        let plugin = Arc::new(StaticPlugin::new(info));
741        let err = registry.register(plugin).expect_err("should fail");
742        assert!(err.to_string().contains("API"));
743    }
744
745    #[test]
746    fn test_has_codec() {
747        let registry = PluginRegistry::empty();
748        let plugin = make_test_plugin("test", &[("h264", true, true), ("h265", true, false)]);
749        registry.register(plugin).expect("should register");
750
751        assert!(registry.has_codec("h264"));
752        assert!(registry.has_codec("h265"));
753        assert!(!registry.has_codec("vp9"));
754    }
755
756    #[test]
757    fn test_has_decoder_encoder() {
758        let registry = PluginRegistry::empty();
759        let plugin = make_test_plugin("test", &[("h264", true, true), ("h265", true, false)]);
760        registry.register(plugin).expect("should register");
761
762        assert!(registry.has_decoder("h264"));
763        assert!(registry.has_encoder("h264"));
764        assert!(registry.has_decoder("h265"));
765        assert!(!registry.has_encoder("h265"));
766        assert!(!registry.has_decoder("nonexistent"));
767    }
768
769    #[test]
770    fn test_list_plugins() {
771        let registry = PluginRegistry::empty();
772        let p1 = make_test_plugin("alpha", &[("h264", true, false)]);
773        let p2 = make_test_plugin("beta", &[("h265", true, false)]);
774        registry.register(p1).expect("should register alpha");
775        registry.register(p2).expect("should register beta");
776
777        let list = registry.list_plugins();
778        assert_eq!(list.len(), 2);
779        // Both default priority 0: FIFO preserved (alpha first)
780        let names: Vec<&str> = list.iter().map(|i| i.name.as_str()).collect();
781        assert!(names.contains(&"alpha"));
782        assert!(names.contains(&"beta"));
783    }
784
785    #[test]
786    fn test_list_codecs() {
787        let registry = PluginRegistry::empty();
788        let p1 = make_test_plugin("p1", &[("h264", true, true)]);
789        let p2 = make_test_plugin("p2", &[("h265", true, false), ("aac", false, true)]);
790        registry.register(p1).expect("should register");
791        registry.register(p2).expect("should register");
792
793        let codecs = registry.list_codecs();
794        assert_eq!(codecs.len(), 3);
795        let names: Vec<&str> = codecs.iter().map(|c| c.codec_name.as_str()).collect();
796        assert!(names.contains(&"h264"));
797        assert!(names.contains(&"h265"));
798        assert!(names.contains(&"aac"));
799    }
800
801    #[test]
802    fn test_find_decoder_not_found() {
803        let registry = PluginRegistry::empty();
804        let result = registry.find_decoder("nonexistent");
805        assert!(result.is_err());
806    }
807
808    #[test]
809    fn test_find_encoder_not_found() {
810        let registry = PluginRegistry::empty();
811        let config = EncoderConfig::default();
812        let result = registry.find_encoder("nonexistent", config);
813        assert!(result.is_err());
814    }
815
816    #[test]
817    fn test_clear() {
818        let registry = PluginRegistry::empty();
819        let plugin = make_test_plugin("test", &[("h264", true, false)]);
820        registry.register(plugin).expect("should register");
821        assert_eq!(registry.plugin_count(), 1);
822        registry.clear();
823        assert_eq!(registry.plugin_count(), 0);
824        // Cache should also be cleared.
825        assert!(!registry.has_codec("h264"));
826    }
827
828    #[test]
829    fn test_find_plugin_for_codec() {
830        let registry = PluginRegistry::empty();
831        let plugin = make_test_plugin("h264-provider", &[("h264", true, true)]);
832        registry.register(plugin).expect("should register");
833
834        let found = registry.find_plugin_for_codec("h264");
835        assert!(found.is_some());
836        assert_eq!(
837            found.as_ref().map(|i| i.name.as_str()),
838            Some("h264-provider")
839        );
840
841        let not_found = registry.find_plugin_for_codec("aac");
842        assert!(not_found.is_none());
843    }
844
845    #[test]
846    fn test_multiple_plugins_first_wins_same_priority() {
847        let registry = PluginRegistry::empty();
848        // Both provide h264 decode, but first registered wins at equal priority
849        let p1 = make_test_plugin("first", &[("h264", true, false)]);
850        let p2 = make_test_plugin("second", &[("h264", true, true)]);
851        registry.register(p1).expect("should register first");
852        registry.register(p2).expect("should register second");
853
854        let found = registry.find_plugin_for_codec("h264");
855        assert_eq!(found.as_ref().map(|i| i.name.as_str()), Some("first"));
856    }
857
858    #[test]
859    fn test_priority_ordering() {
860        let registry = PluginRegistry::empty();
861        // Register "low" first at priority 0, then "high" at priority 10.
862        let low = make_test_plugin("low-priority", &[("h264", true, false)]);
863        let high = make_test_plugin("high-priority", &[("h264", true, true)]);
864
865        registry
866            .register_with_priority(low, 0)
867            .expect("register low");
868        registry
869            .register_with_priority(high, 10)
870            .expect("register high");
871
872        // High-priority plugin should win for h264.
873        let found = registry.find_plugin_for_codec("h264");
874        assert_eq!(
875            found.as_ref().map(|i| i.name.as_str()),
876            Some("high-priority")
877        );
878    }
879
880    #[test]
881    fn test_priority_negative() {
882        let registry = PluginRegistry::empty();
883        let normal = make_test_plugin("normal", &[("vp9", true, true)]);
884        let fallback = make_test_plugin("fallback", &[("vp9", true, false)]);
885
886        registry
887            .register_with_priority(normal, 0)
888            .expect("register normal");
889        registry
890            .register_with_priority(fallback, -5)
891            .expect("register fallback");
892
893        // Normal should win (higher priority).
894        let found = registry.find_plugin_for_codec("vp9");
895        assert_eq!(found.as_ref().map(|i| i.name.as_str()), Some("normal"));
896    }
897
898    #[test]
899    fn test_priority_accessor() {
900        let registry = PluginRegistry::empty();
901        let p = make_test_plugin("prio-test", &[]);
902        registry.register_with_priority(p, 42).expect("register");
903        assert_eq!(registry.plugin_priority("prio-test"), Some(42));
904        assert_eq!(registry.plugin_priority("nonexistent"), None);
905    }
906
907    #[test]
908    fn test_unregister() {
909        let registry = PluginRegistry::empty();
910        let p = make_test_plugin("to-remove", &[("aac", true, false)]);
911        registry.register(p).expect("register");
912        assert_eq!(registry.plugin_count(), 1);
913        assert!(registry.has_codec("aac"));
914
915        registry.unregister("to-remove").expect("unregister");
916        assert_eq!(registry.plugin_count(), 0);
917        assert!(!registry.has_codec("aac"));
918    }
919
920    #[test]
921    fn test_unregister_not_found() {
922        let registry = PluginRegistry::empty();
923        assert!(matches!(
924            registry.unregister("ghost"),
925            Err(PluginError::NotFound(_))
926        ));
927    }
928
929    #[test]
930    fn test_capability_cache_after_clear() {
931        let registry = PluginRegistry::empty();
932        let p = make_test_plugin("cached", &[("opus", true, true)]);
933        registry.register(p).expect("register");
934        assert!(registry.has_decoder("opus"));
935        registry.clear();
936        assert!(!registry.has_decoder("opus"));
937        assert!(!registry.has_encoder("opus"));
938    }
939
940    #[test]
941    fn test_cache_invalidated_on_unregister() {
942        let registry = PluginRegistry::empty();
943        let p1 = make_test_plugin("provider-a", &[("vorbis", true, false)]);
944        let p2 = make_test_plugin("provider-b", &[("flac", true, true)]);
945        registry.register(p1).expect("register a");
946        registry.register(p2).expect("register b");
947
948        assert!(registry.has_decoder("vorbis"));
949        registry.unregister("provider-a").expect("unregister");
950        assert!(!registry.has_decoder("vorbis"));
951        assert!(registry.has_codec("flac")); // b still present
952    }
953
954    #[test]
955    fn test_default_search_paths() {
956        let paths = PluginRegistry::default_search_paths();
957        // Should at least have the home directory path
958        assert!(!paths.is_empty());
959    }
960
961    #[test]
962    fn test_add_search_path() {
963        let mut registry = PluginRegistry::empty();
964        let path = PathBuf::from("/tmp/test-plugins");
965        registry.add_search_path(path.clone());
966        assert!(registry.search_paths().contains(&path));
967
968        // Adding same path again should not duplicate
969        registry.add_search_path(path.clone());
970        assert_eq!(
971            registry
972                .search_paths()
973                .iter()
974                .filter(|p| **p == path)
975                .count(),
976            1
977        );
978    }
979
980    #[test]
981    fn test_load_plugin_without_dynamic_loading() {
982        let registry = PluginRegistry::empty();
983        let result = registry.load_plugin(Path::new("/nonexistent.so"));
984
985        #[cfg(not(feature = "dynamic-loading"))]
986        {
987            assert!(result.is_err());
988            assert!(result
989                .unwrap_err()
990                .to_string()
991                .contains("Dynamic loading not enabled"));
992        }
993
994        #[cfg(feature = "dynamic-loading")]
995        {
996            // With dynamic loading, it will fail trying to load the file
997            assert!(result.is_err());
998        }
999    }
1000}