progit-plugin-sdk 0.3.0

Plugin SDK for ProGit — sandboxed LuaJIT runtime with capability-based security. LSL-1.0 (file-level copyleft, proprietary plugins allowed via the commercial bridge).
Documentation
// SPDX-License-Identifier: LSL-1.0
// Copyright (c) 2026 Markus Maiwald

//! Plugin contribution contract.
//!
//! Contributions are the stable surface a plugin exposes to ProGit. Hooks say
//! what code can receive; contributions say what the host may show, route, and
//! integrate into command palettes or TUI surfaces.

use serde::{Deserialize, Serialize};

/// Manifest envelope used when only contribution metadata is needed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginContributionManifest {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub contributions: PluginContributions,
}

impl PluginContributionManifest {
    /// Parse contribution metadata from a `.progit-plugin.json` document.
    pub fn from_json(input: &str) -> serde_json::Result<Self> {
        serde_json::from_str(input)
    }
}

/// Contributions exposed by a plugin.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginContributions {
    /// Command namespaces the plugin owns under `prog plugin <name> ...`.
    #[serde(default)]
    pub commands: Vec<CommandContribution>,
    /// Top-level command aliases (e.g. `:citadel` instead of `:plugin citadel`).
    #[serde(default)]
    pub command_aliases: Vec<CommandAlias>,
    /// Event subscriptions the plugin wants to receive.
    #[serde(default)]
    pub event_subscriptions: Vec<EventSubscription>,
    /// Auto-activation rules — load plugin automatically when conditions match.
    #[serde(default)]
    pub activation: PluginActivation,
}

impl PluginContributions {
    /// Find the command contribution matching a command namespace.
    pub fn command(&self, name: &str) -> Option<&CommandContribution> {
        self.commands.iter().find(|command| command.matches(name))
    }
}

/// A command namespace exposed by a plugin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandContribution {
    /// Command namespace, e.g. `sober`.
    pub name: String,
    /// Human title for palettes and menus.
    #[serde(default)]
    pub title: Option<String>,
    /// Human description for palettes and menus.
    #[serde(default)]
    pub description: String,
    /// Runtime entrypoint. Currently `on_command`.
    #[serde(default = "default_command_entrypoint")]
    pub entrypoint: String,
    /// Argument contract for the namespace.
    #[serde(default)]
    pub args: CommandArgs,
    /// Additional command names that route to the same entrypoint.
    #[serde(default)]
    pub aliases: Vec<String>,
    /// Whether this command appears in the fuzzy palette.
    #[serde(default = "default_true")]
    pub palette: bool,
    /// TUI display hints.
    #[serde(default)]
    pub tui: CommandTuiContribution,
}

impl CommandContribution {
    /// Does this contribution own `name`?
    pub fn matches(&self, name: &str) -> bool {
        self.name == name || self.aliases.iter().any(|alias| alias == name)
    }

    /// Title to show in palettes and menus.
    pub fn display_title(&self) -> &str {
        self.title.as_deref().unwrap_or(&self.name)
    }
}

/// Argument contract for a command contribution.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommandArgs {
    /// No arguments are expected.
    #[default]
    None,
    /// Plugin owns a fixed command grammar.
    Fixed,
    /// Plugin receives arbitrary argv after the command namespace.
    Passthrough,
}

/// TUI display hints for a command contribution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandTuiContribution {
    /// How ProGit should show command output.
    #[serde(default)]
    pub show_output: CommandOutputMode,
}

impl Default for CommandTuiContribution {
    fn default() -> Self {
        Self {
            show_output: CommandOutputMode::Modal,
        }
    }
}

/// Output presentation mode for a command contribution.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommandOutputMode {
    /// Show output in a dismissible modal.
    #[default]
    Modal,
    /// Show output inline in command status surfaces.
    Inline,
    /// Do not show command output unless the command fails.
    Silent,
}

fn default_command_entrypoint() -> String {
    "on_command".to_string()
}

/// Top-level command alias registered by a plugin.
///
/// Allows `:citadel validate` instead of `:plugin citadel validate`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandAlias {
    /// The alias string, e.g. `citadel`.
    pub alias: String,
    /// Human description for palettes and help.
    pub description: String,
    /// Argument contract shown in help.
    pub args: String,
    /// Maps to which plugin command namespace this alias targets.
    pub target_command: String,
}

/// Event subscription declared by a plugin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EventSubscription {
    /// Event pattern to subscribe to.
    ///
    /// Supported patterns:
    /// - `job:progress` — job progress updates
    /// - `job:complete` — job completion
    /// - `job:failed` — job failure
    /// - `file:changed` — watched file changes
    /// - `issue:*` — all issue events
    /// - `custom:<name>` — plugin-defined custom events
    pub event: String,
}

/// Auto-activation rules for a plugin.
///
/// The host evaluates these at project load time. If any rule matches,
/// the plugin is automatically loaded (but not necessarily initialised
/// until a hook or command triggers it).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginActivation {
    /// Activate when any of these file globs exist in the repo root.
    ///
    /// Examples: `CITADEL.kdl`, `.github/workflows/*.yml`, `Jenkinsfile`.
    #[serde(default)]
    pub files: Vec<String>,
    /// Activate when any of these command prefixes are typed.
    ///
    /// Example: `["citadel"]` means the plugin loads when user types `:citadel`.
    #[serde(default)]
    pub command_prefixes: Vec<String>,
    /// Activate when the repo remote URL matches any of these patterns.
    #[serde(default)]
    pub remote_url_patterns: Vec<String>,
}

fn default_true() -> bool {
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_command_contributions() {
        let manifest = PluginContributionManifest::from_json(
            r#"{
                "name": "sober-raccoon",
                "description": "Sober cockpit",
                "contributions": {
                    "commands": [
                        {
                            "name": "sober",
                            "title": "Sober",
                            "description": "Run Sober",
                            "args": "passthrough",
                            "aliases": ["sober-raccoon"]
                        }
                    ]
                }
            }"#,
        )
        .unwrap();

        let command = manifest.contributions.command("sober-raccoon").unwrap();
        assert_eq!(command.name, "sober");
        assert_eq!(command.display_title(), "Sober");
        assert_eq!(command.args, CommandArgs::Passthrough);
        assert_eq!(command.tui.show_output, CommandOutputMode::Modal);
    }

    #[test]
    fn ignores_root_commands() {
        let manifest = PluginContributionManifest::from_json(
            r#"{
                "name": "legacy",
                "commands": ["old"]
            }"#,
        )
        .unwrap();

        assert!(manifest.contributions.commands.is_empty());
    }
}