use anyhow::{Context, Result, bail};
use clap::{ArgMatches, Command};
use everruns_cli_contract::ContractCommand;
use serde_json::{Map, Value};
use crate::commands::api::ApiClient;
use crate::output::OutputFormat;
pub fn mounted(root: &Command) -> Vec<&'static ContractCommand> {
everruns_cli_contract::commands()
.iter()
.filter(|contract| !defines(root, &contract.path, &contract.verb))
.collect()
}
fn defines(root: &Command, path: &[String], verb: &str) -> bool {
let mut node = root;
for segment in path {
match node.find_subcommand(segment) {
Some(child) => node = child,
None => return false,
}
}
node.find_subcommand(verb).is_some()
}
pub fn augment(mut root: Command) -> Command {
for contract in mounted(&root) {
let leaf = contract.clap_command(&contract.verb);
root = insert(root, &contract.path, leaf);
}
root
}
fn insert(node: Command, path: &[String], leaf: Command) -> Command {
let Some((head, rest)) = path.split_first() else {
return node.subcommand(leaf);
};
if node.find_subcommand(head).is_some() {
node.mut_subcommand(head, |child| insert(child, rest, leaf))
} else {
let created = Command::new(head.clone())
.about(format!("{head} commands"))
.subcommand_required(true)
.arg_required_else_help(true);
node.subcommand(insert(created, rest, leaf))
}
}
fn resolved(matches: &ArgMatches) -> (Vec<String>, &ArgMatches) {
let mut path = Vec::new();
let mut node = matches;
while let Some((name, child)) = node.subcommand() {
path.push(name.to_string());
node = child;
}
(path, node)
}
pub async fn dispatch(
root: &Command,
matches: &ArgMatches,
api_url: &str,
api_key: &str,
org_id: Option<&str>,
output: OutputFormat,
) -> Option<Result<()>> {
let (path, leaf) = resolved(matches);
let (verb, nouns) = path.split_last()?;
let contract = mounted(root)
.into_iter()
.find(|contract| contract.path == nouns && &contract.verb == verb)?;
Some(run(contract, leaf, api_url, api_key, org_id, output).await)
}
async fn run(
contract: &ContractCommand,
matches: &ArgMatches,
api_url: &str,
api_key: &str,
org_id: Option<&str>,
output: OutputFormat,
) -> Result<()> {
let params = everruns_cli_contract::params_from(contract, matches);
let mut body = params.as_object().cloned().unwrap_or_default();
let path = fill_path(&contract.http_path, &mut body)?;
let client = ApiClient::new(api_url, api_key, org_id);
let response = match contract.method {
ref method if method == "GET" => client.get(&with_query(&path, &body)).await,
ref method if method == "DELETE" => client.delete(&with_query(&path, &body)).await,
ref method if method == "POST" => client.post(&path, Some(&Value::Object(body))).await,
ref method if method == "PATCH" => client.patch(&path, &Value::Object(body)).await,
ref method if method == "PUT" => {
bail!(
"`{}` is a PUT and this CLI's client does not send PUT yet",
contract.spelling()
)
}
ref method => bail!(
"unsupported method `{method}` for `{}`",
contract.spelling()
),
}?;
print(&response, output);
Ok(())
}
fn fill_path(template: &str, body: &mut Map<String, Value>) -> Result<String> {
let mut path = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
let close = rest[open..]
.find('}')
.map(|offset| open + offset)
.with_context(|| format!("unterminated placeholder in `{template}`"))?;
path.push_str(&rest[..open]);
let field = &rest[open + 1..close];
let value = body
.remove(field)
.with_context(|| format!("`{template}` needs `--{}`", field.replace('_', "-")))?;
path.push_str(&as_text(&value));
rest = &rest[close + 1..];
}
path.push_str(rest);
Ok(path)
}
fn with_query(path: &str, params: &Map<String, Value>) -> String {
if params.is_empty() {
return path.to_string();
}
let query = params
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(&as_text(value))))
.collect::<Vec<_>>()
.join("&");
format!("{path}?{query}")
}
fn as_text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(*byte as char)
}
other => encoded.push_str(&format!("%{other:02X}")),
}
}
encoded
}
fn print(response: &Value, output: OutputFormat) {
match output {
OutputFormat::Text | OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(response).unwrap_or_else(|_| response.to_string())
);
}
OutputFormat::Yaml => match serde_yaml::to_string(response) {
Ok(text) => print!("{text}"),
Err(_) => println!("{response}"),
},
}
}