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