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