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            let output = run_plugin_subprocess(
192                plugin,
193                SubprocessRequest {
194                    action: "validate",
195                    workspace: &self.workspace,
196                    step: None,
197                    extra_args: &[],
198                },
199            )?;
200            let output = jail_output_paths(plugin_id, &self.workspace, output);
201            return Ok(wire_to_diagnostics(plugin_id, &self.workspace, output));
202        }
203        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
204    }
205
206    pub fn run_all_validators(&self, catalog: &OntologyCatalog) -> Vec<Diagnostic> {
207        let mut all = Vec::new();
208        for plugin in &self.discovered {
209            if !plugin.manifest.capabilities.supports_validation() {
210                continue;
211            }
212            let id = plugin.plugin_id();
213            match self.run_validate_plugin(id, catalog) {
214                Ok(mut diags) => all.append(&mut diags),
215                Err(err) => all.push(plugin_diagnostic(
216                    id,
217                    "plugin_error",
218                    DiagnosticSeverity::Error,
219                    err.to_string(),
220                    self.workspace.clone(),
221                    None,
222                )),
223            }
224        }
225        all
226    }
227
228    pub fn run_export_plugin(
229        &self,
230        plugin_id: &str,
231        catalog: &OntologyCatalog,
232        options: ExportOptions,
233    ) -> Result<RunPluginResult, PluginHostError> {
234        self.ensure_enabled(plugin_id)?;
235        let plugin = self.find_plugin(plugin_id)?;
236        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
237        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
238        if !plugin.manifest.capabilities.export {
239            return Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), "export".into()));
240        }
241        let options = jail_export_options(&self.workspace, options)?;
242        if let Some(e) = self.exporters.get(plugin_id) {
243            let paths = e
244                .export(catalog, &self.workspace, options)
245                .map_err(|e| PluginHostError::Export(e.to_string()))?;
246            return Ok(RunPluginResult {
247                diagnostics: Vec::new(),
248                output_paths: paths.iter().map(|p| p.display().to_string()).collect(),
249                logs: None,
250                view_html: None,
251                success: true,
252            });
253        }
254        if plugin.manifest.entry.is_some() {
255            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
256            let output = run_plugin_subprocess(
257                plugin,
258                SubprocessRequest {
259                    action: "export",
260                    workspace: &self.workspace,
261                    step: None,
262                    extra_args: &[],
263                },
264            )?;
265            let output = jail_output_paths(plugin_id, &self.workspace, output);
266            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
267            return Ok(RunPluginResult {
268                diagnostics: diags,
269                output_paths: output.output_paths,
270                logs: output.logs,
271                view_html: output.view_html,
272                success: true,
273            });
274        }
275        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
276    }
277
278    pub fn run_workflow_plugin(
279        &self,
280        plugin_id: &str,
281        step: &str,
282    ) -> Result<RunPluginResult, PluginHostError> {
283        self.ensure_enabled(plugin_id)?;
284        let plugin = self.find_plugin(plugin_id)?;
285        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
286        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
287        if plugin.manifest.kind != PluginKind::Workflow && plugin.manifest.kind != PluginKind::Build
288        {
289            return Err(PluginHostError::UnsupportedAction(
290                plugin_id.to_string(),
291                "workflow".into(),
292            ));
293        }
294        if let Some(w) = self.workflows.get(plugin_id) {
295            let result =
296                w.run(&self.workspace, WorkflowRequest { step: step.to_string(), dry_run: false });
297            return Ok(RunPluginResult {
298                diagnostics: result.diagnostics,
299                output_paths: Vec::new(),
300                logs: Some(result.logs),
301                view_html: None,
302                success: result.success,
303            });
304        }
305        if plugin.manifest.entry.is_some() {
306            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
307            let output = run_plugin_subprocess(
308                plugin,
309                SubprocessRequest {
310                    action: "workflow",
311                    workspace: &self.workspace,
312                    step: Some(step),
313                    extra_args: &[],
314                },
315            )?;
316            let output = jail_output_paths(plugin_id, &self.workspace, output);
317            let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
318            return Ok(RunPluginResult {
319                diagnostics: diags,
320                output_paths: output.output_paths,
321                logs: output.logs,
322                view_html: output.view_html,
323                success: output.exit_message.is_none(),
324            });
325        }
326        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
327    }
328
329    pub fn run_plugin_action(
330        &self,
331        plugin_id: &str,
332        action: &str,
333        catalog: Option<&OntologyCatalog>,
334        export_options: Option<ExportOptions>,
335        step: Option<&str>,
336        view_id: Option<&str>,
337    ) -> Result<RunPluginResult, PluginHostError> {
338        self.ensure_enabled(plugin_id)?;
339        match action {
340            "validate" => {
341                let catalog = catalog.ok_or_else(|| {
342                    PluginHostError::UnsupportedAction(
343                        plugin_id.to_string(),
344                        "validate (no catalog)".into(),
345                    )
346                })?;
347                let diags = self.run_validate_plugin(plugin_id, catalog)?;
348                Ok(RunPluginResult {
349                    diagnostics: diags,
350                    output_paths: Vec::new(),
351                    logs: None,
352                    view_html: None,
353                    success: true,
354                })
355            }
356            "export" => {
357                let catalog = catalog.ok_or_else(|| {
358                    PluginHostError::UnsupportedAction(
359                        plugin_id.to_string(),
360                        "export (no catalog)".into(),
361                    )
362                })?;
363                let options = export_options
364                    .unwrap_or_else(|| ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR));
365                self.run_export_plugin(plugin_id, catalog, options)
366            }
367            "workflow" => self.run_workflow_plugin(plugin_id, step.unwrap_or("qc")),
368            "ui_view" => {
369                let plugin = self.find_plugin(plugin_id)?;
370                Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
371                Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
372                let view = view_id.ok_or_else(|| {
373                    PluginHostError::UnsupportedAction(
374                        plugin_id.to_string(),
375                        "ui_view (missing view_id)".into(),
376                    )
377                })?;
378                let extra = vec!["--view".to_string(), view.to_string()];
379                let output = run_plugin_subprocess(
380                    plugin,
381                    SubprocessRequest {
382                        action: "ui-view",
383                        workspace: &self.workspace,
384                        step: None,
385                        extra_args: &extra,
386                    },
387                )?;
388                let output = jail_output_paths(plugin_id, &self.workspace, output);
389                let diags = wire_to_diagnostics(plugin_id, &self.workspace, output.clone());
390                Ok(RunPluginResult {
391                    diagnostics: diags,
392                    output_paths: output.output_paths,
393                    logs: output.logs,
394                    view_html: output.view_html,
395                    success: output.exit_message.is_none(),
396                })
397            }
398            _ => Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), action.into())),
399        }
400    }
401}
402
403fn wire_to_diagnostics(plugin_id: &str, workspace: &Path, output: PluginOutput) -> Vec<Diagnostic> {
404    output.diagnostics.into_iter().map(|d| d.into_diagnostic(plugin_id, workspace)).collect()
405}
406
407/// Resolve export output under the workspace root (relative paths join the root, not process CWD).
408fn jail_export_options(
409    workspace: &Path,
410    mut options: ExportOptions,
411) -> Result<ExportOptions, PluginHostError> {
412    let jailed = validate_workspace_scope(&options.output_dir, workspace)
413        .map_err(PluginHostError::Export)?;
414    options.output_dir = jailed;
415    Ok(options)
416}
417
418pub fn merge_plugin_diagnostics(base: &mut Vec<Diagnostic>, plugin: Vec<Diagnostic>) {
419    base.extend(plugin);
420}
421
422pub fn manifest_for_builtin(id: &str) -> Option<PluginManifest> {
423    let text = match id {
424        "ontocode.naming-validator" => include_str!("../fixtures/builtin-naming.toml"),
425        "ontocode.markdown-export" => include_str!("../fixtures/builtin-markdown.toml"),
426        "ontocode.shacl-validator" => include_str!("../fixtures/builtin-shacl.toml"),
427        _ => return None,
428    };
429    crate::manifest::parse_manifest(text).ok()
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use ontocore_core::is_path_within;
436
437    #[test]
438    fn jail_export_options_joins_relative_to_workspace_not_cwd() {
439        let dir = tempfile::tempdir().unwrap();
440        let workspace = dir.path().join("ws");
441        std::fs::create_dir_all(&workspace).unwrap();
442        let cwd = tempfile::tempdir().unwrap();
443        let prev = std::env::current_dir().unwrap();
444        struct RestoreCwd(std::path::PathBuf);
445        impl Drop for RestoreCwd {
446            fn drop(&mut self) {
447                let _ = std::env::set_current_dir(&self.0);
448            }
449        }
450        let _restore = RestoreCwd(prev);
451        std::env::set_current_dir(cwd.path()).unwrap();
452
453        let options =
454            jail_export_options(&workspace, ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR))
455                .expect("jail default export dir");
456
457        let root = workspace.canonicalize().unwrap();
458        assert!(is_path_within(&root, &options.output_dir));
459        assert!(options.output_dir.ends_with(".ontocore/plugin-out"));
460        assert!(!cwd.path().join("plugin-out").exists());
461        assert!(!cwd.path().join(".ontocore/plugin-out").exists());
462    }
463
464    #[test]
465    fn jail_export_options_rejects_absolute_outside_workspace() {
466        let dir = tempfile::tempdir().unwrap();
467        let workspace = dir.path().join("ws");
468        std::fs::create_dir_all(&workspace).unwrap();
469        let outside = dir.path().join("outside-out");
470        let err = jail_export_options(&workspace, ExportOptions::markdown(&outside)).unwrap_err();
471        assert!(matches!(err, PluginHostError::Export(_)));
472    }
473}