Skip to main content

strixonomy_plugin/
host.rs

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
22/// Default in-process export directory (relative to the workspace root).
23const DEFAULT_PLUGIN_EXPORT_DIR: &str = ".strixonomy/plugin-out";
24/// Persisted user-disabled plugin ids (workspace-relative).
25const 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    /// True when user-disabled or inactive (not Active).
69    #[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            // #348: jail violations must not report silent success.
113            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    /// User-disabled plugin ids (persists across activate_all).
150    disabled: HashSet<String>,
151    states: HashMap<String, PluginLifecycleState>,
152    /// Deterministic activation order from the last activate_all / resolve.
153    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        // Resolve dependency graph early so missing deps surface at discover time.
193        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    /// Activate plugins with `on_startup` / `on_workspace_open` activation in dependency order.
206    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        // Lazily allow on_command plugins to run without prior activate_all (auto-activate).
412        if self.state_of(plugin_id) != PluginLifecycleState::Active
413            && plugin.manifest.activation == PluginActivation::OnCommand
414        {
415            // Caller must activate — return NotActive so CLI can activate first, or we activate in run_plugin_action.
416            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        // Auto-activate on_command / registered plugins when running an action.
707        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                // #347: subprocess ui_view can mutate the workspace — require write like validate.
740                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    // #348: surface path-jail violations as error diagnostics (not silent drops).
839    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
876/// Resolve export output under the workspace root (relative paths join the root, not process CWD).
877fn 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}