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