1use serde_json::{Map, Value};
13
14use crate::declare::CliRoute;
15use crate::{ArgKind, ContractArg, ContractCommand, ContractExample};
16
17const MAX_DEPTH: u8 = 8;
21
22pub 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 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
46pub 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 .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 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
142fn 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 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
191fn 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 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 _ => ArgKind::Json,
249 }
250 }
251 _ => ArgKind::Json,
252 }
253}
254
255fn 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
285fn 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 assert_eq!(arg.kind, ArgKind::Integer);
356 assert!(!arg.required);
357 }
358
359 #[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 #[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}