harn-hostlib 0.10.22

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

use harn_vm::{VmDictExt, VmValue};

use crate::error::HostlibError;

use super::{SchemaKind, SCHEMAS};

struct CompiledSchema {
    body: Result<VmValue, String>,
}

type CompiledSchemaDirections = [Option<CompiledSchema>; 2];
type CompiledSchemaMethods = HashMap<&'static str, CompiledSchemaDirections>;
type CompiledSchemaIndex = HashMap<&'static str, CompiledSchemaMethods>;

fn schema_direction_index(kind: SchemaKind) -> usize {
    match kind {
        SchemaKind::Request => 0,
        SchemaKind::Response => 1,
    }
}

fn compiled_schemas() -> &'static CompiledSchemaIndex {
    static COMPILED: OnceLock<CompiledSchemaIndex> = OnceLock::new();
    COMPILED.get_or_init(|| {
        let mut index = CompiledSchemaIndex::new();
        for (module, method, kind, body) in SCHEMAS {
            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));
            let directions = index
                .entry(module)
                .or_default()
                .entry(method)
                .or_insert_with(|| [None, None]);
            let slot = &mut directions[schema_direction_index(*kind)];
            assert!(
                slot.replace(CompiledSchema { body }).is_none(),
                "duplicate {kind:?} schema for {module}.{method}"
            );
        }
        index
    })
}

fn compiled_schema(
    module: &str,
    method: &str,
    kind: SchemaKind,
) -> Option<&'static CompiledSchema> {
    compiled_schemas()
        .get(module)?
        .get(method)?
        .get(schema_direction_index(kind))?
        .as_ref()
}

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 compiled_schema_index_covers_the_catalog_once() {
        let indexed = compiled_schemas()
            .values()
            .flat_map(HashMap::values)
            .flat_map(|directions| directions.iter())
            .filter(|schema| schema.is_some())
            .count();

        assert_eq!(indexed, SCHEMAS.len());
    }

    #[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");
    }
}