1use crate::{
2 CapabilityRequirement, HealthCheckDescriptor, MigrationSet, OperationDescriptor,
3 PluginDescriptor, PluginId, ResourceIntent,
4};
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8use thiserror::Error;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ApplicationGraph {
12 pub plugins: Vec<PluginDescriptor>,
13 pub capabilities: BTreeMap<String, Version>,
14 pub operations: BTreeMap<String, OperationDescriptor>,
15 pub migrations: BTreeMap<String, MigrationSet>,
16 pub health_checks: BTreeMap<String, HealthCheckDescriptor>,
17 pub resources: BTreeMap<String, ResourceIntent>,
18}
19
20#[derive(Debug, Default)]
21pub struct GraphBuilder {
22 plugins: Vec<PluginDescriptor>,
23}
24
25impl GraphBuilder {
26 pub fn add_plugin(&mut self, descriptor: PluginDescriptor) {
27 self.plugins.push(descriptor);
28 }
29
30 pub fn build(self) -> Result<ApplicationGraph, GraphError> {
31 let mut ids = BTreeSet::new();
32 let mut capabilities = BTreeMap::<String, Version>::new();
33 let mut operations = BTreeMap::new();
34 let mut routes = BTreeMap::<(String, String), String>::new();
35 let mut migrations = BTreeMap::new();
36 let mut health_checks = BTreeMap::new();
37 let mut resources = BTreeMap::new();
38
39 for plugin in &self.plugins {
40 if !ids.insert(plugin.id.clone()) {
41 return Err(GraphError::DuplicatePlugin(plugin.id.clone()));
42 }
43 for capability in &plugin.provides {
44 if capabilities
45 .insert(capability.name.clone(), capability.version.clone())
46 .is_some()
47 {
48 return Err(GraphError::DuplicateCapability(capability.name.clone()));
49 }
50 }
51 for operation in &plugin.operations {
52 if operations
53 .insert(operation.operation_id.clone(), operation.clone())
54 .is_some()
55 {
56 return Err(GraphError::DuplicateOperation(
57 operation.operation_id.clone(),
58 ));
59 }
60 let route = (
61 operation.method.to_ascii_uppercase(),
62 operation.path.clone(),
63 );
64 if let Some(existing) = routes.insert(route.clone(), operation.operation_id.clone())
65 {
66 return Err(GraphError::DuplicateRoute {
67 method: route.0,
68 path: route.1,
69 first_operation: existing,
70 second_operation: operation.operation_id.clone(),
71 });
72 }
73 }
74 for migration in &plugin.migrations {
75 if migrations
76 .insert(migration.id.clone(), migration.clone())
77 .is_some()
78 {
79 return Err(GraphError::DuplicateMigration(migration.id.clone()));
80 }
81 }
82 for check in &plugin.health_checks {
83 if health_checks
84 .insert(check.id.clone(), check.clone())
85 .is_some()
86 {
87 return Err(GraphError::DuplicateHealthCheck(check.id.clone()));
88 }
89 }
90 for resource in &plugin.resources {
91 if resources
92 .insert(resource.id.clone(), resource.clone())
93 .is_some()
94 {
95 return Err(GraphError::DuplicateResource(resource.id.clone()));
96 }
97 }
98 }
99
100 for plugin in &self.plugins {
101 validate_plugin_dependencies(plugin, &ids)?;
102 validate_capability_requirements(plugin, &capabilities)?;
103 }
104 validate_plugin_cycles(&self.plugins)?;
105 validate_resource_dependencies(&resources)?;
106 validate_resource_cycles(&resources)?;
107
108 Ok(ApplicationGraph {
109 plugins: self.plugins,
110 capabilities,
111 operations,
112 migrations,
113 health_checks,
114 resources,
115 })
116 }
117}
118
119fn validate_plugin_dependencies(
120 plugin: &PluginDescriptor,
121 ids: &BTreeSet<PluginId>,
122) -> Result<(), GraphError> {
123 for dependency in &plugin.plugin_dependencies {
124 if !ids.contains(dependency) {
125 return Err(GraphError::MissingPluginDependency {
126 plugin: plugin.id.clone(),
127 dependency: dependency.clone(),
128 });
129 }
130 }
131 Ok(())
132}
133
134fn validate_capability_requirements(
135 plugin: &PluginDescriptor,
136 capabilities: &BTreeMap<String, Version>,
137) -> Result<(), GraphError> {
138 for CapabilityRequirement { name, version } in &plugin.requires {
139 let provided = capabilities
140 .get(name)
141 .ok_or_else(|| GraphError::MissingCapability {
142 plugin: plugin.id.clone(),
143 capability: name.clone(),
144 })?;
145 if !version.matches(provided) {
146 return Err(GraphError::CapabilityVersionMismatch {
147 plugin: plugin.id.clone(),
148 capability: name.clone(),
149 required: version.to_string(),
150 provided: provided.to_string(),
151 });
152 }
153 }
154 Ok(())
155}
156
157fn validate_plugin_cycles(plugins: &[PluginDescriptor]) -> Result<(), GraphError> {
158 let graph: BTreeMap<PluginId, Vec<PluginId>> = plugins
159 .iter()
160 .map(|plugin| (plugin.id.clone(), plugin.plugin_dependencies.clone()))
161 .collect();
162 let mut visiting = BTreeSet::new();
163 let mut visited = BTreeSet::new();
164 for id in graph.keys() {
165 visit_plugin(id, &graph, &mut visiting, &mut visited)?;
166 }
167 Ok(())
168}
169
170fn visit_plugin(
171 id: &PluginId,
172 graph: &BTreeMap<PluginId, Vec<PluginId>>,
173 visiting: &mut BTreeSet<PluginId>,
174 visited: &mut BTreeSet<PluginId>,
175) -> Result<(), GraphError> {
176 if visited.contains(id) {
177 return Ok(());
178 }
179 if !visiting.insert(id.clone()) {
180 return Err(GraphError::PluginCycle(id.clone()));
181 }
182 for dependency in graph.get(id).into_iter().flatten() {
183 visit_plugin(dependency, graph, visiting, visited)?;
184 }
185 visiting.remove(id);
186 visited.insert(id.clone());
187 Ok(())
188}
189
190fn validate_resource_dependencies(
191 resources: &BTreeMap<String, ResourceIntent>,
192) -> Result<(), GraphError> {
193 for resource in resources.values() {
194 for dependency in &resource.dependencies {
195 if !resources.contains_key(dependency) {
196 return Err(GraphError::MissingResourceDependency {
197 resource: resource.id.clone(),
198 dependency: dependency.clone(),
199 });
200 }
201 }
202 }
203 Ok(())
204}
205
206fn validate_resource_cycles(
207 resources: &BTreeMap<String, ResourceIntent>,
208) -> Result<(), GraphError> {
209 let mut visiting = BTreeSet::new();
210 let mut visited = BTreeSet::new();
211 for id in resources.keys() {
212 visit_resource(id, resources, &mut visiting, &mut visited)?;
213 }
214 Ok(())
215}
216
217fn visit_resource(
218 id: &str,
219 resources: &BTreeMap<String, ResourceIntent>,
220 visiting: &mut BTreeSet<String>,
221 visited: &mut BTreeSet<String>,
222) -> Result<(), GraphError> {
223 if visited.contains(id) {
224 return Ok(());
225 }
226 if !visiting.insert(id.to_owned()) {
227 return Err(GraphError::ResourceCycle(id.to_owned()));
228 }
229 if let Some(resource) = resources.get(id) {
230 for dependency in &resource.dependencies {
231 visit_resource(dependency, resources, visiting, visited)?;
232 }
233 }
234 visiting.remove(id);
235 visited.insert(id.to_owned());
236 Ok(())
237}
238
239#[derive(Debug, Error, Clone, PartialEq, Eq)]
240pub enum GraphError {
241 #[error("duplicate plugin id: {0}")]
242 DuplicatePlugin(PluginId),
243 #[error("duplicate provided capability: {0}")]
244 DuplicateCapability(String),
245 #[error("duplicate operation id: {0}")]
246 DuplicateOperation(String),
247 #[error("route {method} {path} is bound by both {first_operation} and {second_operation}")]
248 DuplicateRoute {
249 method: String,
250 path: String,
251 first_operation: String,
252 second_operation: String,
253 },
254 #[error("duplicate migration id: {0}")]
255 DuplicateMigration(String),
256 #[error("duplicate health check id: {0}")]
257 DuplicateHealthCheck(String),
258 #[error("duplicate resource id: {0}")]
259 DuplicateResource(String),
260 #[error("plugin {plugin} depends on missing plugin {dependency}")]
261 MissingPluginDependency {
262 plugin: PluginId,
263 dependency: PluginId,
264 },
265 #[error("plugin dependency cycle includes {0}")]
266 PluginCycle(PluginId),
267 #[error("plugin {plugin} requires missing capability {capability}")]
268 MissingCapability {
269 plugin: PluginId,
270 capability: String,
271 },
272 #[error("plugin {plugin} requires {capability} {required}, but {provided} is provided")]
273 CapabilityVersionMismatch {
274 plugin: PluginId,
275 capability: String,
276 required: String,
277 provided: String,
278 },
279 #[error("resource {resource} depends on missing resource {dependency}")]
280 MissingResourceDependency {
281 resource: String,
282 dependency: String,
283 },
284 #[error("resource dependency cycle includes {0}")]
285 ResourceCycle(String),
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::{CapabilityProvision, PluginDescriptor, PluginId};
292 use semver::{Version, VersionReq};
293
294 fn descriptor(id: &str) -> PluginDescriptor {
295 PluginDescriptor::new(PluginId::new(id).unwrap(), Version::new(1, 0, 0), id)
296 }
297
298 #[test]
299 fn validates_capabilities_and_dependencies() {
300 let mut provider = descriptor("provider");
301 provider.provides.push(CapabilityProvision {
302 name: "clock".into(),
303 version: Version::new(1, 2, 0),
304 });
305 let mut consumer = descriptor("consumer");
306 consumer.plugin_dependencies.push(provider.id.clone());
307 consumer.requires.push(CapabilityRequirement {
308 name: "clock".into(),
309 version: VersionReq::parse("^1.0").unwrap(),
310 });
311 let mut builder = GraphBuilder::default();
312 builder.add_plugin(provider);
313 builder.add_plugin(consumer);
314 assert!(builder.build().is_ok());
315 }
316
317 #[test]
318 fn rejects_duplicate_method_and_path_bindings() {
319 let mut first = descriptor("first");
320 first.operations.push(OperationDescriptor {
321 operation_id: "firstOperation".into(),
322 method: "GET".into(),
323 path: "/items".into(),
324 public: true,
325 idempotent: false,
326 });
327 let mut second = descriptor("second");
328 second.operations.push(OperationDescriptor {
329 operation_id: "secondOperation".into(),
330 method: "get".into(),
331 path: "/items".into(),
332 public: true,
333 idempotent: false,
334 });
335 let mut builder = GraphBuilder::default();
336 builder.add_plugin(first);
337 builder.add_plugin(second);
338 assert!(matches!(
339 builder.build(),
340 Err(GraphError::DuplicateRoute { .. })
341 ));
342 }
343
344 #[test]
345 fn rejects_resource_dependency_cycles() {
346 let mut plugin = descriptor("resources");
347 plugin.resources.push(ResourceIntent {
348 id: "first".into(),
349 kind: crate::ResourceKind::Custom("first".into()),
350 idle_cost: crate::IdleCostClass::ZeroCompute,
351 wake_sources: Vec::new(),
352 dependencies: vec!["second".into()],
353 });
354 plugin.resources.push(ResourceIntent {
355 id: "second".into(),
356 kind: crate::ResourceKind::Custom("second".into()),
357 idle_cost: crate::IdleCostClass::ZeroCompute,
358 wake_sources: Vec::new(),
359 dependencies: vec!["first".into()],
360 });
361 let mut builder = GraphBuilder::default();
362 builder.add_plugin(plugin);
363 assert!(matches!(builder.build(), Err(GraphError::ResourceCycle(_))));
364 }
365
366 #[test]
367 fn rejects_missing_capability() {
368 let mut consumer = descriptor("consumer");
369 consumer.requires.push(CapabilityRequirement {
370 name: "clock".into(),
371 version: VersionReq::STAR,
372 });
373 let mut builder = GraphBuilder::default();
374 builder.add_plugin(consumer);
375 assert!(matches!(
376 builder.build(),
377 Err(GraphError::MissingCapability { .. })
378 ));
379 }
380}