Skip to main content

ontocore_plugin/
host.rs

1use crate::discovery::{discover_plugins, PluginDiscoveryError};
2use crate::manifest::{DiscoveredPlugin, PluginKind, PluginManifest};
3use crate::protocol::{plugin_diagnostic, PluginOutput};
4use crate::subprocess::{run_plugin_subprocess, SubprocessError, SubprocessRequest};
5use crate::traits::{ExporterPlugin, ValidatorPlugin, WorkflowPlugin, WorkflowRequest};
6use ontocore_catalog::OntologyCatalog;
7use ontocore_core::{Diagnostic, DiagnosticSeverity};
8use ontocore_docs::ExportOptions;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum PluginHostError {
16    #[error(transparent)]
17    Discovery(#[from] PluginDiscoveryError),
18    #[error(transparent)]
19    Subprocess(#[from] SubprocessError),
20    #[error("plugin not found: {0}")]
21    NotFound(String),
22    #[error("plugin {0} does not support action {1}")]
23    UnsupportedAction(String, String),
24    #[error("export failed: {0}")]
25    Export(String),
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct PluginDescriptor {
30    pub id: String,
31    pub name: String,
32    pub version: String,
33    pub kind: String,
34    pub capabilities: crate::manifest::PluginCapabilities,
35    pub manifest_path: String,
36    pub ui: crate::manifest::PluginUiContributions,
37    pub in_process: bool,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct RunPluginResult {
42    pub diagnostics: Vec<Diagnostic>,
43    pub output_paths: Vec<String>,
44    pub logs: Option<String>,
45    pub success: bool,
46}
47
48pub struct PluginHost {
49    workspace: PathBuf,
50    discovered: Vec<DiscoveredPlugin>,
51    validators: HashMap<String, Box<dyn ValidatorPlugin>>,
52    exporters: HashMap<String, Box<dyn ExporterPlugin>>,
53    workflows: HashMap<String, Box<dyn WorkflowPlugin>>,
54}
55
56impl PluginHost {
57    pub fn new(workspace: impl AsRef<Path>) -> Self {
58        Self {
59            workspace: workspace.as_ref().to_path_buf(),
60            discovered: Vec::new(),
61            validators: HashMap::new(),
62            exporters: HashMap::new(),
63            workflows: HashMap::new(),
64        }
65    }
66
67    pub fn discover(&mut self) -> Result<(), PluginHostError> {
68        self.discovered = discover_plugins(&self.workspace)?;
69        Ok(())
70    }
71
72    pub fn register_validator(&mut self, plugin: Box<dyn ValidatorPlugin>) {
73        self.validators.insert(plugin.id().to_string(), plugin);
74    }
75
76    pub fn register_exporter(&mut self, plugin: Box<dyn ExporterPlugin>) {
77        self.exporters.insert(plugin.id().to_string(), plugin);
78    }
79
80    pub fn register_workflow(&mut self, plugin: Box<dyn WorkflowPlugin>) {
81        self.workflows.insert(plugin.id().to_string(), plugin);
82    }
83
84    pub fn workspace(&self) -> &Path {
85        &self.workspace
86    }
87
88    pub fn discovered(&self) -> &[DiscoveredPlugin] {
89        &self.discovered
90    }
91
92    pub fn list_plugins(&self) -> Vec<PluginDescriptor> {
93        self.discovered
94            .iter()
95            .map(|p| {
96                let id = p.plugin_id().to_string();
97                PluginDescriptor {
98                    id: id.clone(),
99                    name: p.manifest.name.clone(),
100                    version: p.manifest.version.clone(),
101                    kind: p.manifest.kind.as_str().to_string(),
102                    capabilities: p.manifest.capabilities.clone(),
103                    manifest_path: p.manifest_path.display().to_string(),
104                    ui: p.manifest.ui.clone(),
105                    in_process: self.validators.contains_key(&id)
106                        || self.exporters.contains_key(&id)
107                        || self.workflows.contains_key(&id),
108                }
109            })
110            .collect()
111    }
112
113    fn find_plugin(&self, plugin_id: &str) -> Result<&DiscoveredPlugin, PluginHostError> {
114        self.discovered
115            .iter()
116            .find(|p| p.plugin_id() == plugin_id)
117            .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
118    }
119
120    pub fn run_validate_plugin(
121        &self,
122        plugin_id: &str,
123        catalog: &OntologyCatalog,
124    ) -> Result<Vec<Diagnostic>, PluginHostError> {
125        let plugin = self.find_plugin(plugin_id)?;
126        if !plugin.manifest.capabilities.supports_validation() {
127            return Err(PluginHostError::UnsupportedAction(
128                plugin_id.to_string(),
129                "validate".into(),
130            ));
131        }
132        if let Some(v) = self.validators.get(plugin_id) {
133            return Ok(v.validate(catalog, &self.workspace));
134        }
135        if plugin.manifest.entry.is_some() {
136            let output = run_plugin_subprocess(
137                plugin,
138                SubprocessRequest {
139                    action: "validate",
140                    workspace: &self.workspace,
141                    step: None,
142                    extra_args: &[],
143                },
144            )?;
145            return Ok(wire_to_diagnostics(plugin_id, &self.workspace, output));
146        }
147        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
148    }
149
150    pub fn run_all_validators(&self, catalog: &OntologyCatalog) -> Vec<Diagnostic> {
151        let mut all = Vec::new();
152        for plugin in &self.discovered {
153            if !plugin.manifest.capabilities.supports_validation() {
154                continue;
155            }
156            let id = plugin.plugin_id();
157            match self.run_validate_plugin(id, catalog) {
158                Ok(mut diags) => all.append(&mut diags),
159                Err(err) => all.push(plugin_diagnostic(
160                    id,
161                    "plugin_error",
162                    DiagnosticSeverity::Error,
163                    err.to_string(),
164                    self.workspace.clone(),
165                    None,
166                )),
167            }
168        }
169        all
170    }
171
172    pub fn run_export_plugin(
173        &self,
174        plugin_id: &str,
175        catalog: &OntologyCatalog,
176        options: ExportOptions,
177    ) -> Result<RunPluginResult, PluginHostError> {
178        let plugin = self.find_plugin(plugin_id)?;
179        if !plugin.manifest.capabilities.export {
180            return Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), "export".into()));
181        }
182        if let Some(e) = self.exporters.get(plugin_id) {
183            let paths = e
184                .export(catalog, &self.workspace, options)
185                .map_err(|e| PluginHostError::Export(e.to_string()))?;
186            return Ok(RunPluginResult {
187                diagnostics: Vec::new(),
188                output_paths: paths.iter().map(|p| p.display().to_string()).collect(),
189                logs: None,
190                success: true,
191            });
192        }
193        if plugin.manifest.entry.is_some() {
194            let output = run_plugin_subprocess(
195                plugin,
196                SubprocessRequest {
197                    action: "export",
198                    workspace: &self.workspace,
199                    step: None,
200                    extra_args: &[],
201                },
202            )?;
203            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
204            return Ok(RunPluginResult {
205                diagnostics: diags,
206                output_paths: output.output_paths,
207                logs: output.logs,
208                success: true,
209            });
210        }
211        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
212    }
213
214    pub fn run_workflow_plugin(
215        &self,
216        plugin_id: &str,
217        step: &str,
218    ) -> Result<RunPluginResult, PluginHostError> {
219        let plugin = self.find_plugin(plugin_id)?;
220        if plugin.manifest.kind != PluginKind::Workflow && plugin.manifest.kind != PluginKind::Build
221        {
222            return Err(PluginHostError::UnsupportedAction(
223                plugin_id.to_string(),
224                "workflow".into(),
225            ));
226        }
227        if let Some(w) = self.workflows.get(plugin_id) {
228            let result =
229                w.run(&self.workspace, WorkflowRequest { step: step.to_string(), dry_run: false });
230            return Ok(RunPluginResult {
231                diagnostics: result.diagnostics,
232                output_paths: Vec::new(),
233                logs: Some(result.logs),
234                success: result.success,
235            });
236        }
237        if plugin.manifest.entry.is_some() {
238            let output = run_plugin_subprocess(
239                plugin,
240                SubprocessRequest {
241                    action: "workflow",
242                    workspace: &self.workspace,
243                    step: Some(step),
244                    extra_args: &[],
245                },
246            )?;
247            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
248            return Ok(RunPluginResult {
249                diagnostics: diags,
250                output_paths: output.output_paths,
251                logs: output.logs,
252                success: output.exit_message.is_none(),
253            });
254        }
255        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
256    }
257
258    pub fn run_plugin_action(
259        &self,
260        plugin_id: &str,
261        action: &str,
262        catalog: Option<&OntologyCatalog>,
263        export_options: Option<ExportOptions>,
264        step: Option<&str>,
265    ) -> Result<RunPluginResult, PluginHostError> {
266        match action {
267            "validate" => {
268                let catalog = catalog.ok_or_else(|| {
269                    PluginHostError::UnsupportedAction(
270                        plugin_id.to_string(),
271                        "validate (no catalog)".into(),
272                    )
273                })?;
274                let diags = self.run_validate_plugin(plugin_id, catalog)?;
275                Ok(RunPluginResult {
276                    diagnostics: diags,
277                    output_paths: Vec::new(),
278                    logs: None,
279                    success: true,
280                })
281            }
282            "export" => {
283                let catalog = catalog.ok_or_else(|| {
284                    PluginHostError::UnsupportedAction(
285                        plugin_id.to_string(),
286                        "export (no catalog)".into(),
287                    )
288                })?;
289                let options =
290                    export_options.unwrap_or_else(|| ExportOptions::markdown("plugin-out"));
291                self.run_export_plugin(plugin_id, catalog, options)
292            }
293            "workflow" => self.run_workflow_plugin(plugin_id, step.unwrap_or("qc")),
294            _ => Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), action.into())),
295        }
296    }
297}
298
299fn wire_to_diagnostics(plugin_id: &str, workspace: &Path, output: PluginOutput) -> Vec<Diagnostic> {
300    output.diagnostics.into_iter().map(|d| d.into_diagnostic(plugin_id, workspace)).collect()
301}
302
303pub fn merge_plugin_diagnostics(base: &mut Vec<Diagnostic>, plugin: Vec<Diagnostic>) {
304    base.extend(plugin);
305}
306
307pub fn manifest_for_builtin(id: &str) -> Option<PluginManifest> {
308    let text = match id {
309        "ontocode.naming-validator" => include_str!("../fixtures/builtin-naming.toml"),
310        "ontocode.markdown-export" => include_str!("../fixtures/builtin-markdown.toml"),
311        "ontocode.shacl-validator" => include_str!("../fixtures/builtin-shacl.toml"),
312        _ => return None,
313    };
314    crate::manifest::parse_manifest(text).ok()
315}