1use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec};
2use schemars::JsonSchema;
3
4pub struct FieldEntry {
5 pub toml_path: String,
6 pub type_name: String,
7 pub default: Option<String>,
8 pub description: Option<String>,
9 pub enum_variants: Option<Vec<String>>,
10 pub required: bool,
11}
12
13pub fn schema_entries<T: JsonSchema>() -> Vec<FieldEntry> {
14 let root = schemars::schema_for!(T);
15 let defs = &root.definitions;
16 walk_object(&root.schema, defs, "")
17}
18
19pub fn render_schema<T: JsonSchema>() -> String {
20 let entries = schema_entries::<T>();
21 if entries.is_empty() {
22 return String::new();
23 }
24 let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
25 let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
26 entries
27 .iter()
28 .map(|e| {
29 let mut line =
30 format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
31 if let Some(ref d) = e.default {
32 line.push_str(&format!(" [default: {}]", d));
33 }
34 if let Some(ref desc) = e.description {
35 line.push_str(&format!(" # {}", desc));
36 }
37 if let Some(ref variants) = e.enum_variants {
38 line.push_str(&format!(" ({})", variants.join(" | ")));
39 }
40 line
41 })
42 .collect::<Vec<_>>()
43 .join("\n")
44}
45
46fn walk_object(
49 schema: &SchemaObject,
50 defs: &schemars::Map<String, Schema>,
51 prefix: &str,
52) -> Vec<FieldEntry> {
53 let Some(ref obj) = schema.object else {
54 return vec![];
55 };
56 let required_set = &obj.required;
57
58 let mut props: Vec<(&String, &Schema)> = obj.properties.iter().collect();
59 props.sort_by_key(|(k, _)| k.as_str());
60
61 let mut result = Vec::new();
62 for (field_name, field_schema) in props {
63 let Schema::Object(field_obj) = field_schema else {
64 continue;
65 };
66
67 let path = if prefix.is_empty() {
68 field_name.clone()
69 } else {
70 format!("{}.{}", prefix, field_name)
71 };
72 let required = required_set.contains(field_name.as_str());
73
74 let description = field_obj
76 .metadata
77 .as_ref()
78 .and_then(|m| m.description.clone());
79 let default_val = field_obj
80 .metadata
81 .as_ref()
82 .and_then(|m| m.default.as_ref())
83 .map(fmt_default);
84
85 let structural = resolve_structural(field_obj, defs);
87
88 let description = description.or_else(|| {
90 structural
91 .metadata
92 .as_ref()
93 .and_then(|m| m.description.clone())
94 });
95 let default_val = default_val.or_else(|| {
96 structural
97 .metadata
98 .as_ref()
99 .and_then(|m| m.default.as_ref())
100 .map(fmt_default)
101 });
102
103 let entries = classify(structural, defs, &path, required, description, default_val);
104 result.extend(entries);
105 }
106 result
107}
108
109fn resolve_structural<'a>(
112 schema: &'a SchemaObject,
113 defs: &'a schemars::Map<String, Schema>,
114) -> &'a SchemaObject {
115 if let Some(ref r) = schema.reference {
117 if let Some(Schema::Object(def)) = defs.get(ref_name(r)) {
118 return def;
119 }
120 }
121 if let Some(ref subs) = schema.subschemas {
123 if let Some(ref all_of) = subs.all_of {
124 if all_of.len() == 1 {
125 if let Schema::Object(inner) = &all_of[0] {
126 if let Some(ref r) = inner.reference {
127 if let Some(Schema::Object(def)) = defs.get(ref_name(r)) {
128 return def;
129 }
130 }
131 }
132 }
133 }
134 }
135 schema
136}
137
138fn ref_name(r: &str) -> &str {
139 r.strip_prefix("#/definitions/").unwrap_or(r)
140}
141
142fn classify(
143 schema: &SchemaObject,
144 defs: &schemars::Map<String, Schema>,
145 path: &str,
146 required: bool,
147 description: Option<String>,
148 default_val: Option<String>,
149) -> Vec<FieldEntry> {
150 if let Some(ref enum_vals) = schema.enum_values {
152 let variants: Vec<String> = enum_vals
153 .iter()
154 .filter_map(|v| {
155 if let serde_json::Value::String(s) = v {
156 Some(s.clone())
157 } else {
158 None
159 }
160 })
161 .collect();
162 return vec![FieldEntry {
163 toml_path: path.to_string(),
164 type_name: "string".to_string(),
165 default: default_val,
166 description,
167 enum_variants: if variants.is_empty() { None } else { Some(variants) },
168 required,
169 }];
170 }
171
172 if let Some(ref subs) = schema.subschemas {
174 let variants = subs.any_of.as_deref().or(subs.one_of.as_deref());
175 if let Some(vs) = variants {
176 let non_null: Vec<&Schema> =
177 vs.iter().filter(|s| !is_null_schema(s)).collect();
178
179 if non_null.len() == 1 {
180 let Schema::Object(inner) = non_null[0] else {
182 return vec![];
183 };
184 let structural = resolve_structural(inner, defs);
185 return classify(structural, defs, path, required, description, default_val);
186 } else if non_null.len() > 1 {
187 let type_names: Vec<String> = non_null
189 .iter()
190 .filter_map(|s| {
191 if let Schema::Object(obj) = s {
192 let resolved = resolve_structural(obj, defs);
193 Some(instance_type_name(resolved))
194 } else {
195 None
196 }
197 })
198 .collect();
199 return vec![FieldEntry {
200 toml_path: path.to_string(),
201 type_name: type_names.join(" | "),
202 default: default_val,
203 description,
204 enum_variants: None,
205 required,
206 }];
207 }
208 }
209 }
210
211 if let Some(ref arr) = schema.array {
213 if let Some(ref items) = arr.items {
214 let item_structural = match items {
215 SingleOrVec::Single(s) => {
216 if let Schema::Object(obj) = s.as_ref() {
217 resolve_structural(obj, defs)
218 } else {
219 return vec![];
220 }
221 }
222 SingleOrVec::Vec(v) => {
223 if let Some(Schema::Object(obj)) = v.first() {
224 resolve_structural(obj, defs)
225 } else {
226 return vec![];
227 }
228 }
229 };
230
231 let is_struct = item_structural
232 .object
233 .as_ref()
234 .map(|o| !o.properties.is_empty())
235 .unwrap_or(false);
236
237 if is_struct {
238 return walk_object(item_structural, defs, &format!("{}[]", path));
239 } else {
240 return vec![FieldEntry {
241 toml_path: path.to_string(),
242 type_name: format!("list-of-{}", instance_type_name(item_structural)),
243 default: default_val,
244 description,
245 enum_variants: None,
246 required,
247 }];
248 }
249 }
250 return vec![];
251 }
252
253 if let Some(ref obj) = schema.object {
255 if !obj.properties.is_empty() {
256 return walk_object(schema, defs, path);
258 }
259 if obj.additional_properties.is_some() {
260 return vec![FieldEntry {
262 toml_path: path.to_string(),
263 type_name: "map".to_string(),
264 default: default_val,
265 description,
266 enum_variants: None,
267 required,
268 }];
269 }
270 }
271
272 if let Some(ref it) = schema.instance_type {
274 let type_name = match it {
275 SingleOrVec::Single(t) => scalar_name(t),
276 SingleOrVec::Vec(types) => {
277 let non_null: Vec<_> = types
278 .iter()
279 .filter(|t| **t != InstanceType::Null)
280 .collect();
281 non_null
282 .first()
283 .map(|t| scalar_name(t))
284 .unwrap_or_else(|| "null".to_string())
285 }
286 };
287 return vec![FieldEntry {
288 toml_path: path.to_string(),
289 type_name,
290 default: default_val,
291 description,
292 enum_variants: None,
293 required,
294 }];
295 }
296
297 vec![]
298}
299
300fn is_null_schema(schema: &Schema) -> bool {
301 match schema {
302 Schema::Object(obj) => matches!(
303 &obj.instance_type,
304 Some(SingleOrVec::Single(t)) if **t == InstanceType::Null
305 ),
306 Schema::Bool(_) => false,
307 }
308}
309
310fn instance_type_name(schema: &SchemaObject) -> String {
311 if let Some(ref it) = schema.instance_type {
312 match it {
313 SingleOrVec::Single(t) => scalar_name(t),
314 SingleOrVec::Vec(types) => {
315 let non_null: Vec<_> = types
316 .iter()
317 .filter(|t| **t != InstanceType::Null)
318 .collect();
319 non_null
320 .first()
321 .map(|t| scalar_name(t))
322 .unwrap_or_else(|| "null".to_string())
323 }
324 }
325 } else {
326 "unknown".to_string()
327 }
328}
329
330fn scalar_name(t: &InstanceType) -> String {
331 match t {
332 InstanceType::String => "string".to_string(),
333 InstanceType::Integer => "integer".to_string(),
334 InstanceType::Boolean => "bool".to_string(),
335 InstanceType::Number => "number".to_string(),
336 InstanceType::Null => "null".to_string(),
337 InstanceType::Array => "array".to_string(),
338 InstanceType::Object => "object".to_string(),
339 }
340}
341
342fn fmt_default(v: &serde_json::Value) -> String {
343 match v {
344 serde_json::Value::String(s) => s.clone(),
345 serde_json::Value::Number(n) => n.to_string(),
346 serde_json::Value::Bool(b) => b.to_string(),
347 _ => serde_json::to_string(v).unwrap_or_default(),
348 }
349}
350
351#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::config::{Config, WorkflowConfig};
357
358 #[test]
359 fn agents_max_concurrent_has_default_3() {
360 let entries = schema_entries::<Config>();
361 let entry = entries
362 .iter()
363 .find(|e| e.toml_path == "agents.max_concurrent")
364 .expect("agents.max_concurrent not found");
365 assert_eq!(entry.default.as_deref(), Some("3"));
366 assert!(!entry.required);
367 }
368
369 #[test]
370 fn project_name_is_required() {
371 let entries = schema_entries::<Config>();
372 let entry = entries
373 .iter()
374 .find(|e| e.toml_path == "project.name")
375 .expect("project.name not found");
376 assert!(entry.required);
377 }
378
379 #[test]
380 fn workflow_states_uses_array_notation() {
381 let entries = schema_entries::<Config>();
382 assert!(
383 entries.iter().any(|e| e.toml_path.starts_with("workflow.states[].")),
384 "no entry with toml_path starting with 'workflow.states[].'"
385 );
386 }
387
388 #[test]
389 fn completion_strategy_has_enum_variants() {
390 let entries = schema_entries::<Config>();
391 let entry = entries
392 .iter()
393 .find(|e| e.toml_path == "workflow.states[].transitions[].completion")
394 .expect("workflow.states[].transitions[].completion not found");
395 let variants = entry
396 .enum_variants
397 .as_ref()
398 .expect("enum_variants should be Some");
399 assert!(variants.contains(&"none".to_string()), "missing 'none'");
400 assert!(variants.contains(&"pr".to_string()), "missing 'pr'");
401 assert!(variants.contains(&"merge".to_string()), "missing 'merge'");
402 assert!(variants.contains(&"pull".to_string()), "missing 'pull'");
403 assert!(
404 variants.contains(&"pr_or_epic_merge".to_string()),
405 "missing 'pr_or_epic_merge'"
406 );
407 }
408
409 #[test]
410 fn satisfies_deps_has_union_type_name() {
411 let entries = schema_entries::<WorkflowConfig>();
412 let entry = entries
413 .iter()
414 .find(|e| e.toml_path == "states[].satisfies_deps")
415 .expect("states[].satisfies_deps not found");
416 assert_eq!(entry.type_name, "bool | string");
417 assert!(entry.enum_variants.is_none());
418 }
419
420 #[test]
421 fn render_schema_contains_agents_max_concurrent() {
422 let output = render_schema::<Config>();
423 assert!(!output.is_empty());
424 assert!(
425 output.contains("agents.max_concurrent"),
426 "render_schema output does not contain 'agents.max_concurrent'"
427 );
428 }
429}