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

//! # Widget Fragments (experimental_)
//!
//! Mechanism for plugins to inject UI fragments into existing TUI widgets.
//!
//! ## Stability
//!
//! Experimental as of SDK 0.3. Promote to stable after `progit-pr-stacker`
//! v0.1 ships and exercises the API in production.
//!
//! ## Design constraints
//!
//! 1. **Trait Firewall.** Fragments produce typed `RenderFragment` data;
//!    the TUI does the actual drawing. No `crossterm::Print` from plugins.
//! 2. **Bounded slot set.** Each widget exposes a finite set of named slots
//!    (see `FRAGMENT_SLOT_REGISTRY`). Plugins register against existing slots,
//!    they do NOT invent new ones.
//! 3. **Bounded performance.** Fragments rendering on every frame must be
//!    fast (< 1 ms). Slower fragments declare themselves async-friendly via
//!    `RenderFragment::ready = false`; the TUI shows a placeholder until ready.
//! 4. **Conflict resolution.** If two plugins claim the same slot, higher
//!    `priority` wins; ties broken lexicographically; conflicts logged once.

use serde::{Deserialize, Serialize};

use crate::render::TokenSpan;
use crate::traits::core::PluginResult;

/// Slot identifier — `<widget_name>.<slot_name>` (e.g. `"mr_detail.stack_panel"`).
pub type FragmentSlot = String;

/// Registry of all valid slot identifiers in SDK 0.3.
///
/// New slots require an SDK minor bump and TUI core support. The plugin
/// loader rejects manifests that declare slots not in this registry.
pub const FRAGMENT_SLOT_REGISTRY: &[&str] = &[
    "mr_detail.stack_panel",
    "mr_detail.linked_issues",
    "mr_detail.ci_status",
    "mr_list.backend_pill",
    "mr_list.stack_indicator",
    "dashboard.profile_card",
    "dashboard.releases_card",
    "file_tree.node_decoration",
    "review.draft_pill",
];

/// Context the host passes to the plugin when asking it to render a fragment.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FragmentContext {
    pub slot: FragmentSlot,
    /// Slot-specific payload. Schema documented per slot in the user-facing
    /// SDK reference; validated by the SDK before dispatch.
    pub data: serde_json::Value,
    /// Hard cap. Fragment must not return more rows.
    pub max_rows: usize,
    /// Hard cap. Fragment must not return more columns.
    pub max_cols: usize,
}

/// What the plugin returns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderFragment {
    /// Sequence of styled lines. May be empty (means "render nothing for this slot").
    pub lines: Vec<Vec<TokenSpan>>,
    /// `true` = render synchronously next frame.
    /// `false` = TUI shows placeholder, asks again on next tick.
    pub ready: bool,
    /// Optional named actions the user can trigger from this fragment via hotkey.
    pub actions: Vec<FragmentAction>,
}

/// A user-actionable hotkey scoped to a fragment.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FragmentAction {
    /// Hotkey identifier. Format follows the same scheme as the rest of the
    /// TUI (e.g. `"Ctrl+L"`, `"g s"`).
    pub key: String,
    /// User-visible label. Rendered in the status bar when the fragment is
    /// focused.
    pub label: String,
    /// Plugin command name dispatched on key press.
    pub command: String,
}

/// Trait plugins implement.
pub trait FragmentRenderer {
    /// Slots this plugin claims to fill. Must be a subset of `FRAGMENT_SLOT_REGISTRY`.
    fn fragment_slots(&self) -> Vec<FragmentSlot>;

    /// Render a fragment for the given slot + context.
    fn render_fragment(&mut self, ctx: &FragmentContext) -> PluginResult<RenderFragment>;
}

/// Validate that a fragment slot is recognized.
pub fn slot_is_valid(slot: &str) -> bool {
    FRAGMENT_SLOT_REGISTRY.iter().any(|&s| s == slot)
}

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

    #[test]
    fn known_slots_validate() {
        for slot in FRAGMENT_SLOT_REGISTRY {
            assert!(slot_is_valid(slot), "slot {slot} should validate");
        }
    }

    #[test]
    fn unknown_slot_rejected() {
        assert!(!slot_is_valid("unknown.slot"));
        assert!(!slot_is_valid(""));
    }

    #[test]
    fn render_fragment_serde_round_trip() {
        let f = RenderFragment {
            lines: vec![],
            ready: true,
            actions: vec![FragmentAction {
                key: "Ctrl+L".into(),
                label: "Land stack".into(),
                command: "stack-land".into(),
            }],
        };
        let s = serde_json::to_string(&f).unwrap();
        let back: RenderFragment = serde_json::from_str(&s).unwrap();
        assert!(back.ready);
        assert_eq!(back.actions.len(), 1);
        assert_eq!(back.actions[0].command, "stack-land");
    }
}