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