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