Skip to main content

apm_core/
help_schema.rs

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
46// ── internals ──────────────────────────────────────────────────────────────
47
48fn 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        // Metadata lives on the field schema (wrapper), not the definition.
75        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        // Structural information may live in a $ref definition.
86        let structural = resolve_structural(field_obj, defs);
87
88        // Fall back to the definition's metadata when the field wrapper has none.
89        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
109/// Follow a direct `$ref` or an `allOf: [{$ref: ...}]` wrapper to get the
110/// schema that carries type structure (instance_type, object, array, etc.).
111fn resolve_structural<'a>(
112    schema: &'a SchemaObject,
113    defs: &'a schemars::Map<String, Schema>,
114) -> &'a SchemaObject {
115    // Direct $ref
116    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    // allOf with a single $ref (schemars wraps typed fields that also carry defaults)
122    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    // ── string enum ────────────────────────────────────────────────────────
151    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    // ── anyOf / oneOf (untagged enums, Option<T>) ──────────────────────────
173    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                // Option<T> — unwrap and classify the inner type.
181                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                // True union (e.g. SatisfiesDeps: bool | string).
188                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    // ── array ──────────────────────────────────────────────────────────────
212    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    // ── nested struct or map ───────────────────────────────────────────────
254    if let Some(ref obj) = schema.object {
255        if !obj.properties.is_empty() {
256            // Nested struct — recurse (no FieldEntry for the container).
257            return walk_object(schema, defs, path);
258        }
259        if obj.additional_properties.is_some() {
260            // HashMap — emit one entry, do not recurse into values.
261            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    // ── scalar ─────────────────────────────────────────────────────────────
273    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// ── tests ──────────────────────────────────────────────────────────────────
352
353#[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}