Skip to main content

ontocore_plugin/
manifest.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use thiserror::Error;
4
5/// Explicit permissions requested by a plugin.
6///
7/// These are surfaced to UI and enforced for sensitive actions.
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum PluginPermission {
11    WorkspaceRead,
12    WorkspaceWrite,
13    FilesystemRead,
14    FilesystemWrite,
15    Network,
16    AiInvoke,
17    GitRead,
18    GitWrite,
19    ExternalProcess,
20}
21
22impl PluginPermission {
23    pub fn parse(s: &str) -> Result<Self, ManifestValidationError> {
24        match s {
25            "workspace.read" => Ok(Self::WorkspaceRead),
26            "workspace.write" => Ok(Self::WorkspaceWrite),
27            "filesystem.read" => Ok(Self::FilesystemRead),
28            "filesystem.write" => Ok(Self::FilesystemWrite),
29            "network" => Ok(Self::Network),
30            "ai.invoke" => Ok(Self::AiInvoke),
31            "git.read" => Ok(Self::GitRead),
32            "git.write" => Ok(Self::GitWrite),
33            "external_process" => Ok(Self::ExternalProcess),
34            other => Err(ManifestValidationError::UnknownPermission(other.to_string())),
35        }
36    }
37
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::WorkspaceRead => "workspace.read",
41            Self::WorkspaceWrite => "workspace.write",
42            Self::FilesystemRead => "filesystem.read",
43            Self::FilesystemWrite => "filesystem.write",
44            Self::Network => "network",
45            Self::AiInvoke => "ai.invoke",
46            Self::GitRead => "git.read",
47            Self::GitWrite => "git.write",
48            Self::ExternalProcess => "external_process",
49        }
50    }
51}
52
53impl std::fmt::Display for PluginPermission {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.as_str())
56    }
57}
58
59/// Supported plugin kinds (SDK 1.0).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum PluginKind {
63    Validator,
64    Exporter,
65    Workflow,
66    Documentation,
67    Build,
68    Release,
69    Reasoner,
70    Query,
71    Refactor,
72    Graph,
73    Ui,
74    /// Reserved — not hosted in SDK 1.0 (AI Phase → v1.1).
75    Ai,
76}
77
78impl PluginKind {
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            Self::Validator => "validator",
82            Self::Exporter => "exporter",
83            Self::Workflow => "workflow",
84            Self::Documentation => "documentation",
85            Self::Build => "build",
86            Self::Release => "release",
87            Self::Reasoner => "reasoner",
88            Self::Query => "query",
89            Self::Refactor => "refactor",
90            Self::Graph => "graph",
91            Self::Ui => "ui",
92            Self::Ai => "ai",
93        }
94    }
95
96    pub fn parse(s: &str) -> Result<Self, ManifestValidationError> {
97        match s {
98            "validator" => Ok(Self::Validator),
99            "exporter" => Ok(Self::Exporter),
100            "workflow" => Ok(Self::Workflow),
101            "documentation" => Ok(Self::Documentation),
102            "build" => Ok(Self::Build),
103            "release" => Ok(Self::Release),
104            "reasoner" => Ok(Self::Reasoner),
105            "query" => Ok(Self::Query),
106            "refactor" => Ok(Self::Refactor),
107            "graph" => Ok(Self::Graph),
108            "ui" => Ok(Self::Ui),
109            "ai" => Ok(Self::Ai),
110            // Reserved extension-point ids — accepted in manifests for forward-compat
111            // but not hosted until a later release.
112            "editor" | "language_service" | "tool_window" => {
113                Err(ManifestValidationError::ReservedKind(s.to_string()))
114            }
115            other => Err(ManifestValidationError::UnknownKind(other.to_string())),
116        }
117    }
118
119    /// Kinds that the host can activate and run in SDK 1.0.
120    pub fn is_hosted(&self) -> bool {
121        !matches!(self, Self::Ai)
122    }
123}
124
125/// When a discovered plugin becomes Active after discovery.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum PluginActivation {
129    #[default]
130    OnStartup,
131    OnCommand,
132    OnWorkspaceOpen,
133}
134
135impl PluginActivation {
136    pub fn parse(s: &str) -> Result<Self, ManifestValidationError> {
137        match s {
138            "on_startup" | "startup" => Ok(Self::OnStartup),
139            "on_command" | "command" => Ok(Self::OnCommand),
140            "on_workspace_open" | "workspace_open" => Ok(Self::OnWorkspaceOpen),
141            other => Err(ManifestValidationError::UnknownActivation(other.to_string())),
142        }
143    }
144
145    pub fn as_str(&self) -> &'static str {
146        match self {
147            Self::OnStartup => "on_startup",
148            Self::OnCommand => "on_command",
149            Self::OnWorkspaceOpen => "on_workspace_open",
150        }
151    }
152}
153
154/// Lifecycle state for a discovered plugin.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum PluginLifecycleState {
158    Discovered,
159    Validated,
160    Registered,
161    Active,
162    Disabled,
163}
164
165impl PluginLifecycleState {
166    pub fn as_str(&self) -> &'static str {
167        match self {
168            Self::Discovered => "discovered",
169            Self::Validated => "validated",
170            Self::Registered => "registered",
171            Self::Active => "active",
172            Self::Disabled => "disabled",
173        }
174    }
175}
176
177#[derive(Debug, Error, PartialEq, Eq)]
178pub enum ManifestValidationError {
179    #[error("unknown plugin kind: {0}")]
180    UnknownKind(String),
181    #[error("reserved plugin kind (not hosted in SDK 1.0): {0}")]
182    ReservedKind(String),
183    #[error("unknown plugin permission: {0}")]
184    UnknownPermission(String),
185    #[error("unknown activation: {0}")]
186    UnknownActivation(String),
187    #[error("unsupported api_version: {0} (expected \"1\")")]
188    UnsupportedApiVersion(String),
189    #[error("missing required field: {0}")]
190    MissingField(&'static str),
191}
192
193/// Parsed plugin manifest from workspace TOML.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PluginManifest {
196    pub name: String,
197    pub version: String,
198    pub kind: PluginKind,
199    pub id: Option<String>,
200    pub api_version: Option<String>,
201    pub permissions: Vec<PluginPermission>,
202    pub entry: Option<String>,
203    /// Plugin ids that must be Active before this plugin activates.
204    pub depends_on: Vec<String>,
205    pub activation: PluginActivation,
206    pub capabilities: PluginCapabilities,
207    pub config: PluginConfig,
208    pub ui: PluginUiContributions,
209}
210
211#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
212pub struct PluginConfig {
213    #[serde(default)]
214    pub require_label: bool,
215    #[serde(default)]
216    pub iri_prefix: Option<String>,
217    #[serde(default)]
218    pub shapes_dir: Option<String>,
219    #[serde(default)]
220    pub output_dir: Option<String>,
221    /// Optional reasoner profile id or graph_kind contribution for provider plugins.
222    #[serde(default)]
223    pub provider_id: Option<String>,
224    #[serde(default)]
225    pub graph_kind: Option<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct PluginCommandContribution {
230    pub id: String,
231    pub title: String,
232    #[serde(default)]
233    pub scope: Option<String>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct PluginViewContribution {
238    pub id: String,
239    pub title: String,
240    /// Optional view type hint (e.g. "dock", "panel") for UI placement.
241    #[serde(default)]
242    pub kind: Option<String>,
243    /// Optional command to invoke when opening the view.
244    #[serde(default)]
245    pub command: Option<String>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct PluginPreferencePageContribution {
250    pub id: String,
251    pub title: String,
252    #[serde(default)]
253    pub category: Option<String>,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct PluginContextActionContribution {
258    pub id: String,
259    pub title: String,
260    /// Context scopes like "entity", "graphNode", "workspace".
261    #[serde(default)]
262    pub scope: Option<String>,
263    /// Optional entity kinds the action applies to (e.g. "class", "property").
264    #[serde(default)]
265    pub applies_to: Vec<String>,
266    /// Command invoked when the action is selected.
267    pub command: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct PluginInspectorCard {
272    pub id: String,
273    pub title: String,
274    #[serde(default)]
275    pub applies_to: Vec<String>,
276    #[serde(default)]
277    pub command: Option<String>,
278}
279
280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
281pub struct PluginUiContributions {
282    #[serde(default)]
283    pub commands: Vec<PluginCommandContribution>,
284    #[serde(default)]
285    pub views: Vec<PluginViewContribution>,
286    #[serde(default, rename = "preferences_pages")]
287    pub preferences_pages: Vec<PluginPreferencePageContribution>,
288    #[serde(default, rename = "context_actions")]
289    pub context_actions: Vec<PluginContextActionContribution>,
290    #[serde(default, rename = "inspector_cards")]
291    pub inspector_cards: Vec<PluginInspectorCard>,
292}
293
294#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
295pub struct PluginCapabilities {
296    #[serde(default)]
297    pub build: bool,
298    #[serde(default)]
299    pub validate: bool,
300    #[serde(default)]
301    pub release: bool,
302    #[serde(default)]
303    pub diagnostics: bool,
304    #[serde(default)]
305    pub export: bool,
306    #[serde(default)]
307    pub reasoner: bool,
308    #[serde(default)]
309    pub query: bool,
310    #[serde(default)]
311    pub refactor: bool,
312    #[serde(default)]
313    pub graph: bool,
314}
315
316impl PluginCapabilities {
317    pub fn supports_validation(&self) -> bool {
318        self.validate || self.diagnostics
319    }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct DiscoveredPlugin {
324    pub manifest: PluginManifest,
325    pub manifest_path: PathBuf,
326}
327
328impl DiscoveredPlugin {
329    pub fn plugin_id(&self) -> &str {
330        self.manifest.id.as_deref().unwrap_or(&self.manifest.name)
331    }
332}
333
334#[derive(Debug, Deserialize)]
335struct ManifestFile {
336    plugin: PluginSection,
337    #[serde(default)]
338    capabilities: PluginCapabilities,
339    #[serde(default)]
340    config: PluginConfig,
341    #[serde(default)]
342    ui: PluginUiContributions,
343}
344
345#[derive(Debug, Deserialize)]
346struct PluginSection {
347    name: String,
348    version: String,
349    kind: String,
350    #[serde(default)]
351    id: Option<String>,
352    #[serde(default)]
353    api_version: Option<String>,
354    #[serde(default)]
355    permissions: Vec<String>,
356    #[serde(default)]
357    entry: Option<String>,
358    #[serde(default)]
359    depends_on: Vec<String>,
360    #[serde(default)]
361    activation: Option<String>,
362}
363
364/// Parse and validate a plugin manifest from TOML text.
365pub fn parse_manifest(text: &str) -> Result<PluginManifest, ManifestValidationError> {
366    let file: ManifestFile = toml::from_str(text)
367        .map_err(|_| ManifestValidationError::MissingField("valid TOML structure"))?;
368    if file.plugin.name.is_empty() {
369        return Err(ManifestValidationError::MissingField("plugin.name"));
370    }
371    if file.plugin.version.is_empty() {
372        return Err(ManifestValidationError::MissingField("plugin.version"));
373    }
374    if let Some(api) = &file.plugin.api_version {
375        if api != "1" {
376            return Err(ManifestValidationError::UnsupportedApiVersion(api.clone()));
377        }
378    }
379    let kind = PluginKind::parse(&file.plugin.kind)?;
380    let mut permissions = Vec::new();
381    for p in &file.plugin.permissions {
382        permissions.push(PluginPermission::parse(p)?);
383    }
384    // Backward-compatible defaults for v0.14 manifests that didn't declare permissions yet.
385    // v0.15+ plugins should explicitly declare permissions in `[plugin].permissions`.
386    if permissions.is_empty() {
387        permissions.push(PluginPermission::WorkspaceRead);
388        if file.plugin.entry.is_some() {
389            permissions.push(PluginPermission::ExternalProcess);
390        }
391    }
392    let activation = match &file.plugin.activation {
393        Some(s) => PluginActivation::parse(s)?,
394        None => PluginActivation::OnStartup,
395    };
396    Ok(PluginManifest {
397        name: file.plugin.name,
398        version: file.plugin.version,
399        kind,
400        id: file.plugin.id,
401        api_version: file.plugin.api_version,
402        permissions,
403        entry: file.plugin.entry,
404        depends_on: file.plugin.depends_on,
405        activation,
406        capabilities: file.capabilities,
407        config: file.config,
408        ui: file.ui,
409    })
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn parses_example_manifest() {
418        let text = r#"
419[plugin]
420name = "example-workflow"
421version = "0.1.0"
422kind = "workflow"
423
424[capabilities]
425build = true
426validate = true
427"#;
428        let manifest = parse_manifest(text).expect("parse");
429        assert_eq!(manifest.name, "example-workflow");
430        assert_eq!(manifest.kind, PluginKind::Workflow);
431        assert!(manifest.capabilities.build);
432        assert!(manifest.capabilities.validate);
433        assert!(matches!(manifest.activation, PluginActivation::OnStartup));
434        assert!(manifest.depends_on.is_empty());
435    }
436
437    #[test]
438    fn parses_depends_on_and_activation() {
439        let text = r#"
440[plugin]
441name = "graph-overlay"
442version = "0.1.0"
443kind = "graph"
444id = "org.example.graph"
445api_version = "1"
446depends_on = ["ontocode.naming-validator"]
447activation = "on_command"
448permissions = ["workspace.read", "external_process"]
449
450[capabilities]
451graph = true
452"#;
453        let manifest = parse_manifest(text).expect("parse");
454        assert_eq!(manifest.kind, PluginKind::Graph);
455        assert_eq!(manifest.depends_on, vec!["ontocode.naming-validator"]);
456        assert_eq!(manifest.activation, PluginActivation::OnCommand);
457        assert!(manifest.capabilities.graph);
458    }
459
460    #[test]
461    fn rejects_unknown_kind() {
462        let text = r#"
463[plugin]
464name = "bad"
465version = "0.1.0"
466kind = "not-a-kind"
467"#;
468        assert!(parse_manifest(text).is_err());
469    }
470
471    #[test]
472    fn rejects_reserved_kind() {
473        let text = r#"
474[plugin]
475name = "editor"
476version = "0.1.0"
477kind = "editor"
478"#;
479        let err = parse_manifest(text).unwrap_err();
480        assert!(matches!(err, ManifestValidationError::ReservedKind(_)));
481    }
482
483    #[test]
484    fn rejects_bad_api_version() {
485        let text = r#"
486[plugin]
487name = "bad"
488version = "0.1.0"
489kind = "validator"
490api_version = "2"
491"#;
492        assert!(matches!(
493            parse_manifest(text),
494            Err(ManifestValidationError::UnsupportedApiVersion(_))
495        ));
496    }
497
498    #[test]
499    fn parses_provider_kinds() {
500        for kind in ["reasoner", "query", "refactor", "graph"] {
501            let text = format!(
502                r#"
503[plugin]
504name = "p"
505version = "0.1.0"
506kind = "{kind}"
507api_version = "1"
508permissions = ["workspace.read"]
509"#
510            );
511            let m = parse_manifest(&text).expect(kind);
512            assert_eq!(m.kind.as_str(), kind);
513        }
514    }
515
516    #[test]
517    fn parses_ui_contributions() {
518        let text = r#"
519[plugin]
520name = "naming"
521version = "0.1.0"
522kind = "validator"
523id = "ontocode.naming-validator"
524permissions = ["workspace.read"]
525
526[[ui.commands]]
527id = "naming.check"
528title = "Check naming conventions"
529
530[[ui.views]]
531id = "naming.view"
532title = "Naming view"
533
534[[ui.preferences_pages]]
535id = "naming.prefs"
536title = "Naming"
537
538[[ui.context_actions]]
539id = "naming.ctx"
540title = "Check naming for class"
541scope = "entity"
542applies_to = ["class"]
543command = "naming.check"
544
545[[ui.inspector_cards]]
546id = "naming-summary"
547title = "Naming"
548applies_to = ["class"]
549"#;
550        let manifest = parse_manifest(text).expect("parse");
551        assert_eq!(manifest.ui.commands.len(), 1);
552        assert_eq!(manifest.ui.views.len(), 1);
553        assert_eq!(manifest.ui.preferences_pages.len(), 1);
554        assert_eq!(manifest.ui.context_actions.len(), 1);
555        assert_eq!(manifest.ui.inspector_cards.len(), 1);
556        assert_eq!(manifest.permissions.len(), 1);
557    }
558}