use serde_json::{Map, Value};
use crate::declare::CliRoute;
use crate::{ArgKind, ContractArg, ContractCommand, ContractExample};
const MAX_DEPTH: u8 = 8;
pub fn contract_for(
wire_name: &str,
description: &str,
method: &str,
http_path: &str,
route: &CliRoute,
schema: &Value,
) -> ContractCommand {
match contract_with(
wire_name,
description,
method,
http_path,
Some(route),
schema,
) {
Some(contract) => contract,
None => panic!("a declared route always yields a spelling, but {wire_name} produced none"),
}
}
pub fn contract_with(
wire_name: &str,
description: &str,
method: &str,
http_path: &str,
route: Option<&CliRoute>,
schema: &Value,
) -> Option<ContractCommand> {
let derived;
let (path, verb) = match route {
Some(route) => (
route.path.iter().map(|part| (*part).to_string()).collect(),
route.verb.to_string(),
),
None => {
derived = crate::declare::derived_route(wire_name, http_path)?;
derived
}
};
let empty = CliRoute::new(&[], "");
let route = route.unwrap_or(&empty);
Some(ContractCommand {
wire_name: wire_name.to_string(),
path,
verb,
description: description.to_string(),
method: method.to_string(),
http_path: http_path.to_string(),
args: args_for(route, schema),
examples: route
.examples
.iter()
.map(|example| ContractExample {
intent: example.intent.to_string(),
command: example.command.to_string(),
})
.collect(),
})
}
fn args_for(route: &CliRoute, schema: &Value) -> Vec<ContractArg> {
let defs = schema
.get("$defs")
.or_else(|| schema.get("definitions"))
.and_then(Value::as_object);
let mut properties: Map<String, Value> = Map::new();
let mut required: Vec<String> = Vec::new();
collect(schema, defs, &mut properties, &mut required, 0);
let mut args: Vec<ContractArg> = properties
.into_iter()
.map(|(field, property)| {
let resolved = resolve(&property, defs, 0);
let declared = route.arg(&field);
ContractArg {
long: declared
.and_then(|arg| arg.long)
.map(ToOwned::to_owned)
.unwrap_or_else(|| field.replace('_', "-")),
short: declared.and_then(|arg| arg.short),
position: declared.and_then(|arg| arg.position),
kind: kind_of(&resolved, defs, 0),
required: required.contains(&field),
help: resolved
.get("description")
.and_then(Value::as_str)
.map(first_line),
choices: choices_of(&resolved),
field,
}
})
.collect();
args.sort_by(|left, right| match (left.position, right.position) {
(Some(left), Some(right)) => left.cmp(&right),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => left.long.cmp(&right.long),
});
args
}
fn collect(
schema: &Value,
defs: Option<&Map<String, Value>>,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
depth: u8,
) {
if depth >= MAX_DEPTH {
return;
}
let schema = resolve(schema, defs, depth);
if let Some(own) = schema.get("properties").and_then(Value::as_object) {
for (name, property) in own {
properties.entry(name.clone()).or_insert(property.clone());
}
}
if let Some(names) = schema.get("required").and_then(Value::as_array) {
for name in names.iter().filter_map(Value::as_str) {
if !required.iter().any(|existing| existing == name) {
required.push(name.to_string());
}
}
}
for key in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = schema.get(key).and_then(Value::as_array) {
for branch in branches {
let mut branch_required = Vec::new();
collect(branch, defs, properties, &mut branch_required, depth + 1);
if key == "allOf" {
for name in branch_required {
if !required.iter().any(|existing| existing == &name) {
required.push(name);
}
}
}
}
}
}
}
fn resolve(schema: &Value, defs: Option<&Map<String, Value>>, depth: u8) -> Value {
if depth >= MAX_DEPTH {
return schema.clone();
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return schema.clone();
};
let name = reference
.rsplit('/')
.next()
.filter(|_| reference.starts_with("#/"));
match name.and_then(|name| defs?.get(name)) {
Some(target) => resolve(target, defs, depth + 1),
None => schema.clone(),
}
}
fn kind_of(schema: &Value, defs: Option<&Map<String, Value>>, depth: u8) -> ArgKind {
if depth >= MAX_DEPTH {
return ArgKind::Json;
}
if let Some(branches) = schema
.get("anyOf")
.or_else(|| schema.get("oneOf"))
.and_then(Value::as_array)
{
let mut concrete = branches
.iter()
.map(|branch| resolve(branch, defs, depth))
.filter(|branch| !is_null(branch));
if let Some(first) = concrete.next()
&& concrete.next().is_none()
{
return kind_of(&first, defs, depth + 1);
}
return ArgKind::Json;
}
match type_name(schema) {
Some("boolean") => ArgKind::Boolean,
Some("integer") => ArgKind::Integer,
Some("number") => ArgKind::Number,
Some("string") => ArgKind::String,
Some("array") => {
let items = schema
.get("items")
.map(|items| resolve(items, defs, depth))
.unwrap_or(Value::Null);
match kind_of(&items, defs, depth + 1) {
ArgKind::Integer | ArgKind::Number => ArgKind::IntegerList,
ArgKind::String | ArgKind::Boolean => ArgKind::StringList,
_ => ArgKind::Json,
}
}
_ => ArgKind::Json,
}
}
fn type_name(schema: &Value) -> Option<&str> {
match schema.get("type")? {
Value::String(name) => Some(name.as_str()),
Value::Array(names) => names
.iter()
.filter_map(Value::as_str)
.find(|name| *name != "null"),
_ => None,
}
}
fn is_null(schema: &Value) -> bool {
matches!(schema.get("type"), Some(Value::String(name)) if name == "null")
}
fn choices_of(schema: &Value) -> Vec<String> {
schema
.get("enum")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn first_line(description: &str) -> String {
let trimmed = description.trim();
let end = trimmed
.find(". ")
.map(|index| index + 1)
.unwrap_or(trimmed.len());
let mut line = trimmed[..end].trim().replace('\n', " ");
if line.chars().count() > 96 {
line = line.chars().take(93).collect::<String>();
line.push_str("...");
}
line
}
#[cfg(test)]
mod tests {
use super::*;
use crate::declare::{CliArg, CliExample};
use serde_json::json;
const ROUTE: CliRoute = CliRoute::new(&["agents"], "update")
.with_args(&[
CliArg::new("id").at(1),
CliArg::new("harness_name").short('H').long("harness"),
CliArg::new("tag").short('t'),
])
.with_examples(&[CliExample::new(
"Rename an agent",
"everruns agents update agt_01h9 --name triage-v2",
)]);
fn schema() -> Value {
json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "Agent id." },
"harness_name": { "type": "string" },
"tag": { "type": "array", "items": { "type": "string" } },
"name": { "type": "string" },
"max_iterations": { "type": ["integer", "null"] },
"metadata": { "type": "object" },
"status": { "type": "string", "enum": ["active", "archived"] }
},
"required": ["id"]
})
}
fn contract() -> ContractCommand {
contract_for(
"update_agent",
"Update an agent.",
"PATCH",
"/v1/agents/{id}",
&ROUTE,
&schema(),
)
}
#[test]
fn a_long_flag_is_kebab_by_default() {
let contract = contract();
let arg = contract
.args
.iter()
.find(|arg| arg.field == "max_iterations")
.expect("field is present");
assert_eq!(arg.long, "max-iterations");
assert_eq!(arg.kind, ArgKind::Integer);
assert!(!arg.required);
}
#[test]
fn declared_presentation_wins_over_the_field_name() {
let contract = contract();
let harness = contract
.args
.iter()
.find(|arg| arg.field == "harness_name")
.expect("field is present");
assert_eq!(harness.long, "harness");
assert_eq!(harness.short, Some('H'));
}
#[test]
fn a_positional_sorts_first_and_keeps_its_place() {
let contract = contract();
assert_eq!(contract.args[0].field, "id");
assert_eq!(contract.args[0].position, Some(1));
assert!(contract.args[0].required);
}
#[test]
fn types_reduce_to_what_a_command_line_can_carry() {
let contract = contract();
let kind = |field: &str| {
contract
.args
.iter()
.find(|arg| arg.field == field)
.map(|arg| arg.kind)
};
assert_eq!(kind("tag"), Some(ArgKind::StringList));
assert_eq!(kind("metadata"), Some(ArgKind::Json));
assert_eq!(kind("name"), Some(ArgKind::String));
}
#[test]
fn a_declared_enum_becomes_the_flags_choices() {
let contract = contract();
let status = contract
.args
.iter()
.find(|arg| arg.field == "status")
.expect("field is present");
assert_eq!(status.choices, vec!["active", "archived"]);
}
#[test]
fn a_flattened_branch_contributes_its_fields() {
let schema = json!({
"type": "object",
"properties": { "search": { "type": "string" } },
"allOf": [{ "$ref": "#/$defs/Pagination" }],
"$defs": {
"Pagination": {
"type": "object",
"properties": { "limit": { "type": "integer" } },
"required": ["limit"]
}
}
});
let route = CliRoute::new(&["agents"], "list");
let contract = contract_for("list_agents", "List.", "GET", "/v1/agents", &route, &schema);
let limit = contract
.args
.iter()
.find(|arg| arg.field == "limit")
.expect("flattened field is a flag");
assert_eq!(limit.kind, ArgKind::Integer);
assert!(limit.required, "the branch's requirement carries through");
}
#[test]
fn help_is_one_line_per_flag() {
let schema = json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The agent id. Long tail of prose that a JSON catalog wants and a flag list does not."
}
}
});
let route = CliRoute::new(&["agents"], "get");
let contract = contract_for(
"get_agent",
"Get.",
"GET",
"/v1/agents/{id}",
&route,
&schema,
);
assert_eq!(contract.args[0].help.as_deref(), Some("The agent id."));
}
}