use crate::error::CliError;
use clap::{Arg, ArgAction, Command, CommandFactory, ValueHint};
use serde_json::{Map, Value, json};
const SCHEMA_VERSION: i64 = 1;
const CLI_NAME: &str = "md";
const HIDDEN_SUBCOMMANDS: &[&str] = &["gui-schema", "help"];
pub fn run() -> Result<u8, CliError> {
let cmd = crate::Cli::command();
let value = build_schema(&cmd);
println!("{}", serde_json::to_string(&value).unwrap());
Ok(0)
}
pub(crate) fn build_schema(root: &Command) -> Value {
let mut subcommands: Vec<Value> = Vec::new();
for sub in root.get_subcommands() {
let name = sub.get_name();
if HIDDEN_SUBCOMMANDS.contains(&name) {
continue;
}
subcommands.push(subcommand_to_json(sub));
}
json!({
"version": SCHEMA_VERSION,
"cli": CLI_NAME,
"subcommands": subcommands,
})
}
fn subcommand_to_json(sub: &Command) -> Value {
let mut flags: Vec<Value> = Vec::new();
let mut positionals: Vec<Value> = Vec::new();
for arg in sub.get_arguments() {
if matches!(
arg.get_action(),
ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version
) {
continue;
}
if arg.is_positional() {
positionals.push(positional_to_json(arg));
} else {
flags.push(flag_to_json(arg));
}
}
let mut obj = Map::new();
obj.insert("name".into(), Value::String(sub.get_name().to_owned()));
obj.insert("flags".into(), Value::Array(flags));
obj.insert("positionals".into(), Value::Array(positionals));
Value::Object(obj)
}
fn flag_to_json(arg: &Arg) -> Value {
let name = flag_name(arg);
let required = arg.is_required_set();
let (kind, choices) = classify_kind(arg);
let mut obj = Map::new();
obj.insert("name".into(), Value::String(name));
obj.insert("required".into(), Value::Bool(required));
obj.insert("kind".into(), Value::String(kind.into()));
obj.insert("choices".into(), choices);
Value::Object(obj)
}
fn positional_to_json(arg: &Arg) -> Value {
let name = arg
.get_value_names()
.and_then(|v| v.first())
.map(|s| s.as_str().to_owned())
.unwrap_or_else(|| arg.get_id().as_str().to_owned());
let required = arg.is_required_set();
let repeating = matches!(arg.get_action(), ArgAction::Append)
|| arg
.get_num_args()
.map(|r| r.max_values() > 1)
.unwrap_or(false);
let mut obj = Map::new();
obj.insert("name".into(), Value::String(name));
obj.insert("required".into(), Value::Bool(required));
obj.insert("repeating".into(), Value::Bool(repeating));
Value::Object(obj)
}
fn flag_name(arg: &Arg) -> String {
if let Some(long) = arg.get_long() {
return format!("--{long}");
}
if let Some(short) = arg.get_short() {
return format!("-{short}");
}
arg.get_id().as_str().to_owned()
}
fn classify_kind(arg: &Arg) -> (&'static str, Value) {
if matches!(
arg.get_action(),
ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
) {
return ("boolean", Value::Null);
}
let possible = arg.get_possible_values();
if !possible.is_empty() {
let choices: Vec<Value> = possible
.iter()
.map(|pv| Value::String(pv.get_name().to_owned()))
.collect();
return ("dropdown", Value::Array(choices));
}
if matches!(
arg.get_value_hint(),
ValueHint::FilePath | ValueHint::DirPath | ValueHint::AnyPath | ValueHint::ExecutablePath
) {
return ("path", Value::Null);
}
let tid = arg.get_value_parser().type_id();
if tid == std::any::TypeId::of::<std::path::PathBuf>() {
return ("path", Value::Null);
}
let is_numeric = tid == std::any::TypeId::of::<u8>()
|| tid == std::any::TypeId::of::<u16>()
|| tid == std::any::TypeId::of::<u32>()
|| tid == std::any::TypeId::of::<u64>()
|| tid == std::any::TypeId::of::<i8>()
|| tid == std::any::TypeId::of::<i16>()
|| tid == std::any::TypeId::of::<i32>()
|| tid == std::any::TypeId::of::<i64>()
|| tid == std::any::TypeId::of::<usize>()
|| tid == std::any::TypeId::of::<isize>();
if is_numeric {
return ("number", Value::Null);
}
("text", Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn schema() -> Value {
build_schema(&crate::Cli::command())
}
#[test]
fn schema_envelope_fields() {
let v = schema();
assert_eq!(v["version"], json!(1));
assert_eq!(v["cli"], json!("md"));
assert!(v["subcommands"].is_array());
let arr = v["subcommands"].as_array().unwrap();
assert!(!arr.is_empty(), "subcommands array must not be empty");
}
#[test]
fn gui_schema_subcommand_is_hidden() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
for sub in arr {
let name = sub["name"].as_str().unwrap();
assert_ne!(
name, "gui-schema",
"gui-schema must not include itself in the schema"
);
assert_ne!(name, "help");
}
}
#[test]
fn expected_subcommands_present() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let names: Vec<&str> = arr.iter().map(|s| s["name"].as_str().unwrap()).collect();
for expected in [
"encode", "decode", "verify", "inspect", "bytecode", "vectors", "address",
] {
assert!(
names.contains(&expected),
"missing subcommand {expected} in {names:?}"
);
}
}
#[test]
fn encode_context_is_dropdown_with_tap_segwitv0() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let encode = arr
.iter()
.find(|s| s["name"] == json!("encode"))
.expect("encode subcommand");
let flags = encode["flags"].as_array().unwrap();
let ctx = flags
.iter()
.find(|f| f["name"] == json!("--context"))
.expect("--context flag on encode");
assert_eq!(ctx["kind"], json!("dropdown"));
assert_eq!(ctx["choices"], json!(["tap", "segwitv0"]));
assert_eq!(ctx["required"], json!(false));
}
#[test]
fn encode_force_chunked_is_boolean() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let encode = arr.iter().find(|s| s["name"] == json!("encode")).unwrap();
let flags = encode["flags"].as_array().unwrap();
let fc = flags
.iter()
.find(|f| f["name"] == json!("--force-chunked"))
.expect("--force-chunked flag");
assert_eq!(fc["kind"], json!("boolean"));
assert_eq!(fc["choices"], Value::Null);
}
#[test]
fn encode_network_is_dropdown_with_four_choices() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let encode = arr.iter().find(|s| s["name"] == json!("encode")).unwrap();
let flags = encode["flags"].as_array().unwrap();
let net = flags
.iter()
.find(|f| f["name"] == json!("--network"))
.expect("--network flag");
assert_eq!(net["kind"], json!("dropdown"));
assert_eq!(
net["choices"],
json!(["mainnet", "testnet", "signet", "regtest"])
);
}
#[test]
fn address_count_is_number() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let address = arr
.iter()
.find(|s| s["name"] == json!("address"))
.expect("address subcommand");
let flags = address["flags"].as_array().unwrap();
let count = flags
.iter()
.find(|f| f["name"] == json!("--count"))
.expect("--count flag");
assert_eq!(count["kind"], json!("number"));
assert_eq!(count["choices"], Value::Null);
}
#[test]
fn encode_template_positional_present() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let encode = arr.iter().find(|s| s["name"] == json!("encode")).unwrap();
let positionals = encode["positionals"].as_array().unwrap();
assert!(
!positionals.is_empty(),
"encode should have a template positional"
);
let p = &positionals[0];
assert_eq!(p["repeating"], json!(false));
assert_eq!(p["required"], json!(false));
}
#[test]
fn decode_strings_positional_is_repeating_and_required() {
let v = schema();
let arr = v["subcommands"].as_array().unwrap();
let decode = arr
.iter()
.find(|s| s["name"] == json!("decode"))
.expect("decode subcommand");
let positionals = decode["positionals"].as_array().unwrap();
assert_eq!(positionals.len(), 1);
assert_eq!(positionals[0]["required"], json!(true));
assert_eq!(positionals[0]["repeating"], json!(true));
}
}