Skip to main content

sim_codec_json/
schema.rs

1//! A small JSON Schema subset for describing shapes as standard JSON Schema.
2//!
3//! [`ShapeSchema`] is a closed, intentionally minimal description of the data
4//! shapes that interop surfaces (LLM tool parameters, structured outputs) need.
5//! [`shape_to_json_schema`] lowers it to a standard JSON Schema document.
6
7use serde_json::{Map, Value as JsonValue};
8
9/// A minimal, closed schema vocabulary that lowers to standard JSON Schema.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum ShapeSchema {
12    /// Matches any value. Lowers to the empty schema `{}`.
13    Any,
14    /// A JSON object with named properties and a list of required property
15    /// names. Lowers to `{"type":"object","properties":{...},"required":[...]}`.
16    Object(Vec<(String, ShapeSchema)>, Vec<String>),
17    /// A JSON array whose items all match the inner schema. Lowers to
18    /// `{"type":"array","items":...}`.
19    Array(Box<ShapeSchema>),
20    /// A JSON string. Lowers to `{"type":"string"}`.
21    String,
22    /// A JSON number. Lowers to `{"type":"number"}`.
23    Number,
24    /// A JSON integer. Lowers to `{"type":"integer"}`.
25    Integer,
26    /// A JSON boolean. Lowers to `{"type":"boolean"}`.
27    Boolean,
28    /// JSON null. Lowers to `{"type":"null"}`.
29    Null,
30}
31
32/// Lowers a [`ShapeSchema`] to a standard JSON Schema document.
33pub fn shape_to_json_schema(schema: &ShapeSchema) -> JsonValue {
34    match schema {
35        ShapeSchema::Any => JsonValue::Object(Map::new()),
36        ShapeSchema::Object(properties, required) => {
37            let mut props = Map::new();
38            for (name, property) in properties {
39                props.insert(name.clone(), shape_to_json_schema(property));
40            }
41            let mut object = Map::new();
42            object.insert("type".to_owned(), JsonValue::String("object".to_owned()));
43            object.insert("properties".to_owned(), JsonValue::Object(props));
44            object.insert(
45                "required".to_owned(),
46                JsonValue::Array(
47                    required
48                        .iter()
49                        .map(|name| JsonValue::String(name.clone()))
50                        .collect(),
51                ),
52            );
53            JsonValue::Object(object)
54        }
55        ShapeSchema::Array(items) => {
56            let mut object = Map::new();
57            object.insert("type".to_owned(), JsonValue::String("array".to_owned()));
58            object.insert("items".to_owned(), shape_to_json_schema(items));
59            JsonValue::Object(object)
60        }
61        ShapeSchema::String => typed("string"),
62        ShapeSchema::Number => typed("number"),
63        ShapeSchema::Integer => typed("integer"),
64        ShapeSchema::Boolean => typed("boolean"),
65        ShapeSchema::Null => typed("null"),
66    }
67}
68
69fn typed(kind: &str) -> JsonValue {
70    let mut object = Map::new();
71    object.insert("type".to_owned(), JsonValue::String(kind.to_owned()));
72    JsonValue::Object(object)
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use serde_json::json;
79
80    #[test]
81    fn any_is_empty_schema() {
82        assert_eq!(shape_to_json_schema(&ShapeSchema::Any), json!({}));
83    }
84
85    #[test]
86    fn scalar_types() {
87        assert_eq!(
88            shape_to_json_schema(&ShapeSchema::String),
89            json!({"type": "string"})
90        );
91        assert_eq!(
92            shape_to_json_schema(&ShapeSchema::Number),
93            json!({"type": "number"})
94        );
95        assert_eq!(
96            shape_to_json_schema(&ShapeSchema::Integer),
97            json!({"type": "integer"})
98        );
99        assert_eq!(
100            shape_to_json_schema(&ShapeSchema::Boolean),
101            json!({"type": "boolean"})
102        );
103        assert_eq!(
104            shape_to_json_schema(&ShapeSchema::Null),
105            json!({"type": "null"})
106        );
107    }
108
109    #[test]
110    fn array_wraps_items() {
111        let schema = ShapeSchema::Array(Box::new(ShapeSchema::String));
112        assert_eq!(
113            shape_to_json_schema(&schema),
114            json!({"type": "array", "items": {"type": "string"}})
115        );
116    }
117
118    #[test]
119    fn object_with_properties_and_required() {
120        let schema = ShapeSchema::Object(
121            vec![
122                ("name".to_owned(), ShapeSchema::String),
123                ("age".to_owned(), ShapeSchema::Integer),
124            ],
125            vec!["name".to_owned()],
126        );
127        assert_eq!(
128            shape_to_json_schema(&schema),
129            json!({
130                "type": "object",
131                "properties": {
132                    "name": {"type": "string"},
133                    "age": {"type": "integer"},
134                },
135                "required": ["name"],
136            })
137        );
138    }
139
140    #[test]
141    fn nested_object_and_array() {
142        let schema = ShapeSchema::Object(
143            vec![(
144                "tags".to_owned(),
145                ShapeSchema::Array(Box::new(ShapeSchema::String)),
146            )],
147            vec![],
148        );
149        assert_eq!(
150            shape_to_json_schema(&schema),
151            json!({
152                "type": "object",
153                "properties": {
154                    "tags": {"type": "array", "items": {"type": "string"}},
155                },
156                "required": [],
157            })
158        );
159    }
160
161    #[test]
162    fn any_nested_in_object() {
163        let schema = ShapeSchema::Object(vec![("data".to_owned(), ShapeSchema::Any)], vec![]);
164        assert_eq!(
165            shape_to_json_schema(&schema),
166            json!({
167                "type": "object",
168                "properties": {"data": {}},
169                "required": [],
170            })
171        );
172    }
173}