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
14const 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 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
409fn 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}