use serde_json::{Map, Value};
use super::error::CodegenError;
pub fn build_input_skeleton(
schema_file: &std::path::Path,
schema: &Value,
) -> Result<Value, CodegenError> {
let builder = SkeletonBuilder { schema_file };
builder.walk(schema, "")
}
struct SkeletonBuilder<'a> {
schema_file: &'a std::path::Path,
}
impl SkeletonBuilder<'_> {
fn unsupported(&self, pointer: String, construct: String) -> CodegenError {
CodegenError::UnsupportedConstruct {
file: self.schema_file.to_path_buf(),
pointer,
construct,
}
}
fn walk(&self, node: &Value, pointer: &str) -> Result<Value, CodegenError> {
let Value::Object(entries) = node else {
return Err(self.unsupported(
pointer.to_owned(),
"schema must be a JSON object".to_owned(),
));
};
if let Some(values) = entries.get("enum") {
return self.walk_enum(values, pointer);
}
let Some(declared) = entries.get("type").and_then(Value::as_str) else {
return Err(self.unsupported(
pointer.to_owned(),
"schema has neither a single-string `type` nor `enum`".to_owned(),
));
};
match declared {
"string" => Ok(Value::String(String::new())),
"integer" => Ok(Value::Number(0.into())),
"number" => Ok(serde_json::json!(0.0)),
"boolean" => Ok(Value::Bool(false)),
"array" => Ok(Value::Array(Vec::new())),
"object" => self.walk_object(entries, pointer),
other => Err(self.unsupported(
join(pointer, "type"),
format!("unsupported `type` value `{other}`"),
)),
}
}
fn walk_enum(&self, values: &Value, pointer: &str) -> Result<Value, CodegenError> {
let enum_pointer = join(pointer, "enum");
let Value::Array(values) = values else {
return Err(self.unsupported(enum_pointer, "`enum` must be an array".to_owned()));
};
let Some(first) = values.first() else {
return Err(self.unsupported(enum_pointer, "`enum` must not be empty".to_owned()));
};
let Some(wire) = first.as_str() else {
return Err(self.unsupported(
enum_pointer,
"only string `enum` values are supported".to_owned(),
));
};
Ok(Value::String(wire.to_owned()))
}
fn walk_object(
&self,
entries: &Map<String, Value>,
pointer: &str,
) -> Result<Value, CodegenError> {
let Some(properties) = entries.get("properties") else {
return Err(self.unsupported(
pointer.to_owned(),
"`object` schema without `properties`".to_owned(),
));
};
let Value::Object(properties) = properties else {
return Err(self.unsupported(
join(pointer, "properties"),
"`properties` must be an object".to_owned(),
));
};
let required = required_names(entries);
let properties_pointer = join(pointer, "properties");
let mut skeleton = Map::new();
for (property, child) in properties {
if !required.iter().any(|name| name == property) {
continue;
}
let child_pointer = join(&properties_pointer, property);
let value = self.walk(child, &child_pointer)?;
skeleton.insert(property.clone(), value);
}
Ok(Value::Object(skeleton))
}
}
fn required_names(entries: &Map<String, Value>) -> Vec<String> {
entries
.get("required")
.and_then(Value::as_array)
.map(|names| {
names
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
fn join(pointer: &str, segment: &str) -> String {
format!("{pointer}/{segment}")
}
#[cfg(test)]
mod tests {
use std::path::Path;
use serde_json::json;
use super::build_input_skeleton;
use crate::codegen::error::CodegenError;
fn skeleton(schema: &serde_json::Value) -> Result<serde_json::Value, CodegenError> {
build_input_skeleton(Path::new("schemas/input.json"), schema)
}
#[test]
fn required_scalars_get_type_shaped_placeholders() -> Result<(), Box<dyn std::error::Error>> {
let schema = json!({
"type": "object",
"required": ["name", "count", "ratio", "active"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"count": { "type": "integer" },
"ratio": { "type": "number" },
"active": { "type": "boolean" },
"note": { "type": "string" }
}
});
let result = skeleton(&schema)?;
assert_eq!(
result,
json!({ "name": "", "count": 0, "ratio": 0.0, "active": false })
);
Ok(())
}
#[test]
fn nested_required_objects_recurse() -> Result<(), Box<dyn std::error::Error>> {
let schema = json!({
"type": "object",
"required": ["workspace"],
"properties": {
"workspace": {
"type": "object",
"required": ["path"],
"properties": { "path": { "type": "string" } }
}
}
});
assert_eq!(skeleton(&schema)?, json!({ "workspace": { "path": "" } }));
Ok(())
}
#[test]
fn required_enum_uses_the_first_wire_value() -> Result<(), Box<dyn std::error::Error>> {
let schema = json!({
"type": "object",
"required": ["isolation"],
"properties": {
"isolation": { "type": "string", "enum": ["worktree", "copy", "vm"] }
}
});
assert_eq!(skeleton(&schema)?, json!({ "isolation": "worktree" }));
Ok(())
}
#[test]
fn required_arrays_are_empty() -> Result<(), Box<dyn std::error::Error>> {
let schema = json!({
"type": "object",
"required": ["tags"],
"properties": {
"tags": { "type": "array", "items": { "type": "string" } }
}
});
assert_eq!(skeleton(&schema)?, json!({ "tags": [] }));
Ok(())
}
#[test]
fn unsupported_construct_fails_with_pointer() {
let schema = json!({
"type": "object",
"required": ["w"],
"properties": { "w": { "$ref": "#/$defs/w" } }
});
let result = skeleton(&schema);
let Err(CodegenError::UnsupportedConstruct { pointer, .. }) = result else {
unreachable!("expected UnsupportedConstruct, got {result:?}");
};
assert_eq!(pointer, "/properties/w");
}
#[test]
fn empty_enum_fails() {
let schema = json!({ "type": "string", "enum": [] });
assert!(matches!(
skeleton(&schema),
Err(CodegenError::UnsupportedConstruct { .. })
));
}
}