Skip to main content

minco_core/
graph.rs

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}