1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct PluginInfo {
19 pub name: String,
21 pub description: String,
23 pub version: String,
25 pub sha256_hash: Option<String>,
30}
31
32impl PluginInfo {
33 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 #[must_use]
45 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
46 self.description = desc.into();
47 self
48 }
49
50 #[must_use]
52 pub fn with_version(mut self, ver: impl Into<String>) -> Self {
53 self.version = ver.into();
54 self
55 }
56
57 #[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
65pub type ModelFactory =
67 Arc<dyn Fn(&serde_json::Value) -> Result<Box<dyn ForecastModel>, PluginError> + Send + Sync>;
68
69pub type DetectorFactory =
71 Arc<dyn Fn(&serde_json::Value) -> Result<Box<dyn AnomalyDetector>, PluginError> + Send + Sync>;
72
73const RESERVED_MODEL_NAMES: &[&str] = &[
75 "ses",
76 "holt",
77 "holt_linear",
78 "holt_winters",
79 "arima",
80 "sarima",
81 "linear_regression",
82];
83
84const RESERVED_DETECTOR_NAMES: &[&str] = &[
86 "zscore",
87 "modified_zscore",
88 "iqr",
89 "forecast_residual",
90 "moving_average",
91 "dynamic_threshold",
92 "cusum",
93];
94
95const MAX_NAME_LEN: usize = 64;
97
98fn 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
148type Factory<T> = Arc<dyn Fn(&serde_json::Value) -> Result<T, PluginError> + Send + Sync>;
156
157struct 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 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 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 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 #[allow(clippy::needless_pass_by_value)] 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 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 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 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
333pub struct PluginRegistry {
344 models: FactoryMap<dyn ForecastModel>,
345 detectors: FactoryMap<dyn AnomalyDetector>,
346}
347
348impl PluginRegistry {
349 #[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 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 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 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 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 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 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 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 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 pub fn has_model(&self, name: &str) -> bool {
452 self.models.has(name)
453 }
454
455 pub fn model_info(&self, name: &str) -> Option<PluginInfo> {
457 self.models.info(name)
458 }
459
460 pub fn list_models(&self) -> Vec<String> {
462 self.models.list_names()
463 }
464
465 pub fn list_model_infos(&self) -> Vec<PluginInfo> {
467 self.models.list_infos()
468 }
469
470 pub fn unregister_model(&self, name: &str) -> Option<PluginInfo> {
472 self.models.unregister(name)
473 }
474
475 pub fn model_count(&self) -> usize {
477 self.models.len()
478 }
479
480 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 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 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 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 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 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 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 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 pub fn has_detector(&self, name: &str) -> bool {
567 self.detectors.has(name)
568 }
569
570 pub fn detector_info(&self, name: &str) -> Option<PluginInfo> {
572 self.detectors.info(name)
573 }
574
575 pub fn list_detectors(&self) -> Vec<String> {
577 self.detectors.list_names()
578 }
579
580 pub fn list_detector_infos(&self) -> Vec<PluginInfo> {
582 self.detectors.list_infos()
583 }
584
585 pub fn unregister_detector(&self, name: &str) -> Option<PluginInfo> {
587 self.detectors.unregister(name)
588 }
589
590 pub fn detector_count(&self) -> usize {
592 self.detectors.len()
593 }
594
595 pub fn total_plugins(&self) -> usize {
599 self.models.len() + self.detectors.len()
600 }
601
602 pub fn clear(&self) {
604 self.models.clear();
605 self.detectors.clear();
606 }
607
608 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 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
642static GLOBAL_REGISTRY: OnceLock<Arc<PluginRegistry>> = OnceLock::new();
645
646pub 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 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 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 #[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 let prev = reg.replace_model("rp", constant_factory(1.0)).unwrap();
839 assert!(prev.is_none());
840
841 let model = reg.create_model_default("rp").unwrap();
843 assert_eq!(model.predict(1).unwrap().values, vec![1.0]);
844
845 let prev = reg.replace_model("rp", constant_factory(99.0)).unwrap();
847 assert!(prev.is_some());
848
849 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 assert!(reg.try_create_model_default("nope").is_none());
860
861 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 #[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 #[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 #[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 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(®);
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 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(®);
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 assert_eq!(successes, 1);
1103 assert_eq!(failures, 3);
1104 assert!(reg.has_model("race_target"));
1105 }
1106
1107 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[test]
1269 fn factory_does_not_hold_shard_lock() {
1270 use std::thread;
1271
1272 let reg = Arc::new(PluginRegistry::new());
1274 let factory: ModelFactory = Arc::new(|_| {
1275 std::thread::yield_now();
1277 Ok(Box::new(ConstantModel::new(1.0)))
1278 });
1279 reg.register_model("slow", factory).unwrap();
1280
1281 let reg2 = Arc::clone(®);
1283 let create_handle = thread::spawn(move || {
1284 reg2.create_model_default("slow").unwrap();
1285 });
1286
1287 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 #[test]
1299 fn allowlist_empty_permits_all_plugins() {
1300 let reg = PluginRegistry::new();
1301 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 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#[derive(Debug, Clone, thiserror::Error)]
1358#[non_exhaustive]
1359pub enum PluginError {
1360 #[error("plugin already registered: {0}")]
1362 AlreadyRegistered(String),
1363
1364 #[error("plugin not found: {0}")]
1366 NotFound(String),
1367
1368 #[error("plugin factory failed: {0}")]
1370 FactoryFailed(String),
1371
1372 #[error("invalid plugin config: {0}")]
1374 InvalidConfig(String),
1375
1376 #[error("invalid plugin name: {0}")]
1378 InvalidName(String),
1379
1380 #[error("untrusted plugin: {0}")]
1382 UntrustedPlugin(String),
1383}