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