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}
37
38impl PluginContributions {
39    /// Find the command contribution matching a command namespace.
40    pub fn command(&self, name: &str) -> Option<&CommandContribution> {
41        self.commands.iter().find(|command| command.matches(name))
42    }
43}
44
45/// A command namespace exposed by a plugin.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct CommandContribution {
48    /// Command namespace, e.g. `sober`.
49    pub name: String,
50    /// Human title for palettes and menus.
51    #[serde(default)]
52    pub title: Option<String>,
53    /// Human description for palettes and menus.
54    #[serde(default)]
55    pub description: String,
56    /// Runtime entrypoint. Currently `on_command`.
57    #[serde(default = "default_command_entrypoint")]
58    pub entrypoint: String,
59    /// Argument contract for the namespace.
60    #[serde(default)]
61    pub args: CommandArgs,
62    /// Additional command names that route to the same entrypoint.
63    #[serde(default)]
64    pub aliases: Vec<String>,
65    /// Whether this command appears in the fuzzy palette.
66    #[serde(default = "default_true")]
67    pub palette: bool,
68    /// TUI display hints.
69    #[serde(default)]
70    pub tui: CommandTuiContribution,
71}
72
73impl CommandContribution {
74    /// Does this contribution own `name`?
75    pub fn matches(&self, name: &str) -> bool {
76        self.name == name || self.aliases.iter().any(|alias| alias == name)
77    }
78
79    /// Title to show in palettes and menus.
80    pub fn display_title(&self) -> &str {
81        self.title.as_deref().unwrap_or(&self.name)
82    }
83}
84
85/// Argument contract for a command contribution.
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
87#[serde(rename_all = "kebab-case")]
88pub enum CommandArgs {
89    /// No arguments are expected.
90    #[default]
91    None,
92    /// Plugin owns a fixed command grammar.
93    Fixed,
94    /// Plugin receives arbitrary argv after the command namespace.
95    Passthrough,
96}
97
98/// TUI display hints for a command contribution.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct CommandTuiContribution {
101    /// How ProGit should show command output.
102    #[serde(default)]
103    pub show_output: CommandOutputMode,
104}
105
106impl Default for CommandTuiContribution {
107    fn default() -> Self {
108        Self {
109            show_output: CommandOutputMode::Modal,
110        }
111    }
112}
113
114/// Output presentation mode for a command contribution.
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
116#[serde(rename_all = "kebab-case")]
117pub enum CommandOutputMode {
118    /// Show output in a dismissible modal.
119    #[default]
120    Modal,
121    /// Show output inline in command status surfaces.
122    Inline,
123    /// Do not show command output unless the command fails.
124    Silent,
125}
126
127fn default_command_entrypoint() -> String {
128    "on_command".to_string()
129}
130
131fn default_true() -> bool {
132    true
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn parses_command_contributions() {
141        let manifest = PluginContributionManifest::from_json(
142            r#"{
143                "name": "sober-raccoon",
144                "description": "Sober cockpit",
145                "contributions": {
146                    "commands": [
147                        {
148                            "name": "sober",
149                            "title": "Sober",
150                            "description": "Run Sober",
151                            "args": "passthrough",
152                            "aliases": ["sober-raccoon"]
153                        }
154                    ]
155                }
156            }"#,
157        )
158        .unwrap();
159
160        let command = manifest.contributions.command("sober-raccoon").unwrap();
161        assert_eq!(command.name, "sober");
162        assert_eq!(command.display_title(), "Sober");
163        assert_eq!(command.args, CommandArgs::Passthrough);
164        assert_eq!(command.tui.show_output, CommandOutputMode::Modal);
165    }
166
167    #[test]
168    fn ignores_root_commands() {
169        let manifest = PluginContributionManifest::from_json(
170            r#"{
171                "name": "legacy",
172                "commands": ["old"]
173            }"#,
174        )
175        .unwrap();
176
177        assert!(manifest.contributions.commands.is_empty());
178    }
179}