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
39/// Supported plugin kinds per PLUGIN_SPEC.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum PluginKind {
43    Validator,
44    Exporter,
45    Workflow,
46    Documentation,
47    Build,
48    Release,
49    Reasoner,
50    Query,
51    Ui,
52    Ai,
53}
54
55impl PluginKind {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::Validator => "validator",
59            Self::Exporter => "exporter",
60            Self::Workflow => "workflow",
61            Self::Documentation => "documentation",
62            Self::Build => "build",
63            Self::Release => "release",
64            Self::Reasoner => "reasoner",
65            Self::Query => "query",
66            Self::Ui => "ui",
67            Self::Ai => "ai",
68        }
69    }
70
71    pub fn parse(s: &str) -> Result<Self, ManifestValidationError> {
72        match s {
73            "validator" => Ok(Self::Validator),
74            "exporter" => Ok(Self::Exporter),
75            "workflow" => Ok(Self::Workflow),
76            "documentation" => Ok(Self::Documentation),
77            "build" => Ok(Self::Build),
78            "release" => Ok(Self::Release),
79            "reasoner" => Ok(Self::Reasoner),
80            "query" => Ok(Self::Query),
81            "ui" => Ok(Self::Ui),
82            "ai" => Ok(Self::Ai),
83            other => Err(ManifestValidationError::UnknownKind(other.to_string())),
84        }
85    }
86}
87
88#[derive(Debug, Error)]
89pub enum ManifestValidationError {
90    #[error("unknown plugin kind: {0}")]
91    UnknownKind(String),
92    #[error("unknown plugin permission: {0}")]
93    UnknownPermission(String),
94    #[error("unsupported api_version: {0} (expected \"1\")")]
95    UnsupportedApiVersion(String),
96    #[error("missing required field: {0}")]
97    MissingField(&'static str),
98}
99
100/// Parsed plugin manifest from workspace TOML.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PluginManifest {
103    pub name: String,
104    pub version: String,
105    pub kind: PluginKind,
106    pub id: Option<String>,
107    pub api_version: Option<String>,
108    pub permissions: Vec<PluginPermission>,
109    pub entry: Option<String>,
110    pub capabilities: PluginCapabilities,
111    pub config: PluginConfig,
112    pub ui: PluginUiContributions,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct PluginConfig {
117    #[serde(default)]
118    pub require_label: bool,
119    #[serde(default)]
120    pub iri_prefix: Option<String>,
121    #[serde(default)]
122    pub shapes_dir: Option<String>,
123    #[serde(default)]
124    pub output_dir: Option<String>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct PluginCommandContribution {
129    pub id: String,
130    pub title: String,
131    #[serde(default)]
132    pub scope: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct PluginViewContribution {
137    pub id: String,
138    pub title: String,
139    /// Optional view type hint (e.g. "dock", "panel") for UI placement.
140    #[serde(default)]
141    pub kind: Option<String>,
142    /// Optional command to invoke when opening the view.
143    #[serde(default)]
144    pub command: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct PluginPreferencePageContribution {
149    pub id: String,
150    pub title: String,
151    #[serde(default)]
152    pub category: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct PluginContextActionContribution {
157    pub id: String,
158    pub title: String,
159    /// Context scopes like "entity", "graphNode", "workspace".
160    #[serde(default)]
161    pub scope: Option<String>,
162    /// Optional entity kinds the action applies to (e.g. "class", "property").
163    #[serde(default)]
164    pub applies_to: Vec<String>,
165    /// Command invoked when the action is selected.
166    pub command: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct PluginInspectorCard {
171    pub id: String,
172    pub title: String,
173    #[serde(default)]
174    pub applies_to: Vec<String>,
175    #[serde(default)]
176    pub command: Option<String>,
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
180pub struct PluginUiContributions {
181    #[serde(default)]
182    pub commands: Vec<PluginCommandContribution>,
183    #[serde(default)]
184    pub views: Vec<PluginViewContribution>,
185    #[serde(default, rename = "preferences_pages")]
186    pub preferences_pages: Vec<PluginPreferencePageContribution>,
187    #[serde(default, rename = "context_actions")]
188    pub context_actions: Vec<PluginContextActionContribution>,
189    #[serde(default, rename = "inspector_cards")]
190    pub inspector_cards: Vec<PluginInspectorCard>,
191}
192
193#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
194pub struct PluginCapabilities {
195    #[serde(default)]
196    pub build: bool,
197    #[serde(default)]
198    pub validate: bool,
199    #[serde(default)]
200    pub release: bool,
201    #[serde(default)]
202    pub diagnostics: bool,
203    #[serde(default)]
204    pub export: bool,
205}
206
207impl PluginCapabilities {
208    pub fn supports_validation(&self) -> bool {
209        self.validate || self.diagnostics
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct DiscoveredPlugin {
215    pub manifest: PluginManifest,
216    pub manifest_path: PathBuf,
217}
218
219impl DiscoveredPlugin {
220    pub fn plugin_id(&self) -> &str {
221        self.manifest.id.as_deref().unwrap_or(&self.manifest.name)
222    }
223}
224
225#[derive(Debug, Deserialize)]
226struct ManifestFile {
227    plugin: PluginSection,
228    #[serde(default)]
229    capabilities: PluginCapabilities,
230    #[serde(default)]
231    config: PluginConfig,
232    #[serde(default)]
233    ui: PluginUiContributions,
234}
235
236#[derive(Debug, Deserialize)]
237struct PluginSection {
238    name: String,
239    version: String,
240    kind: String,
241    #[serde(default)]
242    id: Option<String>,
243    #[serde(default)]
244    api_version: Option<String>,
245    #[serde(default)]
246    permissions: Vec<String>,
247    #[serde(default)]
248    entry: Option<String>,
249}
250
251/// Parse and validate a plugin manifest from TOML text.
252pub fn parse_manifest(text: &str) -> Result<PluginManifest, ManifestValidationError> {
253    let file: ManifestFile = toml::from_str(text)
254        .map_err(|_| ManifestValidationError::MissingField("valid TOML structure"))?;
255    if file.plugin.name.is_empty() {
256        return Err(ManifestValidationError::MissingField("plugin.name"));
257    }
258    if file.plugin.version.is_empty() {
259        return Err(ManifestValidationError::MissingField("plugin.version"));
260    }
261    if let Some(api) = &file.plugin.api_version {
262        if api != "1" {
263            return Err(ManifestValidationError::UnsupportedApiVersion(api.clone()));
264        }
265    }
266    let kind = PluginKind::parse(&file.plugin.kind)?;
267    let mut permissions = Vec::new();
268    for p in &file.plugin.permissions {
269        permissions.push(PluginPermission::parse(p)?);
270    }
271    // Backward-compatible defaults for v0.14 manifests that didn't declare permissions yet.
272    // v0.15+ plugins should explicitly declare permissions in `[plugin].permissions`.
273    if permissions.is_empty() {
274        permissions.push(PluginPermission::WorkspaceRead);
275        if file.plugin.entry.is_some() {
276            permissions.push(PluginPermission::ExternalProcess);
277        }
278    }
279    Ok(PluginManifest {
280        name: file.plugin.name,
281        version: file.plugin.version,
282        kind,
283        id: file.plugin.id,
284        api_version: file.plugin.api_version,
285        permissions,
286        entry: file.plugin.entry,
287        capabilities: file.capabilities,
288        config: file.config,
289        ui: file.ui,
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn parses_example_manifest() {
299        let text = r#"
300[plugin]
301name = "example-workflow"
302version = "0.1.0"
303kind = "workflow"
304
305[capabilities]
306build = true
307validate = true
308"#;
309        let manifest = parse_manifest(text).expect("parse");
310        assert_eq!(manifest.name, "example-workflow");
311        assert_eq!(manifest.kind, PluginKind::Workflow);
312        assert!(manifest.capabilities.build);
313        assert!(manifest.capabilities.validate);
314    }
315
316    #[test]
317    fn rejects_unknown_kind() {
318        let text = r#"
319[plugin]
320name = "bad"
321version = "0.1.0"
322kind = "not-a-kind"
323"#;
324        assert!(parse_manifest(text).is_err());
325    }
326
327    #[test]
328    fn parses_ui_contributions() {
329        let text = r#"
330[plugin]
331name = "naming"
332version = "0.1.0"
333kind = "validator"
334id = "ontocode.naming-validator"
335permissions = ["workspace.read"]
336
337[[ui.commands]]
338id = "naming.check"
339title = "Check naming conventions"
340
341[[ui.views]]
342id = "naming.view"
343title = "Naming view"
344
345[[ui.preferences_pages]]
346id = "naming.prefs"
347title = "Naming"
348
349[[ui.context_actions]]
350id = "naming.ctx"
351title = "Check naming for class"
352scope = "entity"
353applies_to = ["class"]
354command = "naming.check"
355
356[[ui.inspector_cards]]
357id = "naming-summary"
358title = "Naming"
359applies_to = ["class"]
360"#;
361        let manifest = parse_manifest(text).expect("parse");
362        assert_eq!(manifest.ui.commands.len(), 1);
363        assert_eq!(manifest.ui.views.len(), 1);
364        assert_eq!(manifest.ui.preferences_pages.len(), 1);
365        assert_eq!(manifest.ui.context_actions.len(), 1);
366        assert_eq!(manifest.ui.inspector_cards.len(), 1);
367        assert_eq!(manifest.permissions.len(), 1);
368    }
369}