Skip to main content

harn_vm/schema/
mod.rs

1use crate::value::VmValue;
2
3mod api;
4mod canonicalize;
5mod limits;
6mod result;
7mod transform;
8mod type_check;
9mod validate;
10
11pub(crate) use api::{
12    canonical_param_schema, schema_assert_canonical_param, schema_assert_param,
13    schema_expect_value, schema_extend_value, schema_from_json_schema_value,
14    schema_from_openapi_schema_value, schema_is_value, schema_omit_value, schema_partial_value,
15    schema_pick_value, schema_report_value, schema_result_value, schema_to_json_schema_value,
16    schema_to_openapi_schema_value, CanonicalParamSchema,
17};
18pub use canonicalize::json_to_vm_value;
19
20/// Canonicalize a JSON-Schema-shaped value for use as an MCP elicitation
21/// `requestedSchema`. Reuses the same canonicalizer the rest of the
22/// language uses for `schema_from_json_schema(...)` so behavior is
23/// identical between user-facing schema builtins and elicitation.
24pub fn elicitation_validate_schema(schema: &VmValue) -> Result<VmValue, crate::value::VmError> {
25    schema_from_json_schema_value(schema)
26}
27
28/// Validate `data` against a canonicalized schema. Mirrors the
29/// `schema_expect` semantics — returns the (possibly defaulted) value
30/// on success and a thrown error string on failure.
31pub fn elicitation_validate(
32    data: &VmValue,
33    schema: &VmValue,
34) -> Result<VmValue, crate::value::VmError> {
35    schema_expect_value(data, schema, false)
36}
37
38/// Canonicalize a JSON-Schema-shaped value for repeated validation.
39///
40/// This is the Rust-side sibling of `schema_from_json_schema(...)` in the
41/// Harn stdlib. Embedders use it when a stable boundary schema should be
42/// compiled once and applied to many VM values.
43pub fn canonicalize_json_schema(schema: &VmValue) -> Result<VmValue, String> {
44    canonicalize::canonicalize_schema_value(schema)
45}
46
47/// Validate `data` against a canonical Harn schema or JSON Schema value.
48///
49/// When `apply_defaults` is true, returned values include defaults declared by
50/// the schema. The error string is the same human-readable issue list surfaced
51/// by `schema_expect`, which keeps Rust boundary validators aligned with Harn
52/// script behavior.
53pub fn validate_value_against_schema(
54    data: &VmValue,
55    schema: &VmValue,
56    apply_defaults: bool,
57) -> Result<VmValue, String> {
58    let normalized = canonicalize_json_schema(schema)?;
59    validate_value_against_canonical_schema(data, &normalized, apply_defaults)
60}
61
62/// Validate `data` against a schema that was already canonicalized with
63/// [`canonicalize_json_schema`].
64pub fn validate_value_against_canonical_schema(
65    data: &VmValue,
66    schema: &VmValue,
67    apply_defaults: bool,
68) -> Result<VmValue, String> {
69    let result = validate::validate_schema_value(
70        data,
71        schema,
72        validate::ValidationOptions {
73            apply_defaults,
74            numeric_compat: false,
75        },
76    );
77    if result.errors.is_empty() {
78        Ok(result.value)
79    } else {
80        Err(result::issue_messages(&result.errors).join("; "))
81    }
82}
83
84pub(crate) const BYTES_B64_TAG: &str = "$bytes_b64";
85
86pub(crate) fn tagged_bytes_json(bytes: &[u8]) -> serde_json::Value {
87    use base64::Engine;
88
89    serde_json::json!({
90        BYTES_B64_TAG: base64::engine::general_purpose::STANDARD.encode(bytes),
91    })
92}
93
94fn vm_value_to_serde_json(value: &VmValue) -> serde_json::Value {
95    match value {
96        VmValue::Nil => serde_json::Value::Null,
97        VmValue::Bool(value) => serde_json::Value::Bool(*value),
98        VmValue::Int(value) => serde_json::json!(value),
99        VmValue::Float(value) => serde_json::json!(value),
100        VmValue::String(value) => serde_json::Value::String(value.to_string()),
101        VmValue::Bytes(bytes) => tagged_bytes_json(bytes),
102        VmValue::List(items) => {
103            serde_json::Value::Array(items.iter().map(vm_value_to_serde_json).collect())
104        }
105        VmValue::Set(set) => {
106            serde_json::Value::Array(set.iter().map(vm_value_to_serde_json).collect())
107        }
108        VmValue::Dict(items) => serde_json::Value::Object(
109            items
110                .iter()
111                .map(|(key, value)| (key.to_string(), vm_value_to_serde_json(value)))
112                .collect(),
113        ),
114        _ => serde_json::Value::String(value.display()),
115    }
116}
117
118fn schema_bool(schema: &crate::value::DictMap, key: &str) -> bool {
119    matches!(schema.get(key), Some(VmValue::Bool(true)))
120}
121
122fn schema_i64(schema: &crate::value::DictMap, key: &str) -> Option<i64> {
123    match schema.get(key) {
124        Some(VmValue::Int(value)) => Some(*value),
125        _ => None,
126    }
127}
128
129fn schema_number(schema: &crate::value::DictMap, key: &str) -> Option<f64> {
130    match schema.get(key) {
131        Some(VmValue::Int(value)) => Some(*value as f64),
132        Some(VmValue::Float(value)) => Some(*value),
133        _ => None,
134    }
135}
136
137fn child_path(path: &str, key: &str) -> String {
138    if path.is_empty() {
139        key.to_string()
140    } else {
141        format!("{path}.{key}")
142    }
143}
144
145fn index_path(path: &str, index: usize) -> String {
146    if path.is_empty() {
147        format!("[{index}]")
148    } else {
149        format!("{path}[{index}]")
150    }
151}
152
153#[cfg(test)]
154mod tests;