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