Skip to main content

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