harn-vm 0.10.121

Async bytecode virtual machine for the Harn programming language
Documentation
//! Harn structural types generated from the public provider-catalog schema.

use serde_json::{Map, Value};

use super::{schema_value, PROVIDER_CATALOG_GENERATOR, PROVIDER_CATALOG_SCHEMA_VERSION};

pub fn harn_declarations() -> Result<String, String> {
    let schema = schema_value();
    let definitions = schema
        .get("$defs")
        .and_then(Value::as_object)
        .ok_or_else(|| "provider catalog schema is missing $defs".to_string())?;

    let mut output = format!(
        "// GENERATED by `{PROVIDER_CATALOG_GENERATOR}` - do not edit by hand.\n\
         // Source: Harn runtime provider catalog schema v{PROVIDER_CATALOG_SCHEMA_VERSION}.\n\
         // Language: harn.\n\n"
    );
    render_named_type(&mut output, "HarnProviderCatalog", &schema, "root")?;
    for (name, definition) in definitions {
        render_named_type(
            &mut output,
            &definition_type_name(name),
            definition,
            &format!("$defs.{name}"),
        )?;
    }
    Ok(output)
}

fn render_named_type(
    output: &mut String,
    name: &str,
    schema: &Value,
    context: &str,
) -> Result<(), String> {
    output.push_str("pub type ");
    output.push_str(name);
    output.push_str(" = ");
    output.push_str(&render_type(schema, 0, context)?);
    output.push_str("\n\n");
    Ok(())
}

fn render_type(schema: &Value, indent: usize, context: &str) -> Result<String, String> {
    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
        let name = reference
            .strip_prefix("#/$defs/")
            .ok_or_else(|| format!("{context}: unsupported schema reference `{reference}`"))?;
        return Ok(definition_type_name(name));
    }
    if let Some(values) = schema.get("enum").and_then(Value::as_array) {
        return values
            .iter()
            .map(|value| match value {
                Value::String(value) => {
                    serde_json::to_string(value).map_err(|error| error.to_string())
                }
                _ => Err(format!(
                    "{context}: only string enums can become Harn literals"
                )),
            })
            .collect::<Result<Vec<_>, _>>()
            .map(|values| values.join(" | "));
    }
    if let Some(value) = schema.get("const") {
        return primitive_value_type(value)
            .ok_or_else(|| format!("{context}: unsupported const value `{value}`"));
    }

    match schema.get("type") {
        Some(Value::String(kind)) => render_kind(kind, schema, indent, context),
        Some(Value::Array(kinds)) => {
            let mut rendered = Vec::new();
            let mut nullable = false;
            for kind in kinds {
                let kind = kind
                    .as_str()
                    .ok_or_else(|| format!("{context}: non-string schema type union"))?;
                if kind == "null" {
                    nullable = true;
                } else {
                    rendered.push(render_kind(kind, schema, indent, context)?);
                }
            }
            if rendered.len() != 1 {
                return Err(format!(
                    "{context}: unsupported schema type union `{kinds:?}`"
                ));
            }
            let mut value = rendered.remove(0);
            if nullable {
                value.push('?');
            }
            Ok(value)
        }
        _ => Err(format!(
            "{context}: schema has no supported type, enum, const, or $ref"
        )),
    }
}

fn render_kind(kind: &str, schema: &Value, indent: usize, context: &str) -> Result<String, String> {
    match kind {
        "string" => Ok("string".to_string()),
        "integer" => Ok("int".to_string()),
        "number" => Ok("float".to_string()),
        "boolean" => Ok("bool".to_string()),
        "array" => {
            let items = schema
                .get("items")
                .ok_or_else(|| format!("{context}: array schema has no items"))?;
            Ok(format!("list<{}>", render_type(items, indent, context)?))
        }
        "object" => render_object(schema, indent, context),
        other => Err(format!("{context}: unsupported schema type `{other}`")),
    }
}

fn render_object(schema: &Value, indent: usize, context: &str) -> Result<String, String> {
    if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
        return render_properties(properties, schema, indent, context);
    }
    match schema.get("additionalProperties") {
        Some(Value::Object(value_schema)) => Ok(format!(
            "dict<string, {}>",
            render_type(&Value::Object(value_schema.clone()), indent, context)?
        )),
        Some(Value::Bool(true)) | None => Err(format!(
            "{context}: open object schema cannot become a rigid Harn type"
        )),
        Some(Value::Bool(false)) => Ok("{}".to_string()),
        Some(other) => Err(format!("{context}: invalid additionalProperties `{other}`")),
    }
}

fn render_properties(
    properties: &Map<String, Value>,
    schema: &Value,
    indent: usize,
    context: &str,
) -> Result<String, String> {
    match schema.get("additionalProperties") {
        Some(Value::Bool(false)) => {}
        Some(other) => {
            return Err(format!(
                "{context}: object with properties must be closed, found additionalProperties `{other}`"
            ));
        }
        None => {
            return Err(format!(
                "{context}: object with properties must declare additionalProperties false"
            ));
        }
    }
    let required = schema
        .get("required")
        .and_then(Value::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(Value::as_str)
                .collect::<std::collections::BTreeSet<_>>()
        })
        .unwrap_or_default();
    let field_indent = "  ".repeat(indent + 1);
    let close_indent = "  ".repeat(indent);
    let mut output = String::from("{\n");
    for (name, property) in properties {
        output.push_str(&field_indent);
        output.push_str(name);
        if !required.contains(name.as_str()) {
            output.push('?');
        }
        output.push_str(": ");
        output.push_str(&render_type(
            property,
            indent + 1,
            &format!("{context}.{name}"),
        )?);
        output.push_str(",\n");
    }
    output.push_str(&close_indent);
    output.push('}');
    Ok(output)
}

fn primitive_value_type(value: &Value) -> Option<String> {
    match value {
        Value::String(_) => Some("string".to_string()),
        Value::Number(value) if value.is_i64() || value.is_u64() => Some("int".to_string()),
        Value::Number(_) => Some("float".to_string()),
        Value::Bool(_) => Some("bool".to_string()),
        Value::Null => Some("nil".to_string()),
        _ => None,
    }
}

fn definition_type_name(name: &str) -> String {
    let suffix = name
        .split('_')
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
                None => String::new(),
            }
        })
        .collect::<String>();
    format!("HarnProviderCatalog{suffix}")
}

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

    #[test]
    fn harn_binding_covers_every_schema_definition_without_dynamic_rows() {
        let declarations = harn_declarations().expect("Harn binding renders");
        let definition_count = schema_value()["$defs"]
            .as_object()
            .expect("schema definitions")
            .len();
        assert_eq!(
            declarations.matches("pub type ").count(),
            definition_count + 1
        );
        assert!(declarations.contains("providers: list<HarnProviderCatalogProvider>"));
        assert!(declarations.contains("models: list<HarnProviderCatalogModel>"));
        assert!(declarations.contains("qc_defaults: dict<string, string>"));
        assert!(!declarations.contains(": dict,"));
        assert!(!declarations.contains("list<dict>"));
        assert!(!declarations.contains(": unknown"));
        assert!(!declarations.contains("<unknown"));
        crate::compile_source(&declarations).expect("generated Harn binding compiles");
    }

    #[test]
    fn unsupported_schema_constructs_fail_closed() {
        let error = render_type(
            &serde_json::json!({"type": "mystery"}),
            0,
            "negative_control",
        )
        .expect_err("unknown schema types must not degrade to dynamic Harn types");
        assert!(error.contains("unsupported schema type `mystery`"));

        let error = render_type(
            &serde_json::json!({"type": "object", "additionalProperties": true}),
            0,
            "negative_control",
        )
        .expect_err("open objects must not degrade to unknown-valued maps");
        assert!(error.contains("open object schema cannot become a rigid Harn type"));

        let error = render_type(
            &serde_json::json!({
                "type": "object",
                "properties": {"known": {"type": "string"}}
            }),
            0,
            "negative_control",
        )
        .expect_err("objects with named fields must not silently remain open");
        assert!(error.contains("must declare additionalProperties false"));

        let error = render_type(
            &serde_json::json!({
                "type": "object",
                "properties": {"known": {"type": "string"}},
                "additionalProperties": true
            }),
            0,
            "negative_control",
        )
        .expect_err("named open objects must not masquerade as rigid records");
        assert!(error.contains("object with properties must be closed"));
    }
}