Skip to main content

aion_package/codegen/
input_skeleton.rs

1//! Type-derived input skeletons (`aion input <workflow_type>`).
2//!
3//! Triggering a workflow means handing the engine a JSON input document. Writing
4//! that document from scratch against a schema is exactly the hand-mirroring the
5//! authoring cluster exists to remove (C30, S14). This module derives a *valid*
6//! input skeleton from the workflow's input schema — the same JSON-Schema
7//! document the input codec is generated from — so the emitted document decodes
8//! through that codec without a decode error, and is generated from the type,
9//! never hand-written.
10//!
11//! The skeleton is the minimum structurally-valid document, not a populated one:
12//! it carries no invented semantic defaults (ADR-001). Every *required* property
13//! appears with a type-shaped placeholder — `""`, `0`, `0.0`, `false`, `[]`, a
14//! nested object skeleton, or an enum's first wire value (the only value that
15//! decodes) — and every *optional* property is omitted, since the generated
16//! decoder reads an absent optional as `None`. A placeholder is a structural zero
17//! the author replaces, never a decision made for them about what the value
18//! should be.
19//!
20//! It walks the same supported v1 schema subset as [`super::schema`] and fails
21//! loudly — naming the JSON pointer — for any construct outside it, so an input
22//! skeleton is never silently emitted for a schema the codec generator would
23//! reject.
24
25use serde_json::{Map, Value};
26
27use super::error::CodegenError;
28
29/// Builds a structurally-valid input skeleton from a workflow `input_schema`
30/// JSON-Schema document.
31///
32/// `schema_file` is the path the schema came from, used only to name the file in
33/// a loud error. The returned [`Value`] decodes through the codec generated from
34/// the same schema.
35///
36/// # Errors
37///
38/// Returns [`CodegenError::UnsupportedConstruct`] — naming the file and JSON
39/// pointer — for any construct outside the supported v1 subset (`$ref`, `oneOf`,
40/// `const`, `default`, open objects, type unions, non-string enums, …), matching
41/// the codec generator's subset exactly.
42pub fn build_input_skeleton(
43    schema_file: &std::path::Path,
44    schema: &Value,
45) -> Result<Value, CodegenError> {
46    let builder = SkeletonBuilder { schema_file };
47    builder.walk(schema, "")
48}
49
50struct SkeletonBuilder<'a> {
51    schema_file: &'a std::path::Path,
52}
53
54impl SkeletonBuilder<'_> {
55    fn unsupported(&self, pointer: String, construct: String) -> CodegenError {
56        CodegenError::UnsupportedConstruct {
57            file: self.schema_file.to_path_buf(),
58            pointer,
59            construct,
60        }
61    }
62
63    fn walk(&self, node: &Value, pointer: &str) -> Result<Value, CodegenError> {
64        let Value::Object(entries) = node else {
65            return Err(self.unsupported(
66                pointer.to_owned(),
67                "schema must be a JSON object".to_owned(),
68            ));
69        };
70
71        // A string `enum`: the only decodable placeholder is one of its wire
72        // values, so use the first — the minimum valid value, not a chosen
73        // default among equals.
74        if let Some(values) = entries.get("enum") {
75            return self.walk_enum(values, pointer);
76        }
77
78        let Some(declared) = entries.get("type").and_then(Value::as_str) else {
79            return Err(self.unsupported(
80                pointer.to_owned(),
81                "schema has neither a single-string `type` nor `enum`".to_owned(),
82            ));
83        };
84
85        match declared {
86            "string" => Ok(Value::String(String::new())),
87            "integer" => Ok(Value::Number(0.into())),
88            "number" => Ok(serde_json::json!(0.0)),
89            "boolean" => Ok(Value::Bool(false)),
90            // An `array` skeleton is the empty list: the minimum valid value,
91            // carrying no invented element. (A non-empty list would be an
92            // invented default element, ADR-001.)
93            "array" => Ok(Value::Array(Vec::new())),
94            "object" => self.walk_object(entries, pointer),
95            other => Err(self.unsupported(
96                join(pointer, "type"),
97                format!("unsupported `type` value `{other}`"),
98            )),
99        }
100    }
101
102    fn walk_enum(&self, values: &Value, pointer: &str) -> Result<Value, CodegenError> {
103        let enum_pointer = join(pointer, "enum");
104        let Value::Array(values) = values else {
105            return Err(self.unsupported(enum_pointer, "`enum` must be an array".to_owned()));
106        };
107        let Some(first) = values.first() else {
108            return Err(self.unsupported(enum_pointer, "`enum` must not be empty".to_owned()));
109        };
110        let Some(wire) = first.as_str() else {
111            return Err(self.unsupported(
112                enum_pointer,
113                "only string `enum` values are supported".to_owned(),
114            ));
115        };
116        Ok(Value::String(wire.to_owned()))
117    }
118
119    fn walk_object(
120        &self,
121        entries: &Map<String, Value>,
122        pointer: &str,
123    ) -> Result<Value, CodegenError> {
124        let Some(properties) = entries.get("properties") else {
125            return Err(self.unsupported(
126                pointer.to_owned(),
127                "`object` schema without `properties`".to_owned(),
128            ));
129        };
130        let Value::Object(properties) = properties else {
131            return Err(self.unsupported(
132                join(pointer, "properties"),
133                "`properties` must be an object".to_owned(),
134            ));
135        };
136        let required = required_names(entries);
137
138        let properties_pointer = join(pointer, "properties");
139        let mut skeleton = Map::new();
140        for (property, child) in properties {
141            // Optional properties are omitted: the generated decoder reads an
142            // absent optional as `None`, so leaving it out is the no-default form.
143            if !required.iter().any(|name| name == property) {
144                continue;
145            }
146            let child_pointer = join(&properties_pointer, property);
147            let value = self.walk(child, &child_pointer)?;
148            skeleton.insert(property.clone(), value);
149        }
150        Ok(Value::Object(skeleton))
151    }
152}
153
154/// Reads the `required` property names, treating an absent list as empty.
155fn required_names(entries: &Map<String, Value>) -> Vec<String> {
156    entries
157        .get("required")
158        .and_then(Value::as_array)
159        .map(|names| {
160            names
161                .iter()
162                .filter_map(|value| value.as_str().map(str::to_owned))
163                .collect()
164        })
165        .unwrap_or_default()
166}
167
168/// Joins a JSON pointer with a child segment.
169fn join(pointer: &str, segment: &str) -> String {
170    format!("{pointer}/{segment}")
171}
172
173#[cfg(test)]
174mod tests {
175    use std::path::Path;
176
177    use serde_json::json;
178
179    use super::build_input_skeleton;
180    use crate::codegen::error::CodegenError;
181
182    fn skeleton(schema: &serde_json::Value) -> Result<serde_json::Value, CodegenError> {
183        build_input_skeleton(Path::new("schemas/input.json"), schema)
184    }
185
186    #[test]
187    fn required_scalars_get_type_shaped_placeholders() -> Result<(), Box<dyn std::error::Error>> {
188        let schema = json!({
189            "type": "object",
190            "required": ["name", "count", "ratio", "active"],
191            "additionalProperties": false,
192            "properties": {
193                "name": { "type": "string" },
194                "count": { "type": "integer" },
195                "ratio": { "type": "number" },
196                "active": { "type": "boolean" },
197                "note": { "type": "string" }
198            }
199        });
200        let result = skeleton(&schema)?;
201        // Required scalars present with zero-shaped placeholders; the optional
202        // `note` omitted (no invented default).
203        assert_eq!(
204            result,
205            json!({ "name": "", "count": 0, "ratio": 0.0, "active": false })
206        );
207        Ok(())
208    }
209
210    #[test]
211    fn nested_required_objects_recurse() -> Result<(), Box<dyn std::error::Error>> {
212        let schema = json!({
213            "type": "object",
214            "required": ["workspace"],
215            "properties": {
216                "workspace": {
217                    "type": "object",
218                    "required": ["path"],
219                    "properties": { "path": { "type": "string" } }
220                }
221            }
222        });
223        assert_eq!(skeleton(&schema)?, json!({ "workspace": { "path": "" } }));
224        Ok(())
225    }
226
227    #[test]
228    fn required_enum_uses_the_first_wire_value() -> Result<(), Box<dyn std::error::Error>> {
229        let schema = json!({
230            "type": "object",
231            "required": ["isolation"],
232            "properties": {
233                "isolation": { "type": "string", "enum": ["worktree", "copy", "vm"] }
234            }
235        });
236        assert_eq!(skeleton(&schema)?, json!({ "isolation": "worktree" }));
237        Ok(())
238    }
239
240    #[test]
241    fn required_arrays_are_empty() -> Result<(), Box<dyn std::error::Error>> {
242        let schema = json!({
243            "type": "object",
244            "required": ["tags"],
245            "properties": {
246                "tags": { "type": "array", "items": { "type": "string" } }
247            }
248        });
249        assert_eq!(skeleton(&schema)?, json!({ "tags": [] }));
250        Ok(())
251    }
252
253    #[test]
254    fn unsupported_construct_fails_with_pointer() {
255        let schema = json!({
256            "type": "object",
257            "required": ["w"],
258            "properties": { "w": { "$ref": "#/$defs/w" } }
259        });
260        let result = skeleton(&schema);
261        let Err(CodegenError::UnsupportedConstruct { pointer, .. }) = result else {
262            unreachable!("expected UnsupportedConstruct, got {result:?}");
263        };
264        assert_eq!(pointer, "/properties/w");
265    }
266
267    #[test]
268    fn empty_enum_fails() {
269        let schema = json!({ "type": "string", "enum": [] });
270        assert!(matches!(
271            skeleton(&schema),
272            Err(CodegenError::UnsupportedConstruct { .. })
273        ));
274    }
275}