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        // Key order, never `Map` iteration order: the skeleton is printed for an
141        // operator to edit and commit, so its property order must be the
142        // schema's content and not the build's `serde_json` map representation.
143        for (property, child) in crate::canonical::sorted_entries(properties) {
144            // Optional properties are omitted: the generated decoder reads an
145            // absent optional as `None`, so leaving it out is the no-default form.
146            if !required.iter().any(|name| name == property) {
147                continue;
148            }
149            let child_pointer = join(&properties_pointer, property);
150            let value = self.walk(child, &child_pointer)?;
151            skeleton.insert(property.clone(), value);
152        }
153        Ok(Value::Object(skeleton))
154    }
155}
156
157/// Reads the `required` property names, treating an absent list as empty.
158fn required_names(entries: &Map<String, Value>) -> Vec<String> {
159    entries
160        .get("required")
161        .and_then(Value::as_array)
162        .map(|names| {
163            names
164                .iter()
165                .filter_map(|value| value.as_str().map(str::to_owned))
166                .collect()
167        })
168        .unwrap_or_default()
169}
170
171/// Joins a JSON pointer with a child segment.
172fn join(pointer: &str, segment: &str) -> String {
173    format!("{pointer}/{segment}")
174}
175
176#[cfg(test)]
177mod tests {
178    use std::path::Path;
179
180    use serde_json::json;
181
182    use super::build_input_skeleton;
183    use crate::codegen::error::CodegenError;
184
185    fn skeleton(schema: &serde_json::Value) -> Result<serde_json::Value, CodegenError> {
186        build_input_skeleton(Path::new("schemas/input.json"), schema)
187    }
188
189    #[test]
190    fn required_scalars_get_type_shaped_placeholders() -> Result<(), Box<dyn std::error::Error>> {
191        let schema = json!({
192            "type": "object",
193            "required": ["name", "count", "ratio", "active"],
194            "additionalProperties": false,
195            "properties": {
196                "name": { "type": "string" },
197                "count": { "type": "integer" },
198                "ratio": { "type": "number" },
199                "active": { "type": "boolean" },
200                "note": { "type": "string" }
201            }
202        });
203        let result = skeleton(&schema)?;
204        // Required scalars present with zero-shaped placeholders; the optional
205        // `note` omitted (no invented default).
206        assert_eq!(
207            result,
208            json!({ "name": "", "count": 0, "ratio": 0.0, "active": false })
209        );
210        Ok(())
211    }
212
213    #[test]
214    fn nested_required_objects_recurse() -> Result<(), Box<dyn std::error::Error>> {
215        let schema = json!({
216            "type": "object",
217            "required": ["workspace"],
218            "properties": {
219                "workspace": {
220                    "type": "object",
221                    "required": ["path"],
222                    "properties": { "path": { "type": "string" } }
223                }
224            }
225        });
226        assert_eq!(skeleton(&schema)?, json!({ "workspace": { "path": "" } }));
227        Ok(())
228    }
229
230    #[test]
231    fn required_enum_uses_the_first_wire_value() -> Result<(), Box<dyn std::error::Error>> {
232        let schema = json!({
233            "type": "object",
234            "required": ["isolation"],
235            "properties": {
236                "isolation": { "type": "string", "enum": ["worktree", "copy", "vm"] }
237            }
238        });
239        assert_eq!(skeleton(&schema)?, json!({ "isolation": "worktree" }));
240        Ok(())
241    }
242
243    #[test]
244    fn required_arrays_are_empty() -> Result<(), Box<dyn std::error::Error>> {
245        let schema = json!({
246            "type": "object",
247            "required": ["tags"],
248            "properties": {
249                "tags": { "type": "array", "items": { "type": "string" } }
250            }
251        });
252        assert_eq!(skeleton(&schema)?, json!({ "tags": [] }));
253        Ok(())
254    }
255
256    #[test]
257    fn unsupported_construct_fails_with_pointer() {
258        let schema = json!({
259            "type": "object",
260            "required": ["w"],
261            "properties": { "w": { "$ref": "#/$defs/w" } }
262        });
263        let result = skeleton(&schema);
264        let Err(CodegenError::UnsupportedConstruct { pointer, .. }) = result else {
265            unreachable!("expected UnsupportedConstruct, got {result:?}");
266        };
267        assert_eq!(pointer, "/properties/w");
268    }
269
270    #[test]
271    fn empty_enum_fails() {
272        let schema = json!({ "type": "string", "enum": [] });
273        assert!(matches!(
274            skeleton(&schema),
275            Err(CodegenError::UnsupportedConstruct { .. })
276        ));
277    }
278}