Skip to main content

everruns_cli_contract/
schema.rs

1//! Turning a command's parameter schema into its command line.
2//!
3//! A command already publishes a JSON Schema for its parameters, generated
4//! from the Rust type it deserializes. That schema knows the names, the types,
5//! which fields are required and what each one is for: everything a parser
6//! needs except presentation. Combined with the command's [`CliRoute`], it
7//! produces the [`ContractCommand`] both surfaces build their parser from.
8//!
9//! This runs where the commands are declared, not in the parser. The contract
10//! it produces is the artifact that travels.
11
12use serde_json::{Map, Value};
13
14use crate::declare::CliRoute;
15use crate::{ArgKind, ContractArg, ContractCommand, ContractExample};
16
17/// How deep `$ref` and `allOf` chains are followed before a schema is treated
18/// as opaque. Schemas are generated from Rust types, so real nesting is
19/// shallow; the bound exists so a cyclic `$ref` cannot loop.
20const MAX_DEPTH: u8 = 8;
21
22/// Build one command's contract from what it publishes plus what it declares.
23pub fn contract_for(
24    wire_name: &str,
25    description: &str,
26    method: &str,
27    http_path: &str,
28    route: &CliRoute,
29    schema: &Value,
30) -> ContractCommand {
31    // `contract_with` returns `None` only when no spelling could be derived,
32    // which a declared route rules out by construction.
33    match contract_with(
34        wire_name,
35        description,
36        method,
37        http_path,
38        Some(route),
39        schema,
40    ) {
41        Some(contract) => contract,
42        None => panic!("a declared route always yields a spelling, but {wire_name} produced none"),
43    }
44}
45
46/// Build a contract from a declared route, or from what the command already
47/// carries when it declares none.
48///
49/// `None` means the command has no spelling and none could be derived, which is
50/// a command that must declare one. The caller decides whether that is an error
51/// or an omission; on the hosted catalog it is an error, because a command
52/// without a spelling is unreachable from the command line.
53pub fn contract_with(
54    wire_name: &str,
55    description: &str,
56    method: &str,
57    http_path: &str,
58    route: Option<&CliRoute>,
59    schema: &Value,
60) -> Option<ContractCommand> {
61    let derived;
62    let (path, verb) = match route {
63        Some(route) => (
64            route.path.iter().map(|part| (*part).to_string()).collect(),
65            route.verb.to_string(),
66        ),
67        None => {
68            derived = crate::declare::derived_route(wire_name, http_path)?;
69            derived
70        }
71    };
72    let empty = CliRoute::new(&[], "");
73    let route = route.unwrap_or(&empty);
74
75    Some(ContractCommand {
76        wire_name: wire_name.to_string(),
77        path,
78        verb,
79        description: description.to_string(),
80        method: method.to_string(),
81        http_path: http_path.to_string(),
82        args: args_for(route, schema),
83        examples: route
84            .examples
85            .iter()
86            .map(|example| ContractExample {
87                intent: example.intent.to_string(),
88                command: example.command.to_string(),
89            })
90            .collect(),
91    })
92}
93
94fn args_for(route: &CliRoute, schema: &Value) -> Vec<ContractArg> {
95    let defs = schema
96        .get("$defs")
97        .or_else(|| schema.get("definitions"))
98        .and_then(Value::as_object);
99
100    let mut properties: Map<String, Value> = Map::new();
101    let mut required: Vec<String> = Vec::new();
102    collect(schema, defs, &mut properties, &mut required, 0);
103
104    let mut args: Vec<ContractArg> = properties
105        .into_iter()
106        .map(|(field, property)| {
107            let resolved = resolve(&property, defs, 0);
108            let declared = route.arg(&field);
109            ContractArg {
110                long: declared
111                    .and_then(|arg| arg.long)
112                    .map(ToOwned::to_owned)
113                    // Kebab by default, because that is what a command line
114                    // looks like; the parameter's own snake_case name stays an
115                    // alias, so a script written against either keeps working.
116                    .unwrap_or_else(|| field.replace('_', "-")),
117                short: declared.and_then(|arg| arg.short),
118                position: declared.and_then(|arg| arg.position),
119                kind: kind_of(&resolved, defs, 0),
120                required: required.contains(&field),
121                help: resolved
122                    .get("description")
123                    .and_then(Value::as_str)
124                    .map(first_line),
125                choices: choices_of(&resolved),
126                field,
127            }
128        })
129        .collect();
130
131    // Positionals first and in their declared order, so a rendered usage line
132    // reads the way it is typed.
133    args.sort_by(|left, right| match (left.position, right.position) {
134        (Some(left), Some(right)) => left.cmp(&right),
135        (Some(_), None) => std::cmp::Ordering::Less,
136        (None, Some(_)) => std::cmp::Ordering::Greater,
137        (None, None) => left.long.cmp(&right.long),
138    });
139    args
140}
141
142/// Gather properties and required names through `allOf` and `$ref` wrappers.
143///
144/// Generated schemas wrap flattened structs in `allOf`, so a command whose
145/// parameters include `#[serde(flatten)] pagination` presents those fields one
146/// level down. Missing them would make `--limit` an unknown flag.
147fn collect(
148    schema: &Value,
149    defs: Option<&Map<String, Value>>,
150    properties: &mut Map<String, Value>,
151    required: &mut Vec<String>,
152    depth: u8,
153) {
154    if depth >= MAX_DEPTH {
155        return;
156    }
157    let schema = resolve(schema, defs, depth);
158
159    if let Some(own) = schema.get("properties").and_then(Value::as_object) {
160        for (name, property) in own {
161            properties.entry(name.clone()).or_insert(property.clone());
162        }
163    }
164    if let Some(names) = schema.get("required").and_then(Value::as_array) {
165        for name in names.iter().filter_map(Value::as_str) {
166            if !required.iter().any(|existing| existing == name) {
167                required.push(name.to_string());
168            }
169        }
170    }
171
172    for key in ["allOf", "anyOf", "oneOf"] {
173        if let Some(branches) = schema.get(key).and_then(Value::as_array) {
174            for branch in branches {
175                // Only `allOf` contributes requirements: one branch of a union
176                // requiring a field does not make it required overall.
177                let mut branch_required = Vec::new();
178                collect(branch, defs, properties, &mut branch_required, depth + 1);
179                if key == "allOf" {
180                    for name in branch_required {
181                        if !required.iter().any(|existing| existing == &name) {
182                            required.push(name);
183                        }
184                    }
185                }
186            }
187        }
188    }
189}
190
191/// Follow a `$ref` into `$defs`, once per level.
192fn resolve(schema: &Value, defs: Option<&Map<String, Value>>, depth: u8) -> Value {
193    if depth >= MAX_DEPTH {
194        return schema.clone();
195    }
196    let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
197        return schema.clone();
198    };
199    let name = reference
200        .rsplit('/')
201        .next()
202        .filter(|_| reference.starts_with("#/"));
203    match name.and_then(|name| defs?.get(name)) {
204        Some(target) => resolve(target, defs, depth + 1),
205        None => schema.clone(),
206    }
207}
208
209fn kind_of(schema: &Value, defs: Option<&Map<String, Value>>, depth: u8) -> ArgKind {
210    if depth >= MAX_DEPTH {
211        return ArgKind::Json;
212    }
213
214    // `Option<T>` renders as a union with null: the flag is simply optional,
215    // and its type is whatever the non-null branch says.
216    if let Some(branches) = schema
217        .get("anyOf")
218        .or_else(|| schema.get("oneOf"))
219        .and_then(Value::as_array)
220    {
221        let mut concrete = branches
222            .iter()
223            .map(|branch| resolve(branch, defs, depth))
224            .filter(|branch| !is_null(branch));
225        if let Some(first) = concrete.next()
226            && concrete.next().is_none()
227        {
228            return kind_of(&first, defs, depth + 1);
229        }
230        return ArgKind::Json;
231    }
232
233    match type_name(schema) {
234        Some("boolean") => ArgKind::Boolean,
235        Some("integer") => ArgKind::Integer,
236        Some("number") => ArgKind::Number,
237        Some("string") => ArgKind::String,
238        Some("array") => {
239            let items = schema
240                .get("items")
241                .map(|items| resolve(items, defs, depth))
242                .unwrap_or(Value::Null);
243            match kind_of(&items, defs, depth + 1) {
244                ArgKind::Integer | ArgKind::Number => ArgKind::IntegerList,
245                ArgKind::String | ArgKind::Boolean => ArgKind::StringList,
246                // A list of documents is one document: comma-splitting it
247                // would cut through a comma of its own.
248                _ => ArgKind::Json,
249            }
250        }
251        _ => ArgKind::Json,
252    }
253}
254
255/// The schema's type, ignoring a `null` alternative in a `["T","null"]` union.
256fn type_name(schema: &Value) -> Option<&str> {
257    match schema.get("type")? {
258        Value::String(name) => Some(name.as_str()),
259        Value::Array(names) => names
260            .iter()
261            .filter_map(Value::as_str)
262            .find(|name| *name != "null"),
263        _ => None,
264    }
265}
266
267fn is_null(schema: &Value) -> bool {
268    matches!(schema.get("type"), Some(Value::String(name)) if name == "null")
269}
270
271fn choices_of(schema: &Value) -> Vec<String> {
272    schema
273        .get("enum")
274        .and_then(Value::as_array)
275        .map(|values| {
276            values
277                .iter()
278                .filter_map(Value::as_str)
279                .map(ToOwned::to_owned)
280                .collect()
281        })
282        .unwrap_or_default()
283}
284
285/// Help stays one line per flag: catalog descriptions are written for a JSON
286/// catalog and often run several sentences.
287fn first_line(description: &str) -> String {
288    let trimmed = description.trim();
289    let end = trimmed
290        .find(". ")
291        .map(|index| index + 1)
292        .unwrap_or(trimmed.len());
293    let mut line = trimmed[..end].trim().replace('\n', " ");
294    if line.chars().count() > 96 {
295        line = line.chars().take(93).collect::<String>();
296        line.push_str("...");
297    }
298    line
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::declare::{CliArg, CliExample};
305    use serde_json::json;
306
307    const ROUTE: CliRoute = CliRoute::new(&["agents"], "update")
308        .with_args(&[
309            CliArg::new("id").at(1),
310            CliArg::new("harness_name").short('H').long("harness"),
311            CliArg::new("tag").short('t'),
312        ])
313        .with_examples(&[CliExample::new(
314            "Rename an agent",
315            "everruns agents update agt_01h9 --name triage-v2",
316        )]);
317
318    fn schema() -> Value {
319        json!({
320            "type": "object",
321            "properties": {
322                "id": { "type": "string", "description": "Agent id." },
323                "harness_name": { "type": "string" },
324                "tag": { "type": "array", "items": { "type": "string" } },
325                "name": { "type": "string" },
326                "max_iterations": { "type": ["integer", "null"] },
327                "metadata": { "type": "object" },
328                "status": { "type": "string", "enum": ["active", "archived"] }
329            },
330            "required": ["id"]
331        })
332    }
333
334    fn contract() -> ContractCommand {
335        contract_for(
336            "update_agent",
337            "Update an agent.",
338            "PATCH",
339            "/v1/agents/{id}",
340            &ROUTE,
341            &schema(),
342        )
343    }
344
345    #[test]
346    fn a_long_flag_is_kebab_by_default() {
347        let contract = contract();
348        let arg = contract
349            .args
350            .iter()
351            .find(|arg| arg.field == "max_iterations")
352            .expect("field is present");
353        assert_eq!(arg.long, "max-iterations");
354        // `Option<i64>` is a union with null; the flag is optional and typed.
355        assert_eq!(arg.kind, ArgKind::Integer);
356        assert!(!arg.required);
357    }
358
359    /// Presentation is a choice, so it comes from the declaration and can
360    /// override the field name entirely.
361    #[test]
362    fn declared_presentation_wins_over_the_field_name() {
363        let contract = contract();
364        let harness = contract
365            .args
366            .iter()
367            .find(|arg| arg.field == "harness_name")
368            .expect("field is present");
369        assert_eq!(harness.long, "harness");
370        assert_eq!(harness.short, Some('H'));
371    }
372
373    #[test]
374    fn a_positional_sorts_first_and_keeps_its_place() {
375        let contract = contract();
376        assert_eq!(contract.args[0].field, "id");
377        assert_eq!(contract.args[0].position, Some(1));
378        assert!(contract.args[0].required);
379    }
380
381    #[test]
382    fn types_reduce_to_what_a_command_line_can_carry() {
383        let contract = contract();
384        let kind = |field: &str| {
385            contract
386                .args
387                .iter()
388                .find(|arg| arg.field == field)
389                .map(|arg| arg.kind)
390        };
391        assert_eq!(kind("tag"), Some(ArgKind::StringList));
392        assert_eq!(kind("metadata"), Some(ArgKind::Json));
393        assert_eq!(kind("name"), Some(ArgKind::String));
394    }
395
396    #[test]
397    fn a_declared_enum_becomes_the_flags_choices() {
398        let contract = contract();
399        let status = contract
400            .args
401            .iter()
402            .find(|arg| arg.field == "status")
403            .expect("field is present");
404        assert_eq!(status.choices, vec!["active", "archived"]);
405    }
406
407    /// Pagination reaches commands through `#[serde(flatten)]`, which renders
408    /// as an `allOf` branch. A reduction that only read top-level properties
409    /// would make `--limit` an unknown flag.
410    #[test]
411    fn a_flattened_branch_contributes_its_fields() {
412        let schema = json!({
413            "type": "object",
414            "properties": { "search": { "type": "string" } },
415            "allOf": [{ "$ref": "#/$defs/Pagination" }],
416            "$defs": {
417                "Pagination": {
418                    "type": "object",
419                    "properties": { "limit": { "type": "integer" } },
420                    "required": ["limit"]
421                }
422            }
423        });
424        let route = CliRoute::new(&["agents"], "list");
425        let contract = contract_for("list_agents", "List.", "GET", "/v1/agents", &route, &schema);
426
427        let limit = contract
428            .args
429            .iter()
430            .find(|arg| arg.field == "limit")
431            .expect("flattened field is a flag");
432        assert_eq!(limit.kind, ArgKind::Integer);
433        assert!(limit.required, "the branch's requirement carries through");
434    }
435
436    #[test]
437    fn help_is_one_line_per_flag() {
438        let schema = json!({
439            "type": "object",
440            "properties": {
441                "id": {
442                    "type": "string",
443                    "description": "The agent id. Long tail of prose that a JSON catalog wants and a flag list does not."
444                }
445            }
446        });
447        let route = CliRoute::new(&["agents"], "get");
448        let contract = contract_for(
449            "get_agent",
450            "Get.",
451            "GET",
452            "/v1/agents/{id}",
453            &route,
454            &schema,
455        );
456        assert_eq!(contract.args[0].help.as_deref(), Some("The agent id."));
457    }
458}