harn-hostlib 0.10.16

Opt-in code-intelligence and deterministic-tool host builtins for the Harn VM
Documentation
use std::sync::OnceLock;

use harn_vm::{VmDictExt, VmValue};

use crate::error::HostlibError;

use super::{SchemaKind, SCHEMAS};

struct CompiledSchema {
    module: &'static str,
    method: &'static str,
    kind: SchemaKind,
    body: Result<VmValue, String>,
}

fn compiled_schemas() -> &'static [CompiledSchema] {
    static COMPILED: OnceLock<Vec<CompiledSchema>> = OnceLock::new();
    COMPILED.get_or_init(|| {
        SCHEMAS
            .iter()
            .map(|(module, method, kind, body)| {
                let body = serde_json::from_str::<serde_json::Value>(body)
                    .map_err(|err| format!("schema is not valid JSON: {err}"))
                    .map(|json| harn_vm::json_to_vm_value(&json))
                    .and_then(|schema| harn_vm::schema::canonicalize_json_schema(&schema));
                CompiledSchema {
                    module,
                    method,
                    kind: *kind,
                    body,
                }
            })
            .collect()
    })
}

fn compiled_schema(
    module: &str,
    method: &str,
    kind: SchemaKind,
) -> Option<&'static CompiledSchema> {
    compiled_schemas()
        .iter()
        .find(|schema| schema.module == module && schema.method == method && schema.kind == kind)
}

pub(crate) fn validate_request_args(
    builtin: &'static str,
    module: &'static str,
    method: &'static str,
    args: &[VmValue],
) -> Result<VmValue, HostlibError> {
    let request = normalize_request_arg(builtin, module, method, args)?;
    let schema = compiled_schema(module, method, SchemaKind::Request).ok_or_else(|| {
        HostlibError::Backend {
            builtin,
            message: format!("missing request schema for {module}.{method}"),
        }
    })?;
    let schema = schema
        .body
        .as_ref()
        .map_err(|message| HostlibError::Backend {
            builtin,
            message: format!("invalid request schema for {module}.{method}: {message}"),
        })?;
    harn_vm::schema::validate_value_against_canonical_schema(&request, schema, true).map_err(
        |message| HostlibError::InvalidParameter {
            builtin,
            param: "request",
            message,
        },
    )
}

pub(crate) fn validate_response(
    builtin: &'static str,
    module: &'static str,
    method: &'static str,
    response: VmValue,
) -> Result<VmValue, HostlibError> {
    let schema = compiled_schema(module, method, SchemaKind::Response).ok_or_else(|| {
        HostlibError::Backend {
            builtin,
            message: format!("missing response schema for {module}.{method}"),
        }
    })?;
    let schema = schema
        .body
        .as_ref()
        .map_err(|message| HostlibError::Backend {
            builtin,
            message: format!("invalid response schema for {module}.{method}: {message}"),
        })?;
    harn_vm::schema::validate_value_against_canonical_schema(&response, schema, true).map_err(
        |message| HostlibError::Backend {
            builtin,
            message: format!("response schema violation for {module}.{method}: {message}"),
        },
    )
}

fn normalize_request_arg(
    builtin: &'static str,
    module: &'static str,
    method: &'static str,
    args: &[VmValue],
) -> Result<VmValue, HostlibError> {
    if args.len() > 1 {
        return Err(HostlibError::InvalidParameter {
            builtin,
            param: "request",
            message: format!("expected exactly one request argument, got {}", args.len()),
        });
    }

    let first = args.first().ok_or(HostlibError::MissingParameter {
        builtin,
        param: "request",
    })?;
    match first {
        VmValue::Dict(map) => Ok(prune_top_level_nil_dict_fields(map)),
        VmValue::String(feature) if (module, method) == ("tools", "enable") => {
            let mut normalized = harn_vm::value::DictMap::new();
            normalized.put_str("feature", feature.to_string());
            Ok(VmValue::dict(normalized))
        }
        other => Err(HostlibError::InvalidParameter {
            builtin,
            param: "request",
            message: format!("expected a dict request body, got {}", other.type_name()),
        }),
    }
}

fn prune_top_level_nil_dict_fields(map: &harn_vm::value::DictMap) -> VmValue {
    let mut pruned = harn_vm::value::DictMap::new();
    for (key, child) in map.iter() {
        if matches!(child, VmValue::Nil) {
            continue;
        }
        pruned.insert(key.clone(), child.clone());
    }
    VmValue::dict_map(pruned)
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use harn_vm::VmValue;

    use super::*;

    #[test]
    fn request_validation_prunes_nil_optional_fields() {
        let request = VmValue::dict([
            ("session_id", VmValue::string("session-1")),
            ("scope_id", VmValue::string("scope-1")),
            ("root", VmValue::Nil),
        ]);

        let validated = validate_request_args("hostlib_fs_snapshot", "fs", "snapshot", &[request])
            .expect("nil optional root should be omitted before schema validation");
        let fields = validated.as_dict().expect("validated request is a dict");
        assert!(fields.get("root").is_none());
        assert_eq!(
            fields.get("session_id").map(VmValue::display),
            Some("session-1".to_string())
        );
    }

    #[test]
    fn run_command_request_schema_accepts_env_remove() {
        let request = VmValue::dict([
            (
                "argv",
                VmValue::List(Arc::new(vec![VmValue::string("env")])),
            ),
            (
                "env_remove",
                VmValue::List(Arc::new(vec![VmValue::string("HARN_EVENT_LOG_DIR")])),
            ),
        ]);

        validate_request_args(
            "hostlib_tools_run_command",
            "tools",
            "run_command",
            &[request],
        )
        .expect("env_remove must be a valid run_command request field");
    }

    #[test]
    fn response_validation_rejects_incomplete_run_command_result() {
        let response = VmValue::dict([("status", VmValue::string("completed"))]);

        let err = validate_response(
            "hostlib_tools_run_command",
            "tools",
            "run_command",
            response,
        )
        .expect_err("producer responses must satisfy the public response contract");

        assert!(matches!(err, HostlibError::Backend { .. }));
    }

    #[test]
    fn request_validation_does_not_prune_nested_nil_fields() {
        let request = VmValue::dict([
            (
                "argv",
                VmValue::List(Arc::new(vec![VmValue::string("env")])),
            ),
            ("env", VmValue::dict([("FOO", VmValue::Nil)])),
        ]);

        let err = validate_request_args(
            "hostlib_tools_run_command",
            "tools",
            "run_command",
            &[request],
        )
        .expect_err("nested nil map values must remain visible to schema validation");
        match err {
            HostlibError::InvalidParameter { message, .. } => {
                assert!(
                    message.contains("env") && message.contains("string"),
                    "nested env nil should fail as a non-string value, got: {message}"
                );
            }
            other => panic!("expected request validation error, got {other:?}"),
        }
    }

    #[test]
    fn dry_run_request_schema_allows_handler_rejected_plan_ops() {
        let unknown = VmValue::dict([
            ("op", VmValue::string("blow_up_the_world")),
            ("path", VmValue::string("multi.txt")),
        ]);
        let missing_op = VmValue::dict_map(harn_vm::value::DictMap::new());
        let non_string_op = VmValue::dict([("op", VmValue::Int(1))]);
        let request = VmValue::dict([(
            "plan",
            VmValue::List(Arc::new(vec![unknown, missing_op, non_string_op])),
        )]);

        validate_request_args("hostlib_ast_dry_run", "ast", "dry_run", &[request]).expect(
            "dry_run unknown/missing/non-string ops must reach the handler for structured rejection",
        );
    }

    #[test]
    fn extension_hostlib_rules_search_has_request_schema() {
        let request = VmValue::dict([
            (
                "rule",
                VmValue::string(
                    "id = \"noop\"\nlanguage = \"typescript\"\n[rule]\npattern = \"$X\"",
                ),
            ),
            ("source", VmValue::string("foo();")),
            ("language", VmValue::string("typescript")),
        ]);

        validate_request_args("hostlib_rules_search", "rules", "search", &[request])
            .expect("rules.search should be covered by the shared hostlib schema catalog");
    }
}