progit-plugin-sdk 0.2.1

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) 2025 Markus Maiwald

//! Core plugin trait definitions
//!
//! These traits define the fundamental contract between ProGit and plugins.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Plugin metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
    pub name: String,
    pub version: String,
    pub author: String,
    pub description: String,
    pub hooks: Vec<PluginHook>,
}

/// Available plugin hooks
#[derive(Debug, Clone, Serialize, Deserialize, Eq)]
pub enum PluginHook {
    // === Issue Lifecycle ===
    /// Called when an issue is created
    OnIssueCreated,
    /// Called when an issue is updated
    OnIssueUpdated,
    /// Called when an issue is deleted
    OnIssueDeleted,
    /// Called when an issue status changes
    OnStatusChanged,

    // === Sync Operations ===
    /// Called before sync push
    OnSyncPush,
    /// Called after sync pull
    OnSyncPull,

    // === Git Operations ===
    /// Called when a merge request is created
    OnMergeRequestCreated,

    // === Custom Commands ===
    /// Custom command hook
    OnCommand(String),

    // === Scheduled Operations ===
    /// Called on a named schedule: "hourly", "daily", "sprint_end"
    OnSchedule(String),

    // === Bulk Operations ===
    /// Called before/after bulk operations
    OnBulkOperation(BulkOp),

    // === Integration Hooks ===
    /// External system sync requested
    OnExternalSync,
    /// Incoming webhook from external system
    OnWebhookReceived,

    // === Sprint/Time Hooks ===
    /// Called when a sprint starts
    OnSprintStart(u32),
    /// Called when a sprint ends
    OnSprintEnd(u32),
    /// Called 24h before an issue's due date
    OnDueDateApproaching,
    /// Called when an issue's due date has passed
    OnDueDatePassed,

    // === Analytics Hooks ===
    /// Report generation requested
    OnReportRequested,
    /// Metric computation requested
    OnMetricQuery,
}

// NOTE: OnCommand variants compare equal regardless of the command string
// This allows "hooks" manifest to match OnCommand("hooks") dispatch
impl PartialEq for PluginHook {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (PluginHook::OnCommand(_), PluginHook::OnCommand(_)) => true,
            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
        }
    }
}

/// Bulk operation types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum BulkOp {
    Import,
    Export,
    Archive,
    Delete,
}

/// Issue representation for plugins
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
    pub id: String,
    pub title: String,
    pub description: String,
    pub status: String,
    pub tags: Vec<String>,
    pub assignee: Option<String>,
    pub effort: Option<u8>,
    pub blocked: bool,
    pub created: String,
    pub updated: String,
    pub due: Option<String>,
    pub metadata: HashMap<String, serde_json::Value>,
}

/// Plugin execution context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginContext {
    /// Current repository path
    pub repo_path: String,
    /// Current user
    pub user: Option<String>,
    /// Environment variables accessible to plugin
    pub env: HashMap<String, String>,
    /// Plugin configuration
    pub config: HashMap<String, serde_json::Value>,
}

/// Plugin execution result
pub type PluginResult<T> = Result<T, PluginError>;

/// Plugin error types
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
    #[error("Plugin initialization failed: {0}")]
    InitError(String),

    #[error("Plugin execution failed: {0}")]
    ExecutionError(String),

    #[error("Invalid plugin configuration: {0}")]
    ConfigError(String),

    #[error("Hook not supported: {0:?}")]
    UnsupportedHook(PluginHook),

    #[error("Serialization error: {0}")]
    SerializationError(#[from] serde_json::Error),

    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),

    #[error("Storage error: {0}")]
    StorageError(String),

    #[error("Sync error: {0}")]
    SyncError(String),

    #[error("External API error: {0}")]
    ExternalApiError(String),
}

/// Core plugin trait
///
/// Note: Plugins are single-threaded by design for simplicity and safety.
/// The ProGit TUI loads and executes plugins on the main thread only.
pub trait Plugin {
    /// Get plugin metadata
    fn metadata(&self) -> &PluginMetadata;

    /// Initialize the plugin with context
    fn init(&mut self, context: &PluginContext) -> PluginResult<()>;

    /// Execute a hook
    fn execute_hook(
        &mut self,
        hook: &PluginHook,
        data: &serde_json::Value,
    ) -> PluginResult<serde_json::Value>;

    /// Check if plugin supports a specific hook
    fn supports_hook(&self, hook: &PluginHook) -> bool {
        self.metadata().hooks.contains(hook)
    }

    /// Handle a plugin event (new event system)
    ///
    /// This method supports the event-based plugin API where plugins receive
    /// structured events and return optional responses. This is newer than
    /// the hook-based system and is used for query-style interactions like
    /// CI/CD status queries.
    ///
    /// Returns `Ok(None)` if the plugin doesn't handle this event type.
    /// Returns `Ok(Some(response))` with the response data.
    fn on_event(&mut self, event: &serde_json::Value) -> PluginResult<Option<serde_json::Value>> {
        // Default implementation: plugin doesn't support events
        let _ = event; // Suppress unused warning
        Ok(None)
    }

    /// Render-time hook: provide syntax-highlighted spans for a chunk of text.
    ///
    /// Called from the host's frame-render path (e.g. the diff renderer).
    /// The host caches results aggressively, but on a cache miss the
    /// plugin must respond fast — keep the implementation linear in
    /// `content` length and avoid I/O.
    ///
    /// Returning `Ok(None)` means "not a highlight provider for this
    /// language" — the host tries the next plugin, or falls through to
    /// plain text. Plugins that *are* highlight providers but cannot
    /// handle a specific language should also return `None`, not an error.
    fn highlight(
        &mut self,
        request: &crate::render::HighlightRequest,
    ) -> PluginResult<Option<crate::render::HighlightResponse>> {
        let _ = request;
        Ok(None)
    }
}

/// Convenience trait for issue lifecycle hooks
pub trait IssuePlugin: Plugin {
    fn on_issue_created(&mut self, issue: &Issue) -> PluginResult<()> {
        let data = serde_json::to_value(issue)?;
        self.execute_hook(&PluginHook::OnIssueCreated, &data)?;
        Ok(())
    }

    fn on_issue_updated(&mut self, issue: &Issue) -> PluginResult<()> {
        let data = serde_json::to_value(issue)?;
        self.execute_hook(&PluginHook::OnIssueUpdated, &data)?;
        Ok(())
    }

    fn on_issue_deleted(&mut self, issue_id: &str) -> PluginResult<()> {
        let data = serde_json::json!({ "id": issue_id });
        self.execute_hook(&PluginHook::OnIssueDeleted, &data)?;
        Ok(())
    }
}

/// Convenience trait for sync hooks
pub trait SyncPlugin: Plugin {
    fn on_sync_push(&mut self, issues: &[Issue]) -> PluginResult<()> {
        let data = serde_json::to_value(issues)?;
        self.execute_hook(&PluginHook::OnSyncPush, &data)?;
        Ok(())
    }

    fn on_sync_pull(&mut self, issues: &[Issue]) -> PluginResult<()> {
        let data = serde_json::to_value(issues)?;
        self.execute_hook(&PluginHook::OnSyncPull, &data)?;
        Ok(())
    }
}