1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use thiserror::Error;
4
5#[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#[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 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 "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 pub fn is_hosted(&self) -> bool {
121 !matches!(self, Self::Ai)
122 }
123}
124
125#[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#[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#[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 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 #[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 #[serde(default)]
242 pub kind: Option<String>,
243 #[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 #[serde(default)]
262 pub scope: Option<String>,
263 #[serde(default)]
265 pub applies_to: Vec<String>,
266 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
364pub 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 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}