harn-cli 0.10.17

CLI for the Harn programming language — run, test, REPL, format, and lint
use super::*;
use crate::package::test_support::{test_vm, write_trigger_project};

fn write_local_trigger_project(
    root: &std::path::Path,
    handler: &str,
    module_source: &str,
) -> std::path::PathBuf {
    let manifest = format!(
        r#"
[package]
name = "workspace"

[exports]
handlers = "lib.harn"

[[triggers]]
id = "github-new-issue"
kind = "webhook"
provider = "github"
tier = "suggest"
match = {{ events = ["issues.opened"] }}
handler = "{handler}"
secrets = {{ signing_secret = "github/webhook-secret" }}
"#,
    );
    write_trigger_project(root, &manifest, Some(module_source))
}

fn write_local_hook_project(
    root: &std::path::Path,
    handler: &str,
    module_source: &str,
) -> std::path::PathBuf {
    let manifest = format!(
        r#"
[package]
name = "workspace"

[exports]
handlers = "lib.harn"

[[hooks]]
event = "PostToolUse"
pattern = "*"
handler = "{handler}"
"#,
    );
    write_trigger_project(root, &manifest, Some(module_source))
}

fn write_local_predicate_project(
    root: &std::path::Path,
    module_source: &str,
) -> std::path::PathBuf {
    let manifest = r#"
[package]
name = "workspace"

[exports]
handlers = "lib.harn"

[[triggers]]
id = "github-new-issue"
kind = "webhook"
provider = "github"
tier = "suggest"
match = { events = ["issues.opened"] }
when = "handlers::should_handle"
handler = "handlers::on_new_issue"
secrets = { signing_secret = "github/webhook-secret" }
"#;
    write_trigger_project(root, manifest, Some(module_source))
}

fn write_cross_trigger_predicate_project(
    root: &std::path::Path,
    module_source: &str,
) -> std::path::PathBuf {
    let manifest = r#"
[package]
name = "workspace"

[exports]
handlers = "lib.harn"

[[triggers]]
id = "first-local-handler"
kind = "webhook"
provider = "github"
tier = "suggest"
match = { events = ["issues.opened"] }
handler = "handlers::on_new_issue"
secrets = { signing_secret = "github/webhook-secret" }

[[triggers]]
id = "second-invalid-predicate"
kind = "webhook"
provider = "github"
tier = "suggest"
match = { events = ["issues.edited"] }
when = "handlers::should_handle"
handler = "worker://issues"
secrets = { signing_secret = "github/webhook-secret" }
"#;
    write_trigger_project(root, manifest, Some(module_source))
}

#[tokio::test(flavor = "current_thread")]
async fn lazy_manifest_trigger_collection_defers_handler_module_initialization() {
    let tmp = tempfile::tempdir().unwrap();
    let harn_file = write_local_trigger_project(
        tmp.path(),
        "handlers::on_new_issue",
        r#"
import "std/triggers"

let must_not_initialize = 1 / 0

pub fn on_new_issue(event: TriggerEvent) -> string {
  return event.kind
}
"#,
    );
    let extensions = load_runtime_extensions(&harn_file);
    let mut vm = test_vm();

    let collected = collect_manifest_triggers_with_mode(&mut vm, &extensions, true)
        .await
        .expect("lazy collection must not initialize the handler module");
    let CollectedTriggerHandler::Local { callable, .. } = &collected[0].handler else {
        panic!("expected local handler");
    };
    assert!(matches!(callable, harn_vm::VmCallable::Lazy(_)));

    assert!(
        collect_manifest_triggers(&mut vm, &extensions)
            .await
            .is_err(),
        "eager production collection must still initialize and validate the handler module"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn eager_and_lazy_manifest_trigger_collection_reject_missing_and_private_handlers() {
    for (case, module_source) in [
        (
            "missing",
            "let must_not_initialize = 1 / 0\npub fn other(event) { return event.kind }",
        ),
        (
            "private",
            "let must_not_initialize = 1 / 0\nfn on_new_issue(event) { return event.kind }",
        ),
    ] {
        let tmp = tempfile::tempdir().unwrap();
        let harn_file =
            write_local_trigger_project(tmp.path(), "handlers::on_new_issue", module_source);
        let extensions = load_runtime_extensions(&harn_file);

        for lazy in [false, true] {
            let result =
                collect_manifest_triggers_with_mode(&mut test_vm(), &extensions, lazy).await;
            assert!(
                result.is_err(),
                "{case} trigger handler unexpectedly collected in lazy={lazy} mode"
            );
            assert!(
                result
                    .unwrap_err()
                    .to_string()
                    .contains("handler 'handlers::on_new_issue' is not exported"),
                "{case} trigger handler must fail the export boundary in lazy={lazy} mode"
            );
        }
    }
}

#[tokio::test(flavor = "current_thread")]
async fn eager_and_lazy_manifest_trigger_collection_validate_predicates_before_initialization() {
    let tmp = tempfile::tempdir().unwrap();
    let harn_file = write_local_predicate_project(
        tmp.path(),
        r#"
import "std/triggers"

let must_not_initialize = 1 / 0

pub fn on_new_issue(event: TriggerEvent) -> string {
  return event.kind
}

pub fn should_handle(event: TriggerEvent) -> string {
  return event.kind
}
"#,
    );
    let extensions = load_runtime_extensions(&harn_file);

    for lazy in [false, true] {
        let error = collect_manifest_triggers_with_mode(&mut test_vm(), &extensions, lazy)
            .await
            .expect_err("invalid predicate must fail before module initialization");
        assert!(
            error
                .to_string()
                .contains("must have signature fn(TriggerEvent) -> bool or Result<bool, _>"),
            "predicate signature must win over initialization in lazy={lazy}: {error}"
        );
    }
}

#[tokio::test(flavor = "current_thread")]
async fn all_trigger_declarations_validate_before_any_eager_initialization() {
    let tmp = tempfile::tempdir().unwrap();
    let harn_file = write_cross_trigger_predicate_project(
        tmp.path(),
        r#"
import "std/triggers"

let must_not_initialize = 1 / 0

pub fn on_new_issue(event: TriggerEvent) -> string {
  return event.kind
}

pub fn should_handle(event: TriggerEvent) -> string {
  return event.kind
}
"#,
    );
    let extensions = load_runtime_extensions(&harn_file);

    for lazy in [false, true] {
        let error = collect_manifest_triggers_with_mode(&mut test_vm(), &extensions, lazy)
            .await
            .expect_err(
                "the second trigger predicate must fail before the first module initializes",
            );
        assert!(
            error
                .to_string()
                .contains("must have signature fn(TriggerEvent) -> bool or Result<bool, _>"),
            "whole-set predicate validation must win over initialization in lazy={lazy}: {error}"
        );
    }
}

#[tokio::test(flavor = "current_thread")]
async fn eager_and_lazy_hook_installation_reject_missing_and_private_handlers() {
    for (case, module_source) in [
        ("missing", "pub fn other(ctx) { return ctx }"),
        ("private", "fn handle(ctx) { return ctx }"),
    ] {
        let tmp = tempfile::tempdir().unwrap();
        let harn_file = write_local_hook_project(tmp.path(), "handlers::handle", module_source);
        let extensions = load_runtime_extensions(&harn_file);
        assert_eq!(extensions.hooks.len(), 1, "{case} hook fixture must load");

        for lazy in [false, true] {
            let result = install_manifest_hooks_with_mode(&mut test_vm(), &extensions, lazy).await;
            assert!(
                result.is_err(),
                "{case} handler unexpectedly installed in lazy={lazy} mode"
            );
            let error = result.unwrap_err();
            assert!(
                error
                    .to_string()
                    .contains("hook handler 'handle' is not exported by module 'handlers'"),
                "{case} handler must be rejected in lazy={lazy} mode: {error}"
            );
        }
    }
}