Skip to main content

chronix_analytics/
registry.rs

1//! Thread-safe plugin registry for custom forecast models and anomaly detectors.
2
3use std::collections::HashSet;
4use std::fmt;
5use std::sync::{Arc, OnceLock};
6
7use crate::anomaly::AnomalyDetector;
8use crate::forecast::ForecastModel;
9use dashmap::DashMap;
10use dashmap::mapref::entry::Entry;
11use metrics::counter;
12use serde::{Deserialize, Serialize};
13use tracing::info;
14
15/// Metadata describing a registered plugin.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct PluginInfo {
19    /// Unique plugin name.
20    pub name: String,
21    /// Human-readable description.
22    pub description: String,
23    /// Semantic version string.
24    pub version: String,
25    /// Optional SHA-256 hash of the plugin source (hex-encoded).
26    ///
27    /// When set, the registry can verify this hash against an allowlist
28    /// to ensure only trusted plugins are loaded.
29    pub sha256_hash: Option<String>,
30}
31
32impl PluginInfo {
33    /// Create new plugin info with name only (empty description and version).
34    pub fn new(name: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            description: String::new(),
38            version: String::new(),
39            sha256_hash: None,
40        }
41    }
42
43    /// Set description.
44    #[must_use]
45    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
46        self.description = desc.into();
47        self
48    }
49
50    /// Set version.
51    #[must_use]
52    pub fn with_version(mut self, ver: impl Into<String>) -> Self {
53        self.version = ver.into();
54        self
55    }
56
57    /// Set the SHA-256 hash of the plugin source.
58    #[must_use]
59    pub fn with_sha256(mut self, hash: impl Into<String>) -> Self {
60        self.sha256_hash = Some(hash.into());
61        self
62    }
63}
64
65/// Factory function that creates a forecast model from JSON configuration.
66pub type ModelFactory =
67    Arc<dyn Fn(&serde_json::Value) -> Result<Box<dyn ForecastModel>, PluginError> + Send + Sync>;
68
69/// Factory function that creates an anomaly detector from JSON configuration.
70pub type DetectorFactory =
71    Arc<dyn Fn(&serde_json::Value) -> Result<Box<dyn AnomalyDetector>, PluginError> + Send + Sync>;
72
73/// Built-in model names that cannot be used for custom plugins.
74const RESERVED_MODEL_NAMES: &[&str] = &[
75    "ses",
76    "holt",
77    "holt_linear",
78    "holt_winters",
79    "arima",
80    "sarima",
81    "linear_regression",
82];
83
84/// Built-in detector names that cannot be used for custom plugins.
85const RESERVED_DETECTOR_NAMES: &[&str] = &[
86    "zscore",
87    "modified_zscore",
88    "iqr",
89    "forecast_residual",
90    "moving_average",
91    "dynamic_threshold",
92    "cusum",
93];
94
95/// Maximum allowed length for plugin names.
96const MAX_NAME_LEN: usize = 64;
97
98/// Validate a plugin name.
99///
100/// Rules:
101/// Non-empty and no leading/trailing whitespace.
102/// Max 64 characters.
103/// Must start with an ASCII letter.
104/// Only ASCII letters, digits, underscores, and hyphens allowed.
105/// Must not conflict with a built-in name (case-insensitive).
106fn validate_name(name: &str, reserved: &[&str]) -> Result<(), PluginError> {
107    let trimmed = name.trim();
108    if trimmed.is_empty() {
109        return Err(PluginError::InvalidName(
110            "plugin name must not be empty or whitespace-only".into(),
111        ));
112    }
113    if trimmed != name {
114        return Err(PluginError::InvalidName(format!(
115            "plugin name {name:?} contains leading/trailing whitespace"
116        )));
117    }
118    if name.len() > MAX_NAME_LEN {
119        return Err(PluginError::InvalidName(format!(
120            "plugin name exceeds maximum length of {MAX_NAME_LEN} characters"
121        )));
122    }
123    let mut chars = name.chars();
124    if let Some(first) = chars.next()
125        && !first.is_ascii_alphabetic()
126    {
127        return Err(PluginError::InvalidName(format!(
128            "plugin name must start with an ASCII letter, got {first:?}"
129        )));
130    }
131    for ch in chars {
132        if !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-' {
133            return Err(PluginError::InvalidName(format!(
134                "plugin name contains invalid character {ch:?}; \
135                 only ASCII letters, digits, underscores, and hyphens are allowed"
136            )));
137        }
138    }
139    let lower = name.to_ascii_lowercase();
140    if reserved.contains(&lower.as_str()) {
141        return Err(PluginError::InvalidName(format!(
142            "{name:?} conflicts with built-in name"
143        )));
144    }
145    Ok(())
146}
147
148// ─── Generic factory map ────────────────────────────────────────────
149//
150// Eliminates the model/detector code duplication by extracting the
151// shared DashMap + validation + metrics logic into a reusable type
152// parameterised on the factory's output trait object.
153
154/// Type-erased factory closure stored as `Arc<dyn Fn(…) -> Result<T, PluginError>>`.
155type Factory<T> = Arc<dyn Fn(&serde_json::Value) -> Result<T, PluginError> + Send + Sync>;
156
157/// A named collection of plugin factories sharing the same output type.
158///
159/// All operations are lock-free (DashMap-backed). Registration uses
160/// atomic entry-based insertion to prevent TOCTOU races. Factory
161/// execution is performed *after* releasing the DashMap shard guard.
162struct FactoryMap<T: ?Sized> {
163    map: DashMap<String, (PluginInfo, Factory<Box<T>>)>,
164    reserved: &'static [&'static str],
165    kind: &'static str,
166    metric_registered: &'static str,
167    metric_created: &'static str,
168    /// Optional allowlist of SHA-256 hashes (hex-encoded, lowercase).
169    /// When non-empty, only plugins whose `sha256_hash` matches are accepted.
170    hash_allowlist: parking_lot::RwLock<HashSet<String>>,
171}
172
173impl<T: ?Sized> FactoryMap<T> {
174    fn new(
175        reserved: &'static [&'static str],
176        kind: &'static str,
177        metric_registered: &'static str,
178        metric_created: &'static str,
179    ) -> Self {
180        Self {
181            map: DashMap::new(),
182            reserved,
183            kind,
184            metric_registered,
185            metric_created,
186            hash_allowlist: parking_lot::RwLock::new(HashSet::new()),
187        }
188    }
189
190    /// Verify plugin hash against the allowlist.
191    /// If the allowlist is empty, all plugins are accepted.
192    fn verify_hash(&self, info: &PluginInfo) -> Result<(), PluginError> {
193        let allowlist = self.hash_allowlist.read();
194        if allowlist.is_empty() {
195            return Ok(());
196        }
197        match &info.sha256_hash {
198            Some(hash) => {
199                let normalized = hash.to_ascii_lowercase();
200                if allowlist.contains(&normalized) {
201                    Ok(())
202                } else {
203                    Err(PluginError::UntrustedPlugin(format!(
204                        "plugin {:?} hash {} not in allowlist",
205                        info.name, normalized
206                    )))
207                }
208            }
209            None => Err(PluginError::UntrustedPlugin(format!(
210                "plugin {:?} has no sha256_hash but allowlist is active",
211                info.name
212            ))),
213        }
214    }
215
216    /// Register a factory with explicit metadata. Atomic entry insertion.
217    fn register(&self, info: PluginInfo, factory: Factory<Box<T>>) -> Result<(), PluginError> {
218        validate_name(&info.name, self.reserved)?;
219        self.verify_hash(&info)?;
220        match self.map.entry(info.name.clone()) {
221            Entry::Occupied(_) => Err(PluginError::AlreadyRegistered(info.name)),
222            Entry::Vacant(entry) => {
223                info!(plugin = %info.name, version = %info.version, kind = self.kind, "registered custom plugin");
224                counter!(self.metric_registered).increment(1);
225                entry.insert((info, factory));
226                Ok(())
227            }
228        }
229    }
230
231    /// Replace (or insert) a factory with metadata. Returns previous info.
232    #[allow(clippy::needless_pass_by_value)] // factory ownership is the intended contract
233    fn replace(
234        &self,
235        info: PluginInfo,
236        factory: Factory<Box<T>>,
237    ) -> Result<Option<PluginInfo>, PluginError> {
238        validate_name(&info.name, self.reserved)?;
239        self.verify_hash(&info)?;
240        let prev = self
241            .map
242            .insert(info.name.clone(), (info.clone(), factory))
243            .map(|(pi, _)| pi);
244        // Emit audit event on plugin replacement so operators can
245        // detect unexpected overwrites (e.g. supply-chain attacks).
246        if let Some(ref old_info) = prev {
247            tracing::warn!(
248                plugin = %info.name,
249                old_version = %old_info.version,
250                new_version = %info.version,
251                old_hash = ?old_info.sha256_hash,
252                new_hash = ?info.sha256_hash,
253                kind = self.kind,
254                "plugin replaced — verify this was intentional",
255            );
256            counter!("chronix_plugin_replaced_total").increment(1);
257        } else {
258            info!(plugin = %info.name, version = %info.version, kind = self.kind, "registered new plugin");
259        }
260        counter!(self.metric_registered).increment(1);
261        Ok(prev)
262    }
263
264    /// Create an instance. Clones the factory `Arc` and drops the guard
265    /// before invocation so the shard lock is not held during execution.
266    fn create(&self, name: &str, config: &serde_json::Value) -> Result<Box<T>, PluginError> {
267        let factory = {
268            let entry = self
269                .map
270                .get(name)
271                .ok_or_else(|| PluginError::NotFound(name.to_string()))?;
272            Arc::clone(&entry.value().1)
273        };
274        let result = factory(config);
275        match &result {
276            Ok(_) => counter!(self.metric_created, "status" => "ok").increment(1),
277            Err(_) => counter!(self.metric_created, "status" => "error").increment(1),
278        }
279        result
280    }
281
282    /// Try to create — returns `None` when no plugin is registered.
283    fn try_create(
284        &self,
285        name: &str,
286        config: &serde_json::Value,
287    ) -> Option<Result<Box<T>, PluginError>> {
288        let factory = {
289            let entry = self.map.get(name)?;
290            Arc::clone(&entry.value().1)
291        };
292        let result = factory(config);
293        match &result {
294            Ok(_) => counter!(self.metric_created, "status" => "ok").increment(1),
295            Err(_) => counter!(self.metric_created, "status" => "error").increment(1),
296        }
297        Some(result)
298    }
299
300    fn has(&self, name: &str) -> bool {
301        self.map.contains_key(name)
302    }
303
304    fn info(&self, name: &str) -> Option<PluginInfo> {
305        self.map.get(name).map(|e| e.value().0.clone())
306    }
307
308    fn list_names(&self) -> Vec<String> {
309        let mut names: Vec<_> = self.map.iter().map(|e| e.key().clone()).collect();
310        names.sort();
311        names
312    }
313
314    fn list_infos(&self) -> Vec<PluginInfo> {
315        let mut infos: Vec<_> = self.map.iter().map(|e| e.value().0.clone()).collect();
316        infos.sort_by(|a, b| a.name.cmp(&b.name));
317        infos
318    }
319
320    fn unregister(&self, name: &str) -> Option<PluginInfo> {
321        self.map.remove(name).map(|(_, (info, _))| info)
322    }
323
324    fn len(&self) -> usize {
325        self.map.len()
326    }
327
328    fn clear(&self) {
329        self.map.clear();
330    }
331}
332
333// ─── PluginRegistry ─────────────────────────────────────────────────
334
335/// Thread-safe registry for custom forecast models and anomaly detectors.
336///
337/// Plugins register factory functions keyed by name. The factories are called
338/// at runtime to create model/detector instances with user-supplied config.
339///
340/// All registration and lookup operations are lock-free (DashMap-backed) and
341/// safe under concurrent access. Registration uses atomic entry-based insertion
342/// to prevent TOCTOU races.
343pub struct PluginRegistry {
344    models: FactoryMap<dyn ForecastModel>,
345    detectors: FactoryMap<dyn AnomalyDetector>,
346}
347
348impl PluginRegistry {
349    /// Create an empty registry.
350    #[must_use]
351    pub fn new() -> Self {
352        Self {
353            models: FactoryMap::new(
354                RESERVED_MODEL_NAMES,
355                "model",
356                "chronix_plugin_models_registered_total",
357                "chronix_plugin_model_creations_total",
358            ),
359            detectors: FactoryMap::new(
360                RESERVED_DETECTOR_NAMES,
361                "detector",
362                "chronix_plugin_detectors_registered_total",
363                "chronix_plugin_detector_creations_total",
364            ),
365        }
366    }
367
368    // ── Model factories ───────────────────────────────────────────
369
370    /// Register a custom forecast model factory.
371    ///
372    /// Returns an error if the name is invalid or a model with the same name
373    /// is already registered. Uses atomic entry insertion to prevent TOCTOU races.
374    pub fn register_model(
375        &self,
376        name: impl Into<String>,
377        factory: ModelFactory,
378    ) -> Result<(), PluginError> {
379        let name = name.into();
380        let info = PluginInfo::new(&name);
381        self.register_model_with_info(info, factory)
382    }
383
384    /// Register a custom forecast model factory with explicit metadata.
385    pub fn register_model_with_info(
386        &self,
387        info: PluginInfo,
388        factory: ModelFactory,
389    ) -> Result<(), PluginError> {
390        self.models.register(info, factory)
391    }
392
393    /// Atomically replace a model factory. Inserts if not present.
394    ///
395    /// Returns the previous `PluginInfo` if there was an existing registration.
396    pub fn replace_model(
397        &self,
398        name: impl Into<String>,
399        factory: ModelFactory,
400    ) -> Result<Option<PluginInfo>, PluginError> {
401        let name = name.into();
402        let info = PluginInfo::new(&name);
403        self.replace_model_with_info(info, factory)
404    }
405
406    /// Atomically replace a model factory with explicit metadata.
407    pub fn replace_model_with_info(
408        &self,
409        info: PluginInfo,
410        factory: ModelFactory,
411    ) -> Result<Option<PluginInfo>, PluginError> {
412        self.models.replace(info, factory)
413    }
414
415    /// Create a forecast model instance by plugin name.
416    pub fn create_model(
417        &self,
418        name: &str,
419        config: &serde_json::Value,
420    ) -> Result<Box<dyn ForecastModel>, PluginError> {
421        self.models.create(name, config)
422    }
423
424    /// Create a forecast model with default (null) configuration.
425    pub fn create_model_default(&self, name: &str) -> Result<Box<dyn ForecastModel>, PluginError> {
426        self.models.create(name, &serde_json::Value::Null)
427    }
428
429    /// Try to create a forecast model if the plugin is registered.
430    ///
431    /// Returns `None` if no plugin with the given name exists.
432    /// Returns `Some(Err(...))` if the plugin exists but the factory fails.
433    /// Single DashMap lookup — no TOCTOU.
434    pub fn try_create_model(
435        &self,
436        name: &str,
437        config: &serde_json::Value,
438    ) -> Option<Result<Box<dyn ForecastModel>, PluginError>> {
439        self.models.try_create(name, config)
440    }
441
442    /// Try to create a forecast model with default config (single lookup).
443    pub fn try_create_model_default(
444        &self,
445        name: &str,
446    ) -> Option<Result<Box<dyn ForecastModel>, PluginError>> {
447        self.models.try_create(name, &serde_json::Value::Null)
448    }
449
450    /// Check if a model plugin is registered.
451    pub fn has_model(&self, name: &str) -> bool {
452        self.models.has(name)
453    }
454
455    /// Get metadata for a registered model plugin.
456    pub fn model_info(&self, name: &str) -> Option<PluginInfo> {
457        self.models.info(name)
458    }
459
460    /// List all registered model plugin names (sorted).
461    pub fn list_models(&self) -> Vec<String> {
462        self.models.list_names()
463    }
464
465    /// List all registered model plugins with their metadata (sorted by name).
466    pub fn list_model_infos(&self) -> Vec<PluginInfo> {
467        self.models.list_infos()
468    }
469
470    /// Unregister a model plugin. Returns the `PluginInfo` if it existed.
471    pub fn unregister_model(&self, name: &str) -> Option<PluginInfo> {
472        self.models.unregister(name)
473    }
474
475    /// Number of registered model plugins.
476    pub fn model_count(&self) -> usize {
477        self.models.len()
478    }
479
480    // ── Detector factories ────────────────────────────────────────
481
482    /// Register a custom anomaly detector factory.
483    ///
484    /// Returns an error if the name is invalid or a detector with the same name
485    /// is already registered. Uses atomic entry insertion to prevent TOCTOU races.
486    pub fn register_detector(
487        &self,
488        name: impl Into<String>,
489        factory: DetectorFactory,
490    ) -> Result<(), PluginError> {
491        let name = name.into();
492        let info = PluginInfo::new(&name);
493        self.register_detector_with_info(info, factory)
494    }
495
496    /// Register a custom anomaly detector factory with explicit metadata.
497    pub fn register_detector_with_info(
498        &self,
499        info: PluginInfo,
500        factory: DetectorFactory,
501    ) -> Result<(), PluginError> {
502        self.detectors.register(info, factory)
503    }
504
505    /// Atomically replace a detector factory. Inserts if not present.
506    ///
507    /// Returns the previous `PluginInfo` if there was an existing registration.
508    pub fn replace_detector(
509        &self,
510        name: impl Into<String>,
511        factory: DetectorFactory,
512    ) -> Result<Option<PluginInfo>, PluginError> {
513        let name = name.into();
514        let info = PluginInfo::new(&name);
515        self.replace_detector_with_info(info, factory)
516    }
517
518    /// Atomically replace a detector factory with explicit metadata.
519    pub fn replace_detector_with_info(
520        &self,
521        info: PluginInfo,
522        factory: DetectorFactory,
523    ) -> Result<Option<PluginInfo>, PluginError> {
524        self.detectors.replace(info, factory)
525    }
526
527    /// Create an anomaly detector instance by plugin name.
528    pub fn create_detector(
529        &self,
530        name: &str,
531        config: &serde_json::Value,
532    ) -> Result<Box<dyn AnomalyDetector>, PluginError> {
533        self.detectors.create(name, config)
534    }
535
536    /// Create an anomaly detector with default (null) configuration.
537    pub fn create_detector_default(
538        &self,
539        name: &str,
540    ) -> Result<Box<dyn AnomalyDetector>, PluginError> {
541        self.detectors.create(name, &serde_json::Value::Null)
542    }
543
544    /// Try to create an anomaly detector if the plugin is registered.
545    ///
546    /// Returns `None` if no plugin with the given name exists.
547    /// Returns `Some(Err(...))` if the plugin exists but the factory fails.
548    /// Single DashMap lookup — no TOCTOU.
549    pub fn try_create_detector(
550        &self,
551        name: &str,
552        config: &serde_json::Value,
553    ) -> Option<Result<Box<dyn AnomalyDetector>, PluginError>> {
554        self.detectors.try_create(name, config)
555    }
556
557    /// Try to create an anomaly detector with default config (single lookup).
558    pub fn try_create_detector_default(
559        &self,
560        name: &str,
561    ) -> Option<Result<Box<dyn AnomalyDetector>, PluginError>> {
562        self.detectors.try_create(name, &serde_json::Value::Null)
563    }
564
565    /// Check if a detector plugin is registered.
566    pub fn has_detector(&self, name: &str) -> bool {
567        self.detectors.has(name)
568    }
569
570    /// Get metadata for a registered detector plugin.
571    pub fn detector_info(&self, name: &str) -> Option<PluginInfo> {
572        self.detectors.info(name)
573    }
574
575    /// List all registered detector plugin names (sorted).
576    pub fn list_detectors(&self) -> Vec<String> {
577        self.detectors.list_names()
578    }
579
580    /// List all registered detector plugins with their metadata (sorted by name).
581    pub fn list_detector_infos(&self) -> Vec<PluginInfo> {
582        self.detectors.list_infos()
583    }
584
585    /// Unregister a detector plugin. Returns the `PluginInfo` if it existed.
586    pub fn unregister_detector(&self, name: &str) -> Option<PluginInfo> {
587        self.detectors.unregister(name)
588    }
589
590    /// Number of registered detector plugins.
591    pub fn detector_count(&self) -> usize {
592        self.detectors.len()
593    }
594
595    // ── Combined queries ──────────────────────────────────────────
596
597    /// Total number of registered plugins (models + detectors).
598    pub fn total_plugins(&self) -> usize {
599        self.models.len() + self.detectors.len()
600    }
601
602    /// Remove all registered models and detectors.
603    pub fn clear(&self) {
604        self.models.clear();
605        self.detectors.clear();
606    }
607
608    // ── Plugin allowlist ──────────────────────────────────
609
610    /// Set the SHA-256 hash allowlist for model plugins.
611    ///
612    /// When the allowlist is non-empty, only plugins whose
613    /// `PluginInfo::sha256_hash` is present in the list are accepted.
614    /// Hashes should be lowercase hex-encoded.
615    pub fn set_model_allowlist(&self, hashes: impl IntoIterator<Item = String>) {
616        *self.models.hash_allowlist.write() =
617            hashes.into_iter().map(|h| h.to_ascii_lowercase()).collect();
618    }
619
620    /// Set the SHA-256 hash allowlist for detector plugins.
621    pub fn set_detector_allowlist(&self, hashes: impl IntoIterator<Item = String>) {
622        *self.detectors.hash_allowlist.write() =
623            hashes.into_iter().map(|h| h.to_ascii_lowercase()).collect();
624    }
625}
626
627impl Default for PluginRegistry {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633impl fmt::Debug for PluginRegistry {
634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635        f.debug_struct("PluginRegistry")
636            .field("models", &self.list_models())
637            .field("detectors", &self.list_detectors())
638            .finish()
639    }
640}
641
642// ─── Global singleton ───────────────────────────────────────────────
643
644static GLOBAL_REGISTRY: OnceLock<Arc<PluginRegistry>> = OnceLock::new();
645
646/// Returns the global plugin registry.
647///
648/// The registry is lazily initialised on first access.
649pub fn global_registry() -> &'static Arc<PluginRegistry> {
650    GLOBAL_REGISTRY.get_or_init(|| Arc::new(PluginRegistry::new()))
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::anomaly::{AnomalyScore, DetectorType};
657    use crate::forecast::{ForecastResult, ModelParams, ModelType};
658
659    // ── helpers ──────────────────────────────────────────────────
660
661    /// Trivial "constant" forecast model for testing.
662    struct ConstantModel {
663        value: f64,
664        params: ModelParams,
665    }
666
667    impl ConstantModel {
668        fn new(value: f64) -> Self {
669            Self {
670                value,
671                params: ModelParams::Custom {
672                    name: "constant".into(),
673                    data: Vec::new(),
674                },
675            }
676        }
677    }
678
679    impl ForecastModel for ConstantModel {
680        fn fit(
681            &mut self,
682            _ts: &[i64],
683            _vals: &[f64],
684        ) -> Result<(), crate::forecast::ForecastError> {
685            Ok(())
686        }
687
688        fn predict(
689            &self,
690            horizon: usize,
691        ) -> Result<ForecastResult, crate::forecast::ForecastError> {
692            Ok(ForecastResult {
693                values: vec![self.value; horizon],
694                timestamps: (1..=horizon as i64).collect(),
695                confidence_lower: vec![self.value; horizon],
696                confidence_upper: vec![self.value; horizon],
697                confidence_level: 1.0,
698            })
699        }
700
701        fn update(&mut self, _ts: i64, _val: f64) -> Result<(), crate::forecast::ForecastError> {
702            Ok(())
703        }
704
705        fn model_type(&self) -> ModelType {
706            ModelType::Custom("constant".into())
707        }
708
709        fn params(&self) -> &ModelParams {
710            &self.params
711        }
712    }
713
714    /// Trivial "always normal" anomaly detector for testing.
715    struct AlwaysNormalDetector;
716
717    impl crate::anomaly::AnomalyDetector for AlwaysNormalDetector {
718        fn fit(&mut self, _ts: &[i64], _vals: &[f64]) -> Result<(), crate::anomaly::AnomalyError> {
719            Ok(())
720        }
721
722        fn detect(
723            &mut self,
724            timestamps: &[i64],
725            values: &[f64],
726        ) -> Result<Vec<AnomalyScore>, crate::anomaly::AnomalyError> {
727            Ok(timestamps
728                .iter()
729                .zip(values)
730                .map(|(&t, &v)| AnomalyScore {
731                    timestamp: t,
732                    value: v,
733                    score: 0.0,
734                    is_anomaly: false,
735                    method: DetectorType::Custom("always_normal".into()),
736                    threshold: 0.0,
737                    details: String::new(),
738                })
739                .collect())
740        }
741
742        fn detect_point(
743            &mut self,
744            timestamp: i64,
745            value: f64,
746        ) -> Result<AnomalyScore, crate::anomaly::AnomalyError> {
747            Ok(AnomalyScore {
748                timestamp,
749                value,
750                score: 0.0,
751                is_anomaly: false,
752                method: DetectorType::Custom("always_normal".into()),
753                threshold: 0.0,
754                details: String::new(),
755            })
756        }
757
758        fn detector_type(&self) -> DetectorType {
759            DetectorType::Custom("always_normal".into())
760        }
761    }
762
763    fn constant_factory(value: f64) -> ModelFactory {
764        Arc::new(move |_config| Ok(Box::new(ConstantModel::new(value))))
765    }
766
767    fn normal_detector_factory() -> DetectorFactory {
768        Arc::new(|_config| Ok(Box::new(AlwaysNormalDetector)))
769    }
770
771    // ── model tests ──────────────────────────────────────────────
772
773    #[test]
774    fn register_and_create_model() {
775        let reg = PluginRegistry::new();
776        reg.register_model("constant", constant_factory(42.0))
777            .unwrap();
778
779        assert!(reg.has_model("constant"));
780        assert!(!reg.has_model("nonexistent"));
781        assert_eq!(reg.list_models(), vec!["constant"]);
782
783        let mut model = reg.create_model_default("constant").unwrap();
784        model.fit(&[1, 2, 3], &[1.0, 2.0, 3.0]).unwrap();
785        let result = model.predict(3).unwrap();
786        assert_eq!(result.values, vec![42.0, 42.0, 42.0]);
787        assert_eq!(model.model_type(), ModelType::Custom("constant".into()));
788    }
789
790    #[test]
791    fn register_model_with_metadata() {
792        let reg = PluginRegistry::new();
793        let info = PluginInfo::new("fancy_model")
794            .with_description("A fancy forecasting model")
795            .with_version("1.2.3");
796        reg.register_model_with_info(info, constant_factory(1.0))
797            .unwrap();
798
799        let retrieved = reg.model_info("fancy_model").unwrap();
800        assert_eq!(retrieved.name, "fancy_model");
801        assert_eq!(retrieved.description, "A fancy forecasting model");
802        assert_eq!(retrieved.version, "1.2.3");
803    }
804
805    #[test]
806    fn duplicate_model_registration_fails() {
807        let reg = PluginRegistry::new();
808        reg.register_model("dup", constant_factory(1.0)).unwrap();
809        let err = reg.register_model("dup", constant_factory(2.0));
810        assert!(matches!(err, Err(PluginError::AlreadyRegistered(_))));
811    }
812
813    #[test]
814    fn create_missing_model_fails() {
815        let reg = PluginRegistry::new();
816        let err = reg.create_model_default("nope");
817        assert!(matches!(err, Err(PluginError::NotFound(_))));
818    }
819
820    #[test]
821    fn unregister_model() {
822        let reg = PluginRegistry::new();
823        reg.register_model("tmp", constant_factory(1.0)).unwrap();
824        assert!(reg.has_model("tmp"));
825
826        let info = reg.unregister_model("tmp");
827        assert!(info.is_some());
828        assert_eq!(info.unwrap().name, "tmp");
829        assert!(!reg.has_model("tmp"));
830        assert!(reg.unregister_model("tmp").is_none());
831    }
832
833    #[test]
834    fn replace_model_inserts_and_replaces() {
835        let reg = PluginRegistry::new();
836
837        // Insert (no previous)
838        let prev = reg.replace_model("rp", constant_factory(1.0)).unwrap();
839        assert!(prev.is_none());
840
841        // Verify old factory produces value 1.0
842        let model = reg.create_model_default("rp").unwrap();
843        assert_eq!(model.predict(1).unwrap().values, vec![1.0]);
844
845        // Replace with new factory
846        let prev = reg.replace_model("rp", constant_factory(99.0)).unwrap();
847        assert!(prev.is_some());
848
849        // Verify new factory produces value 99.0
850        let model = reg.create_model_default("rp").unwrap();
851        assert_eq!(model.predict(1).unwrap().values, vec![99.0]);
852    }
853
854    #[test]
855    fn try_create_model_single_lookup() {
856        let reg = PluginRegistry::new();
857
858        // Missing → None
859        assert!(reg.try_create_model_default("nope").is_none());
860
861        // Present → Some(Ok(...))
862        reg.register_model("tc", constant_factory(7.0)).unwrap();
863        let result = reg.try_create_model_default("tc");
864        assert!(result.is_some());
865        let model = result.unwrap().unwrap();
866        assert_eq!(model.predict(1).unwrap().values, vec![7.0]);
867    }
868
869    // ── detector tests ───────────────────────────────────────────
870
871    #[test]
872    fn register_and_create_detector() {
873        let reg = PluginRegistry::new();
874        reg.register_detector("always_normal", normal_detector_factory())
875            .unwrap();
876
877        assert!(reg.has_detector("always_normal"));
878        assert_eq!(reg.list_detectors(), vec!["always_normal"]);
879
880        let mut det = reg.create_detector_default("always_normal").unwrap();
881        det.fit(&[1, 2, 3], &[1.0, 2.0, 3.0]).unwrap();
882        let scores = det.detect(&[4], &[100.0]).unwrap();
883        assert_eq!(scores.len(), 1);
884        assert!(!scores[0].is_anomaly);
885        assert_eq!(
886            det.detector_type(),
887            DetectorType::Custom("always_normal".into())
888        );
889    }
890
891    #[test]
892    fn duplicate_detector_registration_fails() {
893        let reg = PluginRegistry::new();
894        reg.register_detector("dup", normal_detector_factory())
895            .unwrap();
896        let err = reg.register_detector("dup", normal_detector_factory());
897        assert!(matches!(err, Err(PluginError::AlreadyRegistered(_))));
898    }
899
900    #[test]
901    fn create_missing_detector_fails() {
902        let reg = PluginRegistry::new();
903        let err = reg.create_detector_default("nope");
904        assert!(matches!(err, Err(PluginError::NotFound(_))));
905    }
906
907    #[test]
908    fn unregister_detector() {
909        let reg = PluginRegistry::new();
910        reg.register_detector("tmp", normal_detector_factory())
911            .unwrap();
912        assert!(reg.has_detector("tmp"));
913
914        let info = reg.unregister_detector("tmp");
915        assert!(info.is_some());
916        assert!(!reg.has_detector("tmp"));
917        assert!(reg.unregister_detector("tmp").is_none());
918    }
919
920    #[test]
921    fn try_create_detector_single_lookup() {
922        let reg = PluginRegistry::new();
923
924        assert!(reg.try_create_detector_default("nope").is_none());
925
926        reg.register_detector("td", normal_detector_factory())
927            .unwrap();
928        let result = reg.try_create_detector_default("td");
929        assert!(result.is_some());
930        let mut det = result.unwrap().unwrap();
931        let scores = det.detect(&[1], &[1.0]).unwrap();
932        assert!(!scores[0].is_anomaly);
933    }
934
935    // ── name validation tests ────────────────────────────────────
936
937    #[test]
938    fn reject_empty_name() {
939        let reg = PluginRegistry::new();
940        let err = reg.register_model("", constant_factory(1.0));
941        assert!(matches!(err, Err(PluginError::InvalidName(_))));
942
943        let err = reg.register_model("   ", constant_factory(1.0));
944        assert!(matches!(err, Err(PluginError::InvalidName(_))));
945    }
946
947    #[test]
948    fn reject_whitespace_padded_name() {
949        let reg = PluginRegistry::new();
950        let err = reg.register_model(" leading", constant_factory(1.0));
951        assert!(matches!(err, Err(PluginError::InvalidName(_))));
952
953        let err = reg.register_detector("trailing ", normal_detector_factory());
954        assert!(matches!(err, Err(PluginError::InvalidName(_))));
955    }
956
957    #[test]
958    fn reject_reserved_model_names() {
959        let reg = PluginRegistry::new();
960        for name in ["ses", "SES", "holt", "Arima", "SARIMA", "linear_regression"] {
961            let err = reg.register_model(name, constant_factory(1.0));
962            assert!(
963                matches!(err, Err(PluginError::InvalidName(_))),
964                "should reject reserved model name: {name}"
965            );
966        }
967    }
968
969    #[test]
970    fn reject_reserved_detector_names() {
971        let reg = PluginRegistry::new();
972        for name in ["zscore", "ZScore", "iqr", "cusum", "CUSUM"] {
973            let err = reg.register_detector(name, normal_detector_factory());
974            assert!(
975                matches!(err, Err(PluginError::InvalidName(_))),
976                "should reject reserved detector name: {name}"
977            );
978        }
979    }
980
981    // ── combined & misc tests ────────────────────────────────────
982
983    #[test]
984    fn total_plugins_and_clear() {
985        let reg = PluginRegistry::new();
986        assert_eq!(reg.total_plugins(), 0);
987
988        reg.register_model("m1", constant_factory(1.0)).unwrap();
989        reg.register_model("m2", constant_factory(2.0)).unwrap();
990        reg.register_detector("d1", normal_detector_factory())
991            .unwrap();
992        assert_eq!(reg.total_plugins(), 3);
993
994        reg.clear();
995        assert_eq!(reg.total_plugins(), 0);
996        assert!(reg.list_models().is_empty());
997        assert!(reg.list_detectors().is_empty());
998    }
999
1000    #[test]
1001    fn factory_error_propagated() {
1002        let reg = PluginRegistry::new();
1003        let factory: ModelFactory =
1004            Arc::new(|_| Err(PluginError::FactoryFailed("out of memory".into())));
1005        reg.register_model("broken", factory).unwrap();
1006
1007        let err = reg.create_model_default("broken");
1008        assert!(matches!(err, Err(PluginError::FactoryFailed(_))));
1009    }
1010
1011    #[test]
1012    fn model_factory_with_config() {
1013        let reg = PluginRegistry::new();
1014        let factory: ModelFactory = Arc::new(|config| {
1015            let value = config
1016                .get("value")
1017                .and_then(serde_json::Value::as_f64)
1018                .unwrap_or(0.0);
1019            Ok(Box::new(ConstantModel::new(value)))
1020        });
1021        reg.register_model("configurable", factory).unwrap();
1022
1023        let config = serde_json::json!({"value": 99.0});
1024        let model = reg.create_model("configurable", &config).unwrap();
1025        let result = model.predict(1).unwrap();
1026        assert_eq!(result.values, vec![99.0]);
1027    }
1028
1029    #[test]
1030    fn list_model_infos_sorted() {
1031        let reg = PluginRegistry::new();
1032        reg.register_model("zeta", constant_factory(1.0)).unwrap();
1033        reg.register_model("alpha", constant_factory(2.0)).unwrap();
1034        let infos = reg.list_model_infos();
1035        assert_eq!(infos.len(), 2);
1036        assert_eq!(infos[0].name, "alpha");
1037        assert_eq!(infos[1].name, "zeta");
1038    }
1039
1040    #[test]
1041    fn debug_impl() {
1042        let reg = PluginRegistry::new();
1043        reg.register_model("m1", constant_factory(1.0)).unwrap();
1044        let dbg = format!("{:?}", reg);
1045        assert!(dbg.contains("PluginRegistry"));
1046        assert!(dbg.contains("m1"));
1047    }
1048
1049    #[test]
1050    fn global_registry_is_accessible() {
1051        let reg = global_registry();
1052        // Verify it's a valid registry that can be queried
1053        let models = reg.list_models();
1054        let detectors = reg.list_detectors();
1055        assert_eq!(reg.total_plugins(), models.len() + detectors.len());
1056    }
1057
1058    #[test]
1059    fn concurrent_registration_is_safe() {
1060        use std::thread;
1061
1062        let reg = Arc::new(PluginRegistry::new());
1063        let mut handles = Vec::new();
1064
1065        for i in 0..8 {
1066            let reg = Arc::clone(&reg);
1067            handles.push(thread::spawn(move || {
1068                let name = format!("concurrent_model_{i}");
1069                reg.register_model(&name, constant_factory(i as f64))
1070            }));
1071        }
1072
1073        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1074        // All 8 should succeed since names are unique
1075        assert!(results.iter().all(std::result::Result::is_ok));
1076        assert_eq!(reg.total_plugins(), 8);
1077    }
1078
1079    #[test]
1080    fn concurrent_duplicate_registration_one_wins() {
1081        use std::sync::Barrier;
1082        use std::thread;
1083
1084        let reg = Arc::new(PluginRegistry::new());
1085        let barrier = Arc::new(Barrier::new(4));
1086        let mut handles = Vec::new();
1087
1088        for _ in 0..4 {
1089            let reg = Arc::clone(&reg);
1090            let barrier = Arc::clone(&barrier);
1091            handles.push(thread::spawn(move || {
1092                barrier.wait();
1093                reg.register_model("race_target", constant_factory(1.0))
1094            }));
1095        }
1096
1097        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1098        let successes = results.iter().filter(|r| r.is_ok()).count();
1099        let failures = results.iter().filter(|r| r.is_err()).count();
1100
1101        // Exactly one thread wins the entry; the rest get AlreadyRegistered
1102        assert_eq!(successes, 1);
1103        assert_eq!(failures, 3);
1104        assert!(reg.has_model("race_target"));
1105    }
1106
1107    // ── name charset & length tests ──────────────────────────────
1108
1109    #[test]
1110    fn reject_name_starting_with_digit() {
1111        let reg = PluginRegistry::new();
1112        let err = reg.register_model("9lives", constant_factory(1.0));
1113        assert!(matches!(err, Err(PluginError::InvalidName(_))));
1114    }
1115
1116    #[test]
1117    fn reject_name_with_special_chars() {
1118        let reg = PluginRegistry::new();
1119        for bad in ["foo/bar", "foo bar", "hello!", "a@b", "a.b", "a+b"] {
1120            let err = reg.register_model(bad, constant_factory(1.0));
1121            assert!(
1122                matches!(err, Err(PluginError::InvalidName(_))),
1123                "should reject name with special chars: {bad:?}"
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn accept_valid_name_chars() {
1130        let reg = PluginRegistry::new();
1131        for good in ["alpha", "my_model", "my-model", "Model123", "a"] {
1132            reg.register_model(good, constant_factory(1.0)).unwrap();
1133        }
1134    }
1135
1136    #[test]
1137    fn reject_name_exceeding_max_length() {
1138        let reg = PluginRegistry::new();
1139        let long_name = format!("a{}", "b".repeat(MAX_NAME_LEN));
1140        assert!(long_name.len() > MAX_NAME_LEN);
1141        let err = reg.register_model(&long_name, constant_factory(1.0));
1142        assert!(matches!(err, Err(PluginError::InvalidName(_))));
1143
1144        // Exactly MAX_NAME_LEN should be fine
1145        let exact = format!("a{}", "b".repeat(MAX_NAME_LEN - 1));
1146        assert_eq!(exact.len(), MAX_NAME_LEN);
1147        reg.register_model(&exact, constant_factory(1.0)).unwrap();
1148    }
1149
1150    // ── replace_*_with_info tests ────────────────────────────────
1151
1152    #[test]
1153    fn replace_model_with_info_preserves_metadata() {
1154        let reg = PluginRegistry::new();
1155
1156        let info = PluginInfo::new("rp_info")
1157            .with_description("v1 model")
1158            .with_version("1.0.0");
1159        reg.register_model_with_info(info, constant_factory(1.0))
1160            .unwrap();
1161
1162        let new_info = PluginInfo::new("rp_info")
1163            .with_description("v2 model — improved")
1164            .with_version("2.0.0");
1165        let prev = reg
1166            .replace_model_with_info(new_info, constant_factory(99.0))
1167            .unwrap();
1168
1169        assert_eq!(prev.unwrap().version, "1.0.0");
1170        let current = reg.model_info("rp_info").unwrap();
1171        assert_eq!(current.description, "v2 model — improved");
1172        assert_eq!(current.version, "2.0.0");
1173
1174        // Verify the new factory is active
1175        let model = reg.create_model_default("rp_info").unwrap();
1176        assert_eq!(model.predict(1).unwrap().values, vec![99.0]);
1177    }
1178
1179    #[test]
1180    fn replace_detector_with_info_preserves_metadata() {
1181        let reg = PluginRegistry::new();
1182
1183        let info = PluginInfo::new("rd_info")
1184            .with_description("v1 detector")
1185            .with_version("1.0.0");
1186        reg.register_detector_with_info(info, normal_detector_factory())
1187            .unwrap();
1188
1189        let new_info = PluginInfo::new("rd_info")
1190            .with_description("v2 detector")
1191            .with_version("2.0.0");
1192        let prev = reg
1193            .replace_detector_with_info(new_info, normal_detector_factory())
1194            .unwrap();
1195
1196        assert_eq!(prev.unwrap().version, "1.0.0");
1197        let current = reg.detector_info("rd_info").unwrap();
1198        assert_eq!(current.description, "v2 detector");
1199        assert_eq!(current.version, "2.0.0");
1200    }
1201
1202    // ── count tests ─────────────────────────────────────────────
1203
1204    #[test]
1205    fn model_and_detector_count() {
1206        let reg = PluginRegistry::new();
1207        assert_eq!(reg.model_count(), 0);
1208        assert_eq!(reg.detector_count(), 0);
1209
1210        reg.register_model("m1", constant_factory(1.0)).unwrap();
1211        reg.register_model("m2", constant_factory(2.0)).unwrap();
1212        reg.register_detector("d1", normal_detector_factory())
1213            .unwrap();
1214
1215        assert_eq!(reg.model_count(), 2);
1216        assert_eq!(reg.detector_count(), 1);
1217        assert_eq!(reg.total_plugins(), 3);
1218
1219        reg.unregister_model("m1");
1220        assert_eq!(reg.model_count(), 1);
1221    }
1222
1223    // ── error clone test ─────────────────────────────────────────
1224
1225    #[test]
1226    fn plugin_error_is_cloneable() {
1227        let err = PluginError::NotFound("missing".into());
1228        let cloned = err.clone();
1229        assert_eq!(format!("{err}"), format!("{cloned}"));
1230
1231        let err2 = PluginError::InvalidName("bad name".into());
1232        let cloned2 = err2.clone();
1233        assert_eq!(format!("{err2}"), format!("{cloned2}"));
1234    }
1235
1236    // ── PluginInfo equality test ─────────────────────────────────
1237
1238    #[test]
1239    fn plugin_info_equality() {
1240        let a = PluginInfo::new("test")
1241            .with_description("desc")
1242            .with_version("1.0.0");
1243        let b = PluginInfo::new("test")
1244            .with_description("desc")
1245            .with_version("1.0.0");
1246        let c = PluginInfo::new("test")
1247            .with_description("different")
1248            .with_version("1.0.0");
1249
1250        assert_eq!(a, b);
1251        assert_ne!(a, c);
1252    }
1253
1254    // ── PluginInfo serialization test ────────────────────────────
1255
1256    #[test]
1257    fn plugin_info_serde_roundtrip() {
1258        let info = PluginInfo::new("serde_test")
1259            .with_description("A test plugin")
1260            .with_version("0.1.0");
1261        let json = serde_json::to_string(&info).unwrap();
1262        let deserialized: PluginInfo = serde_json::from_str(&json).unwrap();
1263        assert_eq!(info, deserialized);
1264    }
1265
1266    // ── guard-release test ───────────────────────────────────────
1267
1268    #[test]
1269    fn factory_does_not_hold_shard_lock() {
1270        use std::thread;
1271
1272        // Verify that factory execution doesn't block concurrent writes.
1273        let reg = Arc::new(PluginRegistry::new());
1274        let factory: ModelFactory = Arc::new(|_| {
1275            // Simulate a non-trivial factory
1276            std::thread::yield_now();
1277            Ok(Box::new(ConstantModel::new(1.0)))
1278        });
1279        reg.register_model("slow", factory).unwrap();
1280
1281        // Spawn a thread that creates a model (invokes the factory)
1282        let reg2 = Arc::clone(&reg);
1283        let create_handle = thread::spawn(move || {
1284            reg2.create_model_default("slow").unwrap();
1285        });
1286
1287        // Meanwhile, register another model concurrently
1288        // (would deadlock or block if the guard were held during factory exec)
1289        reg.register_model("other", constant_factory(2.0)).unwrap();
1290
1291        create_handle.join().unwrap();
1292        assert!(reg.has_model("slow"));
1293        assert!(reg.has_model("other"));
1294    }
1295
1296    // ── Plugin source verification ──────────────────────
1297
1298    #[test]
1299    fn allowlist_empty_permits_all_plugins() {
1300        let reg = PluginRegistry::new();
1301        // No allowlist set → any plugin accepted
1302        reg.register_model("open_model", constant_factory(1.0))
1303            .unwrap();
1304        assert!(reg.has_model("open_model"));
1305    }
1306
1307    #[test]
1308    fn allowlist_rejects_plugin_without_hash() {
1309        let reg = PluginRegistry::new();
1310        reg.set_model_allowlist(vec!["abcd1234".to_string()]);
1311
1312        // PluginInfo has no sha256_hash → rejected
1313        let info = PluginInfo::new("unhashed");
1314        let result = reg.register_model_with_info(info, constant_factory(1.0));
1315        assert!(matches!(result, Err(PluginError::UntrustedPlugin(_))));
1316    }
1317
1318    #[test]
1319    fn allowlist_rejects_plugin_with_wrong_hash() {
1320        let reg = PluginRegistry::new();
1321        reg.set_model_allowlist(vec!["aaaa".to_string()]);
1322
1323        let info = PluginInfo::new("wronghash").with_sha256("bbbb");
1324        let result = reg.register_model_with_info(info, constant_factory(1.0));
1325        assert!(
1326            matches!(result, Err(PluginError::UntrustedPlugin(ref msg)) if msg.contains("not in allowlist"))
1327        );
1328    }
1329
1330    #[test]
1331    fn allowlist_accepts_plugin_with_matching_hash() {
1332        let reg = PluginRegistry::new();
1333        let hash = "a1b2c3d4e5f6".to_string();
1334        reg.set_model_allowlist(vec![hash.clone()]);
1335
1336        let info = PluginInfo::new("trusted").with_sha256(&hash);
1337        reg.register_model_with_info(info, constant_factory(1.0))
1338            .unwrap();
1339        assert!(reg.has_model("trusted"));
1340    }
1341
1342    #[test]
1343    fn allowlist_case_insensitive() {
1344        let reg = PluginRegistry::new();
1345        reg.set_model_allowlist(vec!["ABCDEF".to_string()]);
1346
1347        let info = PluginInfo::new("casetest").with_sha256("abcdef");
1348        reg.register_model_with_info(info, constant_factory(1.0))
1349            .unwrap();
1350        assert!(reg.has_model("casetest"));
1351    }
1352}
1353
1354// ── Error type (moved from chronix-plugin) ──────────────────────────
1355
1356/// Errors from the custom model/detector registry.
1357#[derive(Debug, Clone, thiserror::Error)]
1358#[non_exhaustive]
1359pub enum PluginError {
1360    /// A plugin with the given name is already registered.
1361    #[error("plugin already registered: {0}")]
1362    AlreadyRegistered(String),
1363
1364    /// No plugin with the given name.
1365    #[error("plugin not found: {0}")]
1366    NotFound(String),
1367
1368    /// The factory function returned an error.
1369    #[error("plugin factory failed: {0}")]
1370    FactoryFailed(String),
1371
1372    /// Invalid plugin configuration.
1373    #[error("invalid plugin config: {0}")]
1374    InvalidConfig(String),
1375
1376    /// Invalid plugin name (empty, whitespace-only, or conflicts with built-in).
1377    #[error("invalid plugin name: {0}")]
1378    InvalidName(String),
1379
1380    /// Plugin source hash is missing or not in the allowlist.
1381    #[error("untrusted plugin: {0}")]
1382    UntrustedPlugin(String),
1383}