Skip to main content

minco_core/
plugin.rs

1use crate::{
2    ApplicationGraph, CORE_API_VERSION, ConfigurationField, ConfigurationValueKind,
3    ContributionCollection, FrozenContributions, FrozenServices, GraphBuilder, GraphError,
4    PluginDescriptor, PluginId, ServiceCollection, ServiceError,
5};
6use semver::Version;
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    sync::Arc,
11};
12use thiserror::Error;
13
14/// Statically linked Minco extension.
15///
16/// `install` must be deterministic and side-effect free with respect to remote systems.
17/// Connections, migrations, background work, and other runtime effects belong in explicitly
18/// registered services and lifecycle components, not in plugin discovery.
19pub trait Plugin: Send + Sync + 'static {
20    fn descriptor(&self) -> PluginDescriptor;
21
22    /// Adjusts graph metadata from the plugin's validated runtime configuration.
23    ///
24    /// This hook is intentionally limited to the descriptor: it must be deterministic,
25    /// side-effect free, and must not construct clients or connect to infrastructure. It allows
26    /// configuration-dependent capabilities, dependencies, resources, operations, migrations,
27    /// and health checks to participate in graph validation before installation. Plugin identity,
28    /// version, default selection, and configuration schema are immutable.
29    fn configure_descriptor(
30        &self,
31        _descriptor: &mut PluginDescriptor,
32        _configuration: Option<&serde_json::Value>,
33    ) -> Result<(), PluginError> {
34        Ok(())
35    }
36
37    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError>;
38
39    /// Completes composition after every enabled plugin has installed its services and
40    /// contributions.
41    ///
42    /// Finalization is the narrow startup hook for registries that aggregate independent
43    /// contributions, such as health checks. It must remain deterministic and must not perform
44    /// migrations, network calls, or background work.
45    fn finalize(&self, _context: &mut PluginFinalizeContext<'_>) -> Result<(), PluginError> {
46        Ok(())
47    }
48}
49
50#[derive(Debug)]
51pub struct PluginContext<'a> {
52    plugin_id: &'a PluginId,
53    configuration: Option<&'a serde_json::Value>,
54    services: &'a mut ServiceCollection,
55    contributions: &'a mut ContributionCollection,
56}
57
58/// Second-pass plugin context exposed after every plugin has completed installation.
59///
60/// Services remain mutable so an authoritative registry can be populated, while contributions
61/// are read-only to make the two-phase lifecycle deterministic.
62#[derive(Debug)]
63pub struct PluginFinalizeContext<'a> {
64    plugin_id: &'a PluginId,
65    configuration: Option<&'a serde_json::Value>,
66    services: &'a mut ServiceCollection,
67    contributions: &'a ContributionCollection,
68}
69
70impl PluginFinalizeContext<'_> {
71    pub const fn plugin_id(&self) -> &PluginId {
72        self.plugin_id
73    }
74
75    pub const fn services(&mut self) -> &mut ServiceCollection {
76        self.services
77    }
78
79    pub const fn contributions(&self) -> &ContributionCollection {
80        self.contributions
81    }
82
83    pub const fn raw_configuration(&self) -> Option<&serde_json::Value> {
84        self.configuration
85    }
86
87    pub fn configuration<T>(&self) -> Result<T, PluginError>
88    where
89        T: DeserializeOwned + Default,
90    {
91        deserialize_configuration(self.plugin_id, self.configuration)
92    }
93}
94
95impl PluginContext<'_> {
96    pub const fn plugin_id(&self) -> &PluginId {
97        self.plugin_id
98    }
99
100    pub const fn services(&mut self) -> &mut ServiceCollection {
101        self.services
102    }
103
104    pub const fn contributions(&mut self) -> &mut ContributionCollection {
105        self.contributions
106    }
107
108    pub const fn raw_configuration(&self) -> Option<&serde_json::Value> {
109        self.configuration
110    }
111
112    /// Deserializes the selected plugin configuration, or returns `T::default` when no
113    /// configuration was supplied.
114    pub fn configuration<T>(&self) -> Result<T, PluginError>
115    where
116        T: DeserializeOwned + Default,
117    {
118        deserialize_configuration(self.plugin_id, self.configuration)
119    }
120}
121
122fn deserialize_configuration<T>(
123    plugin_id: &PluginId,
124    configuration: Option<&serde_json::Value>,
125) -> Result<T, PluginError>
126where
127    T: DeserializeOwned + Default,
128{
129    configuration.map_or_else(
130        || Ok(T::default()),
131        |value| {
132            serde_json::from_value(value.clone()).map_err(|source| {
133                PluginError::InvalidConfiguration {
134                    plugin: plugin_id.clone(),
135                    source,
136                }
137            })
138        },
139    )
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct PluginSelection {
144    #[serde(default)]
145    pub enabled: BTreeSet<PluginId>,
146    #[serde(default)]
147    pub disabled: BTreeSet<PluginId>,
148    /// Plugin-specific configuration indexed by stable plugin ID.
149    #[serde(default)]
150    pub configuration: BTreeMap<PluginId, serde_json::Value>,
151}
152
153impl PluginSelection {
154    pub fn is_enabled(&self, descriptor: &PluginDescriptor) -> bool {
155        if self.disabled.contains(&descriptor.id) {
156            return false;
157        }
158        self.enabled.contains(&descriptor.id) || descriptor.default_enabled
159    }
160
161    pub fn set_configuration<T>(
162        &mut self,
163        plugin_id: PluginId,
164        configuration: &T,
165    ) -> Result<(), PluginError>
166    where
167        T: Serialize,
168    {
169        let value = serde_json::to_value(configuration).map_err(|source| {
170            PluginError::InvalidConfiguration {
171                plugin: plugin_id.clone(),
172                source,
173            }
174        })?;
175        self.configuration.insert(plugin_id, value);
176        Ok(())
177    }
178}
179
180struct RegisteredPlugin {
181    plugin: Arc<dyn Plugin>,
182    descriptor: PluginDescriptor,
183}
184
185impl std::fmt::Debug for RegisteredPlugin {
186    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        formatter
188            .debug_struct("RegisteredPlugin")
189            .field("descriptor", &self.descriptor)
190            .finish_non_exhaustive()
191    }
192}
193
194#[derive(Clone)]
195struct EffectivePlugin {
196    plugin: Arc<dyn Plugin>,
197    descriptor: PluginDescriptor,
198    configuration: Option<serde_json::Value>,
199}
200
201impl std::fmt::Debug for EffectivePlugin {
202    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        formatter
204            .debug_struct("EffectivePlugin")
205            .field("descriptor", &self.descriptor)
206            .field("configuration_present", &self.configuration.is_some())
207            .finish_non_exhaustive()
208    }
209}
210
211struct ResolvedGraph {
212    enabled: BTreeMap<PluginId, EffectivePlugin>,
213    ordered: Vec<PluginId>,
214    graph: ApplicationGraph,
215}
216
217#[derive(Default)]
218pub struct PluginManager {
219    plugins: BTreeMap<PluginId, RegisteredPlugin>,
220}
221
222impl std::fmt::Debug for PluginManager {
223    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        formatter
225            .debug_struct("PluginManager")
226            .field("plugin_ids", &self.plugins.keys())
227            .finish()
228    }
229}
230
231impl PluginManager {
232    pub fn register<P>(&mut self, plugin: P) -> Result<(), PluginError>
233    where
234        P: Plugin,
235    {
236        self.register_arc(Arc::new(plugin))
237    }
238
239    pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) -> Result<(), PluginError> {
240        let descriptor = plugin.descriptor();
241        validate_configuration_descriptor(&descriptor)?;
242        let core_version =
243            Version::parse(CORE_API_VERSION).map_err(|source| PluginError::InvalidCoreVersion {
244                value: CORE_API_VERSION.to_owned(),
245                source,
246            })?;
247        if !descriptor.core_compatibility.matches(&core_version) {
248            return Err(PluginError::IncompatibleCore {
249                plugin: descriptor.id,
250                requirement: descriptor.core_compatibility.to_string(),
251                actual: core_version,
252            });
253        }
254        if self.plugins.contains_key(&descriptor.id) {
255            return Err(PluginError::DuplicatePlugin(descriptor.id));
256        }
257        self.plugins.insert(
258            descriptor.id.clone(),
259            RegisteredPlugin { plugin, descriptor },
260        );
261        Ok(())
262    }
263
264    pub fn descriptors(&self) -> Vec<PluginDescriptor> {
265        self.plugins
266            .values()
267            .map(|registration| registration.descriptor.clone())
268            .collect()
269    }
270
271    pub fn compose(&self, selection: &PluginSelection) -> Result<ComposedApplication, PluginError> {
272        self.compose_with(
273            selection,
274            ServiceCollection::default(),
275            ContributionCollection::default(),
276        )
277    }
278
279    /// Resolves and validates the configured application graph without installing services.
280    ///
281    /// Deployment planning and other read-only tooling use this method so graph inspection
282    /// cannot construct clients, connect to infrastructure, or trigger plugin lifecycle hooks.
283    pub fn build_graph(
284        &self,
285        selection: &PluginSelection,
286    ) -> Result<ApplicationGraph, PluginError> {
287        Ok(self.resolve_graph(selection)?.graph)
288    }
289
290    /// Composes plugins on top of application-provided services and contributions.
291    ///
292    /// This is the explicit dependency-injection boundary for concrete database pools, AWS
293    /// clients, clocks, and other composition-root concerns. Registrations remain typed and
294    /// duplicate service types are still rejected; Minco never falls back to a global locator.
295    pub fn compose_with(
296        &self,
297        selection: &PluginSelection,
298        mut services: ServiceCollection,
299        mut contributions: ContributionCollection,
300    ) -> Result<ComposedApplication, PluginError> {
301        // Validate the complete configured application graph before constructing services. This
302        // prevents externally backed services from being created for an invalid composition.
303        let ResolvedGraph {
304            enabled,
305            ordered,
306            graph,
307        } = self.resolve_graph(selection)?;
308
309        for id in &ordered {
310            let effective = enabled
311                .get(id)
312                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
313            effective.plugin.install(&mut PluginContext {
314                plugin_id: id,
315                configuration: effective.configuration.as_ref(),
316                services: &mut services,
317                contributions: &mut contributions,
318            })?;
319        }
320
321        for id in &ordered {
322            let effective = enabled
323                .get(id)
324                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
325            effective.plugin.finalize(&mut PluginFinalizeContext {
326                plugin_id: id,
327                configuration: effective.configuration.as_ref(),
328                services: &mut services,
329                contributions: &contributions,
330            })?;
331        }
332
333        Ok(ComposedApplication {
334            graph,
335            services: services.freeze(),
336            contributions: contributions.freeze(),
337        })
338    }
339
340    fn resolve_graph(&self, selection: &PluginSelection) -> Result<ResolvedGraph, PluginError> {
341        self.validate_selection(selection)?;
342        let enabled = self.resolve_enabled(selection)?;
343        let ordered = topological_order(&enabled)?;
344        let mut graph_builder = GraphBuilder::default();
345        for id in &ordered {
346            let effective = enabled
347                .get(id)
348                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
349            graph_builder.add_plugin(effective.descriptor.clone());
350        }
351        let graph = graph_builder.build()?;
352        Ok(ResolvedGraph {
353            enabled,
354            ordered,
355            graph,
356        })
357    }
358
359    fn validate_selection(&self, selection: &PluginSelection) -> Result<(), PluginError> {
360        if let Some(id) = selection.enabled.intersection(&selection.disabled).next() {
361            return Err(PluginError::ContradictorySelection(id.clone()));
362        }
363        for selected in selection
364            .enabled
365            .iter()
366            .chain(&selection.disabled)
367            .chain(selection.configuration.keys())
368        {
369            if !self.plugins.contains_key(selected) {
370                return Err(PluginError::UnknownPlugin(selected.clone()));
371            }
372        }
373        Ok(())
374    }
375
376    fn resolve_enabled(
377        &self,
378        selection: &PluginSelection,
379    ) -> Result<BTreeMap<PluginId, EffectivePlugin>, PluginError> {
380        let mut enabled = BTreeMap::new();
381        for (id, registration) in &self.plugins {
382            if selection.is_enabled(&registration.descriptor) {
383                enabled.insert(id.clone(), self.effective_plugin(id, selection)?);
384            }
385        }
386
387        let mut changed = true;
388        while changed {
389            changed = false;
390            let descriptors = enabled
391                .values()
392                .map(|effective| effective.descriptor.clone())
393                .collect::<Vec<_>>();
394            for descriptor in descriptors {
395                for dependency in descriptor.plugin_dependencies {
396                    if selection.disabled.contains(&dependency) {
397                        return Err(PluginError::DisabledRequiredPlugin {
398                            plugin: descriptor.id,
399                            dependency,
400                        });
401                    }
402                    if !enabled.contains_key(&dependency) {
403                        if !self.plugins.contains_key(&dependency) {
404                            return Err(PluginError::MissingPluginDependency {
405                                plugin: descriptor.id,
406                                dependency,
407                            });
408                        }
409                        enabled.insert(
410                            dependency.clone(),
411                            self.effective_plugin(&dependency, selection)?,
412                        );
413                        changed = true;
414                    }
415                }
416            }
417        }
418        Ok(enabled)
419    }
420
421    fn effective_plugin(
422        &self,
423        id: &PluginId,
424        selection: &PluginSelection,
425    ) -> Result<EffectivePlugin, PluginError> {
426        let registration = self
427            .plugins
428            .get(id)
429            .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
430        let configuration =
431            normalize_configuration(&registration.descriptor, selection.configuration.get(id))?;
432        let mut descriptor = registration.descriptor.clone();
433        registration
434            .plugin
435            .configure_descriptor(&mut descriptor, configuration.as_ref())?;
436        validate_configured_descriptor(&registration.descriptor, &descriptor)?;
437        Ok(EffectivePlugin {
438            plugin: Arc::clone(&registration.plugin),
439            descriptor,
440            configuration,
441        })
442    }
443}
444
445fn validate_configuration_descriptor(descriptor: &PluginDescriptor) -> Result<(), PluginError> {
446    let mut keys = BTreeSet::new();
447    for field in &descriptor.configuration {
448        if field.key.trim().is_empty() {
449            return Err(PluginError::InvalidConfigurationDescriptor {
450                plugin: descriptor.id.clone(),
451                message: "configuration field keys must not be empty".into(),
452            });
453        }
454        if !keys.insert(field.key.clone()) {
455            return Err(PluginError::InvalidConfigurationDescriptor {
456                plugin: descriptor.id.clone(),
457                message: format!("duplicate configuration field: {}", field.key),
458            });
459        }
460        if field.secret && field.default.is_some() {
461            return Err(PluginError::InvalidConfigurationDescriptor {
462                plugin: descriptor.id.clone(),
463                message: "secret configuration fields cannot have defaults".into(),
464            });
465        }
466        if let Some(default) = &field.default {
467            validate_configuration_value(&descriptor.id, field, default).map_err(|error| {
468                PluginError::InvalidConfigurationDescriptor {
469                    plugin: descriptor.id.clone(),
470                    message: error.to_string(),
471                }
472            })?;
473        }
474    }
475    Ok(())
476}
477
478fn validate_configured_descriptor(
479    base: &PluginDescriptor,
480    configured: &PluginDescriptor,
481) -> Result<(), PluginError> {
482    if configured.id != base.id
483        || configured.version != base.version
484        || configured.default_enabled != base.default_enabled
485        || configured.configuration_namespace != base.configuration_namespace
486        || configured.configuration != base.configuration
487    {
488        return Err(PluginError::ConfiguredDescriptorIdentityChanged {
489            plugin: base.id.clone(),
490        });
491    }
492    validate_configuration_descriptor(configured)?;
493
494    let core_version =
495        Version::parse(CORE_API_VERSION).map_err(|source| PluginError::InvalidCoreVersion {
496            value: CORE_API_VERSION.to_owned(),
497            source,
498        })?;
499    if !configured.core_compatibility.matches(&core_version) {
500        return Err(PluginError::IncompatibleCore {
501            plugin: configured.id.clone(),
502            requirement: configured.core_compatibility.to_string(),
503            actual: core_version,
504        });
505    }
506    Ok(())
507}
508
509fn normalize_configuration(
510    descriptor: &PluginDescriptor,
511    raw: Option<&serde_json::Value>,
512) -> Result<Option<serde_json::Value>, PluginError> {
513    // Plugins that do not publish a configuration contract retain backwards-compatible
514    // ownership of their raw configuration object. Official and ecosystem plugins should
515    // publish fields so Minco can validate and apply defaults before installation.
516    if descriptor.configuration.is_empty() {
517        return Ok(raw.cloned());
518    }
519
520    let supplied = match raw {
521        None => serde_json::Map::new(),
522        Some(serde_json::Value::Object(values)) => values.clone(),
523        Some(_) => {
524            return Err(PluginError::ConfigurationMustBeObject {
525                plugin: descriptor.id.clone(),
526            });
527        }
528    };
529
530    let fields = descriptor
531        .configuration
532        .iter()
533        .map(|field| (field.key.as_str(), field))
534        .collect::<BTreeMap<_, _>>();
535
536    for key in supplied.keys() {
537        if !fields.contains_key(key.as_str()) {
538            return Err(PluginError::UnknownConfigurationField {
539                plugin: descriptor.id.clone(),
540                field: key.clone(),
541            });
542        }
543    }
544
545    let mut normalized = serde_json::Map::new();
546    for field in &descriptor.configuration {
547        let value = supplied
548            .get(&field.key)
549            .cloned()
550            .filter(|value| field.required || !value.is_null())
551            .or_else(|| field.default.clone());
552        match value {
553            Some(value) => {
554                validate_configuration_value(&descriptor.id, field, &value)?;
555                normalized.insert(field.key.clone(), value);
556            }
557            None if field.required => {
558                return Err(PluginError::MissingConfigurationField {
559                    plugin: descriptor.id.clone(),
560                    field: field.key.clone(),
561                });
562            }
563            None => {}
564        }
565    }
566
567    Ok(Some(serde_json::Value::Object(normalized)))
568}
569
570fn validate_configuration_value(
571    plugin: &PluginId,
572    field: &ConfigurationField,
573    value: &serde_json::Value,
574) -> Result<(), PluginError> {
575    let matches = match field.kind {
576        ConfigurationValueKind::String => value.is_string(),
577        ConfigurationValueKind::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
578        ConfigurationValueKind::Number => value.is_number(),
579        ConfigurationValueKind::Boolean => value.is_boolean(),
580        ConfigurationValueKind::StringList => value
581            .as_array()
582            .is_some_and(|values| values.iter().all(serde_json::Value::is_string)),
583        ConfigurationValueKind::Object => value.is_object(),
584    };
585    if matches {
586        Ok(())
587    } else {
588        Err(PluginError::ConfigurationTypeMismatch {
589            plugin: plugin.clone(),
590            field: field.key.clone(),
591            expected: field.kind,
592        })
593    }
594}
595
596fn topological_order(
597    plugins: &BTreeMap<PluginId, EffectivePlugin>,
598) -> Result<Vec<PluginId>, PluginError> {
599    let mut visiting = BTreeSet::new();
600    let mut visited = BTreeSet::new();
601    let mut ordered = Vec::new();
602    for id in plugins.keys() {
603        visit(id, plugins, &mut visiting, &mut visited, &mut ordered)?;
604    }
605    Ok(ordered)
606}
607
608fn visit(
609    id: &PluginId,
610    plugins: &BTreeMap<PluginId, EffectivePlugin>,
611    visiting: &mut BTreeSet<PluginId>,
612    visited: &mut BTreeSet<PluginId>,
613    ordered: &mut Vec<PluginId>,
614) -> Result<(), PluginError> {
615    if visited.contains(id) {
616        return Ok(());
617    }
618    if !visiting.insert(id.clone()) {
619        return Err(PluginError::DependencyCycle(id.clone()));
620    }
621    let registration = plugins
622        .get(id)
623        .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
624    for dependency in &registration.descriptor.plugin_dependencies {
625        visit(dependency, plugins, visiting, visited, ordered)?;
626    }
627    visiting.remove(id);
628    visited.insert(id.clone());
629    ordered.push(id.clone());
630    Ok(())
631}
632
633#[derive(Debug)]
634pub struct ComposedApplication {
635    pub graph: ApplicationGraph,
636    pub services: FrozenServices,
637    pub contributions: FrozenContributions,
638}
639
640#[derive(Debug, Error)]
641pub enum PluginError {
642    #[error("duplicate plugin registration: {0}")]
643    DuplicatePlugin(PluginId),
644    #[error("unknown plugin: {0}")]
645    UnknownPlugin(PluginId),
646    #[error("plugin is both explicitly enabled and disabled: {0}")]
647    ContradictorySelection(PluginId),
648    #[error("plugin {plugin} depends on unregistered plugin {dependency}")]
649    MissingPluginDependency {
650        plugin: PluginId,
651        dependency: PluginId,
652    },
653    #[error("plugin {plugin} requires disabled plugin {dependency}")]
654    DisabledRequiredPlugin {
655        plugin: PluginId,
656        dependency: PluginId,
657    },
658    #[error("plugin dependency cycle includes {0}")]
659    DependencyCycle(PluginId),
660    #[error(
661        "plugin {plugin} requires Minco core {requirement}, but this application uses {actual}"
662    )]
663    IncompatibleCore {
664        plugin: PluginId,
665        requirement: String,
666        actual: Version,
667    },
668    #[error("Minco core reported invalid version {value}: {source}")]
669    InvalidCoreVersion {
670        value: String,
671        source: semver::Error,
672    },
673    #[error("invalid configuration for plugin {plugin}: {source}")]
674    InvalidConfiguration {
675        plugin: PluginId,
676        source: serde_json::Error,
677    },
678    #[error("plugin {plugin} configuration must be a JSON object")]
679    ConfigurationMustBeObject { plugin: PluginId },
680    #[error("unknown configuration field for plugin {plugin}: {field}")]
681    UnknownConfigurationField { plugin: PluginId, field: String },
682    #[error("missing required configuration field for plugin {plugin}: {field}")]
683    MissingConfigurationField { plugin: PluginId, field: String },
684    #[error("configuration field {field} for plugin {plugin} must be {expected:?}")]
685    ConfigurationTypeMismatch {
686        plugin: PluginId,
687        field: String,
688        expected: ConfigurationValueKind,
689    },
690    #[error("invalid configuration descriptor for plugin {plugin}: {message}")]
691    InvalidConfigurationDescriptor { plugin: PluginId, message: String },
692    #[error(
693        "configured descriptor for plugin {plugin} changed immutable identity, version, selection, or configuration-schema fields"
694    )]
695    ConfiguredDescriptorIdentityChanged { plugin: PluginId },
696    #[error(transparent)]
697    Service(#[from] ServiceError),
698    #[error(transparent)]
699    Graph(#[from] GraphError),
700    #[error("plugin installation failed: {0}")]
701    Installation(String),
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use semver::{Version, VersionReq};
708
709    #[derive(Debug)]
710    struct TestPlugin {
711        descriptor: PluginDescriptor,
712        value: Option<u64>,
713        contribution: Option<String>,
714    }
715
716    impl Plugin for TestPlugin {
717        fn descriptor(&self) -> PluginDescriptor {
718            self.descriptor.clone()
719        }
720
721        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
722            if let Some(value) = self.value {
723                context.services().insert(Arc::new(value))?;
724            }
725            if let Some(value) = &self.contribution {
726                context.contributions().push(Arc::new(value.clone()));
727            }
728            Ok(())
729        }
730    }
731
732    fn plugin(id: &str, default_enabled: bool, value: Option<u64>) -> TestPlugin {
733        let mut descriptor =
734            PluginDescriptor::new(PluginId::new(id).unwrap(), Version::new(1, 0, 0), id);
735        descriptor.default_enabled = default_enabled;
736        TestPlugin {
737            descriptor,
738            value,
739            contribution: None,
740        }
741    }
742
743    #[test]
744    fn default_plugins_can_be_disabled() {
745        let mut manager = PluginManager::default();
746        manager.register(plugin("default", true, Some(42))).unwrap();
747        let mut selection = PluginSelection::default();
748        selection.disabled.insert(PluginId::new("default").unwrap());
749        let composed = manager.compose(&selection).unwrap();
750        assert!(composed.graph.plugins.is_empty());
751    }
752
753    #[test]
754    fn duplicate_registration_does_not_replace_the_original_plugin() {
755        let mut manager = PluginManager::default();
756        manager.register(plugin("service", true, Some(1))).unwrap();
757        assert!(matches!(
758            manager.register(plugin("service", true, Some(2))),
759            Err(PluginError::DuplicatePlugin(_))
760        ));
761        let composed = manager.compose(&PluginSelection::default()).unwrap();
762        assert_eq!(*composed.services.get::<u64>().unwrap(), 1);
763    }
764
765    #[test]
766    fn contributions_are_multi_bound_in_installation_order() {
767        let mut manager = PluginManager::default();
768        let mut first = plugin("first", true, None);
769        first.contribution = Some("one".into());
770        let mut second = plugin("second", true, None);
771        second.contribution = Some("two".into());
772        manager.register(first).unwrap();
773        manager.register(second).unwrap();
774        let composed = manager.compose(&PluginSelection::default()).unwrap();
775        let values = composed
776            .contributions
777            .get::<String>()
778            .into_iter()
779            .map(|value| (*value).clone())
780            .collect::<Vec<_>>();
781        assert_eq!(values, ["one", "two"]);
782    }
783
784    #[test]
785    fn unknown_runtime_selection_fails_closed() {
786        let manager = PluginManager::default();
787        let mut selection = PluginSelection::default();
788        selection.enabled.insert(PluginId::new("missing").unwrap());
789        assert!(matches!(
790            manager.compose(&selection),
791            Err(PluginError::UnknownPlugin(_))
792        ));
793    }
794
795    #[test]
796    fn contradictory_runtime_selection_fails_closed() {
797        let mut manager = PluginManager::default();
798        manager.register(plugin("example", false, None)).unwrap();
799        let id = PluginId::new("example").unwrap();
800        let mut selection = PluginSelection::default();
801        selection.enabled.insert(id.clone());
802        selection.disabled.insert(id);
803        assert!(matches!(
804            manager.compose(&selection),
805            Err(PluginError::ContradictorySelection(_))
806        ));
807    }
808
809    #[test]
810    fn explicit_plugin_install_exposes_typed_service() {
811        let mut manager = PluginManager::default();
812        manager
813            .register(plugin("service", false, Some(42)))
814            .unwrap();
815        let mut selection = PluginSelection::default();
816        selection.enabled.insert(PluginId::new("service").unwrap());
817        let composed = manager.compose(&selection).unwrap();
818        assert_eq!(*composed.services.get::<u64>().unwrap(), 42);
819    }
820
821    #[test]
822    fn application_services_and_contributions_can_be_injected_before_plugins_install() {
823        #[derive(Debug)]
824        struct DependsOnApplicationState;
825
826        impl Plugin for DependsOnApplicationState {
827            fn descriptor(&self) -> PluginDescriptor {
828                let mut descriptor = PluginDescriptor::new(
829                    PluginId::new("depends-on-app").unwrap(),
830                    Version::new(1, 0, 0),
831                    "depends on composition-root state",
832                );
833                descriptor.default_enabled = true;
834                descriptor
835            }
836
837            fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
838                let value = context.services().get::<String>()?;
839                context
840                    .contributions()
841                    .push(Arc::new(format!("plugin:{value}")));
842                Ok(())
843            }
844        }
845
846        let mut manager = PluginManager::default();
847        manager.register(DependsOnApplicationState).unwrap();
848        let mut services = ServiceCollection::default();
849        services.insert(Arc::new("application".to_owned())).unwrap();
850        let mut contributions = ContributionCollection::default();
851        contributions.push(Arc::new("application:base".to_owned()));
852
853        let composed = manager
854            .compose_with(&PluginSelection::default(), services, contributions)
855            .unwrap();
856        assert_eq!(
857            composed
858                .contributions
859                .get::<String>()
860                .into_iter()
861                .map(|value| (*value).clone())
862                .collect::<Vec<_>>(),
863            ["application:base", "plugin:application"]
864        );
865    }
866
867    #[test]
868    fn incompatible_core_requirement_is_rejected_during_registration() {
869        let mut incompatible = plugin("future", false, None);
870        incompatible.descriptor.core_compatibility = VersionReq::parse(">=99").unwrap();
871        assert!(matches!(
872            PluginManager::default().register(incompatible),
873            Err(PluginError::IncompatibleCore { .. })
874        ));
875    }
876
877    #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
878    struct ExampleConfiguration {
879        message: String,
880    }
881
882    #[derive(Debug)]
883    struct ConfiguredPlugin;
884
885    impl Plugin for ConfiguredPlugin {
886        fn descriptor(&self) -> PluginDescriptor {
887            let mut descriptor = PluginDescriptor::new(
888                PluginId::new("configured").unwrap(),
889                Version::new(1, 0, 0),
890                "configured",
891            );
892            descriptor.default_enabled = true;
893            descriptor
894        }
895
896        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
897            let configuration = context.configuration::<ExampleConfiguration>()?;
898            context.services().insert(Arc::new(configuration))?;
899            Ok(())
900        }
901    }
902
903    #[test]
904    fn typed_plugin_configuration_is_available_during_installation() {
905        let mut manager = PluginManager::default();
906        manager.register(ConfiguredPlugin).unwrap();
907        let id = PluginId::new("configured").unwrap();
908        let mut selection = PluginSelection::default();
909        selection
910            .set_configuration(
911                id,
912                &ExampleConfiguration {
913                    message: "hello".into(),
914                },
915            )
916            .unwrap();
917        let composed = manager.compose(&selection).unwrap();
918        assert_eq!(
919            composed
920                .services
921                .get::<ExampleConfiguration>()
922                .unwrap()
923                .message,
924            "hello"
925        );
926    }
927
928    #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
929    struct SchemaConfiguration {
930        name: String,
931        enabled: bool,
932        alias: Option<String>,
933    }
934
935    #[derive(Debug)]
936    struct SchemaPlugin;
937
938    impl Plugin for SchemaPlugin {
939        fn descriptor(&self) -> PluginDescriptor {
940            let mut descriptor = PluginDescriptor::new(
941                PluginId::new("schema").unwrap(),
942                Version::new(1, 0, 0),
943                "schema",
944            );
945            descriptor.default_enabled = true;
946            descriptor.configuration.extend([
947                ConfigurationField {
948                    key: "name".into(),
949                    kind: ConfigurationValueKind::String,
950                    required: true,
951                    secret: false,
952                    description: "name".into(),
953                    default: None,
954                },
955                ConfigurationField {
956                    key: "enabled".into(),
957                    kind: ConfigurationValueKind::Boolean,
958                    required: false,
959                    secret: false,
960                    description: "enabled".into(),
961                    default: Some(serde_json::json!(true)),
962                },
963                ConfigurationField {
964                    key: "alias".into(),
965                    kind: ConfigurationValueKind::String,
966                    required: false,
967                    secret: false,
968                    description: "optional alias".into(),
969                    default: None,
970                },
971            ]);
972            descriptor
973        }
974
975        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
976            let configuration = context.configuration::<SchemaConfiguration>()?;
977            context.services().insert(Arc::new(configuration))?;
978            Ok(())
979        }
980    }
981
982    #[derive(Debug)]
983    struct ConditionalDependencyPlugin;
984
985    impl Plugin for ConditionalDependencyPlugin {
986        fn descriptor(&self) -> PluginDescriptor {
987            let mut descriptor = PluginDescriptor::new(
988                PluginId::new("conditional").unwrap(),
989                Version::new(1, 0, 0),
990                "configuration-dependent dependency",
991            );
992            descriptor.default_enabled = true;
993            descriptor.configuration.push(ConfigurationField {
994                key: "use-provider".into(),
995                kind: ConfigurationValueKind::Boolean,
996                required: false,
997                secret: false,
998                description: "enable the provider dependency".into(),
999                default: Some(serde_json::json!(false)),
1000            });
1001            descriptor
1002        }
1003
1004        fn configure_descriptor(
1005            &self,
1006            descriptor: &mut PluginDescriptor,
1007            configuration: Option<&serde_json::Value>,
1008        ) -> Result<(), PluginError> {
1009            if configuration
1010                .and_then(|value| value.get("use-provider"))
1011                .and_then(serde_json::Value::as_bool)
1012                .unwrap_or(false)
1013            {
1014                descriptor
1015                    .plugin_dependencies
1016                    .push(PluginId::new("provider").unwrap());
1017                descriptor.requires.push(crate::CapabilityRequirement {
1018                    name: "conditional.provider".into(),
1019                    version: VersionReq::parse("^1").unwrap(),
1020                });
1021            }
1022            Ok(())
1023        }
1024
1025        fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1026            Ok(())
1027        }
1028    }
1029
1030    #[test]
1031    fn configured_dependencies_participate_in_resolution_and_graph_validation() {
1032        let mut provider = plugin("provider", false, None);
1033        provider
1034            .descriptor
1035            .provides
1036            .push(crate::CapabilityProvision {
1037                name: "conditional.provider".into(),
1038                version: Version::new(1, 0, 0),
1039            });
1040        let mut manager = PluginManager::default();
1041        manager.register(provider).unwrap();
1042        manager.register(ConditionalDependencyPlugin).unwrap();
1043
1044        let mut selection = PluginSelection::default();
1045        selection.configuration.insert(
1046            PluginId::new("conditional").unwrap(),
1047            serde_json::json!({"use-provider": true}),
1048        );
1049        let application = manager.compose(&selection).unwrap();
1050        let ids = application
1051            .graph
1052            .plugins
1053            .iter()
1054            .map(|plugin| plugin.id.as_str())
1055            .collect::<Vec<_>>();
1056        assert_eq!(ids, ["provider", "conditional"]);
1057    }
1058
1059    #[test]
1060    fn published_configuration_schema_applies_defaults() {
1061        let mut manager = PluginManager::default();
1062        manager.register(SchemaPlugin).unwrap();
1063        let mut selection = PluginSelection::default();
1064        selection.configuration.insert(
1065            PluginId::new("schema").unwrap(),
1066            serde_json::json!({ "name": "feedback" }),
1067        );
1068
1069        let composed = manager.compose(&selection).unwrap();
1070        assert_eq!(
1071            *composed.services.get::<SchemaConfiguration>().unwrap(),
1072            SchemaConfiguration {
1073                name: "feedback".into(),
1074                enabled: true,
1075                alias: None,
1076            }
1077        );
1078    }
1079
1080    #[test]
1081    fn typed_optional_none_is_normalized_as_an_absent_field() {
1082        let mut manager = PluginManager::default();
1083        manager.register(SchemaPlugin).unwrap();
1084        let id = PluginId::new("schema").unwrap();
1085        let mut selection = PluginSelection::default();
1086        selection
1087            .set_configuration(
1088                id,
1089                &SchemaConfiguration {
1090                    name: "feedback".into(),
1091                    enabled: true,
1092                    alias: None,
1093                },
1094            )
1095            .unwrap();
1096
1097        let composed = manager.compose(&selection).unwrap();
1098        assert_eq!(
1099            *composed.services.get::<SchemaConfiguration>().unwrap(),
1100            SchemaConfiguration {
1101                name: "feedback".into(),
1102                enabled: true,
1103                alias: None,
1104            }
1105        );
1106    }
1107
1108    #[test]
1109    fn published_configuration_schema_rejects_unknown_missing_and_mistyped_fields() {
1110        let mut manager = PluginManager::default();
1111        manager.register(SchemaPlugin).unwrap();
1112        let id = PluginId::new("schema").unwrap();
1113
1114        for (configuration, expected) in [
1115            (
1116                serde_json::json!({ "name": "feedback", "unknown": true }),
1117                "unknown configuration field",
1118            ),
1119            (
1120                serde_json::json!({}),
1121                "missing required configuration field",
1122            ),
1123            (
1124                serde_json::json!({ "name": "feedback", "enabled": "yes" }),
1125                "must be Boolean",
1126            ),
1127        ] {
1128            let mut selection = PluginSelection::default();
1129            selection.configuration.insert(id.clone(), configuration);
1130            let error = manager.compose(&selection).unwrap_err().to_string();
1131            assert!(error.contains(expected), "{error}");
1132        }
1133    }
1134
1135    #[test]
1136    fn graph_planning_never_installs_plugin_services() {
1137        #[derive(Debug)]
1138        struct PlanningOnly;
1139
1140        impl Plugin for PlanningOnly {
1141            fn descriptor(&self) -> PluginDescriptor {
1142                let mut descriptor = PluginDescriptor::new(
1143                    PluginId::new("planning-only").unwrap(),
1144                    Version::new(1, 0, 0),
1145                    "planning only",
1146                );
1147                descriptor.default_enabled = true;
1148                descriptor
1149            }
1150
1151            fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1152                panic!("planning must not install services");
1153            }
1154        }
1155
1156        let mut manager = PluginManager::default();
1157        manager.register(PlanningOnly).unwrap();
1158
1159        let graph = manager.build_graph(&PluginSelection::default()).unwrap();
1160
1161        assert_eq!(graph.plugins[0].id.as_str(), "planning-only");
1162    }
1163
1164    #[test]
1165    fn secret_configuration_fields_cannot_publish_default_values() {
1166        #[derive(Debug)]
1167        struct UnsafeSecretDefault;
1168
1169        impl Plugin for UnsafeSecretDefault {
1170            fn descriptor(&self) -> PluginDescriptor {
1171                let mut descriptor = PluginDescriptor::new(
1172                    PluginId::new("unsafe-secret").unwrap(),
1173                    Version::new(1, 0, 0),
1174                    "unsafe secret",
1175                );
1176                descriptor.configuration.push(ConfigurationField {
1177                    key: "api_token".into(),
1178                    kind: ConfigurationValueKind::String,
1179                    required: true,
1180                    secret: true,
1181                    description: "provider API token".into(),
1182                    default: Some(serde_json::json!("must-not-leak")),
1183                });
1184                descriptor
1185            }
1186
1187            fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1188                Ok(())
1189            }
1190        }
1191
1192        let error = PluginManager::default()
1193            .register(UnsafeSecretDefault)
1194            .unwrap_err()
1195            .to_string();
1196
1197        assert!(error.contains("secret configuration fields cannot have defaults"));
1198    }
1199}