Skip to main content

progit_plugin_sdk/
contributions.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2026 Markus Maiwald
3
4//! Plugin contribution contract.
5//!
6//! Contributions are the stable surface a plugin exposes to ProGit. Hooks say
7//! what code can receive; contributions say what the host may show, route, and
8//! integrate into command palettes or TUI surfaces.
9
10use serde::{Deserialize, Serialize};
11
12/// Manifest envelope used when only contribution metadata is needed.
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct PluginContributionManifest {
15    #[serde(default)]
16    pub name: String,
17    #[serde(default)]
18    pub description: String,
19    #[serde(default)]
20    pub contributions: PluginContributions,
21}
22
23impl PluginContributionManifest {
24    /// Parse contribution metadata from a `.progit-plugin.json` document.
25    pub fn from_json(input: &str) -> serde_json::Result<Self> {
26        serde_json::from_str(input)
27    }
28}
29
30/// Contributions exposed by a plugin.
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct PluginContributions {
33    /// Command namespaces the plugin owns under `prog plugin <name> ...`.
34    #[serde(default)]
35    pub commands: Vec<CommandContribution>,
36    /// Top-level command aliases (e.g. `:citadel` instead of `:plugin citadel`).
37    #[serde(default)]
38    pub command_aliases: Vec<CommandAlias>,
39    /// Event subscriptions the plugin wants to receive.
40    #[serde(default)]
41    pub event_subscriptions: Vec<EventSubscription>,
42    /// Auto-activation rules — load plugin automatically when conditions match.
43    #[serde(default)]
44    pub activation: PluginActivation,
45}
46
47impl PluginContributions {
48    /// Find the command contribution matching a command namespace.
49    pub fn command(&self, name: &str) -> Option<&CommandContribution> {
50        self.commands.iter().find(|command| command.matches(name))
51    }
52}
53
54/// A command namespace exposed by a plugin.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct CommandContribution {
57    /// Command namespace, e.g. `sober`.
58    pub name: String,
59    /// Human title for palettes and menus.
60    #[serde(default)]
61    pub title: Option<String>,
62    /// Human description for palettes and menus.
63    #[serde(default)]
64    pub description: String,
65    /// Runtime entrypoint. Currently `on_command`.
66    #[serde(default = "default_command_entrypoint")]
67    pub entrypoint: String,
68    /// Argument contract for the namespace.
69    #[serde(default)]
70    pub args: CommandArgs,
71    /// Additional command names that route to the same entrypoint.
72    #[serde(default)]
73    pub aliases: Vec<String>,
74    /// Whether this command appears in the fuzzy palette.
75    #[serde(default = "default_true")]
76    pub palette: bool,
77    /// TUI display hints.
78    #[serde(default)]
79    pub tui: CommandTuiContribution,
80}
81
82impl CommandContribution {
83    /// Does this contribution own `name`?
84    pub fn matches(&self, name: &str) -> bool {
85        self.name == name || self.aliases.iter().any(|alias| alias == name)
86    }
87
88    /// Title to show in palettes and menus.
89    pub fn display_title(&self) -> &str {
90        self.title.as_deref().unwrap_or(&self.name)
91    }
92}
93
94/// Argument contract for a command contribution.
95#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
96#[serde(rename_all = "kebab-case")]
97pub enum CommandArgs {
98    /// No arguments are expected.
99    #[default]
100    None,
101    /// Plugin owns a fixed command grammar.
102    Fixed,
103    /// Plugin receives arbitrary argv after the command namespace.
104    Passthrough,
105}
106
107/// TUI display hints for a command contribution.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct CommandTuiContribution {
110    /// How ProGit should show command output.
111    #[serde(default)]
112    pub show_output: CommandOutputMode,
113}
114
115impl Default for CommandTuiContribution {
116    fn default() -> Self {
117        Self {
118            show_output: CommandOutputMode::Modal,
119        }
120    }
121}
122
123/// Output presentation mode for a command contribution.
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
125#[serde(rename_all = "kebab-case")]
126pub enum CommandOutputMode {
127    /// Show output in a dismissible modal.
128    #[default]
129    Modal,
130    /// Show output inline in command status surfaces.
131    Inline,
132    /// Do not show command output unless the command fails.
133    Silent,
134}
135
136fn default_command_entrypoint() -> String {
137    "on_command".to_string()
138}
139
140/// Top-level command alias registered by a plugin.
141///
142/// Allows `:citadel validate` instead of `:plugin citadel validate`.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct CommandAlias {
145    /// The alias string, e.g. `citadel`.
146    pub alias: String,
147    /// Human description for palettes and help.
148    pub description: String,
149    /// Argument contract shown in help.
150    pub args: String,
151    /// Maps to which plugin command namespace this alias targets.
152    pub target_command: String,
153}
154
155/// Event subscription declared by a plugin.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct EventSubscription {
158    /// Event pattern to subscribe to.
159    ///
160    /// Supported patterns:
161    /// - `job:progress` — job progress updates
162    /// - `job:complete` — job completion
163    /// - `job:failed` — job failure
164    /// - `file:changed` — watched file changes
165    /// - `issue:*` — all issue events
166    /// - `custom:<name>` — plugin-defined custom events
167    pub event: String,
168}
169
170/// Auto-activation rules for a plugin.
171///
172/// The host evaluates these at project load time. If any rule matches,
173/// the plugin is automatically loaded (but not necessarily initialised
174/// until a hook or command triggers it).
175#[derive(Debug, Clone, Serialize, Deserialize, Default)]
176pub struct PluginActivation {
177    /// Activate when any of these file globs exist in the repo root.
178    ///
179    /// Examples: `CITADEL.kdl`, `.github/workflows/*.yml`, `Jenkinsfile`.
180    #[serde(default)]
181    pub files: Vec<String>,
182    /// Activate when any of these command prefixes are typed.
183    ///
184    /// Example: `["citadel"]` means the plugin loads when user types `:citadel`.
185    #[serde(default)]
186    pub command_prefixes: Vec<String>,
187    /// Activate when the repo remote URL matches any of these patterns.
188    #[serde(default)]
189    pub remote_url_patterns: Vec<String>,
190}
191
192fn default_true() -> bool {
193    true
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn parses_command_contributions() {
202        let manifest = PluginContributionManifest::from_json(
203            r#"{
204                "name": "sober-raccoon",
205                "description": "Sober cockpit",
206                "contributions": {
207                    "commands": [
208                        {
209                            "name": "sober",
210                            "title": "Sober",
211                            "description": "Run Sober",
212                            "args": "passthrough",
213                            "aliases": ["sober-raccoon"]
214                        }
215                    ]
216                }
217            }"#,
218        )
219        .unwrap();
220
221        let command = manifest.contributions.command("sober-raccoon").unwrap();
222        assert_eq!(command.name, "sober");
223        assert_eq!(command.display_title(), "Sober");
224        assert_eq!(command.args, CommandArgs::Passthrough);
225        assert_eq!(command.tui.show_output, CommandOutputMode::Modal);
226    }
227
228    #[test]
229    fn ignores_root_commands() {
230        let manifest = PluginContributionManifest::from_json(
231            r#"{
232                "name": "legacy",
233                "commands": ["old"]
234            }"#,
235        )
236        .unwrap();
237
238        assert!(manifest.contributions.commands.is_empty());
239    }
240}