Skip to main content

minco_core/
plugin.rs

1use crate::{
2    ApplicationGraph, CORE_API_VERSION, ConfigurationField, ConfigurationValueKind,
3    ContributionCollection, ContributionRegistrar, FrozenContributions, FrozenServices,
4    GraphBuilder, GraphError, PluginDescriptor, PluginId, RegistrationOwner,
5    RegistrationProvenance, ServiceCollection, ServiceError, ServiceRegistrar,
6};
7use semver::Version;
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    sync::Arc,
12};
13use thiserror::Error;
14
15/// Statically linked Minco extension.
16///
17/// `install` must be deterministic and side-effect free with respect to remote systems.
18/// Connections, migrations, background work, and other runtime effects belong in explicitly
19/// registered services and lifecycle components, not in plugin discovery.
20pub trait Plugin: Send + Sync + 'static {
21    fn descriptor(&self) -> PluginDescriptor;
22
23    /// Adjusts graph metadata from the plugin's validated runtime configuration.
24    ///
25    /// This hook is intentionally limited to the descriptor: it must be deterministic,
26    /// side-effect free, and must not construct clients or connect to infrastructure. It allows
27    /// configuration-dependent capabilities, dependencies, resources, operations, migrations,
28    /// and health checks to participate in graph validation before installation. Plugin identity,
29    /// version, default selection, and configuration schema are immutable.
30    fn configure_descriptor(
31        &self,
32        _descriptor: &mut PluginDescriptor,
33        _configuration: Option<&serde_json::Value>,
34    ) -> Result<(), PluginError> {
35        Ok(())
36    }
37
38    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError>;
39
40    /// Completes composition after every enabled plugin has installed its services and
41    /// contributions.
42    ///
43    /// Finalization is the narrow startup hook for registries that aggregate independent
44    /// contributions, such as health checks. It must remain deterministic and must not perform
45    /// migrations, network calls, or background work.
46    fn finalize(&self, _context: &mut PluginFinalizeContext<'_>) -> Result<(), PluginError> {
47        Ok(())
48    }
49}
50
51#[derive(Debug)]
52pub struct PluginContext<'a> {
53    plugin_id: &'a PluginId,
54    configuration: Option<&'a serde_json::Value>,
55    services: &'a mut ServiceCollection,
56    contributions: &'a mut ContributionCollection,
57}
58
59/// Second-pass plugin context exposed after every plugin has completed installation.
60///
61/// Services remain mutable so an authoritative registry can be populated, while contributions
62/// are read-only to make the two-phase lifecycle deterministic.
63#[derive(Debug)]
64pub struct PluginFinalizeContext<'a> {
65    plugin_id: &'a PluginId,
66    configuration: Option<&'a serde_json::Value>,
67    services: &'a mut ServiceCollection,
68    contributions: &'a ContributionCollection,
69}
70
71impl PluginFinalizeContext<'_> {
72    pub const fn plugin_id(&self) -> &PluginId {
73        self.plugin_id
74    }
75
76    pub fn services(&mut self) -> ServiceRegistrar<'_> {
77        ServiceRegistrar {
78            services: self.services,
79            owner: RegistrationOwner::plugin(self.plugin_id.clone()),
80        }
81    }
82
83    pub const fn contributions(&self) -> &ContributionCollection {
84        self.contributions
85    }
86
87    pub const fn raw_configuration(&self) -> Option<&serde_json::Value> {
88        self.configuration
89    }
90
91    pub fn configuration<T>(&self) -> Result<T, PluginError>
92    where
93        T: DeserializeOwned + Default,
94    {
95        deserialize_configuration(self.plugin_id, self.configuration)
96    }
97}
98
99impl PluginContext<'_> {
100    pub const fn plugin_id(&self) -> &PluginId {
101        self.plugin_id
102    }
103
104    pub fn services(&mut self) -> ServiceRegistrar<'_> {
105        ServiceRegistrar {
106            services: self.services,
107            owner: RegistrationOwner::plugin(self.plugin_id.clone()),
108        }
109    }
110
111    pub fn contributions(&mut self) -> ContributionRegistrar<'_> {
112        ContributionRegistrar {
113            contributions: self.contributions,
114            owner: RegistrationOwner::plugin(self.plugin_id.clone()),
115        }
116    }
117
118    pub const fn raw_configuration(&self) -> Option<&serde_json::Value> {
119        self.configuration
120    }
121
122    /// Deserializes the selected plugin configuration, or returns `T::default` when no
123    /// configuration was supplied.
124    pub fn configuration<T>(&self) -> Result<T, PluginError>
125    where
126        T: DeserializeOwned + Default,
127    {
128        deserialize_configuration(self.plugin_id, self.configuration)
129    }
130}
131
132fn deserialize_configuration<T>(
133    plugin_id: &PluginId,
134    configuration: Option<&serde_json::Value>,
135) -> Result<T, PluginError>
136where
137    T: DeserializeOwned + Default,
138{
139    configuration.map_or_else(
140        || Ok(T::default()),
141        |value| {
142            serde_json::from_value(value.clone()).map_err(|source| {
143                PluginError::InvalidConfiguration {
144                    plugin: plugin_id.clone(),
145                    source,
146                }
147            })
148        },
149    )
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct PluginSelection {
154    #[serde(default)]
155    pub enabled: BTreeSet<PluginId>,
156    #[serde(default)]
157    pub disabled: BTreeSet<PluginId>,
158    /// Plugin-specific configuration indexed by stable plugin ID.
159    #[serde(default)]
160    pub configuration: BTreeMap<PluginId, serde_json::Value>,
161}
162
163impl PluginSelection {
164    pub fn is_enabled(&self, descriptor: &PluginDescriptor) -> bool {
165        if self.disabled.contains(&descriptor.id) {
166            return false;
167        }
168        self.enabled.contains(&descriptor.id) || descriptor.default_enabled
169    }
170
171    pub fn set_configuration<T>(
172        &mut self,
173        plugin_id: PluginId,
174        configuration: &T,
175    ) -> Result<(), PluginError>
176    where
177        T: Serialize,
178    {
179        let value = serde_json::to_value(configuration).map_err(|source| {
180            PluginError::InvalidConfiguration {
181                plugin: plugin_id.clone(),
182                source,
183            }
184        })?;
185        self.configuration.insert(plugin_id, value);
186        Ok(())
187    }
188}
189
190struct RegisteredPlugin {
191    plugin: Arc<dyn Plugin>,
192    descriptor: PluginDescriptor,
193}
194
195impl std::fmt::Debug for RegisteredPlugin {
196    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        formatter
198            .debug_struct("RegisteredPlugin")
199            .field("descriptor", &self.descriptor)
200            .finish_non_exhaustive()
201    }
202}
203
204#[derive(Clone)]
205struct EffectivePlugin {
206    plugin: Arc<dyn Plugin>,
207    descriptor: PluginDescriptor,
208    configuration: Option<serde_json::Value>,
209}
210
211impl std::fmt::Debug for EffectivePlugin {
212    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        formatter
214            .debug_struct("EffectivePlugin")
215            .field("descriptor", &self.descriptor)
216            .field("configuration_present", &self.configuration.is_some())
217            .finish_non_exhaustive()
218    }
219}
220
221struct ResolvedGraph {
222    enabled: BTreeMap<PluginId, EffectivePlugin>,
223    ordered: Vec<PluginId>,
224    graph: ApplicationGraph,
225}
226
227#[derive(Default)]
228pub struct PluginManager {
229    plugins: BTreeMap<PluginId, RegisteredPlugin>,
230}
231
232impl std::fmt::Debug for PluginManager {
233    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        formatter
235            .debug_struct("PluginManager")
236            .field("plugin_ids", &self.plugins.keys())
237            .finish()
238    }
239}
240
241impl PluginManager {
242    pub fn register<P>(&mut self, plugin: P) -> Result<(), PluginError>
243    where
244        P: Plugin,
245    {
246        self.register_arc(Arc::new(plugin))
247    }
248
249    pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) -> Result<(), PluginError> {
250        let descriptor = plugin.descriptor();
251        validate_configuration_descriptor(&descriptor)?;
252        let core_version =
253            Version::parse(CORE_API_VERSION).map_err(|source| PluginError::InvalidCoreVersion {
254                value: CORE_API_VERSION.to_owned(),
255                source,
256            })?;
257        if !descriptor.core_compatibility.matches(&core_version) {
258            return Err(PluginError::IncompatibleCore {
259                plugin: descriptor.id,
260                requirement: descriptor.core_compatibility.to_string(),
261                actual: core_version,
262            });
263        }
264        if self.plugins.contains_key(&descriptor.id) {
265            return Err(PluginError::DuplicatePlugin(descriptor.id));
266        }
267        self.plugins.insert(
268            descriptor.id.clone(),
269            RegisteredPlugin { plugin, descriptor },
270        );
271        Ok(())
272    }
273
274    pub fn descriptors(&self) -> Vec<PluginDescriptor> {
275        self.plugins
276            .values()
277            .map(|registration| registration.descriptor.clone())
278            .collect()
279    }
280
281    /// Resolve selection and return only descriptors that participate in the
282    /// effective graph, including statically declared plugin dependencies.
283    pub fn enabled_descriptors(
284        &self,
285        selection: &PluginSelection,
286    ) -> Result<Vec<PluginDescriptor>, PluginError> {
287        Ok(self
288            .resolve_enabled(selection)?
289            .into_values()
290            .map(|effective| effective.descriptor)
291            .collect())
292    }
293
294    pub fn compose(&self, selection: &PluginSelection) -> Result<ComposedApplication, PluginError> {
295        self.compose_with(
296            selection,
297            ServiceCollection::default(),
298            ContributionCollection::default(),
299        )
300    }
301
302    /// Resolves and validates the configured application graph without installing services.
303    ///
304    /// Deployment planning and other read-only tooling use this method so graph inspection
305    /// cannot construct clients, connect to infrastructure, or trigger plugin lifecycle hooks.
306    pub fn build_graph(
307        &self,
308        selection: &PluginSelection,
309    ) -> Result<ApplicationGraph, PluginError> {
310        Ok(self.resolve_graph(selection)?.graph)
311    }
312
313    /// Composes plugins on top of application-provided services and contributions.
314    ///
315    /// This is the explicit dependency-injection boundary for concrete database pools, AWS
316    /// clients, clocks, and other composition-root concerns. Registrations remain typed and
317    /// duplicate service types are still rejected; Minco never falls back to a global locator.
318    pub fn compose_with(
319        &self,
320        selection: &PluginSelection,
321        mut services: ServiceCollection,
322        mut contributions: ContributionCollection,
323    ) -> Result<ComposedApplication, PluginError> {
324        // Validate the complete configured application graph before constructing services. This
325        // prevents externally backed services from being created for an invalid composition.
326        let ResolvedGraph {
327            enabled,
328            ordered,
329            graph,
330        } = self.resolve_graph(selection)?;
331
332        for id in &ordered {
333            let effective = enabled
334                .get(id)
335                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
336            effective.plugin.install(&mut PluginContext {
337                plugin_id: id,
338                configuration: effective.configuration.as_ref(),
339                services: &mut services,
340                contributions: &mut contributions,
341            })?;
342        }
343
344        for id in &ordered {
345            let effective = enabled
346                .get(id)
347                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
348            effective.plugin.finalize(&mut PluginFinalizeContext {
349                plugin_id: id,
350                configuration: effective.configuration.as_ref(),
351                services: &mut services,
352                contributions: &contributions,
353            })?;
354        }
355
356        Ok(ComposedApplication {
357            graph,
358            services: services.freeze(),
359            contributions: contributions.freeze(),
360        })
361    }
362
363    fn resolve_graph(&self, selection: &PluginSelection) -> Result<ResolvedGraph, PluginError> {
364        self.validate_selection(selection)?;
365        let enabled = self.resolve_enabled(selection)?;
366        let ordered = topological_order(&enabled)?;
367        let mut graph_builder = GraphBuilder::default();
368        for id in &ordered {
369            let effective = enabled
370                .get(id)
371                .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
372            graph_builder.add_plugin(effective.descriptor.clone());
373        }
374        let graph = graph_builder.build()?;
375        Ok(ResolvedGraph {
376            enabled,
377            ordered,
378            graph,
379        })
380    }
381
382    fn validate_selection(&self, selection: &PluginSelection) -> Result<(), PluginError> {
383        if let Some(id) = selection.enabled.intersection(&selection.disabled).next() {
384            return Err(PluginError::ContradictorySelection(id.clone()));
385        }
386        for selected in selection
387            .enabled
388            .iter()
389            .chain(&selection.disabled)
390            .chain(selection.configuration.keys())
391        {
392            if !self.plugins.contains_key(selected) {
393                return Err(PluginError::UnknownPlugin(selected.clone()));
394            }
395        }
396        Ok(())
397    }
398
399    fn resolve_enabled(
400        &self,
401        selection: &PluginSelection,
402    ) -> Result<BTreeMap<PluginId, EffectivePlugin>, PluginError> {
403        let mut enabled = BTreeMap::new();
404        for (id, registration) in &self.plugins {
405            if selection.is_enabled(&registration.descriptor) {
406                enabled.insert(id.clone(), self.effective_plugin(id, selection)?);
407            }
408        }
409
410        let mut changed = true;
411        while changed {
412            changed = false;
413            let descriptors = enabled
414                .values()
415                .map(|effective| effective.descriptor.clone())
416                .collect::<Vec<_>>();
417            for descriptor in descriptors {
418                for dependency in descriptor.plugin_dependencies {
419                    if selection.disabled.contains(&dependency) {
420                        return Err(PluginError::DisabledRequiredPlugin {
421                            plugin: descriptor.id,
422                            dependency,
423                        });
424                    }
425                    if !enabled.contains_key(&dependency) {
426                        if !self.plugins.contains_key(&dependency) {
427                            return Err(PluginError::MissingPluginDependency {
428                                plugin: descriptor.id,
429                                dependency,
430                            });
431                        }
432                        enabled.insert(
433                            dependency.clone(),
434                            self.effective_plugin(&dependency, selection)?,
435                        );
436                        changed = true;
437                    }
438                }
439            }
440        }
441        Ok(enabled)
442    }
443
444    fn effective_plugin(
445        &self,
446        id: &PluginId,
447        selection: &PluginSelection,
448    ) -> Result<EffectivePlugin, PluginError> {
449        let registration = self
450            .plugins
451            .get(id)
452            .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
453        let configuration =
454            normalize_configuration(&registration.descriptor, selection.configuration.get(id))?;
455        let mut descriptor = registration.descriptor.clone();
456        registration
457            .plugin
458            .configure_descriptor(&mut descriptor, configuration.as_ref())?;
459        validate_configured_descriptor(&registration.descriptor, &descriptor)?;
460        Ok(EffectivePlugin {
461            plugin: Arc::clone(&registration.plugin),
462            descriptor,
463            configuration,
464        })
465    }
466}
467
468fn validate_configuration_descriptor(descriptor: &PluginDescriptor) -> Result<(), PluginError> {
469    let mut keys = BTreeSet::new();
470    for field in &descriptor.configuration {
471        if field.key.trim().is_empty() {
472            return Err(PluginError::InvalidConfigurationDescriptor {
473                plugin: descriptor.id.clone(),
474                message: "configuration field keys must not be empty".into(),
475            });
476        }
477        if !keys.insert(field.key.clone()) {
478            return Err(PluginError::InvalidConfigurationDescriptor {
479                plugin: descriptor.id.clone(),
480                message: format!("duplicate configuration field: {}", field.key),
481            });
482        }
483        if field.secret && field.default.is_some() {
484            return Err(PluginError::InvalidConfigurationDescriptor {
485                plugin: descriptor.id.clone(),
486                message: "secret configuration fields cannot have defaults".into(),
487            });
488        }
489        if let Some(default) = &field.default {
490            validate_configuration_value(&descriptor.id, field, default).map_err(|error| {
491                PluginError::InvalidConfigurationDescriptor {
492                    plugin: descriptor.id.clone(),
493                    message: error.to_string(),
494                }
495            })?;
496        }
497    }
498    Ok(())
499}
500
501fn validate_configured_descriptor(
502    base: &PluginDescriptor,
503    configured: &PluginDescriptor,
504) -> Result<(), PluginError> {
505    if configured.id != base.id
506        || configured.version != base.version
507        || configured.default_enabled != base.default_enabled
508        || configured.configuration_namespace != base.configuration_namespace
509        || configured.configuration != base.configuration
510    {
511        return Err(PluginError::ConfiguredDescriptorIdentityChanged {
512            plugin: base.id.clone(),
513        });
514    }
515    validate_configuration_descriptor(configured)?;
516
517    let core_version =
518        Version::parse(CORE_API_VERSION).map_err(|source| PluginError::InvalidCoreVersion {
519            value: CORE_API_VERSION.to_owned(),
520            source,
521        })?;
522    if !configured.core_compatibility.matches(&core_version) {
523        return Err(PluginError::IncompatibleCore {
524            plugin: configured.id.clone(),
525            requirement: configured.core_compatibility.to_string(),
526            actual: core_version,
527        });
528    }
529    Ok(())
530}
531
532fn normalize_configuration(
533    descriptor: &PluginDescriptor,
534    raw: Option<&serde_json::Value>,
535) -> Result<Option<serde_json::Value>, PluginError> {
536    // Plugins that do not publish a configuration contract retain backwards-compatible
537    // ownership of their raw configuration object. Official and ecosystem plugins should
538    // publish fields so Minco can validate and apply defaults before installation.
539    if descriptor.configuration.is_empty() {
540        return Ok(raw.cloned());
541    }
542
543    let supplied = match raw {
544        None => serde_json::Map::new(),
545        Some(serde_json::Value::Object(values)) => values.clone(),
546        Some(_) => {
547            return Err(PluginError::ConfigurationMustBeObject {
548                plugin: descriptor.id.clone(),
549            });
550        }
551    };
552
553    let fields = descriptor
554        .configuration
555        .iter()
556        .map(|field| (field.key.as_str(), field))
557        .collect::<BTreeMap<_, _>>();
558
559    for key in supplied.keys() {
560        if !fields.contains_key(key.as_str()) {
561            return Err(PluginError::UnknownConfigurationField {
562                plugin: descriptor.id.clone(),
563                field: key.clone(),
564            });
565        }
566    }
567
568    let mut normalized = serde_json::Map::new();
569    for field in &descriptor.configuration {
570        let value = supplied
571            .get(&field.key)
572            .cloned()
573            .filter(|value| field.required || !value.is_null())
574            .or_else(|| field.default.clone());
575        match value {
576            Some(value) => {
577                validate_configuration_value(&descriptor.id, field, &value)?;
578                normalized.insert(field.key.clone(), value);
579            }
580            None if field.required => {
581                return Err(PluginError::MissingConfigurationField {
582                    plugin: descriptor.id.clone(),
583                    field: field.key.clone(),
584                });
585            }
586            None => {}
587        }
588    }
589
590    Ok(Some(serde_json::Value::Object(normalized)))
591}
592
593fn validate_configuration_value(
594    plugin: &PluginId,
595    field: &ConfigurationField,
596    value: &serde_json::Value,
597) -> Result<(), PluginError> {
598    let matches = match field.kind {
599        ConfigurationValueKind::String => value.is_string(),
600        ConfigurationValueKind::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
601        ConfigurationValueKind::Number => value.is_number(),
602        ConfigurationValueKind::Boolean => value.is_boolean(),
603        ConfigurationValueKind::StringList => value
604            .as_array()
605            .is_some_and(|values| values.iter().all(serde_json::Value::is_string)),
606        ConfigurationValueKind::Object => value.is_object(),
607    };
608    if matches {
609        Ok(())
610    } else {
611        Err(PluginError::ConfigurationTypeMismatch {
612            plugin: plugin.clone(),
613            field: field.key.clone(),
614            expected: field.kind,
615        })
616    }
617}
618
619fn topological_order(
620    plugins: &BTreeMap<PluginId, EffectivePlugin>,
621) -> Result<Vec<PluginId>, PluginError> {
622    let mut visiting = BTreeSet::new();
623    let mut visited = BTreeSet::new();
624    let mut ordered = Vec::new();
625    for id in plugins.keys() {
626        visit(id, plugins, &mut visiting, &mut visited, &mut ordered)?;
627    }
628    Ok(ordered)
629}
630
631fn visit(
632    id: &PluginId,
633    plugins: &BTreeMap<PluginId, EffectivePlugin>,
634    visiting: &mut BTreeSet<PluginId>,
635    visited: &mut BTreeSet<PluginId>,
636    ordered: &mut Vec<PluginId>,
637) -> Result<(), PluginError> {
638    if visited.contains(id) {
639        return Ok(());
640    }
641    if !visiting.insert(id.clone()) {
642        return Err(PluginError::DependencyCycle(id.clone()));
643    }
644    let registration = plugins
645        .get(id)
646        .ok_or_else(|| PluginError::UnknownPlugin(id.clone()))?;
647    for dependency in &registration.descriptor.plugin_dependencies {
648        visit(dependency, plugins, visiting, visited, ordered)?;
649    }
650    visiting.remove(id);
651    visited.insert(id.clone());
652    ordered.push(id.clone());
653    Ok(())
654}
655
656#[derive(Debug)]
657pub struct ComposedApplication {
658    pub graph: ApplicationGraph,
659    pub services: FrozenServices,
660    pub contributions: FrozenContributions,
661}
662
663impl ComposedApplication {
664    /// Returns deterministic composition metadata without serializing registered values.
665    pub fn registration_provenance(&self) -> RegistrationProvenance {
666        RegistrationProvenance {
667            services: self.services.registrations().to_vec(),
668            contributions: self.contributions.registrations().to_vec(),
669        }
670    }
671}
672
673#[derive(Debug, Error)]
674pub enum PluginError {
675    #[error("duplicate plugin registration: {0}")]
676    DuplicatePlugin(PluginId),
677    #[error("unknown plugin: {0}")]
678    UnknownPlugin(PluginId),
679    #[error("plugin is both explicitly enabled and disabled: {0}")]
680    ContradictorySelection(PluginId),
681    #[error("plugin {plugin} depends on unregistered plugin {dependency}")]
682    MissingPluginDependency {
683        plugin: PluginId,
684        dependency: PluginId,
685    },
686    #[error("plugin {plugin} requires disabled plugin {dependency}")]
687    DisabledRequiredPlugin {
688        plugin: PluginId,
689        dependency: PluginId,
690    },
691    #[error("plugin dependency cycle includes {0}")]
692    DependencyCycle(PluginId),
693    #[error(
694        "plugin {plugin} requires Minco core {requirement}, but this application uses {actual}"
695    )]
696    IncompatibleCore {
697        plugin: PluginId,
698        requirement: String,
699        actual: Version,
700    },
701    #[error("Minco core reported invalid version {value}: {source}")]
702    InvalidCoreVersion {
703        value: String,
704        source: semver::Error,
705    },
706    #[error("invalid configuration for plugin {plugin}: {source}")]
707    InvalidConfiguration {
708        plugin: PluginId,
709        source: serde_json::Error,
710    },
711    #[error("plugin {plugin} configuration must be a JSON object")]
712    ConfigurationMustBeObject { plugin: PluginId },
713    #[error("unknown configuration field for plugin {plugin}: {field}")]
714    UnknownConfigurationField { plugin: PluginId, field: String },
715    #[error("missing required configuration field for plugin {plugin}: {field}")]
716    MissingConfigurationField { plugin: PluginId, field: String },
717    #[error("configuration field {field} for plugin {plugin} must be {expected:?}")]
718    ConfigurationTypeMismatch {
719        plugin: PluginId,
720        field: String,
721        expected: ConfigurationValueKind,
722    },
723    #[error("invalid configuration descriptor for plugin {plugin}: {message}")]
724    InvalidConfigurationDescriptor { plugin: PluginId, message: String },
725    #[error(
726        "configured descriptor for plugin {plugin} changed immutable identity, version, selection, or configuration-schema fields"
727    )]
728    ConfiguredDescriptorIdentityChanged { plugin: PluginId },
729    #[error(transparent)]
730    Service(#[from] ServiceError),
731    #[error(transparent)]
732    Graph(#[from] GraphError),
733    #[error("plugin installation failed: {0}")]
734    Installation(String),
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use semver::{Version, VersionReq};
741    use std::sync::atomic::{AtomicUsize, Ordering};
742
743    #[derive(Debug)]
744    struct TestPlugin {
745        descriptor: PluginDescriptor,
746        value: Option<u64>,
747        contribution: Option<String>,
748    }
749
750    impl Plugin for TestPlugin {
751        fn descriptor(&self) -> PluginDescriptor {
752            self.descriptor.clone()
753        }
754
755        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
756            if let Some(value) = self.value {
757                context.services().insert(Arc::new(value))?;
758            }
759            if let Some(value) = &self.contribution {
760                context.contributions().push(Arc::new(value.clone()));
761            }
762            Ok(())
763        }
764    }
765
766    fn plugin(id: &str, default_enabled: bool, value: Option<u64>) -> TestPlugin {
767        let mut descriptor =
768            PluginDescriptor::new(PluginId::new(id).unwrap(), Version::new(1, 0, 0), id);
769        descriptor.default_enabled = default_enabled;
770        TestPlugin {
771            descriptor,
772            value,
773            contribution: None,
774        }
775    }
776
777    fn owner_ids(provenance: &RegistrationProvenance) -> Vec<String> {
778        provenance
779            .services
780            .iter()
781            .map(|registration| registration.owner.to_string())
782            .collect()
783    }
784
785    #[test]
786    fn default_plugins_can_be_disabled() {
787        let mut manager = PluginManager::default();
788        manager.register(plugin("default", true, Some(42))).unwrap();
789        let mut selection = PluginSelection::default();
790        selection.disabled.insert(PluginId::new("default").unwrap());
791        let composed = manager.compose(&selection).unwrap();
792        assert!(composed.graph.plugins.is_empty());
793    }
794
795    #[test]
796    fn duplicate_registration_does_not_replace_the_original_plugin() {
797        let mut manager = PluginManager::default();
798        manager.register(plugin("service", true, Some(1))).unwrap();
799        assert!(matches!(
800            manager.register(plugin("service", true, Some(2))),
801            Err(PluginError::DuplicatePlugin(_))
802        ));
803        let composed = manager.compose(&PluginSelection::default()).unwrap();
804        assert_eq!(*composed.services.get::<u64>().unwrap(), 1);
805    }
806
807    #[test]
808    fn contributions_are_multi_bound_in_installation_order() {
809        let mut manager = PluginManager::default();
810        let mut first = plugin("first", true, None);
811        first.contribution = Some("one".into());
812        let mut second = plugin("second", true, None);
813        second.contribution = Some("two".into());
814        manager.register(first).unwrap();
815        manager.register(second).unwrap();
816        let composed = manager.compose(&PluginSelection::default()).unwrap();
817        let values = composed
818            .contributions
819            .get::<String>()
820            .into_iter()
821            .map(|value| (*value).clone())
822            .collect::<Vec<_>>();
823        assert_eq!(values, ["one", "two"]);
824        let provenance = composed.registration_provenance();
825        assert_eq!(provenance.contributions.len(), 1);
826        assert_eq!(
827            provenance.contributions[0].rust_type,
828            std::any::type_name::<String>()
829        );
830        assert_eq!(
831            provenance.contributions[0]
832                .registrations
833                .iter()
834                .map(|registration| (
835                    registration.owner.to_string(),
836                    registration.installation_index
837                ))
838                .collect::<Vec<_>>(),
839            [("plugin:first".into(), 0), ("plugin:second".into(), 1)]
840        );
841    }
842
843    #[test]
844    fn application_seeded_service_duplicate_names_both_owners_and_type() {
845        let mut manager = PluginManager::default();
846        manager
847            .register(plugin("plugin-owner", true, Some(2)))
848            .unwrap();
849        let mut services = ServiceCollection::default();
850        services.insert(Arc::new(1_u64)).unwrap();
851
852        let error = manager
853            .compose_with(
854                &PluginSelection::default(),
855                services,
856                ContributionCollection::default(),
857            )
858            .unwrap_err();
859
860        let PluginError::Service(ServiceError::Duplicate(duplicate)) = error else {
861            panic!("expected duplicate service error");
862        };
863        assert_eq!(duplicate.rust_type, std::any::type_name::<u64>());
864        assert_eq!(duplicate.first_owner.to_string(), "application");
865        assert_eq!(duplicate.attempted_owner.to_string(), "plugin:plugin-owner");
866        assert_eq!(
867            duplicate.to_string(),
868            "u64 (first owner: application, attempted owner: plugin:plugin-owner)"
869        );
870    }
871
872    #[test]
873    fn plugin_duplicate_names_first_and_attempted_plugin_owners() {
874        let mut manager = PluginManager::default();
875        manager.register(plugin("first", true, Some(1))).unwrap();
876        manager.register(plugin("second", true, Some(2))).unwrap();
877
878        let error = manager.compose(&PluginSelection::default()).unwrap_err();
879
880        let PluginError::Service(ServiceError::Duplicate(duplicate)) = error else {
881            panic!("expected duplicate service error");
882        };
883        assert_eq!(duplicate.rust_type, std::any::type_name::<u64>());
884        assert_eq!(duplicate.first_owner.to_string(), "plugin:first");
885        assert_eq!(duplicate.attempted_owner.to_string(), "plugin:second");
886    }
887
888    trait ProvenanceTrait: Send + Sync {
889        fn value(&self) -> u64;
890    }
891
892    #[derive(Debug)]
893    struct ProvenanceTraitValue(u64);
894
895    impl ProvenanceTrait for ProvenanceTraitValue {
896        fn value(&self) -> u64 {
897            self.0
898        }
899    }
900
901    #[derive(Debug)]
902    struct TraitServicePlugin {
903        id: &'static str,
904        value: u64,
905    }
906
907    impl Plugin for TraitServicePlugin {
908        fn descriptor(&self) -> PluginDescriptor {
909            let mut descriptor = PluginDescriptor::new(
910                PluginId::new(self.id).unwrap(),
911                Version::new(1, 0, 0),
912                self.id,
913            );
914            descriptor.default_enabled = true;
915            descriptor
916        }
917
918        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
919            context
920                .services()
921                .insert_shared::<dyn ProvenanceTrait>(Arc::new(ProvenanceTraitValue(self.value)))?;
922            Ok(())
923        }
924    }
925
926    #[test]
927    fn trait_object_singleton_duplicates_preserve_typed_shared_ownership() {
928        let mut manager = PluginManager::default();
929        manager
930            .register(TraitServicePlugin {
931                id: "first-trait",
932                value: 1,
933            })
934            .unwrap();
935        manager
936            .register(TraitServicePlugin {
937                id: "second-trait",
938                value: 2,
939            })
940            .unwrap();
941
942        let error = manager.compose(&PluginSelection::default()).unwrap_err();
943
944        let PluginError::Service(ServiceError::Duplicate(duplicate)) = error else {
945            panic!("expected duplicate service error");
946        };
947        assert_eq!(
948            duplicate.rust_type,
949            std::any::type_name::<crate::Shared<dyn ProvenanceTrait>>()
950        );
951        assert_eq!(duplicate.first_owner.to_string(), "plugin:first-trait");
952        assert_eq!(duplicate.attempted_owner.to_string(), "plugin:second-trait");
953    }
954
955    #[derive(Debug)]
956    struct TraitContributionPlugin {
957        id: &'static str,
958        value: u64,
959    }
960
961    impl Plugin for TraitContributionPlugin {
962        fn descriptor(&self) -> PluginDescriptor {
963            let mut descriptor = PluginDescriptor::new(
964                PluginId::new(self.id).unwrap(),
965                Version::new(1, 0, 0),
966                self.id,
967            );
968            descriptor.default_enabled = true;
969            descriptor
970        }
971
972        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
973            context
974                .contributions()
975                .push_shared::<dyn ProvenanceTrait>(Arc::new(ProvenanceTraitValue(self.value)));
976            Ok(())
977        }
978    }
979
980    #[test]
981    fn trait_object_contribution_summaries_preserve_owner_and_global_installation_index() {
982        let mut manager = PluginManager::default();
983        manager
984            .register(TraitContributionPlugin {
985                id: "first-trait",
986                value: 1,
987            })
988            .unwrap();
989        manager
990            .register(TraitContributionPlugin {
991                id: "second-trait",
992                value: 2,
993            })
994            .unwrap();
995        let mut contributions = ContributionCollection::default();
996        contributions.push(Arc::new(String::from("application")));
997
998        let composed = manager
999            .compose_with(
1000                &PluginSelection::default(),
1001                ServiceCollection::default(),
1002                contributions,
1003            )
1004            .unwrap();
1005        let values = composed
1006            .contributions
1007            .get_shared::<dyn ProvenanceTrait>()
1008            .into_iter()
1009            .map(|value| value.value())
1010            .collect::<Vec<_>>();
1011        assert_eq!(values, [1, 2]);
1012
1013        let provenance = composed.registration_provenance();
1014        let trait_metadata = provenance
1015            .contributions
1016            .iter()
1017            .find(|registration| {
1018                registration.rust_type
1019                    == std::any::type_name::<crate::Shared<dyn ProvenanceTrait>>()
1020            })
1021            .unwrap();
1022        assert_eq!(
1023            trait_metadata
1024                .registrations
1025                .iter()
1026                .map(|registration| (
1027                    registration.owner.to_string(),
1028                    registration.installation_index
1029                ))
1030                .collect::<Vec<_>>(),
1031            [
1032                ("plugin:first-trait".into(), 1),
1033                ("plugin:second-trait".into(), 2)
1034            ]
1035        );
1036    }
1037
1038    #[test]
1039    fn registration_provenance_is_deterministic_across_repeated_composition() {
1040        let mut manager = PluginManager::default();
1041        let mut first = plugin("first", true, Some(1));
1042        first.contribution = Some("one".into());
1043        manager.register(first).unwrap();
1044
1045        let first = manager.compose(&PluginSelection::default()).unwrap();
1046        let second = manager.compose(&PluginSelection::default()).unwrap();
1047        assert_eq!(
1048            serde_json::to_string(&first.registration_provenance()).unwrap(),
1049            serde_json::to_string(&second.registration_provenance()).unwrap()
1050        );
1051    }
1052
1053    #[test]
1054    fn graph_planning_has_no_registration_provenance_before_composition() {
1055        #[derive(Debug)]
1056        struct CountedInstall(Arc<AtomicUsize>);
1057
1058        impl Plugin for CountedInstall {
1059            fn descriptor(&self) -> PluginDescriptor {
1060                let mut descriptor = PluginDescriptor::new(
1061                    PluginId::new("counted").unwrap(),
1062                    Version::new(1, 0, 0),
1063                    "counted",
1064                );
1065                descriptor.default_enabled = true;
1066                descriptor
1067            }
1068
1069            fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1070                self.0.fetch_add(1, Ordering::SeqCst);
1071                context.services().insert(Arc::new(7_u16))?;
1072                Ok(())
1073            }
1074        }
1075
1076        let installs = Arc::new(AtomicUsize::new(0));
1077        let mut manager = PluginManager::default();
1078        manager
1079            .register(CountedInstall(Arc::clone(&installs)))
1080            .unwrap();
1081        let graph = manager.build_graph(&PluginSelection::default()).unwrap();
1082        assert_eq!(graph.plugins.len(), 1);
1083        assert_eq!(installs.load(Ordering::SeqCst), 0);
1084
1085        let composed = manager.compose(&PluginSelection::default()).unwrap();
1086        assert_eq!(installs.load(Ordering::SeqCst), 1);
1087        assert_eq!(
1088            owner_ids(&composed.registration_provenance()),
1089            ["plugin:counted"]
1090        );
1091    }
1092
1093    #[test]
1094    fn provenance_json_never_serializes_service_values_or_debug_output() {
1095        struct SensitiveValue(&'static str);
1096
1097        impl std::fmt::Debug for SensitiveValue {
1098            fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1099                formatter.write_str(self.0)
1100            }
1101        }
1102
1103        #[derive(Debug)]
1104        struct SensitivePlugin;
1105
1106        impl Plugin for SensitivePlugin {
1107            fn descriptor(&self) -> PluginDescriptor {
1108                let mut descriptor = PluginDescriptor::new(
1109                    PluginId::new("sensitive").unwrap(),
1110                    Version::new(1, 0, 0),
1111                    "sensitive",
1112                );
1113                descriptor.default_enabled = true;
1114                descriptor
1115            }
1116
1117            fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1118                context
1119                    .services()
1120                    .insert(Arc::new(SensitiveValue("DO_NOT_SERIALIZE")))?;
1121                Ok(())
1122            }
1123        }
1124
1125        let mut manager = PluginManager::default();
1126        manager.register(SensitivePlugin).unwrap();
1127        let composed = manager.compose(&PluginSelection::default()).unwrap();
1128        let json = serde_json::to_string_pretty(&composed.registration_provenance()).unwrap();
1129
1130        assert!(json.contains("SensitiveValue"));
1131        assert!(json.contains("\"plugin_id\": \"sensitive\""));
1132        assert!(!json.contains("DO_NOT_SERIALIZE"));
1133    }
1134
1135    #[test]
1136    fn plugin_context_cannot_forge_registration_owner_from_service_content() {
1137        #[derive(Debug)]
1138        struct ClaimedOwner(&'static str);
1139
1140        #[derive(Debug)]
1141        struct ClaimingPlugin;
1142
1143        impl Plugin for ClaimingPlugin {
1144            fn descriptor(&self) -> PluginDescriptor {
1145                let mut descriptor = PluginDescriptor::new(
1146                    PluginId::new("actual-owner").unwrap(),
1147                    Version::new(1, 0, 0),
1148                    "actual owner",
1149                );
1150                descriptor.default_enabled = true;
1151                descriptor
1152            }
1153
1154            fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1155                context
1156                    .services()
1157                    .insert(Arc::new(ClaimedOwner("forged-owner")))?;
1158                Ok(())
1159            }
1160        }
1161
1162        let mut manager = PluginManager::default();
1163        manager.register(ClaimingPlugin).unwrap();
1164        let composed = manager.compose(&PluginSelection::default()).unwrap();
1165        let provenance = composed.registration_provenance();
1166        let json = serde_json::to_string(&provenance).unwrap();
1167
1168        assert_eq!(owner_ids(&provenance), ["plugin:actual-owner"]);
1169        assert!(!json.contains("forged-owner"));
1170        assert_eq!(
1171            composed.services.get::<ClaimedOwner>().unwrap().0,
1172            "forged-owner"
1173        );
1174    }
1175
1176    #[test]
1177    fn disabled_plugins_produce_no_registration_provenance() {
1178        let mut manager = PluginManager::default();
1179        let mut disabled = plugin("disabled", true, Some(1));
1180        disabled.contribution = Some("hidden".into());
1181        manager.register(disabled).unwrap();
1182        let mut selection = PluginSelection::default();
1183        selection
1184            .disabled
1185            .insert(PluginId::new("disabled").unwrap());
1186
1187        let composed = manager.compose(&selection).unwrap();
1188
1189        assert!(composed.registration_provenance().services.is_empty());
1190        assert!(composed.registration_provenance().contributions.is_empty());
1191    }
1192
1193    #[test]
1194    fn dependency_auto_enabled_plugin_owns_its_registrations() {
1195        let mut provider = plugin("provider", false, Some(1));
1196        provider.contribution = Some("provider".into());
1197        let mut consumer = plugin("consumer", true, None);
1198        consumer
1199            .descriptor
1200            .plugin_dependencies
1201            .push(PluginId::new("provider").unwrap());
1202        let mut manager = PluginManager::default();
1203        manager.register(provider).unwrap();
1204        manager.register(consumer).unwrap();
1205
1206        let composed = manager.compose(&PluginSelection::default()).unwrap();
1207        let provenance = composed.registration_provenance();
1208
1209        assert_eq!(owner_ids(&provenance), ["plugin:provider"]);
1210        assert_eq!(
1211            provenance.contributions[0].registrations[0]
1212                .owner
1213                .to_string(),
1214            "plugin:provider"
1215        );
1216    }
1217
1218    #[test]
1219    fn failed_composition_does_not_retain_a_partially_frozen_application() {
1220        #[derive(Debug)]
1221        struct ApplicationProbe;
1222
1223        let probe = Arc::new(ApplicationProbe);
1224        let mut services = ServiceCollection::default();
1225        services.insert(Arc::clone(&probe)).unwrap();
1226        let mut manager = PluginManager::default();
1227        manager.register(plugin("first", true, Some(1))).unwrap();
1228        manager.register(plugin("second", true, Some(2))).unwrap();
1229
1230        let result = manager.compose_with(
1231            &PluginSelection::default(),
1232            services,
1233            ContributionCollection::default(),
1234        );
1235
1236        assert!(result.is_err());
1237        assert_eq!(Arc::strong_count(&probe), 1);
1238    }
1239
1240    #[test]
1241    fn unknown_runtime_selection_fails_closed() {
1242        let manager = PluginManager::default();
1243        let mut selection = PluginSelection::default();
1244        selection.enabled.insert(PluginId::new("missing").unwrap());
1245        assert!(matches!(
1246            manager.compose(&selection),
1247            Err(PluginError::UnknownPlugin(_))
1248        ));
1249    }
1250
1251    #[test]
1252    fn contradictory_runtime_selection_fails_closed() {
1253        let mut manager = PluginManager::default();
1254        manager.register(plugin("example", false, None)).unwrap();
1255        let id = PluginId::new("example").unwrap();
1256        let mut selection = PluginSelection::default();
1257        selection.enabled.insert(id.clone());
1258        selection.disabled.insert(id);
1259        assert!(matches!(
1260            manager.compose(&selection),
1261            Err(PluginError::ContradictorySelection(_))
1262        ));
1263    }
1264
1265    #[test]
1266    fn explicit_plugin_install_exposes_typed_service() {
1267        let mut manager = PluginManager::default();
1268        manager
1269            .register(plugin("service", false, Some(42)))
1270            .unwrap();
1271        let mut selection = PluginSelection::default();
1272        selection.enabled.insert(PluginId::new("service").unwrap());
1273        let composed = manager.compose(&selection).unwrap();
1274        assert_eq!(*composed.services.get::<u64>().unwrap(), 42);
1275    }
1276
1277    #[test]
1278    fn application_services_and_contributions_can_be_injected_before_plugins_install() {
1279        #[derive(Debug)]
1280        struct DependsOnApplicationState;
1281
1282        impl Plugin for DependsOnApplicationState {
1283            fn descriptor(&self) -> PluginDescriptor {
1284                let mut descriptor = PluginDescriptor::new(
1285                    PluginId::new("depends-on-app").unwrap(),
1286                    Version::new(1, 0, 0),
1287                    "depends on composition-root state",
1288                );
1289                descriptor.default_enabled = true;
1290                descriptor
1291            }
1292
1293            fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1294                let value = context.services().get::<String>()?;
1295                context
1296                    .contributions()
1297                    .push(Arc::new(format!("plugin:{value}")));
1298                Ok(())
1299            }
1300        }
1301
1302        let mut manager = PluginManager::default();
1303        manager.register(DependsOnApplicationState).unwrap();
1304        let mut services = ServiceCollection::default();
1305        services.insert(Arc::new("application".to_owned())).unwrap();
1306        let mut contributions = ContributionCollection::default();
1307        contributions.push(Arc::new("application:base".to_owned()));
1308
1309        let composed = manager
1310            .compose_with(&PluginSelection::default(), services, contributions)
1311            .unwrap();
1312        assert_eq!(
1313            composed
1314                .contributions
1315                .get::<String>()
1316                .into_iter()
1317                .map(|value| (*value).clone())
1318                .collect::<Vec<_>>(),
1319            ["application:base", "plugin:application"]
1320        );
1321    }
1322
1323    #[test]
1324    fn incompatible_core_requirement_is_rejected_during_registration() {
1325        let mut incompatible = plugin("future", false, None);
1326        incompatible.descriptor.core_compatibility = VersionReq::parse(">=99").unwrap();
1327        assert!(matches!(
1328            PluginManager::default().register(incompatible),
1329            Err(PluginError::IncompatibleCore { .. })
1330        ));
1331    }
1332
1333    #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
1334    struct ExampleConfiguration {
1335        message: String,
1336    }
1337
1338    #[derive(Debug)]
1339    struct ConfiguredPlugin;
1340
1341    impl Plugin for ConfiguredPlugin {
1342        fn descriptor(&self) -> PluginDescriptor {
1343            let mut descriptor = PluginDescriptor::new(
1344                PluginId::new("configured").unwrap(),
1345                Version::new(1, 0, 0),
1346                "configured",
1347            );
1348            descriptor.default_enabled = true;
1349            descriptor
1350        }
1351
1352        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1353            let configuration = context.configuration::<ExampleConfiguration>()?;
1354            context.services().insert(Arc::new(configuration))?;
1355            Ok(())
1356        }
1357    }
1358
1359    #[test]
1360    fn typed_plugin_configuration_is_available_during_installation() {
1361        let mut manager = PluginManager::default();
1362        manager.register(ConfiguredPlugin).unwrap();
1363        let id = PluginId::new("configured").unwrap();
1364        let mut selection = PluginSelection::default();
1365        selection
1366            .set_configuration(
1367                id,
1368                &ExampleConfiguration {
1369                    message: "hello".into(),
1370                },
1371            )
1372            .unwrap();
1373        let composed = manager.compose(&selection).unwrap();
1374        assert_eq!(
1375            composed
1376                .services
1377                .get::<ExampleConfiguration>()
1378                .unwrap()
1379                .message,
1380            "hello"
1381        );
1382    }
1383
1384    #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
1385    struct SchemaConfiguration {
1386        name: String,
1387        enabled: bool,
1388        alias: Option<String>,
1389    }
1390
1391    #[derive(Debug)]
1392    struct SchemaPlugin;
1393
1394    impl Plugin for SchemaPlugin {
1395        fn descriptor(&self) -> PluginDescriptor {
1396            let mut descriptor = PluginDescriptor::new(
1397                PluginId::new("schema").unwrap(),
1398                Version::new(1, 0, 0),
1399                "schema",
1400            );
1401            descriptor.default_enabled = true;
1402            descriptor.configuration.extend([
1403                ConfigurationField {
1404                    key: "name".into(),
1405                    kind: ConfigurationValueKind::String,
1406                    required: true,
1407                    secret: false,
1408                    description: "name".into(),
1409                    default: None,
1410                },
1411                ConfigurationField {
1412                    key: "enabled".into(),
1413                    kind: ConfigurationValueKind::Boolean,
1414                    required: false,
1415                    secret: false,
1416                    description: "enabled".into(),
1417                    default: Some(serde_json::json!(true)),
1418                },
1419                ConfigurationField {
1420                    key: "alias".into(),
1421                    kind: ConfigurationValueKind::String,
1422                    required: false,
1423                    secret: false,
1424                    description: "optional alias".into(),
1425                    default: None,
1426                },
1427            ]);
1428            descriptor
1429        }
1430
1431        fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1432            let configuration = context.configuration::<SchemaConfiguration>()?;
1433            context.services().insert(Arc::new(configuration))?;
1434            Ok(())
1435        }
1436    }
1437
1438    #[derive(Debug)]
1439    struct ConditionalDependencyPlugin;
1440
1441    impl Plugin for ConditionalDependencyPlugin {
1442        fn descriptor(&self) -> PluginDescriptor {
1443            let mut descriptor = PluginDescriptor::new(
1444                PluginId::new("conditional").unwrap(),
1445                Version::new(1, 0, 0),
1446                "configuration-dependent dependency",
1447            );
1448            descriptor.default_enabled = true;
1449            descriptor.configuration.push(ConfigurationField {
1450                key: "use-provider".into(),
1451                kind: ConfigurationValueKind::Boolean,
1452                required: false,
1453                secret: false,
1454                description: "enable the provider dependency".into(),
1455                default: Some(serde_json::json!(false)),
1456            });
1457            descriptor
1458        }
1459
1460        fn configure_descriptor(
1461            &self,
1462            descriptor: &mut PluginDescriptor,
1463            configuration: Option<&serde_json::Value>,
1464        ) -> Result<(), PluginError> {
1465            if configuration
1466                .and_then(|value| value.get("use-provider"))
1467                .and_then(serde_json::Value::as_bool)
1468                .unwrap_or(false)
1469            {
1470                descriptor
1471                    .plugin_dependencies
1472                    .push(PluginId::new("provider").unwrap());
1473                descriptor.requires.push(crate::CapabilityRequirement {
1474                    name: "conditional.provider".into(),
1475                    version: VersionReq::parse("^1").unwrap(),
1476                });
1477            }
1478            Ok(())
1479        }
1480
1481        fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1482            Ok(())
1483        }
1484    }
1485
1486    #[test]
1487    fn configured_dependencies_participate_in_resolution_and_graph_validation() {
1488        let mut provider = plugin("provider", false, None);
1489        provider
1490            .descriptor
1491            .provides
1492            .push(crate::CapabilityProvision {
1493                name: "conditional.provider".into(),
1494                version: Version::new(1, 0, 0),
1495            });
1496        let mut manager = PluginManager::default();
1497        manager.register(provider).unwrap();
1498        manager.register(ConditionalDependencyPlugin).unwrap();
1499
1500        let mut selection = PluginSelection::default();
1501        selection.configuration.insert(
1502            PluginId::new("conditional").unwrap(),
1503            serde_json::json!({"use-provider": true}),
1504        );
1505        let application = manager.compose(&selection).unwrap();
1506        let ids = application
1507            .graph
1508            .plugins
1509            .iter()
1510            .map(|plugin| plugin.id.as_str())
1511            .collect::<Vec<_>>();
1512        assert_eq!(ids, ["provider", "conditional"]);
1513    }
1514
1515    #[test]
1516    fn published_configuration_schema_applies_defaults() {
1517        let mut manager = PluginManager::default();
1518        manager.register(SchemaPlugin).unwrap();
1519        let mut selection = PluginSelection::default();
1520        selection.configuration.insert(
1521            PluginId::new("schema").unwrap(),
1522            serde_json::json!({ "name": "feedback" }),
1523        );
1524
1525        let composed = manager.compose(&selection).unwrap();
1526        assert_eq!(
1527            *composed.services.get::<SchemaConfiguration>().unwrap(),
1528            SchemaConfiguration {
1529                name: "feedback".into(),
1530                enabled: true,
1531                alias: None,
1532            }
1533        );
1534    }
1535
1536    #[test]
1537    fn typed_optional_none_is_normalized_as_an_absent_field() {
1538        let mut manager = PluginManager::default();
1539        manager.register(SchemaPlugin).unwrap();
1540        let id = PluginId::new("schema").unwrap();
1541        let mut selection = PluginSelection::default();
1542        selection
1543            .set_configuration(
1544                id,
1545                &SchemaConfiguration {
1546                    name: "feedback".into(),
1547                    enabled: true,
1548                    alias: None,
1549                },
1550            )
1551            .unwrap();
1552
1553        let composed = manager.compose(&selection).unwrap();
1554        assert_eq!(
1555            *composed.services.get::<SchemaConfiguration>().unwrap(),
1556            SchemaConfiguration {
1557                name: "feedback".into(),
1558                enabled: true,
1559                alias: None,
1560            }
1561        );
1562    }
1563
1564    #[test]
1565    fn published_configuration_schema_rejects_unknown_missing_and_mistyped_fields() {
1566        let mut manager = PluginManager::default();
1567        manager.register(SchemaPlugin).unwrap();
1568        let id = PluginId::new("schema").unwrap();
1569
1570        for (configuration, expected) in [
1571            (
1572                serde_json::json!({ "name": "feedback", "unknown": true }),
1573                "unknown configuration field",
1574            ),
1575            (
1576                serde_json::json!({}),
1577                "missing required configuration field",
1578            ),
1579            (
1580                serde_json::json!({ "name": "feedback", "enabled": "yes" }),
1581                "must be Boolean",
1582            ),
1583        ] {
1584            let mut selection = PluginSelection::default();
1585            selection.configuration.insert(id.clone(), configuration);
1586            let error = manager.compose(&selection).unwrap_err().to_string();
1587            assert!(error.contains(expected), "{error}");
1588        }
1589    }
1590
1591    #[test]
1592    fn graph_planning_never_installs_plugin_services() {
1593        #[derive(Debug)]
1594        struct PlanningOnly;
1595
1596        impl Plugin for PlanningOnly {
1597            fn descriptor(&self) -> PluginDescriptor {
1598                let mut descriptor = PluginDescriptor::new(
1599                    PluginId::new("planning-only").unwrap(),
1600                    Version::new(1, 0, 0),
1601                    "planning only",
1602                );
1603                descriptor.default_enabled = true;
1604                descriptor
1605            }
1606
1607            fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1608                panic!("planning must not install services");
1609            }
1610        }
1611
1612        let mut manager = PluginManager::default();
1613        manager.register(PlanningOnly).unwrap();
1614
1615        let graph = manager.build_graph(&PluginSelection::default()).unwrap();
1616
1617        assert_eq!(graph.plugins[0].id.as_str(), "planning-only");
1618    }
1619
1620    #[test]
1621    fn secret_configuration_fields_cannot_publish_default_values() {
1622        #[derive(Debug)]
1623        struct UnsafeSecretDefault;
1624
1625        impl Plugin for UnsafeSecretDefault {
1626            fn descriptor(&self) -> PluginDescriptor {
1627                let mut descriptor = PluginDescriptor::new(
1628                    PluginId::new("unsafe-secret").unwrap(),
1629                    Version::new(1, 0, 0),
1630                    "unsafe secret",
1631                );
1632                descriptor.configuration.push(ConfigurationField {
1633                    key: "api_token".into(),
1634                    kind: ConfigurationValueKind::String,
1635                    required: true,
1636                    secret: true,
1637                    description: "provider API token".into(),
1638                    default: Some(serde_json::json!("must-not-leak")),
1639                });
1640                descriptor
1641            }
1642
1643            fn install(&self, _context: &mut PluginContext<'_>) -> Result<(), PluginError> {
1644                Ok(())
1645            }
1646        }
1647
1648        let error = PluginManager::default()
1649            .register(UnsafeSecretDefault)
1650            .unwrap_err()
1651            .to_string();
1652
1653        assert!(error.contains("secret configuration fields cannot have defaults"));
1654    }
1655}