#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use clap::builder::{BoolishValueParser, PossibleValuesParser};
use clap::{Arg, ArgAction, ColorChoice, Command};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArgKind {
Boolean,
Integer,
Number,
String,
StringList,
IntegerList,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractArg {
pub field: String,
pub long: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short: Option<char>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<usize>,
pub kind: ArgKind,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractExample {
pub intent: String,
pub command: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractCommand {
pub wire_name: String,
pub path: Vec<String>,
pub verb: String,
pub description: String,
pub method: String,
pub http_path: String,
#[serde(default)]
pub args: Vec<ContractArg>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<ContractExample>,
}
impl ContractCommand {
pub fn after_help(&self) -> String {
let mut text = String::new();
if !self.examples.is_empty() {
text.push_str("Examples:\n");
for (index, example) in self.examples.iter().enumerate() {
if index > 0 {
text.push('\n');
}
text.push_str(&format!(" {}:\n {}\n", example.intent, example.command));
}
text.push('\n');
}
text.push_str(&format!("Wire name: {}", self.wire_name));
text
}
pub fn spelling(&self) -> String {
let mut parts = self.path.clone();
parts.push(self.verb.clone());
parts.join(" ")
}
pub fn clap_command(&self, display_name: &str) -> Command {
let declares_help = self.args.iter().any(|arg| arg.long == "help");
let mut command = Command::new(display_name.to_string())
.about(self.description.clone())
.disable_version_flag(true)
.disable_help_flag(declares_help)
.color(ColorChoice::Never);
command = command.after_help(self.after_help());
for arg in &self.args {
command = command.arg(flag(arg));
if arg.position.is_some() {
command = command.arg(positional(arg));
}
}
command
}
}
pub const POSITIONAL_SUFFIX: &str = "\u{1}positional";
fn flag(arg: &ContractArg) -> Arg {
let mut built = Arg::new(arg.field.clone()).long(arg.long.clone());
if arg.long != arg.field {
built = built.alias(arg.field.clone());
}
if let Some(short) = arg.short {
built = built.short(short);
}
if let Some(help) = &arg.help {
built = built.help(help.clone());
}
built = built.required(arg.required && arg.position.is_none());
apply_kind(built, arg)
}
fn positional(arg: &ContractArg) -> Arg {
let built = Arg::new(format!("{}{POSITIONAL_SUFFIX}", arg.field))
.index(arg.position.unwrap_or(1))
.value_name(arg.long.to_uppercase().replace('-', "_"))
.conflicts_with(arg.field.clone())
.required(false)
.help(match &arg.help {
Some(help) => format!("{help} (may also be given as --{})", arg.long),
None => format!("{} (may also be given as --{})", arg.field, arg.long),
});
apply_kind(built, arg)
}
fn apply_kind(built: Arg, arg: &ContractArg) -> Arg {
match arg.kind {
ArgKind::Boolean => built
.num_args(0..=1)
.default_missing_value("true")
.value_parser(BoolishValueParser::new()),
ArgKind::Integer => built.value_parser(clap::value_parser!(i64)),
ArgKind::Number => built.value_parser(clap::value_parser!(f64)),
ArgKind::String if !arg.choices.is_empty() => {
built.value_parser(PossibleValuesParser::new(arg.choices.clone()))
}
ArgKind::String | ArgKind::Json => built,
ArgKind::StringList | ArgKind::IntegerList => {
built.action(ArgAction::Append).value_delimiter(',')
}
}
}
pub mod declare;
pub mod render;
pub mod schema;
pub use declare::{CliArg, CliExample, CliRoute};
pub fn params_from(command: &ContractCommand, matches: &clap::ArgMatches) -> serde_json::Value {
let mut object = serde_json::Map::new();
for arg in &command.args {
let ids = [
arg.field.clone(),
format!("{}{POSITIONAL_SUFFIX}", arg.field),
];
for id in ids {
if let Some(value) = read(matches, &id, arg.kind) {
object.insert(arg.field.clone(), value);
break;
}
}
}
serde_json::Value::Object(object)
}
fn read(matches: &clap::ArgMatches, id: &str, kind: ArgKind) -> Option<serde_json::Value> {
use serde_json::Value;
if !matches!(
matches.try_get_one::<String>(id).err(),
None | Some(clap::parser::MatchesError::Downcast { .. })
) {
return None;
}
if !matches!(
matches.value_source(id),
Some(clap::parser::ValueSource::CommandLine)
) {
return None;
}
match kind {
ArgKind::Boolean => matches.get_one::<bool>(id).copied().map(Value::Bool),
ArgKind::Integer => matches
.get_one::<i64>(id)
.copied()
.map(|value| Value::Number(value.into())),
ArgKind::Number => matches
.get_one::<f64>(id)
.copied()
.and_then(|value| serde_json::Number::from_f64(value).map(Value::Number)),
ArgKind::String => matches.get_one::<String>(id).cloned().map(Value::String),
ArgKind::Json => matches.get_one::<String>(id).map(|text| json_or_text(text)),
ArgKind::StringList => Some(Value::Array(
matches
.get_many::<String>(id)?
.cloned()
.map(Value::String)
.collect(),
)),
ArgKind::IntegerList => Some(Value::Array(
matches
.get_many::<String>(id)?
.map(|text| json_or_text(text))
.collect(),
)),
}
}
fn json_or_text(text: &str) -> serde_json::Value {
serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
}
pub fn commands() -> &'static [ContractCommand] {
static COMMANDS: std::sync::OnceLock<Vec<ContractCommand>> = std::sync::OnceLock::new();
COMMANDS.get_or_init(|| {
match serde_json::from_str(include_str!("../commands.json")) {
Ok(commands) => commands,
Err(error) => panic!(
"commands.json is generated and checked in; a parse failure means it is stale: {error}"
),
}
})
}
#[cfg(test)]
mod round_trip {
use super::*;
use declare::{CliArg, CliExample, CliRoute};
use serde_json::json;
const ROUTE: CliRoute = CliRoute::new(&["widgets"], "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 a widget",
"everruns widgets update w_1 --name blue",
)]);
fn contract() -> ContractCommand {
schema::contract_for(
"update_widget",
"Update a widget.",
"PATCH",
"/v1/widgets/{id}",
&ROUTE,
&json!({
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"harness_name": { "type": "string" },
"tag": { "type": "array", "items": { "type": "string" } },
"limit": { "type": "integer" },
"archived": { "type": "boolean" },
"metadata": { "type": "object" }
},
"required": ["id"]
}),
)
}
fn parse(line: &str) -> serde_json::Value {
let contract = contract();
let parser = contract.clap_command("everruns widgets update");
let argv = std::iter::once("everruns widgets update".to_string())
.chain(line.split_whitespace().map(ToOwned::to_owned));
let matches = parser
.try_get_matches_from(argv)
.unwrap_or_else(|error| panic!("{line}: {error}"));
params_from(&contract, &matches)
}
#[test]
fn values_come_back_as_the_types_the_schema_declared() {
let params = parse("w_1 --limit 5 --archived --tag a,b --metadata {\"k\":1}");
assert_eq!(params["id"], "w_1");
assert_eq!(params["limit"], 5);
assert_eq!(params["archived"], true);
assert_eq!(params["tag"], json!(["a", "b"]));
assert_eq!(params["metadata"], json!({ "k": 1 }));
}
#[test]
fn presentation_does_not_reach_the_command() {
for line in ["w_1 -H generic", "w_1 --harness generic"] {
assert_eq!(parse(line)["harness_name"], "generic", "{line}");
}
}
#[test]
fn the_schemas_own_spelling_is_still_accepted() {
assert_eq!(
parse("w_1 --harness_name generic")["harness_name"],
"generic"
);
}
#[test]
fn a_bare_word_and_its_flag_reach_the_same_parameter() {
assert_eq!(parse("w_1")["id"], "w_1");
assert_eq!(parse("--id w_1")["id"], "w_1");
}
#[test]
fn a_version_like_value_stays_a_string() {
assert_eq!(parse("w_1 --name 1.20")["name"], "1.20");
}
#[test]
fn an_untouched_flag_is_not_sent() {
let params = parse("w_1");
assert_eq!(params, json!({ "id": "w_1" }));
}
#[test]
fn help_carries_the_worked_examples_and_the_wire_name() {
let help = contract()
.clap_command("everruns widgets update")
.render_long_help()
.to_string();
assert!(help.contains("Rename a widget:"), "{help}");
assert!(
help.contains("everruns widgets update w_1 --name blue"),
"{help}"
);
assert!(help.contains("Wire name: update_widget"), "{help}");
assert!(!help.contains('\u{1b}'), "escape bytes in help: {help:?}");
}
}