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
15pub trait Plugin: Send + Sync + 'static {
21 fn descriptor(&self) -> PluginDescriptor;
22
23 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 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#[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 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 #[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 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 pub fn build_graph(
307 &self,
308 selection: &PluginSelection,
309 ) -> Result<ApplicationGraph, PluginError> {
310 Ok(self.resolve_graph(selection)?.graph)
311 }
312
313 pub fn compose_with(
319 &self,
320 selection: &PluginSelection,
321 mut services: ServiceCollection,
322 mut contributions: ContributionCollection,
323 ) -> Result<ComposedApplication, PluginError> {
324 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(®istration.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(®istration.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(®istration.descriptor, &descriptor)?;
460 Ok(EffectivePlugin {
461 plugin: Arc::clone(®istration.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 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 ®istration.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 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}