1use crate::discovery::{discover_plugins, PluginDiscoveryError};
2use crate::lifecycle::{activation_order, dependents_of, LifecycleError};
3use crate::manifest::{
4 DiscoveredPlugin, PluginActivation, PluginKind, PluginLifecycleState, PluginManifest,
5 PluginPermission,
6};
7use crate::protocol::{jail_output_paths, plugin_diagnostic, PluginOutput};
8use crate::subprocess::{run_plugin_subprocess, SubprocessError, SubprocessRequest};
9use crate::traits::{
10 ExporterPlugin, GraphPlugin, GraphProviderResult, QueryPlugin, QueryProviderResult,
11 ReasonerPlugin, ReasonerProviderResult, RefactorPlugin, RefactorProviderResult,
12 ValidatorPlugin, WorkflowPlugin, WorkflowRequest,
13};
14use ontocore_catalog::OntologyCatalog;
15use ontocore_core::{validate_workspace_scope, Diagnostic, DiagnosticSeverity};
16use ontocore_docs::ExportOptions;
17use serde::Serialize;
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21
22const DEFAULT_PLUGIN_EXPORT_DIR: &str = ".ontocore/plugin-out";
24const DISABLED_STATE_FILE: &str = ".ontocore/plugin-disabled.json";
26
27#[derive(Debug, Error)]
28pub enum PluginHostError {
29 #[error(transparent)]
30 Discovery(#[from] PluginDiscoveryError),
31 #[error(transparent)]
32 Subprocess(#[from] SubprocessError),
33 #[error(transparent)]
34 Lifecycle(#[from] LifecycleError),
35 #[error("plugin not found: {0}")]
36 NotFound(String),
37 #[error("plugin is disabled: {0}")]
38 Disabled(String),
39 #[error("plugin is not active: {0}")]
40 NotActive(String),
41 #[error("plugin {0} is missing required permission: {1}")]
42 MissingPermission(String, String),
43 #[error("plugin {0} does not support action {1}")]
44 UnsupportedAction(String, String),
45 #[error("export failed: {0}")]
46 Export(String),
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct PluginDescriptor {
51 pub id: String,
52 pub name: String,
53 pub version: String,
54 pub kind: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub api_version: Option<String>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub permissions: Vec<PluginPermission>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub depends_on: Vec<String>,
61 pub activation: String,
62 pub state: String,
63 pub capabilities: crate::manifest::PluginCapabilities,
64 pub manifest_path: String,
65 pub ui: crate::manifest::PluginUiContributions,
66 pub in_process: bool,
67 #[serde(default)]
69 pub disabled: bool,
70 #[serde(default)]
71 pub enabled: bool,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct RunPluginResult {
76 pub diagnostics: Vec<Diagnostic>,
77 pub output_paths: Vec<String>,
78 pub logs: Option<String>,
79 pub view_html: Option<String>,
80 pub success: bool,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub result: Option<serde_json::Value>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub columns: Option<Vec<String>>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub rows: Option<Vec<Vec<String>>>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub unsatisfiable: Option<Vec<String>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub affected_iris: Option<Vec<String>>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub root_iris: Option<Vec<String>>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub graph_kind: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub hints: Option<Vec<String>>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub profile: Option<String>,
99}
100
101impl RunPluginResult {
102 fn from_output(plugin_id: &str, workspace: &Path, output: PluginOutput, success: bool) -> Self {
103 let output = jail_output_paths(plugin_id, workspace, output);
104 let has_violations = !output.violations.is_empty();
105 let diags = wire_to_diagnostics(plugin_id, workspace, output.clone());
106 Self {
107 diagnostics: diags,
108 output_paths: output.output_paths,
109 logs: output.logs,
110 view_html: output.view_html,
111 success: success && !has_violations,
113 result: output.result,
114 columns: output.columns,
115 rows: output.rows,
116 unsatisfiable: output.unsatisfiable,
117 affected_iris: output.affected_iris,
118 root_iris: output.root_iris,
119 graph_kind: output.graph_kind,
120 hints: output.hints,
121 profile: output.profile,
122 }
123 }
124
125 fn empty_ok() -> Self {
126 Self {
127 diagnostics: Vec::new(),
128 output_paths: Vec::new(),
129 logs: None,
130 view_html: None,
131 success: true,
132 result: None,
133 columns: None,
134 rows: None,
135 unsatisfiable: None,
136 affected_iris: None,
137 root_iris: None,
138 graph_kind: None,
139 hints: None,
140 profile: None,
141 }
142 }
143}
144
145pub struct PluginHost {
146 workspace: PathBuf,
147 discovered: Vec<DiscoveredPlugin>,
148 disabled: HashSet<String>,
150 states: HashMap<String, PluginLifecycleState>,
151 activation_events: Vec<String>,
153 validators: HashMap<String, Box<dyn ValidatorPlugin>>,
154 exporters: HashMap<String, Box<dyn ExporterPlugin>>,
155 workflows: HashMap<String, Box<dyn WorkflowPlugin>>,
156 reasoners: HashMap<String, Box<dyn ReasonerPlugin>>,
157 queries: HashMap<String, Box<dyn QueryPlugin>>,
158 refactors: HashMap<String, Box<dyn RefactorPlugin>>,
159 graphs: HashMap<String, Box<dyn GraphPlugin>>,
160}
161
162impl PluginHost {
163 pub fn new(workspace: impl AsRef<Path>) -> Self {
164 let workspace = workspace.as_ref().to_path_buf();
165 let disabled = load_disabled_ids(&workspace);
166 Self {
167 workspace,
168 discovered: Vec::new(),
169 disabled,
170 states: HashMap::new(),
171 activation_events: Vec::new(),
172 validators: HashMap::new(),
173 exporters: HashMap::new(),
174 workflows: HashMap::new(),
175 reasoners: HashMap::new(),
176 queries: HashMap::new(),
177 refactors: HashMap::new(),
178 graphs: HashMap::new(),
179 }
180 }
181
182 pub fn discover(&mut self) -> Result<(), PluginHostError> {
183 self.discovered = discover_plugins(&self.workspace)?;
184 for p in &self.discovered {
185 let id = p.plugin_id().to_string();
186 self.states.entry(id.clone()).or_insert(PluginLifecycleState::Discovered);
187 if p.manifest.kind.is_hosted() {
188 *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Validated;
189 }
190 }
191 let _ = activation_order(&self.discovered)?;
193 for p in &self.discovered {
194 let id = p.plugin_id().to_string();
195 if self.disabled.contains(&id) {
196 *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Disabled;
197 } else if p.manifest.kind.is_hosted() {
198 *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Registered;
199 }
200 }
201 Ok(())
202 }
203
204 pub fn activate_all(&mut self) -> Result<Vec<String>, PluginHostError> {
206 let order = activation_order(&self.discovered)?;
207 self.activation_events.clear();
208 for id in &order {
209 let plugin = self.find_plugin(id)?;
210 if self.disabled.contains(id) {
211 *self.states.entry(id.clone()).or_insert(PluginLifecycleState::Disabled) =
212 PluginLifecycleState::Disabled;
213 continue;
214 }
215 if !plugin.manifest.kind.is_hosted() {
216 continue;
217 }
218 match plugin.manifest.activation {
219 PluginActivation::OnStartup | PluginActivation::OnWorkspaceOpen => {
220 self.activate(id)?;
221 }
222 PluginActivation::OnCommand => {
223 *self.states.entry(id.clone()).or_insert(PluginLifecycleState::Registered) =
224 PluginLifecycleState::Registered;
225 }
226 }
227 }
228 Ok(self.activation_events.clone())
229 }
230
231 pub fn activate(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
232 if self.disabled.contains(plugin_id) {
233 return Err(PluginHostError::Disabled(plugin_id.to_string()));
234 }
235 let plugin = self.find_plugin(plugin_id)?.clone();
236 if !plugin.manifest.kind.is_hosted() {
237 return Err(PluginHostError::UnsupportedAction(
238 plugin_id.to_string(),
239 "activate (reserved kind)".into(),
240 ));
241 }
242 for dep in &plugin.manifest.depends_on {
243 if self.disabled.contains(dep) {
244 return Err(PluginHostError::Lifecycle(LifecycleError::MissingDependency(
245 plugin_id.to_string(),
246 format!("{dep} (disabled)"),
247 )));
248 }
249 if self.state_of(dep) != PluginLifecycleState::Active {
250 self.activate(dep)?;
251 }
252 }
253 *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Active) =
254 PluginLifecycleState::Active;
255 if !self.activation_events.iter().any(|e| e == plugin_id) {
256 self.activation_events.push(plugin_id.to_string());
257 }
258 Ok(())
259 }
260
261 pub fn deactivate(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
262 let _ = self.find_plugin(plugin_id)?;
263 for dep in dependents_of(&self.discovered, plugin_id) {
264 if self.state_of(&dep) == PluginLifecycleState::Active {
265 self.deactivate(&dep)?;
266 }
267 }
268 *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Registered) =
269 PluginLifecycleState::Registered;
270 self.activation_events.retain(|e| e != plugin_id);
271 Ok(())
272 }
273
274 pub fn state_of(&self, plugin_id: &str) -> PluginLifecycleState {
275 self.states.get(plugin_id).copied().unwrap_or(PluginLifecycleState::Discovered)
276 }
277
278 pub fn activation_events(&self) -> &[String] {
279 &self.activation_events
280 }
281
282 pub fn register_validator(&mut self, plugin: Box<dyn ValidatorPlugin>) {
283 self.validators.insert(plugin.id().to_string(), plugin);
284 }
285
286 pub fn register_exporter(&mut self, plugin: Box<dyn ExporterPlugin>) {
287 self.exporters.insert(plugin.id().to_string(), plugin);
288 }
289
290 pub fn register_workflow(&mut self, plugin: Box<dyn WorkflowPlugin>) {
291 self.workflows.insert(plugin.id().to_string(), plugin);
292 }
293
294 pub fn register_reasoner(&mut self, plugin: Box<dyn ReasonerPlugin>) {
295 self.reasoners.insert(plugin.id().to_string(), plugin);
296 }
297
298 pub fn register_query(&mut self, plugin: Box<dyn QueryPlugin>) {
299 self.queries.insert(plugin.id().to_string(), plugin);
300 }
301
302 pub fn register_refactor(&mut self, plugin: Box<dyn RefactorPlugin>) {
303 self.refactors.insert(plugin.id().to_string(), plugin);
304 }
305
306 pub fn register_graph(&mut self, plugin: Box<dyn GraphPlugin>) {
307 self.graphs.insert(plugin.id().to_string(), plugin);
308 }
309
310 pub fn workspace(&self) -> &Path {
311 &self.workspace
312 }
313
314 pub fn discovered(&self) -> &[DiscoveredPlugin] {
315 &self.discovered
316 }
317
318 pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
319 let _ = self.find_plugin(plugin_id)?;
320 let dependents = dependents_of(&self.discovered, plugin_id);
321 for dep in &dependents {
322 if dep != plugin_id {
323 let _ = self.disable_plugin(dep);
324 }
325 }
326 let _ = self.deactivate(plugin_id);
327 self.disabled.insert(plugin_id.to_string());
328 *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Disabled) =
329 PluginLifecycleState::Disabled;
330 self.validators.remove(plugin_id);
331 self.exporters.remove(plugin_id);
332 self.workflows.remove(plugin_id);
333 self.reasoners.remove(plugin_id);
334 self.queries.remove(plugin_id);
335 self.refactors.remove(plugin_id);
336 self.graphs.remove(plugin_id);
337 persist_disabled_ids(&self.workspace, &self.disabled);
338 Ok(())
339 }
340
341 pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
342 let activation = self.find_plugin(plugin_id)?.manifest.activation;
343 self.disabled.remove(plugin_id);
344 persist_disabled_ids(&self.workspace, &self.disabled);
345 *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Registered) =
346 PluginLifecycleState::Registered;
347 if matches!(activation, PluginActivation::OnStartup | PluginActivation::OnWorkspaceOpen) {
348 self.activate(plugin_id)?;
349 }
350 Ok(())
351 }
352
353 pub fn is_disabled(&self, plugin_id: &str) -> bool {
354 self.disabled.contains(plugin_id)
355 }
356
357 pub fn plugin_info(&self, plugin_id: &str) -> Result<PluginDescriptor, PluginHostError> {
358 self.list_plugins()
359 .into_iter()
360 .find(|p| p.id == plugin_id)
361 .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
362 }
363
364 pub fn list_plugins(&self) -> Vec<PluginDescriptor> {
365 self.discovered
366 .iter()
367 .map(|p| {
368 let id = p.plugin_id().to_string();
369 let state = self.state_of(&id);
370 let disabled = self.is_disabled(&id) || state == PluginLifecycleState::Disabled;
371 PluginDescriptor {
372 id: id.clone(),
373 name: p.manifest.name.clone(),
374 version: p.manifest.version.clone(),
375 kind: p.manifest.kind.as_str().to_string(),
376 api_version: p.manifest.api_version.clone(),
377 permissions: p.manifest.permissions.clone(),
378 depends_on: p.manifest.depends_on.clone(),
379 activation: p.manifest.activation.as_str().to_string(),
380 state: state.as_str().to_string(),
381 capabilities: p.manifest.capabilities.clone(),
382 manifest_path: p.manifest_path.display().to_string(),
383 ui: p.manifest.ui.clone(),
384 in_process: self.validators.contains_key(&id)
385 || self.exporters.contains_key(&id)
386 || self.workflows.contains_key(&id)
387 || self.reasoners.contains_key(&id)
388 || self.queries.contains_key(&id)
389 || self.refactors.contains_key(&id)
390 || self.graphs.contains_key(&id),
391 disabled,
392 enabled: !disabled && state == PluginLifecycleState::Active,
393 }
394 })
395 .collect()
396 }
397
398 fn find_plugin(&self, plugin_id: &str) -> Result<&DiscoveredPlugin, PluginHostError> {
399 self.discovered
400 .iter()
401 .find(|p| p.plugin_id() == plugin_id)
402 .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
403 }
404
405 fn ensure_runnable(&self, plugin_id: &str) -> Result<(), PluginHostError> {
406 if self.is_disabled(plugin_id) {
407 return Err(PluginHostError::Disabled(plugin_id.to_string()));
408 }
409 let plugin = self.find_plugin(plugin_id)?;
410 if self.state_of(plugin_id) != PluginLifecycleState::Active
412 && plugin.manifest.activation == PluginActivation::OnCommand
413 {
414 return Ok(());
416 }
417 if self.state_of(plugin_id) != PluginLifecycleState::Active
418 && self.state_of(plugin_id) != PluginLifecycleState::Registered
419 {
420 return Err(PluginHostError::NotActive(plugin_id.to_string()));
421 }
422 Ok(())
423 }
424
425 fn ensure_permission(
426 plugin: &DiscoveredPlugin,
427 plugin_id: &str,
428 required: PluginPermission,
429 ) -> Result<(), PluginHostError> {
430 if plugin.manifest.permissions.contains(&required) {
431 return Ok(());
432 }
433 Err(PluginHostError::MissingPermission(plugin_id.to_string(), required.to_string()))
434 }
435
436 pub fn run_validate_plugin(
437 &self,
438 plugin_id: &str,
439 catalog: &OntologyCatalog,
440 ) -> Result<Vec<Diagnostic>, PluginHostError> {
441 self.ensure_runnable(plugin_id)?;
442 let plugin = self.find_plugin(plugin_id)?;
443 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
444 if !plugin.manifest.capabilities.supports_validation() {
445 return Err(PluginHostError::UnsupportedAction(
446 plugin_id.to_string(),
447 "validate".into(),
448 ));
449 }
450 if let Some(v) = self.validators.get(plugin_id) {
451 return Ok(v.validate(catalog, &self.workspace));
452 }
453 if plugin.manifest.entry.is_some() {
454 Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
455 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
456 let output = run_plugin_subprocess(
457 plugin,
458 SubprocessRequest {
459 action: "validate",
460 workspace: &self.workspace,
461 step: None,
462 extra_args: &[],
463 },
464 )?;
465 let output = jail_output_paths(plugin_id, &self.workspace, output);
466 return Ok(wire_to_diagnostics(plugin_id, &self.workspace, output));
467 }
468 Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
469 }
470
471 pub fn run_all_validators(&self, catalog: &OntologyCatalog) -> Vec<Diagnostic> {
472 let mut all = Vec::new();
473 for plugin in &self.discovered {
474 if !plugin.manifest.capabilities.supports_validation() {
475 continue;
476 }
477 let id = plugin.plugin_id();
478 if self.is_disabled(id) || self.state_of(id) == PluginLifecycleState::Disabled {
479 continue;
480 }
481 match self.run_validate_plugin(id, catalog) {
482 Ok(mut diags) => all.append(&mut diags),
483 Err(err) => all.push(plugin_diagnostic(
484 id,
485 "plugin_error",
486 DiagnosticSeverity::Error,
487 err.to_string(),
488 self.workspace.clone(),
489 None,
490 )),
491 }
492 }
493 all
494 }
495
496 pub fn run_export_plugin(
497 &self,
498 plugin_id: &str,
499 catalog: &OntologyCatalog,
500 options: ExportOptions,
501 ) -> Result<RunPluginResult, PluginHostError> {
502 self.ensure_runnable(plugin_id)?;
503 let plugin = self.find_plugin(plugin_id)?;
504 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
505 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
506 if !plugin.manifest.capabilities.export {
507 return Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), "export".into()));
508 }
509 let options = jail_export_options(&self.workspace, options)?;
510 if let Some(e) = self.exporters.get(plugin_id) {
511 let paths = e
512 .export(catalog, &self.workspace, options)
513 .map_err(|e| PluginHostError::Export(e.to_string()))?;
514 let mut result = RunPluginResult::empty_ok();
515 result.output_paths = paths.iter().map(|p| p.display().to_string()).collect();
516 return Ok(result);
517 }
518 if plugin.manifest.entry.is_some() {
519 Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
520 let output = run_plugin_subprocess(
521 plugin,
522 SubprocessRequest {
523 action: "export",
524 workspace: &self.workspace,
525 step: None,
526 extra_args: &[],
527 },
528 )?;
529 return Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, true));
530 }
531 Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
532 }
533
534 pub fn run_workflow_plugin(
535 &self,
536 plugin_id: &str,
537 step: &str,
538 ) -> Result<RunPluginResult, PluginHostError> {
539 self.ensure_runnable(plugin_id)?;
540 let plugin = self.find_plugin(plugin_id)?;
541 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
542 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
543 if plugin.manifest.kind != PluginKind::Workflow && plugin.manifest.kind != PluginKind::Build
544 {
545 return Err(PluginHostError::UnsupportedAction(
546 plugin_id.to_string(),
547 "workflow".into(),
548 ));
549 }
550 if let Some(w) = self.workflows.get(plugin_id) {
551 let result =
552 w.run(&self.workspace, WorkflowRequest { step: step.to_string(), dry_run: false });
553 let mut out = RunPluginResult::empty_ok();
554 out.diagnostics = result.diagnostics;
555 out.logs = Some(result.logs);
556 out.success = result.success;
557 return Ok(out);
558 }
559 if plugin.manifest.entry.is_some() {
560 Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
561 let output = run_plugin_subprocess(
562 plugin,
563 SubprocessRequest {
564 action: "workflow",
565 workspace: &self.workspace,
566 step: Some(step),
567 extra_args: &[],
568 },
569 )?;
570 let success = output.exit_message.is_none();
571 return Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success));
572 }
573 Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
574 }
575
576 pub fn run_reasoner_plugin(
577 &self,
578 plugin_id: &str,
579 catalog: &OntologyCatalog,
580 ) -> Result<RunPluginResult, PluginHostError> {
581 self.ensure_runnable(plugin_id)?;
582 let plugin = self.find_plugin(plugin_id)?;
583 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
584 if !plugin.manifest.capabilities.reasoner && plugin.manifest.kind != PluginKind::Reasoner {
585 return Err(PluginHostError::UnsupportedAction(
586 plugin_id.to_string(),
587 "reasoner.classify".into(),
588 ));
589 }
590 if let Some(r) = self.reasoners.get(plugin_id) {
591 let result = r.classify(catalog, &self.workspace);
592 return Ok(provider_reasoner_result(result));
593 }
594 self.run_subprocess_provider(plugin, "reasoner.classify", &[])
595 }
596
597 pub fn run_query_plugin(
598 &self,
599 plugin_id: &str,
600 catalog: &OntologyCatalog,
601 query: &str,
602 ) -> Result<RunPluginResult, PluginHostError> {
603 self.ensure_runnable(plugin_id)?;
604 let plugin = self.find_plugin(plugin_id)?;
605 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
606 if !plugin.manifest.capabilities.query && plugin.manifest.kind != PluginKind::Query {
607 return Err(PluginHostError::UnsupportedAction(
608 plugin_id.to_string(),
609 "query.run".into(),
610 ));
611 }
612 if let Some(q) = self.queries.get(plugin_id) {
613 let result = q.run(catalog, &self.workspace, query);
614 return Ok(provider_query_result(result));
615 }
616 let extra = vec!["--query".to_string(), query.to_string()];
617 self.run_subprocess_provider(plugin, "query.run", &extra)
618 }
619
620 pub fn run_refactor_plugin(
621 &self,
622 plugin_id: &str,
623 catalog: &OntologyCatalog,
624 focus_iri: Option<&str>,
625 ) -> Result<RunPluginResult, PluginHostError> {
626 self.ensure_runnable(plugin_id)?;
627 let plugin = self.find_plugin(plugin_id)?;
628 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
629 if !plugin.manifest.capabilities.refactor && plugin.manifest.kind != PluginKind::Refactor {
630 return Err(PluginHostError::UnsupportedAction(
631 plugin_id.to_string(),
632 "refactor.preview".into(),
633 ));
634 }
635 if let Some(r) = self.refactors.get(plugin_id) {
636 let result = r.preview(catalog, &self.workspace, focus_iri);
637 return Ok(provider_refactor_result(result));
638 }
639 let mut extra = Vec::new();
640 if let Some(iri) = focus_iri {
641 extra.push("--iri".to_string());
642 extra.push(iri.to_string());
643 }
644 self.run_subprocess_provider(plugin, "refactor.preview", &extra)
645 }
646
647 pub fn run_graph_plugin(
648 &self,
649 plugin_id: &str,
650 catalog: &OntologyCatalog,
651 root_iri: Option<&str>,
652 ) -> Result<RunPluginResult, PluginHostError> {
653 self.ensure_runnable(plugin_id)?;
654 let plugin = self.find_plugin(plugin_id)?;
655 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
656 if !plugin.manifest.capabilities.graph && plugin.manifest.kind != PluginKind::Graph {
657 return Err(PluginHostError::UnsupportedAction(
658 plugin_id.to_string(),
659 "graph.build".into(),
660 ));
661 }
662 if let Some(g) = self.graphs.get(plugin_id) {
663 let result = g.build(catalog, &self.workspace, root_iri);
664 return Ok(provider_graph_result(result));
665 }
666 let mut extra = Vec::new();
667 if let Some(iri) = root_iri {
668 extra.push("--root".to_string());
669 extra.push(iri.to_string());
670 }
671 self.run_subprocess_provider(plugin, "graph.build", &extra)
672 }
673
674 fn run_subprocess_provider(
675 &self,
676 plugin: &DiscoveredPlugin,
677 action: &str,
678 extra_args: &[String],
679 ) -> Result<RunPluginResult, PluginHostError> {
680 let plugin_id = plugin.plugin_id();
681 if plugin.manifest.entry.is_none() {
682 return Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")));
683 }
684 Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
685 let output = run_plugin_subprocess(
686 plugin,
687 SubprocessRequest { action, workspace: &self.workspace, step: None, extra_args },
688 )?;
689 let success = output.exit_message.is_none();
690 Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success))
691 }
692
693 #[allow(clippy::too_many_arguments)]
694 pub fn run_plugin_action(
695 &mut self,
696 plugin_id: &str,
697 action: &str,
698 catalog: Option<&OntologyCatalog>,
699 export_options: Option<ExportOptions>,
700 step: Option<&str>,
701 view_id: Option<&str>,
702 query: Option<&str>,
703 focus_iri: Option<&str>,
704 ) -> Result<RunPluginResult, PluginHostError> {
705 if !self.is_disabled(plugin_id) && self.state_of(plugin_id) != PluginLifecycleState::Active
707 {
708 let _ = self.activate(plugin_id);
709 }
710 match action {
711 "validate" => {
712 let catalog = catalog.ok_or_else(|| {
713 PluginHostError::UnsupportedAction(
714 plugin_id.to_string(),
715 "validate (no catalog)".into(),
716 )
717 })?;
718 let diags = self.run_validate_plugin(plugin_id, catalog)?;
719 let mut out = RunPluginResult::empty_ok();
720 out.diagnostics = diags;
721 Ok(out)
722 }
723 "export" => {
724 let catalog = catalog.ok_or_else(|| {
725 PluginHostError::UnsupportedAction(
726 plugin_id.to_string(),
727 "export (no catalog)".into(),
728 )
729 })?;
730 let options = export_options
731 .unwrap_or_else(|| ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR));
732 self.run_export_plugin(plugin_id, catalog, options)
733 }
734 "workflow" => self.run_workflow_plugin(plugin_id, step.unwrap_or("qc")),
735 "ui_view" => {
736 let plugin = self.find_plugin(plugin_id)?;
737 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
738 Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
740 Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
741 let view = view_id.ok_or_else(|| {
742 PluginHostError::UnsupportedAction(
743 plugin_id.to_string(),
744 "ui_view (missing view_id)".into(),
745 )
746 })?;
747 let extra = vec!["--view".to_string(), view.to_string()];
748 let output = run_plugin_subprocess(
749 plugin,
750 SubprocessRequest {
751 action: "ui-view",
752 workspace: &self.workspace,
753 step: None,
754 extra_args: &extra,
755 },
756 )?;
757 let success = output.exit_message.is_none();
758 Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success))
759 }
760 "reasoner.classify" | "reasoner" => {
761 let catalog = catalog.ok_or_else(|| {
762 PluginHostError::UnsupportedAction(
763 plugin_id.to_string(),
764 "reasoner.classify (no catalog)".into(),
765 )
766 })?;
767 self.run_reasoner_plugin(plugin_id, catalog)
768 }
769 "query.run" | "query" => {
770 let catalog = catalog.ok_or_else(|| {
771 PluginHostError::UnsupportedAction(
772 plugin_id.to_string(),
773 "query.run (no catalog)".into(),
774 )
775 })?;
776 self.run_query_plugin(plugin_id, catalog, query.unwrap_or("SELECT * FROM classes"))
777 }
778 "refactor.preview" | "refactor" => {
779 let catalog = catalog.ok_or_else(|| {
780 PluginHostError::UnsupportedAction(
781 plugin_id.to_string(),
782 "refactor.preview (no catalog)".into(),
783 )
784 })?;
785 self.run_refactor_plugin(plugin_id, catalog, focus_iri)
786 }
787 "graph.build" | "graph" => {
788 let catalog = catalog.ok_or_else(|| {
789 PluginHostError::UnsupportedAction(
790 plugin_id.to_string(),
791 "graph.build (no catalog)".into(),
792 )
793 })?;
794 self.run_graph_plugin(plugin_id, catalog, focus_iri)
795 }
796 _ => Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), action.into())),
797 }
798 }
799}
800
801fn provider_reasoner_result(result: ReasonerProviderResult) -> RunPluginResult {
802 let mut out = RunPluginResult::empty_ok();
803 out.unsatisfiable = Some(result.unsatisfiable.clone());
804 out.profile = Some(result.profile.clone());
805 out.logs = result.logs.clone();
806 out.result = serde_json::to_value(&result).ok();
807 out
808}
809
810fn provider_query_result(result: QueryProviderResult) -> RunPluginResult {
811 let mut out = RunPluginResult::empty_ok();
812 out.columns = Some(result.columns.clone());
813 out.rows = Some(result.rows.clone());
814 out.result = serde_json::to_value(&result).ok();
815 out
816}
817
818fn provider_refactor_result(result: RefactorProviderResult) -> RunPluginResult {
819 let mut out = RunPluginResult::empty_ok();
820 out.affected_iris = Some(result.affected_iris.clone());
821 out.hints = Some(result.hints.clone());
822 out.result = serde_json::to_value(&result).ok();
823 out
824}
825
826fn provider_graph_result(mut result: GraphProviderResult) -> RunPluginResult {
827 let mut out = RunPluginResult::empty_ok();
828 out.graph_kind = Some(result.graph_kind.clone());
829 out.root_iris = Some(result.root_iris.clone());
830 out.result = result.result.take().or_else(|| serde_json::to_value(&result).ok());
831 out
832}
833
834fn wire_to_diagnostics(plugin_id: &str, workspace: &Path, output: PluginOutput) -> Vec<Diagnostic> {
835 let mut diags: Vec<_> =
836 output.diagnostics.into_iter().map(|d| d.into_diagnostic(plugin_id, workspace)).collect();
837 for violation in output.violations {
839 diags.push(plugin_diagnostic(
840 plugin_id,
841 "path_jail",
842 DiagnosticSeverity::Error,
843 violation,
844 workspace.to_path_buf(),
845 None,
846 ));
847 }
848 diags
849}
850
851fn load_disabled_ids(workspace: &Path) -> HashSet<String> {
852 let path = workspace.join(DISABLED_STATE_FILE);
853 let Ok(bytes) = std::fs::read(&path) else {
854 return HashSet::new();
855 };
856 serde_json::from_slice::<Vec<String>>(&bytes).unwrap_or_default().into_iter().collect()
857}
858
859fn persist_disabled_ids(workspace: &Path, disabled: &HashSet<String>) {
860 let path = workspace.join(DISABLED_STATE_FILE);
861 if let Some(parent) = path.parent() {
862 let _ = std::fs::create_dir_all(parent);
863 }
864 let mut ids: Vec<_> = disabled.iter().cloned().collect();
865 ids.sort();
866 if let Ok(bytes) = serde_json::to_vec_pretty(&ids) {
867 let _ = std::fs::write(path, bytes);
868 }
869}
870
871fn jail_export_options(
873 workspace: &Path,
874 mut options: ExportOptions,
875) -> Result<ExportOptions, PluginHostError> {
876 let jailed = validate_workspace_scope(&options.output_dir, workspace)
877 .map_err(PluginHostError::Export)?;
878 options.output_dir = jailed;
879 Ok(options)
880}
881
882pub fn merge_plugin_diagnostics(base: &mut Vec<Diagnostic>, plugin: Vec<Diagnostic>) {
883 base.extend(plugin);
884}
885
886pub fn manifest_for_builtin(id: &str) -> Option<PluginManifest> {
887 let text = match id {
888 "ontocode.naming-validator" => include_str!("../fixtures/builtin-naming.toml"),
889 "ontocode.markdown-export" => include_str!("../fixtures/builtin-markdown.toml"),
890 "ontocode.shacl-validator" => include_str!("../fixtures/builtin-shacl.toml"),
891 _ => return None,
892 };
893 crate::manifest::parse_manifest(text).ok()
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use ontocore_core::is_path_within;
900
901 #[test]
902 fn jail_export_options_joins_relative_to_workspace_not_cwd() {
903 let dir = tempfile::tempdir().unwrap();
904 let workspace = dir.path().join("ws");
905 std::fs::create_dir_all(&workspace).unwrap();
906 let cwd = tempfile::tempdir().unwrap();
907 let prev = std::env::current_dir().unwrap();
908 struct RestoreCwd(std::path::PathBuf);
909 impl Drop for RestoreCwd {
910 fn drop(&mut self) {
911 let _ = std::env::set_current_dir(&self.0);
912 }
913 }
914 let _restore = RestoreCwd(prev);
915 std::env::set_current_dir(cwd.path()).unwrap();
916
917 let options =
918 jail_export_options(&workspace, ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR))
919 .expect("jail default export dir");
920
921 let root = workspace.canonicalize().unwrap();
922 assert!(is_path_within(&root, &options.output_dir));
923 assert!(options.output_dir.ends_with(".ontocore/plugin-out"));
924 assert!(!cwd.path().join("plugin-out").exists());
925 assert!(!cwd.path().join(".ontocore/plugin-out").exists());
926 }
927
928 #[test]
929 fn jail_export_options_rejects_absolute_outside_workspace() {
930 let dir = tempfile::tempdir().unwrap();
931 let workspace = dir.path().join("ws");
932 std::fs::create_dir_all(&workspace).unwrap();
933 let outside = dir.path().join("outside-out");
934 let err = jail_export_options(&workspace, ExportOptions::markdown(&outside)).unwrap_err();
935 assert!(matches!(err, PluginHostError::Export(_)));
936 }
937
938 #[test]
939 fn activate_all_orders_dependencies() {
940 let dir = tempfile::tempdir().unwrap();
941 let plugins = dir.path().join(".ontocore/plugins");
942 std::fs::create_dir_all(&plugins).unwrap();
943 std::fs::write(
944 plugins.join("a.toml"),
945 r#"
946[plugin]
947name = "a"
948version = "0.1.0"
949kind = "validator"
950id = "a"
951api_version = "1"
952permissions = ["workspace.read"]
953[capabilities]
954validate = true
955"#,
956 )
957 .unwrap();
958 std::fs::write(
959 plugins.join("b.toml"),
960 r#"
961[plugin]
962name = "b"
963version = "0.1.0"
964kind = "graph"
965id = "b"
966api_version = "1"
967depends_on = ["a"]
968permissions = ["workspace.read"]
969[capabilities]
970graph = true
971"#,
972 )
973 .unwrap();
974 let mut host = PluginHost::new(dir.path());
975 host.discover().expect("discover");
976 let order = host.activate_all().expect("activate");
977 assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
978 assert_eq!(host.state_of("a"), PluginLifecycleState::Active);
979 assert_eq!(host.state_of("b"), PluginLifecycleState::Active);
980 }
981
982 #[test]
983 fn disable_cascades_dependents() {
984 let dir = tempfile::tempdir().unwrap();
985 let plugins = dir.path().join(".ontocore/plugins");
986 std::fs::create_dir_all(&plugins).unwrap();
987 std::fs::write(
988 plugins.join("a.toml"),
989 r#"
990[plugin]
991name = "a"
992version = "0.1.0"
993kind = "validator"
994id = "a"
995api_version = "1"
996permissions = ["workspace.read"]
997"#,
998 )
999 .unwrap();
1000 std::fs::write(
1001 plugins.join("b.toml"),
1002 r#"
1003[plugin]
1004name = "b"
1005version = "0.1.0"
1006kind = "graph"
1007id = "b"
1008api_version = "1"
1009depends_on = ["a"]
1010permissions = ["workspace.read"]
1011"#,
1012 )
1013 .unwrap();
1014 let mut host = PluginHost::new(dir.path());
1015 host.discover().unwrap();
1016 host.activate_all().unwrap();
1017 host.disable_plugin("a").unwrap();
1018 assert_eq!(host.state_of("a"), PluginLifecycleState::Disabled);
1019 assert_eq!(host.state_of("b"), PluginLifecycleState::Disabled);
1020 }
1021}