Skip to main content

ontocore_plugin/
host.rs

1use crate::discovery::{discover_plugins, PluginDiscoveryError};
2use crate::manifest::{DiscoveredPlugin, PluginKind, PluginManifest, PluginPermission};
3use crate::protocol::{jail_output_paths, 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 is disabled: {0}")]
23    Disabled(String),
24    #[error("plugin {0} is missing required permission: {1}")]
25    MissingPermission(String, String),
26    #[error("plugin {0} does not support action {1}")]
27    UnsupportedAction(String, String),
28    #[error("export failed: {0}")]
29    Export(String),
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct PluginDescriptor {
34    pub id: String,
35    pub name: String,
36    pub version: String,
37    pub kind: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub api_version: Option<String>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub permissions: Vec<PluginPermission>,
42    pub capabilities: crate::manifest::PluginCapabilities,
43    pub manifest_path: String,
44    pub ui: crate::manifest::PluginUiContributions,
45    pub in_process: bool,
46    #[serde(default)]
47    pub disabled: bool,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct RunPluginResult {
52    pub diagnostics: Vec<Diagnostic>,
53    pub output_paths: Vec<String>,
54    pub logs: Option<String>,
55    pub view_html: Option<String>,
56    pub success: bool,
57}
58
59pub struct PluginHost {
60    workspace: PathBuf,
61    discovered: Vec<DiscoveredPlugin>,
62    disabled: std::collections::HashSet<String>,
63    validators: HashMap<String, Box<dyn ValidatorPlugin>>,
64    exporters: HashMap<String, Box<dyn ExporterPlugin>>,
65    workflows: HashMap<String, Box<dyn WorkflowPlugin>>,
66}
67
68impl PluginHost {
69    pub fn new(workspace: impl AsRef<Path>) -> Self {
70        Self {
71            workspace: workspace.as_ref().to_path_buf(),
72            discovered: Vec::new(),
73            disabled: std::collections::HashSet::new(),
74            validators: HashMap::new(),
75            exporters: HashMap::new(),
76            workflows: HashMap::new(),
77        }
78    }
79
80    pub fn discover(&mut self) -> Result<(), PluginHostError> {
81        self.discovered = discover_plugins(&self.workspace)?;
82        Ok(())
83    }
84
85    pub fn register_validator(&mut self, plugin: Box<dyn ValidatorPlugin>) {
86        self.validators.insert(plugin.id().to_string(), plugin);
87    }
88
89    pub fn register_exporter(&mut self, plugin: Box<dyn ExporterPlugin>) {
90        self.exporters.insert(plugin.id().to_string(), plugin);
91    }
92
93    pub fn register_workflow(&mut self, plugin: Box<dyn WorkflowPlugin>) {
94        self.workflows.insert(plugin.id().to_string(), plugin);
95    }
96
97    pub fn workspace(&self) -> &Path {
98        &self.workspace
99    }
100
101    pub fn discovered(&self) -> &[DiscoveredPlugin] {
102        &self.discovered
103    }
104
105    pub fn disable_plugin(&mut self, plugin_id: &str) {
106        self.disabled.insert(plugin_id.to_string());
107        self.validators.remove(plugin_id);
108        self.exporters.remove(plugin_id);
109        self.workflows.remove(plugin_id);
110    }
111
112    pub fn enable_plugin(&mut self, plugin_id: &str) {
113        self.disabled.remove(plugin_id);
114    }
115
116    pub fn is_disabled(&self, plugin_id: &str) -> bool {
117        self.disabled.contains(plugin_id)
118    }
119
120    pub fn list_plugins(&self) -> Vec<PluginDescriptor> {
121        self.discovered
122            .iter()
123            .map(|p| {
124                let id = p.plugin_id().to_string();
125                PluginDescriptor {
126                    id: id.clone(),
127                    name: p.manifest.name.clone(),
128                    version: p.manifest.version.clone(),
129                    kind: p.manifest.kind.as_str().to_string(),
130                    api_version: p.manifest.api_version.clone(),
131                    permissions: p.manifest.permissions.clone(),
132                    capabilities: p.manifest.capabilities.clone(),
133                    manifest_path: p.manifest_path.display().to_string(),
134                    ui: p.manifest.ui.clone(),
135                    in_process: self.validators.contains_key(&id)
136                        || self.exporters.contains_key(&id)
137                        || self.workflows.contains_key(&id),
138                    disabled: self.is_disabled(&id),
139                }
140            })
141            .collect()
142    }
143
144    fn find_plugin(&self, plugin_id: &str) -> Result<&DiscoveredPlugin, PluginHostError> {
145        self.discovered
146            .iter()
147            .find(|p| p.plugin_id() == plugin_id)
148            .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
149    }
150
151    fn ensure_enabled(&self, plugin_id: &str) -> Result<(), PluginHostError> {
152        if self.is_disabled(plugin_id) {
153            return Err(PluginHostError::Disabled(plugin_id.to_string()));
154        }
155        Ok(())
156    }
157
158    fn ensure_permission(
159        plugin: &DiscoveredPlugin,
160        plugin_id: &str,
161        required: PluginPermission,
162    ) -> Result<(), PluginHostError> {
163        if plugin.manifest.permissions.contains(&required) {
164            return Ok(());
165        }
166        Err(PluginHostError::MissingPermission(plugin_id.to_string(), format!("{required:?}")))
167    }
168
169    pub fn run_validate_plugin(
170        &self,
171        plugin_id: &str,
172        catalog: &OntologyCatalog,
173    ) -> Result<Vec<Diagnostic>, PluginHostError> {
174        self.ensure_enabled(plugin_id)?;
175        let plugin = self.find_plugin(plugin_id)?;
176        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
177        if !plugin.manifest.capabilities.supports_validation() {
178            return Err(PluginHostError::UnsupportedAction(
179                plugin_id.to_string(),
180                "validate".into(),
181            ));
182        }
183        if let Some(v) = self.validators.get(plugin_id) {
184            return Ok(v.validate(catalog, &self.workspace));
185        }
186        if plugin.manifest.entry.is_some() {
187            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
188            let output = run_plugin_subprocess(
189                plugin,
190                SubprocessRequest {
191                    action: "validate",
192                    workspace: &self.workspace,
193                    step: None,
194                    extra_args: &[],
195                },
196            )?;
197            let output = jail_output_paths(plugin_id, &self.workspace, output);
198            return Ok(wire_to_diagnostics(plugin_id, &self.workspace, output));
199        }
200        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
201    }
202
203    pub fn run_all_validators(&self, catalog: &OntologyCatalog) -> Vec<Diagnostic> {
204        let mut all = Vec::new();
205        for plugin in &self.discovered {
206            if !plugin.manifest.capabilities.supports_validation() {
207                continue;
208            }
209            let id = plugin.plugin_id();
210            match self.run_validate_plugin(id, catalog) {
211                Ok(mut diags) => all.append(&mut diags),
212                Err(err) => all.push(plugin_diagnostic(
213                    id,
214                    "plugin_error",
215                    DiagnosticSeverity::Error,
216                    err.to_string(),
217                    self.workspace.clone(),
218                    None,
219                )),
220            }
221        }
222        all
223    }
224
225    pub fn run_export_plugin(
226        &self,
227        plugin_id: &str,
228        catalog: &OntologyCatalog,
229        options: ExportOptions,
230    ) -> Result<RunPluginResult, PluginHostError> {
231        self.ensure_enabled(plugin_id)?;
232        let plugin = self.find_plugin(plugin_id)?;
233        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
234        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
235        if !plugin.manifest.capabilities.export {
236            return Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), "export".into()));
237        }
238        if let Some(e) = self.exporters.get(plugin_id) {
239            let paths = e
240                .export(catalog, &self.workspace, options)
241                .map_err(|e| PluginHostError::Export(e.to_string()))?;
242            return Ok(RunPluginResult {
243                diagnostics: Vec::new(),
244                output_paths: paths.iter().map(|p| p.display().to_string()).collect(),
245                logs: None,
246                view_html: None,
247                success: true,
248            });
249        }
250        if plugin.manifest.entry.is_some() {
251            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
252            let output = run_plugin_subprocess(
253                plugin,
254                SubprocessRequest {
255                    action: "export",
256                    workspace: &self.workspace,
257                    step: None,
258                    extra_args: &[],
259                },
260            )?;
261            let output = jail_output_paths(plugin_id, &self.workspace, output);
262            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
263            return Ok(RunPluginResult {
264                diagnostics: diags,
265                output_paths: output.output_paths,
266                logs: output.logs,
267                view_html: output.view_html,
268                success: true,
269            });
270        }
271        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
272    }
273
274    pub fn run_workflow_plugin(
275        &self,
276        plugin_id: &str,
277        step: &str,
278    ) -> Result<RunPluginResult, PluginHostError> {
279        self.ensure_enabled(plugin_id)?;
280        let plugin = self.find_plugin(plugin_id)?;
281        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
282        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
283        if plugin.manifest.kind != PluginKind::Workflow && plugin.manifest.kind != PluginKind::Build
284        {
285            return Err(PluginHostError::UnsupportedAction(
286                plugin_id.to_string(),
287                "workflow".into(),
288            ));
289        }
290        if let Some(w) = self.workflows.get(plugin_id) {
291            let result =
292                w.run(&self.workspace, WorkflowRequest { step: step.to_string(), dry_run: false });
293            return Ok(RunPluginResult {
294                diagnostics: result.diagnostics,
295                output_paths: Vec::new(),
296                logs: Some(result.logs),
297                view_html: None,
298                success: result.success,
299            });
300        }
301        if plugin.manifest.entry.is_some() {
302            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
303            let output = run_plugin_subprocess(
304                plugin,
305                SubprocessRequest {
306                    action: "workflow",
307                    workspace: &self.workspace,
308                    step: Some(step),
309                    extra_args: &[],
310                },
311            )?;
312            let output = jail_output_paths(plugin_id, &self.workspace, output);
313            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
314            return Ok(RunPluginResult {
315                diagnostics: diags,
316                output_paths: output.output_paths,
317                logs: output.logs,
318                view_html: output.view_html,
319                success: output.exit_message.is_none(),
320            });
321        }
322        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
323    }
324
325    pub fn run_plugin_action(
326        &self,
327        plugin_id: &str,
328        action: &str,
329        catalog: Option<&OntologyCatalog>,
330        export_options: Option<ExportOptions>,
331        step: Option<&str>,
332        view_id: Option<&str>,
333    ) -> Result<RunPluginResult, PluginHostError> {
334        self.ensure_enabled(plugin_id)?;
335        match action {
336            "validate" => {
337                let catalog = catalog.ok_or_else(|| {
338                    PluginHostError::UnsupportedAction(
339                        plugin_id.to_string(),
340                        "validate (no catalog)".into(),
341                    )
342                })?;
343                let diags = self.run_validate_plugin(plugin_id, catalog)?;
344                Ok(RunPluginResult {
345                    diagnostics: diags,
346                    output_paths: Vec::new(),
347                    logs: None,
348                    view_html: None,
349                    success: true,
350                })
351            }
352            "export" => {
353                let catalog = catalog.ok_or_else(|| {
354                    PluginHostError::UnsupportedAction(
355                        plugin_id.to_string(),
356                        "export (no catalog)".into(),
357                    )
358                })?;
359                let options =
360                    export_options.unwrap_or_else(|| ExportOptions::markdown("plugin-out"));
361                self.run_export_plugin(plugin_id, catalog, options)
362            }
363            "workflow" => self.run_workflow_plugin(plugin_id, step.unwrap_or("qc")),
364            "ui_view" => {
365                let plugin = self.find_plugin(plugin_id)?;
366                Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
367                Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
368                let view = view_id.ok_or_else(|| {
369                    PluginHostError::UnsupportedAction(
370                        plugin_id.to_string(),
371                        "ui_view (missing view_id)".into(),
372                    )
373                })?;
374                let extra = vec!["--view".to_string(), view.to_string()];
375                let output = run_plugin_subprocess(
376                    plugin,
377                    SubprocessRequest {
378                        action: "ui-view",
379                        workspace: &self.workspace,
380                        step: None,
381                        extra_args: &extra,
382                    },
383                )?;
384                let output = jail_output_paths(plugin_id, &self.workspace, output);
385                let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
386                Ok(RunPluginResult {
387                    diagnostics: diags,
388                    output_paths: output.output_paths,
389                    logs: output.logs,
390                    view_html: output.view_html,
391                    success: output.exit_message.is_none(),
392                })
393            }
394            _ => Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), action.into())),
395        }
396    }
397}
398
399fn wire_to_diagnostics(plugin_id: &str, workspace: &Path, output: PluginOutput) -> Vec<Diagnostic> {
400    output.diagnostics.into_iter().map(|d| d.into_diagnostic(plugin_id, workspace)).collect()
401}
402
403pub fn merge_plugin_diagnostics(base: &mut Vec<Diagnostic>, plugin: Vec<Diagnostic>) {
404    base.extend(plugin);
405}
406
407pub fn manifest_for_builtin(id: &str) -> Option<PluginManifest> {
408    let text = match id {
409        "ontocode.naming-validator" => include_str!("../fixtures/builtin-naming.toml"),
410        "ontocode.markdown-export" => include_str!("../fixtures/builtin-markdown.toml"),
411        "ontocode.shacl-validator" => include_str!("../fixtures/builtin-shacl.toml"),
412        _ => return None,
413    };
414    crate::manifest::parse_manifest(text).ok()
415}