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 compose(&self, selection: &PluginSelection) -> Result<ComposedApplication, PluginError> {
282 self.compose_with(
283 selection,
284 ServiceCollection::default(),
285 ContributionCollection::default(),
286 )
287 }
288
289 pub fn build_graph(
294 &self,
295 selection: &PluginSelection,
296 ) -> Result<ApplicationGraph, PluginError> {
297 Ok(self.resolve_graph(selection)?.graph)
298 }
299
300 pub fn compose_with(
306 &self,
307 selection: &PluginSelection,
308 mut services: ServiceCollection,
309 mut contributions: ContributionCollection,
310 ) -> Result<ComposedApplication, PluginError> {
311 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(®istration.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(®istration.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(®istration.descriptor, &descriptor)?;
447 Ok(EffectivePlugin {
448 plugin: Arc::clone(®istration.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 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 ®istration.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 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}