Skip to main content

ontocore_plugin/
lifecycle.rs

1//! Dependency resolution and activation ordering for the plugin host.
2
3use crate::manifest::DiscoveredPlugin;
4use std::collections::{HashMap, HashSet, VecDeque};
5use thiserror::Error;
6
7#[derive(Debug, Error, PartialEq, Eq)]
8pub enum LifecycleError {
9    #[error("plugin dependency cycle involving: {0}")]
10    Cycle(String),
11    #[error("plugin {0} depends on missing plugin {1}")]
12    MissingDependency(String, String),
13}
14
15/// Topologically sort plugin ids by `depends_on`.
16///
17/// Only plugins present in `plugins` are ordered. Missing dependencies fail.
18pub fn activation_order(plugins: &[DiscoveredPlugin]) -> Result<Vec<String>, LifecycleError> {
19    let ids: HashSet<String> = plugins.iter().map(|p| p.plugin_id().to_string()).collect();
20    let mut deps: HashMap<String, Vec<String>> = HashMap::new();
21    let mut indegree: HashMap<String, usize> = HashMap::new();
22
23    for p in plugins {
24        let id = p.plugin_id().to_string();
25        indegree.entry(id.clone()).or_insert(0);
26        let mut unique_deps = HashSet::new();
27        for dep in &p.manifest.depends_on {
28            if !ids.contains(dep) {
29                return Err(LifecycleError::MissingDependency(id, dep.clone()));
30            }
31            if unique_deps.insert(dep.clone()) {
32                deps.entry(dep.clone()).or_default().push(id.clone());
33                *indegree.entry(id.clone()).or_insert(0) += 1;
34            }
35        }
36    }
37
38    // Stable seed order by plugin id for deterministic queues.
39    let mut queue: VecDeque<String> =
40        indegree.iter().filter(|(_, d)| **d == 0).map(|(id, _)| id.clone()).collect();
41    let mut q_vec: Vec<_> = queue.drain(..).collect();
42    q_vec.sort();
43    queue.extend(q_vec);
44
45    let mut order = Vec::new();
46    while let Some(id) = queue.pop_front() {
47        order.push(id.clone());
48        if let Some(dependents) = deps.get(&id) {
49            let mut next = Vec::new();
50            for dep in dependents {
51                if let Some(d) = indegree.get_mut(dep) {
52                    *d = d.saturating_sub(1);
53                    if *d == 0 {
54                        next.push(dep.clone());
55                    }
56                }
57            }
58            next.sort();
59            queue.extend(next);
60        }
61    }
62
63    if order.len() != plugins.len() {
64        let leftover: Vec<_> =
65            indegree.into_iter().filter(|(_, d)| *d > 0).map(|(id, _)| id).collect();
66        return Err(LifecycleError::Cycle(leftover.join(", ")));
67    }
68    Ok(order)
69}
70
71/// Plugins that directly or transitively depend on `plugin_id`.
72pub fn dependents_of(plugins: &[DiscoveredPlugin], plugin_id: &str) -> Vec<String> {
73    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
74    for p in plugins {
75        let id = p.plugin_id().to_string();
76        for dep in &p.manifest.depends_on {
77            graph.entry(dep.clone()).or_default().push(id.clone());
78        }
79    }
80    let mut out = Vec::new();
81    let mut stack = vec![plugin_id.to_string()];
82    let mut seen = HashSet::new();
83    while let Some(cur) = stack.pop() {
84        if let Some(deps) = graph.get(&cur) {
85            for d in deps {
86                if seen.insert(d.clone()) {
87                    out.push(d.clone());
88                    stack.push(d.clone());
89                }
90            }
91        }
92    }
93    out.sort();
94    out
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::manifest::parse_manifest;
101    use std::path::PathBuf;
102
103    fn plugin(toml: &str) -> DiscoveredPlugin {
104        DiscoveredPlugin {
105            manifest: parse_manifest(toml).expect("manifest"),
106            manifest_path: PathBuf::from("/tmp/.ontocore/plugins/x.toml"),
107        }
108    }
109
110    #[test]
111    fn orders_dependencies() {
112        let a = plugin(
113            r#"
114[plugin]
115name = "a"
116version = "0.1.0"
117kind = "validator"
118id = "a"
119permissions = ["workspace.read"]
120"#,
121        );
122        let b = plugin(
123            r#"
124[plugin]
125name = "b"
126version = "0.1.0"
127kind = "graph"
128id = "b"
129depends_on = ["a"]
130permissions = ["workspace.read"]
131"#,
132        );
133        let order = activation_order(&[b, a]).expect("order");
134        assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
135    }
136
137    #[test]
138    fn detects_cycle() {
139        let a = plugin(
140            r#"
141[plugin]
142name = "a"
143version = "0.1.0"
144kind = "validator"
145id = "a"
146depends_on = ["b"]
147permissions = ["workspace.read"]
148"#,
149        );
150        let b = plugin(
151            r#"
152[plugin]
153name = "b"
154version = "0.1.0"
155kind = "validator"
156id = "b"
157depends_on = ["a"]
158permissions = ["workspace.read"]
159"#,
160        );
161        assert!(matches!(activation_order(&[a, b]), Err(LifecycleError::Cycle(_))));
162    }
163
164    #[test]
165    fn missing_dependency_errors() {
166        let b = plugin(
167            r#"
168[plugin]
169name = "b"
170version = "0.1.0"
171kind = "graph"
172id = "b"
173depends_on = ["missing"]
174permissions = ["workspace.read"]
175"#,
176        );
177        assert!(matches!(activation_order(&[b]), Err(LifecycleError::MissingDependency(_, _))));
178    }
179}