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) 2025 Markus Maiwald
//
// Plugin harness — drives the bundled reference plugins through a
// realistic event stream and asserts they don't blow up. This is the
// regression net that would have caught the slack-notify v0.1 bug
// (it called `progit.http_post` and `log_info` which never existed).

use progit_plugin_sdk::prelude::*;
use std::path::PathBuf;

fn plugins_dir() -> PathBuf {
    // workspace layout: progit-plugin-sdk/tests/harness.rs
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("manifest dir has a parent")
        .join("progit-plugins")
}

fn loose_opts(repo_root: PathBuf) -> LuaPluginOptions {
    let mut o = LuaPluginOptions::default();
    o.network = false; // tests must never hit the network
    o.max_instructions = 0; // no instruction cap during harness runs
    o.repo_root = Some(repo_root);
    o
}

fn ctx(repo_root: &std::path::Path) -> PluginContext {
    PluginContext {
        repo_path: repo_root.to_string_lossy().to_string(),
        user: Some("harness".into()),
        env: Default::default(),
        config: Default::default(),
    }
}

#[test]
fn git_hooks_handles_full_event_stream() {
    let path = plugins_dir().join("git-hooks/main.lua");
    if !path.exists() {
        eprintln!("skipping: {} not present", path.display());
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let mut p = LuaPlugin::load_with_options(&path, loose_opts(temp.path().to_path_buf()))
        .expect("git-hooks should load");
    p.init(&ctx(temp.path())).expect("init should succeed");

    // on_sync_push: a well-formed commit message
    let r = p
        .execute_hook(
            &PluginHook::OnSyncPush,
            &serde_json::json!({
                "message": "fixes #42 resolve login crash",
                "branch": "main",
                "files_changed": 3,
            }),
        )
        .expect("on_sync_push should not error");
    assert_eq!(r["allow"], true);

    // on_sync_push: empty message — must not block, must warn
    let r = p
        .execute_hook(
            &PluginHook::OnSyncPush,
            &serde_json::json!({
                "message": "",
                "branch": "main",
                "files_changed": 1,
            }),
        )
        .expect("on_sync_push should not error");
    assert_eq!(r["allow"], true);
    assert!(r["warnings"].is_array());

    // on_issue_updated: closing keyword + matching ref → status set
    let r = p
        .execute_hook(
            &PluginHook::OnIssueUpdated,
            &serde_json::json!({
                "id": 42,
                "title": "login crash",
                "description": "closes #42 resolved",
                "status": "in-progress",
            }),
        )
        .expect("on_issue_updated should not error");
    assert_eq!(r["action"], "set_status");
}

#[test]
fn forgejo_notify_runs_in_dry_mode_without_token() {
    let path = plugins_dir().join("forgejo-notify/main.lua");
    if !path.exists() {
        eprintln!("skipping: {} not present", path.display());
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let mut opts = loose_opts(temp.path().to_path_buf());
    // forgejo-notify uses os.getenv to detect FORGEJO_URL; allow env access.
    opts.env_access = true;
    let mut p = LuaPlugin::load_with_options(&path, opts)
        .expect("forgejo-notify should load");
    p.init(&ctx(temp.path())).expect("init should succeed");

    // No FORGEJO_URL/TOKEN set → plugin enters dry-run mode silently.
    let r = p
        .execute_hook(
            &PluginHook::OnStatusChanged,
            &serde_json::json!({
                "id": "ABC-1",
                "title": "demo",
                "old_status": "Todo",
                "new_status": "Done",
            }),
        )
        .expect("on_status_changed should not error in dry-run");
    // No MR linked → plugin reports `notified=false`. Either way, no panic.
    assert!(r.is_object());
}

#[test]
fn slack_notify_loads_and_fails_init_without_webhook() {
    let path = plugins_dir().join("slack-notify/main.lua");
    if !path.exists() {
        eprintln!("skipping: {} not present", path.display());
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let p = LuaPlugin::load_with_options(&path, loose_opts(temp.path().to_path_buf()))
        .expect("slack-notify should load (script is well-formed)");
    assert_eq!(p.metadata().name, "slack-notify");
    assert!(p.supports_hook(&PluginHook::OnIssueCreated));

    // Without webhook_url in context.config, init() should error cleanly
    // rather than panic or kill the host.
    let mut p = p;
    let err = p
        .init(&ctx(temp.path()))
        .err()
        .expect("init should fail without webhook_url");
    assert!(format!("{err}").to_lowercase().contains("webhook"));
}

#[test]
fn manifest_is_loadable_for_every_bundled_plugin() {
    let plugins = [
        "slack-notify",
        "git-hooks",
        "forgejo-notify",
        "syntax-highlight",
    ];
    for name in plugins {
        let manifest_path = plugins_dir().join(name).join(".progit-plugin.json");
        if !manifest_path.exists() {
            eprintln!("skipping {} (no manifest yet)", name);
            continue;
        }
        let m = PluginManifest::load(&manifest_path)
            .unwrap_or_else(|e| panic!("{} manifest failed to parse: {}", name, e));
        assert_eq!(m.name, name, "manifest name mismatch for {}", name);
        m.check_sdk_compat(progit_plugin_sdk::SDK_API_VERSION)
            .unwrap_or_else(|e| panic!("{} sdk_version constraint not met: {}", name, e));
    }
}

#[test]
fn syntax_highlight_returns_coloured_spans_for_rust() {
    let path = plugins_dir().join("syntax-highlight/main.lua");
    if !path.exists() {
        eprintln!("skipping: {} not present", path.display());
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let mut p = LuaPlugin::load_with_options(&path, loose_opts(temp.path().to_path_buf()))
        .expect("syntax-highlight should load");
    p.init(&ctx(temp.path())).expect("init should succeed");

    let resp = p
        .highlight(&HighlightRequest {
            language: Some("rust".into()),
            content: "fn main() { let x = 42; } // comment".into(),
        })
        .expect("highlight should not error")
        .expect("rust should produce spans");

    // Concatenated spans must reproduce the input verbatim.
    let joined: String = resp.spans.iter().map(|s| s.text.clone()).collect();
    assert_eq!(joined, "fn main() { let x = 42; } // comment");

    // We expect at least three coloured token classes on this snippet:
    // keyword (`fn`, `let`), number (`42`), comment (`// comment`).
    let coloured = resp.spans.iter().filter(|s| s.fg.is_some()).count();
    assert!(
        coloured >= 3,
        "expected ≥3 coloured spans, got {} ({:?})",
        coloured,
        resp.spans
    );

    // Find at least one bold span (keywords are bold).
    let bold = resp.spans.iter().filter(|s| s.bold).count();
    assert!(bold >= 1, "expected ≥1 bold (keyword) span, got 0");

    // Find at least one italic span (comment is italic).
    let italic = resp.spans.iter().filter(|s| s.italic).count();
    assert!(italic >= 1, "expected ≥1 italic (comment) span, got 0");
}

#[test]
fn syntax_highlight_declines_unknown_language() {
    let path = plugins_dir().join("syntax-highlight/main.lua");
    if !path.exists() {
        eprintln!("skipping: {} not present", path.display());
        return;
    }
    let temp = tempfile::tempdir().unwrap();
    let mut p = LuaPlugin::load_with_options(&path, loose_opts(temp.path().to_path_buf()))
        .expect("syntax-highlight should load");
    p.init(&ctx(temp.path())).expect("init should succeed");

    let resp = p
        .highlight(&HighlightRequest {
            language: Some("brainfuck".into()),
            content: "+++++[->+<]".into(),
        })
        .expect("highlight should not error");
    assert!(resp.is_none(), "unknown language should yield None");
}