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}