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 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
407fn 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}