Skip to main content

optirs_core/plugin/
registry.rs

1// Plugin registry for managing and discovering optimizer plugins
2//
3// This module provides a centralized registry system for managing optimizer plugins,
4// including registration, discovery, loading, and version management.
5
6use super::core::*;
7use crate::error::{OptimError, Result};
8use scirs2_core::numeric::Float;
9use std::any::Any;
10use std::collections::HashMap;
11use std::fmt::Debug;
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
14
15/// Take a read lock, recovering from poisoning instead of propagating the
16/// panic. A panic inside one plugin call (which runs while these locks are
17/// held) must never permanently brick the process-wide registry: the data
18/// behind these locks stays structurally consistent even if one accessor
19/// panicked partway through a call, since every mutation here is a single
20/// insert/remove/assign with no multi-step invariant spanning the guard.
21fn read_lock<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> {
22    lock.read().unwrap_or_else(|poisoned| poisoned.into_inner())
23}
24
25/// Take a write lock, recovering from poisoning. See [`read_lock`].
26fn write_lock<T>(lock: &RwLock<T>) -> RwLockWriteGuard<'_, T> {
27    lock.write()
28        .unwrap_or_else(|poisoned| poisoned.into_inner())
29}
30
31/// Take a mutex lock, recovering from poisoning. See [`read_lock`].
32fn mutex_lock<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
33    lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
34}
35
36/// Parse a `major.minor.patch` prefix (ignoring any `-pre`/`+build` suffix,
37/// per semver's separator rules) into a numeric triplet. `None` when the
38/// string does not start with a dotted numeric version.
39fn parse_version_triplet(version: &str) -> Option<(u64, u64, u64)> {
40    let core = version.split(['-', '+']).next().unwrap_or(version);
41    let mut parts = core.split('.');
42    let major = parts.next()?.parse().ok()?;
43    let minor = parts.next().unwrap_or("0").parse().ok()?;
44    let patch = parts.next().unwrap_or("0").parse().ok()?;
45    Some((major, minor, patch))
46}
47
48/// Compare two version strings numerically by `(major, minor, patch)` when
49/// both parse as dotted numeric versions (this crate has no `semver`
50/// dependency, so pre-release/build metadata ordering is not modelled).
51/// Falls back to a byte-lexicographic comparison for non-numeric version
52/// strings so callers still get a total order rather than a panic.
53///
54/// Byte-lexicographic comparison alone is wrong for numeric versions --
55/// `"0.10.0" < "0.9.0"` and `"1.10.0" < "1.9.0"` under `str`'s `Ord`, so an
56/// ecosystem that ever reaches a double-digit minor or patch would silently
57/// mis-resolve plugin version requirements.
58fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
59    match (parse_version_triplet(a), parse_version_triplet(b)) {
60        (Some(va), Some(vb)) => va.cmp(&vb),
61        _ => a.cmp(b),
62    }
63}
64
65/// Central plugin registry for managing all optimizer plugins
66#[derive(Debug)]
67pub struct PluginRegistry {
68    /// Registered plugin factories
69    factories: RwLock<HashMap<String, PluginRegistration>>,
70    /// Plugin search paths
71    search_paths: RwLock<Vec<PathBuf>>,
72    /// Registry configuration
73    config: RegistryConfig,
74    /// Plugin cache
75    cache: Mutex<PluginCache>,
76    /// Event listeners
77    event_listeners: RwLock<Vec<Box<dyn RegistryEventListener>>>,
78}
79
80/// Plugin registration entry
81#[derive(Debug)]
82pub struct PluginRegistration {
83    /// Plugin factory
84    pub factory: Box<dyn PluginFactoryWrapper>,
85    /// Plugin metadata
86    pub info: PluginInfo,
87    /// Capabilities declared by the factory at registration time, used to
88    /// enforce `PluginQuery::required_capabilities` in `matches_query`.
89    pub capabilities: PluginCapabilities,
90    /// Registration timestamp
91    pub registered_at: std::time::SystemTime,
92    /// Plugin status
93    pub status: PluginStatus,
94    /// Load count
95    pub load_count: usize,
96    /// Last used timestamp
97    pub last_used: Option<std::time::SystemTime>,
98}
99
100/// Wrapper trait for type-erased plugin factories
101pub trait PluginFactoryWrapper: Debug + Send + Sync {
102    /// Create optimizer with f32 precision
103    fn create_f32(&self, config: OptimizerConfig) -> Result<Box<dyn OptimizerPlugin<f32>>>;
104
105    /// Create optimizer with f64 precision
106    fn create_f64(&self, config: OptimizerConfig) -> Result<Box<dyn OptimizerPlugin<f64>>>;
107
108    /// Get factory information
109    fn info(&self) -> PluginInfo;
110
111    /// Get the capabilities the produced optimizer declares. Backed by a
112    /// default so existing `PluginFactoryWrapper` implementors outside this
113    /// crate keep compiling; the default reports every capability absent
114    /// (`PluginCapabilities::default()` is all-`false`), which is the safe
115    /// direction to fail in for `PluginQuery::required_capabilities`
116    /// filtering -- an unimplemented override under-promises rather than
117    /// over-promising what the plugin can do.
118    fn capabilities(&self) -> PluginCapabilities {
119        PluginCapabilities::default()
120    }
121
122    /// Validate configuration
123    fn validate_config(&self, config: &OptimizerConfig) -> Result<()>;
124
125    /// Get default configuration
126    fn default_config(&self) -> OptimizerConfig;
127
128    /// Get configuration schema
129    fn config_schema(&self) -> ConfigSchema;
130
131    /// Check if factory supports the given data type
132    fn supports_type(&self, datatype: &DataType) -> bool;
133}
134
135/// Plugin status
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum PluginStatus {
138    /// Plugin is active and available
139    Active,
140    /// Plugin is disabled
141    Disabled,
142    /// Plugin failed to load
143    Failed(String),
144    /// Plugin is deprecated
145    Deprecated,
146    /// Plugin is in maintenance mode
147    Maintenance,
148}
149
150/// Registry configuration
151#[derive(Debug, Clone)]
152pub struct RegistryConfig {
153    /// Enable automatic plugin discovery
154    pub auto_discovery: bool,
155    /// Enable plugin validation on registration
156    pub validate_on_registration: bool,
157    /// Enable plugin caching. When `false`, [`PluginRegistry::create_optimizer`]
158    /// never reads or writes [`PluginCache`] -- every call reaches the
159    /// plugin factory, and `get_cache_stats()` stays at all-zero. See
160    /// [`PluginCache`] for the (f64-only) scope of what caching covers.
161    pub enable_caching: bool,
162    /// Maximum number of distinct plugin names [`PluginCache`] holds at
163    /// once. Once reached, inserting a new entry evicts the
164    /// least-recently-used one first (see [`PluginCache`]). `0` means
165    /// "cache nothing": caching stays logically enabled (bypass is still
166    /// controlled solely by `enable_caching`) but every lookup misses and
167    /// nothing is ever retained.
168    pub max_cache_size: usize,
169    /// Plugin load timeout
170    pub load_timeout: std::time::Duration,
171    /// Enable plugin sandboxing (future feature)
172    pub enable_sandboxing: bool,
173    /// Allowed plugin sources
174    pub allowed_sources: Vec<PluginSource>,
175}
176
177/// Plugin source types
178#[derive(Debug, Clone)]
179pub enum PluginSource {
180    /// Built-in plugins
181    BuiltIn,
182    /// Local filesystem
183    Local(PathBuf),
184    /// Remote repository
185    Remote(String),
186    /// Package manager
187    Package(String),
188}
189
190/// Plugin cache for performance optimization.
191///
192/// # Scope: `f64` only
193///
194/// `CachedPlugin::plugin` is monomorphized to `Box<dyn OptimizerPlugin<f64>>`
195/// -- there is no generic `PluginCache<A>`. [`PluginRegistry::create_optimizer::<A>`]
196/// only consults this cache when `A = f64`; a call with `A = f32` (or any
197/// other `Float` impl) always goes straight to the factory and never
198/// touches `instances`, `stats.hits`, or `stats.misses`. This is a
199/// deliberate scope limitation, not an oversight: caching f32 instances
200/// too would need either a second, separately-bounded `HashMap` or an
201/// `Any`-erased value type, and nothing in this crate currently creates
202/// enough f32 optimizers through the registry to justify that complexity.
203///
204/// # Cache key and correctness
205///
206/// Entries are keyed by plugin *name*, but a lookup is only a hit when the
207/// caller's [`OptimizerConfig`] also equals the config the cached instance
208/// was built with (`CachedPlugin::config`). A name-only key would let a
209/// caller requesting e.g. a different `learning_rate` silently receive an
210/// instance built with someone else's config -- that would be exactly the
211/// kind of fabricated-success this crate's stub-removal pass exists to
212/// eliminate, so a config mismatch is treated as a miss (the stale entry is
213/// replaced by a freshly created one) rather than returned.
214///
215/// # Eviction
216///
217/// Bounded by [`RegistryConfig::max_cache_size`]: inserting past the limit
218/// evicts the least-recently-used entry first. "Recently used" is tracked
219/// with a monotonically increasing `u64` sequence number bumped on every
220/// insert and every hit, not a wall-clock timestamp -- two cache
221/// operations completing within the same clock tick (common on fast
222/// hardware or under `#[test]`) would otherwise tie under
223/// `SystemTime`-based LRU and evict a nondeterministically-chosen entry.
224#[derive(Debug)]
225pub struct PluginCache {
226    /// Cached plugin instances, keyed by plugin name.
227    instances: HashMap<String, CachedPlugin>,
228    /// Cache statistics
229    stats: CacheStats,
230    /// Source of the next `CachedPlugin::sequence` value; incremented on
231    /// every insert and every hit so eviction has a real, deterministic
232    /// "least recently used" ordering (see the struct-level doc comment).
233    next_sequence: u64,
234}
235
236/// Cached plugin instance
237#[derive(Debug)]
238pub struct CachedPlugin {
239    /// Plugin instance
240    pub plugin: Box<dyn OptimizerPlugin<f64>>,
241    /// The exact [`OptimizerConfig`] this instance was created with. A
242    /// lookup with a different config is treated as a miss -- see
243    /// [`PluginCache`]'s "Cache key and correctness" section.
244    pub config: OptimizerConfig,
245    /// Cache timestamp
246    pub cached_at: std::time::SystemTime,
247    /// Access count
248    pub access_count: usize,
249    /// Last accessed
250    pub last_accessed: std::time::SystemTime,
251    /// Recency ordinal used for LRU eviction; see [`PluginCache::next_sequence`].
252    pub(super) sequence: u64,
253}
254
255/// Cache statistics
256#[derive(Debug, Default, Clone)]
257pub struct CacheStats {
258    /// Total cache hits
259    pub hits: usize,
260    /// Total cache misses (an f64 `create_optimizer` call that reached the
261    /// factory: no matching cached entry existed, or its config differed)
262    pub misses: usize,
263    /// Total evictions
264    pub evictions: usize,
265    /// Approximate memory used by currently cached instances, in bytes.
266    /// Computed as `sum(size_of_val(&*cached.plugin))` -- the real,
267    /// runtime size of each cached optimizer's own concrete struct
268    /// (resolved through its vtable, not guessed). This deliberately does
269    /// **not** account for any heap allocations *inside* that struct
270    /// (e.g. a `Vec<f64>` momentum buffer): this crate has no allocator
271    /// instrumentation to attribute those bytes, and reporting only the
272    /// immediate struct size is an honest undercount rather than a
273    /// fabricated total.
274    pub memory_used: usize,
275}
276
277/// Registry event listener trait
278pub trait RegistryEventListener: Debug + Send + Sync {
279    /// Called when a plugin is registered
280    fn on_plugin_registered(&mut self, _info: &PluginInfo) {}
281
282    /// Called when a plugin is unregistered
283    fn on_plugin_unregistered(&mut self, _name: &str) {}
284
285    /// Called when a plugin is loaded
286    fn on_plugin_loaded(&mut self, _name: &str) {}
287
288    /// Called when a plugin fails to load
289    fn on_plugin_load_failed(&mut self, _name: &str, _error: &str) {}
290
291    /// Called when a plugin is enabled/disabled
292    fn on_plugin_status_changed(&mut self, _name: &str, _status: &PluginStatus) {}
293}
294
295/// Plugin search query
296#[derive(Debug, Clone, Default)]
297pub struct PluginQuery {
298    /// Plugin name pattern
299    pub name_pattern: Option<String>,
300    /// Plugin category filter
301    pub category: Option<PluginCategory>,
302    /// Required capabilities
303    pub required_capabilities: Vec<String>,
304    /// Supported data types
305    pub data_types: Vec<DataType>,
306    /// Version requirements
307    pub version_requirements: Option<VersionRequirement>,
308    /// Tags filter
309    pub tags: Vec<String>,
310    /// Maximum results
311    pub limit: Option<usize>,
312}
313
314/// Version requirement specification
315#[derive(Debug, Clone)]
316pub struct VersionRequirement {
317    /// Minimum version (inclusive)
318    pub min_version: Option<String>,
319    /// Maximum version (exclusive)
320    pub max_version: Option<String>,
321    /// Exact version match
322    pub exact_version: Option<String>,
323}
324
325/// Plugin search result
326#[derive(Debug, Clone)]
327pub struct PluginSearchResult {
328    /// Matching plugins
329    pub plugins: Vec<PluginInfo>,
330    /// Total count (before limit)
331    pub total_count: usize,
332    /// Search query used
333    pub query: PluginQuery,
334    /// Search execution time
335    pub search_time: std::time::Duration,
336}
337
338impl PluginRegistry {
339    /// Create a new plugin registry
340    pub fn new(config: RegistryConfig) -> Self {
341        Self {
342            factories: RwLock::new(HashMap::new()),
343            search_paths: RwLock::new(Vec::new()),
344            config,
345            cache: Mutex::new(PluginCache::new()),
346            event_listeners: RwLock::new(Vec::new()),
347        }
348    }
349
350    /// Get the global plugin registry instance
351    pub fn global() -> &'static Self {
352        static INSTANCE: std::sync::OnceLock<PluginRegistry> = std::sync::OnceLock::new();
353        INSTANCE.get_or_init(|| {
354            let config = RegistryConfig::default();
355            let mut registry = PluginRegistry::new(config);
356            registry.register_builtin_plugins();
357            registry
358        })
359    }
360
361    /// Register a plugin factory
362    pub fn register_plugin<F>(&self, factory: F) -> Result<()>
363    where
364        F: PluginFactoryWrapper + 'static,
365    {
366        let info = factory.info();
367        let name = info.name.clone();
368
369        // Validate plugin if enabled
370        if self.config.validate_on_registration {
371            self.validate_plugin(&factory)?;
372        }
373
374        let capabilities = factory.capabilities();
375        let registration = PluginRegistration {
376            factory: Box::new(factory),
377            info: info.clone(),
378            capabilities,
379            registered_at: std::time::SystemTime::now(),
380            status: PluginStatus::Active,
381            load_count: 0,
382            last_used: None,
383        };
384
385        {
386            let mut factories = write_lock(&self.factories);
387            factories.insert(name.clone(), registration);
388        }
389
390        // Notify event listeners
391        {
392            let mut listeners = write_lock(&self.event_listeners);
393            for listener in listeners.iter_mut() {
394                listener.on_plugin_registered(&info);
395            }
396        }
397
398        Ok(())
399    }
400
401    /// Unregister a plugin
402    pub fn unregister_plugin(&self, name: &str) -> Result<()> {
403        let mut factories = write_lock(&self.factories);
404        if factories.remove(name).is_some() {
405            // Notify event listeners
406            drop(factories);
407            let mut listeners = write_lock(&self.event_listeners);
408            for listener in listeners.iter_mut() {
409                listener.on_plugin_unregistered(name);
410            }
411            Ok(())
412        } else {
413            Err(OptimError::PluginNotFound(name.to_string()))
414        }
415    }
416
417    /// Create optimizer instance from plugin
418    pub fn create_optimizer<A>(
419        &self,
420        name: &str,
421        config: OptimizerConfig,
422    ) -> Result<Box<dyn OptimizerPlugin<A>>>
423    where
424        A: Float + Debug + Send + Sync + 'static,
425    {
426        // A single write guard covers status check, validation, creation,
427        // and the load_count/last_used update -- there is no read-then-
428        // reacquire-as-write gap for another thread to unregister the
429        // plugin (or race a concurrent `create_optimizer` call) in between.
430        // The previous version dropped its read lock and reacquired a write
431        // lock purely to bump the usage counters, so `factories.get_mut(name)`
432        // could silently find nothing if the plugin was unregistered in
433        // that window -- the statistics update for an otherwise-successful
434        // creation would vanish with no error. Third-party factory code
435        // still runs under `catch_unwind` (as before), so a panicking
436        // plugin cannot poison this exclusive lock either.
437        let mut factories = write_lock(&self.factories);
438        let registration = factories
439            .get(name)
440            .ok_or_else(|| OptimError::PluginNotFound(name.to_string()))?;
441
442        // Check plugin status
443        match registration.status {
444            PluginStatus::Active => {}
445            PluginStatus::Disabled => {
446                return Err(OptimError::PluginDisabled(name.to_string()));
447            }
448            PluginStatus::Failed(ref error) => {
449                return Err(OptimError::PluginLoadError(error.clone()));
450            }
451            PluginStatus::Deprecated => {
452                // Log warning but continue
453                log::warn!("Plugin '{}' is deprecated", name);
454            }
455            PluginStatus::Maintenance => {
456                return Err(OptimError::PluginInMaintenance(name.to_string()));
457            }
458        }
459
460        // Validate configuration
461        registration.factory.validate_config(&config)?;
462
463        // Create optimizer based on type. Third-party factory code runs here
464        // while `factories` is held write-locked, so a panic is caught
465        // rather than allowed to poison the registry-wide lock.
466        let optimizer = if std::any::TypeId::of::<A>() == std::any::TypeId::of::<f32>() {
467            // `PluginCache` is monomorphized to `Box<dyn OptimizerPlugin<f64>>`
468            // (see its doc comment) and so cannot represent an f32 instance
469            // at all -- this branch always reaches the factory, regardless
470            // of `enable_caching`.
471            let opt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
472                registration.factory.create_f32(config)
473            }))
474            .map_err(|_| {
475                OptimError::PluginLoadError(format!(
476                    "plugin '{name}' panicked while creating an f32 optimizer"
477                ))
478            })??;
479            // Safe downcast: A and f32 are the same type here (proven by the
480            // TypeId check above), so boxing `opt` into `dyn Any` and
481            // downcasting to `Box<dyn OptimizerPlugin<A>>` succeeds via
482            // ordinary `Any` machinery -- no `transmute` of a trait object
483            // (whose fat-pointer/vtable layout across distinct generic
484            // instantiations is not guaranteed) is required.
485            let boxed_any: Box<dyn Any> = Box::new(opt);
486            *boxed_any
487                .downcast::<Box<dyn OptimizerPlugin<A>>>()
488                .map_err(|_| {
489                    OptimError::UnsupportedDataType(
490                        "internal error: f32 downcast failed".to_string(),
491                    )
492                })?
493        } else if std::any::TypeId::of::<A>() == std::any::TypeId::of::<f64>() {
494            let use_cache = self.config.enable_caching;
495
496            // A cache hit must match on *both* plugin name and config (see
497            // `PluginCache`'s "Cache key and correctness" doc section), so
498            // the lookup runs before the factory call and can skip it
499            // entirely on a hit -- `get_or_record_miss` also folds in the
500            // `stats.misses` bump for every other outcome.
501            let cached_hit = if use_cache {
502                let mut cache = mutex_lock(&self.cache);
503                cache.get_or_record_miss(name, &config)?
504            } else {
505                None
506            };
507
508            let opt_f64: Box<dyn OptimizerPlugin<f64>> = if let Some(hit) = cached_hit {
509                hit
510            } else {
511                // Only clone `config` when it will actually be stored --
512                // avoids the clone entirely when caching is disabled.
513                let config_for_cache = use_cache.then(|| config.clone());
514                let created = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
515                    registration.factory.create_f64(config)
516                }))
517                .map_err(|_| {
518                    OptimError::PluginLoadError(format!(
519                        "plugin '{name}' panicked while creating an f64 optimizer"
520                    ))
521                })??;
522                if let Some(cache_config) = config_for_cache {
523                    // Same rationale as `get_or_record_miss`: `clone_plugin`
524                    // is third-party code, called here under `factories`'s
525                    // write lock, so a panic must be caught rather than
526                    // allowed to unwind through it.
527                    let created_ref = &created;
528                    let cloned_for_cache =
529                        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
530                            created_ref.clone_plugin()
531                        }))
532                        .map_err(|_| {
533                            OptimError::PluginLoadError(format!(
534                                "plugin '{name}' panicked while cloning a newly created f64 \
535                                 optimizer for the cache"
536                            ))
537                        })?;
538                    let mut cache = mutex_lock(&self.cache);
539                    cache.insert(
540                        name.to_string(),
541                        cloned_for_cache,
542                        cache_config,
543                        self.config.max_cache_size,
544                    );
545                }
546                created
547            };
548
549            let boxed_any: Box<dyn Any> = Box::new(opt_f64);
550            *boxed_any
551                .downcast::<Box<dyn OptimizerPlugin<A>>>()
552                .map_err(|_| {
553                    OptimError::UnsupportedDataType(
554                        "internal error: f64 downcast failed".to_string(),
555                    )
556                })?
557        } else {
558            return Err(OptimError::UnsupportedDataType(format!(
559                "Type {} not supported",
560                std::any::type_name::<A>()
561            )));
562        };
563
564        // Update usage statistics under the same write guard used to read
565        // and create -- no reacquisition, so this entry cannot have been
566        // removed since the lookup above.
567        if let Some(registration) = factories.get_mut(name) {
568            registration.load_count += 1;
569            registration.last_used = Some(std::time::SystemTime::now());
570        }
571
572        // Notify event listeners
573        drop(factories);
574        let mut listeners = write_lock(&self.event_listeners);
575        for listener in listeners.iter_mut() {
576            listener.on_plugin_loaded(name);
577        }
578
579        Ok(optimizer)
580    }
581
582    /// List all registered plugins
583    pub fn list_plugins(&self) -> Vec<PluginInfo> {
584        let factories = read_lock(&self.factories);
585        factories.values().map(|reg| reg.info.clone()).collect()
586    }
587
588    /// Search for plugins matching criteria
589    pub fn search_plugins(&self, query: PluginQuery) -> PluginSearchResult {
590        let start_time = std::time::Instant::now();
591        let factories = read_lock(&self.factories);
592
593        let mut matching_plugins = Vec::new();
594
595        for registration in factories.values() {
596            if self.matches_query(&registration.info, &registration.capabilities, &query) {
597                matching_plugins.push(registration.info.clone());
598            }
599        }
600
601        let total_count = matching_plugins.len();
602
603        // Apply limit if specified
604        if let Some(limit) = query.limit {
605            matching_plugins.truncate(limit);
606        }
607
608        let search_time = start_time.elapsed();
609
610        PluginSearchResult {
611            plugins: matching_plugins,
612            total_count,
613            query,
614            search_time,
615        }
616    }
617
618    /// Get plugin information
619    pub fn get_plugin_info(&self, name: &str) -> Option<PluginInfo> {
620        let factories = read_lock(&self.factories);
621        factories.get(name).map(|reg| reg.info.clone())
622    }
623
624    /// Get plugin status
625    pub fn get_plugin_status(&self, name: &str) -> Option<PluginStatus> {
626        let factories = read_lock(&self.factories);
627        factories.get(name).map(|reg| reg.status.clone())
628    }
629
630    /// Enable/disable plugin
631    pub fn set_plugin_status(&self, name: &str, status: PluginStatus) -> Result<()> {
632        let mut factories = write_lock(&self.factories);
633        let registration = factories
634            .get_mut(name)
635            .ok_or_else(|| OptimError::PluginNotFound(name.to_string()))?;
636
637        let old_status = registration.status.clone();
638        registration.status = status.clone();
639
640        // Notify event listeners if status changed
641        if old_status != status {
642            drop(factories);
643            let mut listeners = write_lock(&self.event_listeners);
644            for listener in listeners.iter_mut() {
645                listener.on_plugin_status_changed(name, &status);
646            }
647        }
648
649        Ok(())
650    }
651
652    /// Add plugin search path
653    pub fn add_search_path<P: AsRef<Path>>(&self, path: P) {
654        let mut search_paths = write_lock(&self.search_paths);
655        search_paths.push(path.as_ref().to_path_buf());
656    }
657
658    /// Discover plugins in search paths
659    pub fn discover_plugins(&self) -> Result<usize> {
660        if !self.config.auto_discovery {
661            return Ok(0);
662        }
663
664        let search_paths = read_lock(&self.search_paths);
665        let mut discovered_count = 0;
666
667        for path in search_paths.iter() {
668            if path.exists() && path.is_dir() {
669                discovered_count += self.discover_plugins_in_directory(path)?;
670            }
671        }
672
673        Ok(discovered_count)
674    }
675
676    /// Add event listener
677    pub fn add_event_listener(&self, listener: Box<dyn RegistryEventListener>) {
678        let mut listeners = write_lock(&self.event_listeners);
679        listeners.push(listener);
680    }
681
682    /// Get cache statistics
683    pub fn get_cache_stats(&self) -> CacheStats {
684        let cache = mutex_lock(&self.cache);
685        cache.stats.clone()
686    }
687
688    /// Clear plugin cache
689    pub fn clear_cache(&self) {
690        let mut cache = mutex_lock(&self.cache);
691        cache.instances.clear();
692        cache.stats = CacheStats::default();
693    }
694
695    // Private helper methods
696
697    fn validate_plugin(&self, factory: &dyn PluginFactoryWrapper) -> Result<()> {
698        // Basic validation - check if plugin can be created
699        let config = factory.default_config();
700        let _optimizer = factory.create_f64(config)?;
701        Ok(())
702    }
703
704    fn matches_query(
705        &self,
706        info: &PluginInfo,
707        capabilities: &PluginCapabilities,
708        query: &PluginQuery,
709    ) -> bool {
710        // Check name pattern
711        if let Some(ref pattern) = query.name_pattern {
712            if !info.name.contains(pattern) {
713                return false;
714            }
715        }
716
717        // Check category
718        if let Some(ref category) = query.category {
719            if info.category != *category {
720                return false;
721            }
722        }
723
724        // Check data types
725        if !query.data_types.is_empty() {
726            let has_common_type = query
727                .data_types
728                .iter()
729                .any(|dt| info.supported_types.contains(dt));
730            if !has_common_type {
731                return false;
732            }
733        }
734
735        // Check tags
736        if !query.tags.is_empty() {
737            let has_common_tag = query.tags.iter().any(|tag| info.tags.contains(tag));
738            if !has_common_tag {
739                return false;
740            }
741        }
742
743        // Check version requirements
744        if let Some(ref version_req) = query.version_requirements {
745            if !self.version_matches(&info.version, version_req) {
746                return false;
747            }
748        }
749
750        // Check required capabilities: every named capability must be
751        // declared `true` by the plugin, or it is excluded from the
752        // results. Previously this field was declared on `PluginQuery` and
753        // never consulted at all, so a caller searching for e.g.
754        // `["gpu_support"]` got back plugins that do not support GPUs.
755        if !query
756            .required_capabilities
757            .iter()
758            .all(|cap| capabilities.has_capability(cap))
759        {
760            return false;
761        }
762
763        true
764    }
765
766    fn version_matches(&self, version: &str, requirement: &VersionRequirement) -> bool {
767        if let Some(ref exact) = requirement.exact_version {
768            return version == exact;
769        }
770
771        if let Some(ref min) = requirement.min_version {
772            if version_cmp(version, min) == std::cmp::Ordering::Less {
773                return false;
774            }
775        }
776
777        if let Some(ref max) = requirement.max_version {
778            if version_cmp(version, max) != std::cmp::Ordering::Less {
779                return false;
780            }
781        }
782
783        true
784    }
785
786    /// Recursively count candidate plugin files under `path` (same
787    /// extension/name convention as `PluginLoader::is_plugin_file`: shared
788    /// libraries, or a `plugin.toml` manifest).
789    ///
790    /// This crate has no dynamic-loading backend (see the module-level note
791    /// in `plugin::loader` on why `dlopen`/`libloading` is not wired up),
792    /// so a discovered file cannot actually be turned into a registered
793    /// `PluginRegistration` here -- previously this returned a hardcoded
794    /// `Ok(0)` regardless of what was on disk, which reads identically to
795    /// "no plugins present" and "discovery is unimplemented". Returning the
796    /// real count at least tells a caller the truth about what discovery
797    /// *found*, even though loading them still requires
798    /// `PluginRegistry::register_plugin` with a statically compiled
799    /// factory.
800    fn discover_plugins_in_directory(&self, path: &Path) -> Result<usize> {
801        let mut count = 0;
802        for entry in std::fs::read_dir(path)? {
803            let entry = entry?;
804            let entry_path = entry.path();
805            if entry_path.is_dir() {
806                count += self.discover_plugins_in_directory(&entry_path)?;
807                continue;
808            }
809            let is_candidate = match entry_path.extension().and_then(|e| e.to_str()) {
810                Some("so") | Some("dylib") | Some("dll") => true,
811                _ => entry_path.file_name().and_then(|n| n.to_str()) == Some("plugin.toml"),
812            };
813            if is_candidate {
814                count += 1;
815            }
816        }
817        Ok(count)
818    }
819
820    /// Register any statically-compiled built-in plugins. There are
821    /// currently none shipped with this crate -- optimizers ship as their
822    /// own `OptimizerPlugin` implementations registered directly by the
823    /// caller via `register_plugin`, not as a fixed built-in set -- so this
824    /// legitimately has nothing to do. Kept as an explicit extension point
825    /// (and call site in `global()`) rather than removed, so adding a
826    /// future built-in plugin is a one-line change here.
827    fn register_builtin_plugins(&mut self) {}
828}
829
830impl PluginCache {
831    fn new() -> Self {
832        Self {
833            instances: HashMap::new(),
834            stats: CacheStats::default(),
835            next_sequence: 0,
836        }
837    }
838
839    /// Look up a cached instance for `name`. Only a hit when `config`
840    /// equals the config the cached instance was built with (see
841    /// [`PluginCache`]'s "Cache key and correctness" doc section); any
842    /// other outcome -- no entry, or a config mismatch -- is a miss and
843    /// bumps `stats.misses`. On a hit, returns an independent clone (via
844    /// [`OptimizerPlugin::clone_plugin`]) so the caller gets an owned
845    /// instance while the cache keeps its own, and refreshes the entry's
846    /// recency so it is not the next eviction candidate.
847    ///
848    /// `clone_plugin` is third-party trait code -- the registered plugin
849    /// author's own impl, not anything this crate controls -- called here
850    /// while both the caller's `factories` write lock and this cache's own
851    /// mutex are held. A panic inside it is caught the same way the
852    /// adjacent factory-call sites in `create_optimizer` already catch
853    /// `create_f32`/`create_f64` panics, so it surfaces as `Err` instead of
854    /// unwinding through two held locks. The poisoned entry is evicted
855    /// (bumping `stats.evictions`) rather than left cached: recency was
856    /// already refreshed above, so leaving it in place would make this
857    /// plugin name return `Err` on every subsequent call forever instead
858    /// of just this one, once a broken `clone_plugin` demonstrated it
859    /// cannot be trusted.
860    fn get_or_record_miss(
861        &mut self,
862        name: &str,
863        config: &OptimizerConfig,
864    ) -> Result<Option<Box<dyn OptimizerPlugin<f64>>>> {
865        if let Some(entry) = self.instances.get_mut(name) {
866            if &entry.config == config {
867                self.next_sequence += 1;
868                entry.access_count += 1;
869                entry.last_accessed = std::time::SystemTime::now();
870                entry.sequence = self.next_sequence;
871
872                let plugin = &entry.plugin;
873                let clone_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
874                    plugin.clone_plugin()
875                }));
876
877                return match clone_result {
878                    Ok(cloned) => {
879                        self.stats.hits += 1;
880                        Ok(Some(cloned))
881                    }
882                    Err(_) => {
883                        self.instances.remove(name);
884                        self.stats.evictions += 1;
885                        Err(OptimError::PluginLoadError(format!(
886                            "plugin '{name}' panicked while cloning a cached f64 optimizer; \
887                             the poisoned cache entry has been evicted"
888                        )))
889                    }
890                };
891            }
892        }
893        self.stats.misses += 1;
894        Ok(None)
895    }
896
897    /// Insert a freshly created instance, evicting the least-recently-used
898    /// entry first if this insertion would exceed `max_size`.
899    /// `max_size == 0` means "cache nothing" -- the entry is not inserted
900    /// (and, since there is nothing to make room for, nothing is evicted
901    /// either).
902    fn insert(
903        &mut self,
904        name: String,
905        plugin: Box<dyn OptimizerPlugin<f64>>,
906        config: OptimizerConfig,
907        max_size: usize,
908    ) {
909        if max_size == 0 {
910            return;
911        }
912        if !self.instances.contains_key(&name) && self.instances.len() >= max_size {
913            self.evict_lru();
914        }
915        self.next_sequence += 1;
916        let now = std::time::SystemTime::now();
917        self.instances.insert(
918            name,
919            CachedPlugin {
920                plugin,
921                config,
922                cached_at: now,
923                access_count: 1,
924                last_accessed: now,
925                sequence: self.next_sequence,
926            },
927        );
928        self.recompute_memory_used();
929    }
930
931    /// Evict the entry with the smallest `sequence` (the one least
932    /// recently inserted or hit). A no-op on an empty cache.
933    fn evict_lru(&mut self) {
934        let lru_name = self
935            .instances
936            .iter()
937            .min_by_key(|(_, cached)| cached.sequence)
938            .map(|(name, _)| name.clone());
939        if let Some(lru_name) = lru_name {
940            self.instances.remove(&lru_name);
941            self.stats.evictions += 1;
942            self.recompute_memory_used();
943        }
944    }
945
946    /// Recompute `stats.memory_used` from the currently cached instances;
947    /// see [`CacheStats::memory_used`] for exactly what this does and does
948    /// not account for.
949    fn recompute_memory_used(&mut self) {
950        self.stats.memory_used = self
951            .instances
952            .values()
953            .map(|cached| std::mem::size_of_val(&*cached.plugin))
954            .sum();
955    }
956
957    /// Number of distinct plugin names currently cached. Exposed (crate-
958    /// visible only) for tests asserting eviction actually bounds cache
959    /// size rather than merely incrementing a counter.
960    #[cfg(test)]
961    fn len(&self) -> usize {
962        self.instances.len()
963    }
964}
965
966impl Default for RegistryConfig {
967    fn default() -> Self {
968        Self {
969            auto_discovery: true,
970            validate_on_registration: true,
971            enable_caching: true,
972            max_cache_size: 100,
973            load_timeout: std::time::Duration::from_secs(30),
974            enable_sandboxing: false,
975            allowed_sources: vec![
976                PluginSource::BuiltIn,
977                PluginSource::Local(PathBuf::from("./plugins")),
978            ],
979        }
980    }
981}
982
983// Helper macro for registering plugins
984#[macro_export]
985macro_rules! register_optimizer_plugin {
986    ($factory:expr) => {
987        $crate::plugin::PluginRegistry::global().register_plugin($factory)?
988    };
989}
990
991// Builder pattern for plugin queries
992pub struct PluginQueryBuilder {
993    query: PluginQuery,
994}
995
996impl Default for PluginQueryBuilder {
997    fn default() -> Self {
998        Self::new()
999    }
1000}
1001
1002impl PluginQueryBuilder {
1003    pub fn new() -> Self {
1004        Self {
1005            query: PluginQuery::default(),
1006        }
1007    }
1008
1009    pub fn name_pattern(mut self, pattern: &str) -> Self {
1010        self.query.name_pattern = Some(pattern.to_string());
1011        self
1012    }
1013
1014    pub fn category(mut self, category: PluginCategory) -> Self {
1015        self.query.category = Some(category);
1016        self
1017    }
1018
1019    pub fn data_type(mut self, datatype: DataType) -> Self {
1020        self.query.data_types.push(datatype);
1021        self
1022    }
1023
1024    pub fn tag(mut self, tag: &str) -> Self {
1025        self.query.tags.push(tag.to_string());
1026        self
1027    }
1028
1029    pub fn limit(mut self, limit: usize) -> Self {
1030        self.query.limit = Some(limit);
1031        self
1032    }
1033
1034    pub fn build(self) -> PluginQuery {
1035        self.query
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    #[test]
1044    fn test_plugin_registry_creation() {
1045        let config = RegistryConfig::default();
1046        let registry = PluginRegistry::new(config);
1047        assert_eq!(registry.list_plugins().len(), 0);
1048    }
1049
1050    #[test]
1051    fn test_plugin_query_builder() {
1052        let query = PluginQueryBuilder::new()
1053            .name_pattern("adam")
1054            .category(PluginCategory::FirstOrder)
1055            .data_type(DataType::F32)
1056            .limit(10)
1057            .build();
1058
1059        assert_eq!(query.name_pattern, Some("adam".to_string()));
1060        assert_eq!(query.category, Some(PluginCategory::FirstOrder));
1061        assert_eq!(query.limit, Some(10));
1062    }
1063
1064    #[test]
1065    fn discover_plugins_counts_real_files_on_disk() {
1066        // F69 regression: `discover_plugins_in_directory` previously
1067        // returned a hardcoded `Ok(0)` regardless of directory contents,
1068        // making a directory full of plugin files indistinguishable from
1069        // an empty one.
1070        let root = std::env::temp_dir().join(format!(
1071            "optirs_registry_discover_{}_{}",
1072            std::process::id(),
1073            std::time::SystemTime::now()
1074                .duration_since(std::time::UNIX_EPOCH)
1075                .map(|d| d.as_nanos())
1076                .unwrap_or(0)
1077        ));
1078        let nested = root.join("nested");
1079        std::fs::create_dir_all(&nested).expect("create temp dir tree");
1080
1081        std::fs::write(root.join("plugin.toml"), "[plugin]\nname = \"x\"").expect("write");
1082        std::fs::write(root.join("libfoo.so"), b"not a real library").expect("write");
1083        std::fs::write(root.join("readme.txt"), b"not a plugin").expect("write");
1084        std::fs::write(nested.join("bar.dylib"), b"not a real library").expect("write");
1085
1086        let config = RegistryConfig {
1087            auto_discovery: true,
1088            ..RegistryConfig::default()
1089        };
1090        let registry = PluginRegistry::new(config);
1091        registry.add_search_path(&root);
1092
1093        let discovered = registry
1094            .discover_plugins()
1095            .expect("discovery should succeed");
1096        assert_eq!(
1097            discovered, 3,
1098            "expected plugin.toml + libfoo.so + nested/bar.dylib, not readme.txt"
1099        );
1100
1101        let _ = std::fs::remove_dir_all(&root);
1102    }
1103
1104    #[test]
1105    fn discover_plugins_is_a_noop_when_auto_discovery_disabled() {
1106        let root = std::env::temp_dir().join(format!(
1107            "optirs_registry_discover_disabled_{}_{}",
1108            std::process::id(),
1109            std::time::SystemTime::now()
1110                .duration_since(std::time::UNIX_EPOCH)
1111                .map(|d| d.as_nanos())
1112                .unwrap_or(0)
1113        ));
1114        std::fs::create_dir_all(&root).expect("create temp dir");
1115        std::fs::write(root.join("plugin.toml"), "[plugin]\nname = \"x\"").expect("write");
1116
1117        let config = RegistryConfig {
1118            auto_discovery: false,
1119            ..RegistryConfig::default()
1120        };
1121        let registry = PluginRegistry::new(config);
1122        registry.add_search_path(&root);
1123
1124        assert_eq!(registry.discover_plugins().expect("should succeed"), 0);
1125
1126        let _ = std::fs::remove_dir_all(&root);
1127    }
1128}
1129
1130// Regression tests for the static plugin path: register -> create -> step,
1131// the panic-at-the-trust-boundary guard (a plugin panic must surface as an
1132// error, never poison the process-wide registry), and the lock-poisoning
1133// recovery helpers themselves.
1134#[cfg(test)]
1135mod regression_tests;