use clap::{Arg, ArgAction, Command, CommandFactory};
use serde::Serialize;
use crate::cli::Cli;
use crate::json_envelope::{to_string_pretty, JsonEnvelope};
const SCHEMA_VERSION: u32 = 1;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommandSchema {
name: String,
aliases: Vec<String>,
subcommand_required: bool,
arguments: Vec<ArgumentSchema>,
subcommands: Vec<CommandSchema>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ArgumentSchema {
id: String,
kind: &'static str,
long: Option<String>,
short: Option<char>,
long_aliases: Vec<String>,
short_aliases: Vec<char>,
index: Option<usize>,
required: bool,
global: bool,
trailing: bool,
action: &'static str,
min_values: usize,
max_values: Option<usize>,
value_names: Vec<String>,
possible_values: Vec<String>,
}
pub(crate) fn run() {
println!(
"{}",
to_string_pretty(&JsonEnvelope::ok(SCHEMA_VERSION, native_schema()))
);
}
const SURFACE_HEADER: &str = "\
# Every `harn` command, alias, flag, and positional argument a caller can type.
# GENERATED by `make gen-cli-surface` from the live Clap parser; do not edit.
# A pull request that removes or renames a line here breaks a caller, so it
# must add a `changelog.d/<id>.breaking.md` fragment with a `Migration:`
# section. Adding lines is not breaking.
";
pub(crate) fn dump_surface(output: &str, check: bool) {
let generated = surface_listing(&native_schema());
let path = std::path::Path::new(output);
if check {
let existing = std::fs::read_to_string(path).unwrap_or_default();
if existing.replace("\r\n", "\n") != generated {
eprintln!(
"error: {} is stale relative to the Clap parser.",
path.display()
);
eprintln!("hint: run `make gen-cli-surface` to regenerate.");
std::process::exit(1);
}
return;
}
if let Err(error) = std::fs::write(path, &generated) {
eprintln!("error: cannot write {}: {error}", path.display());
std::process::exit(1);
}
println!("wrote {}", path.display());
}
fn surface_listing(root: &CommandSchema) -> String {
let mut lines = std::collections::BTreeSet::new();
collect_surface(root, &root.name, &[], &mut lines);
let mut listing = SURFACE_HEADER.to_owned();
for line in lines {
listing.push_str(&line);
listing.push('\n');
}
listing
}
fn collect_surface(
command: &CommandSchema,
path: &str,
inherited_globals: &[&str],
lines: &mut std::collections::BTreeSet<String>,
) {
lines.insert(path.to_owned());
let mut globals = inherited_globals.to_vec();
for argument in &command.arguments {
let generated = matches!(
argument.action,
"help" | "helpShort" | "helpLong" | "version"
);
if generated || (argument.global && inherited_globals.contains(&argument.id.as_str())) {
continue;
}
if argument.global {
globals.push(&argument.id);
}
let mut spellings: Vec<String> = Vec::new();
spellings.extend(argument.long.iter().map(|long| format!("--{long}")));
spellings.extend(argument.long_aliases.iter().map(|long| format!("--{long}")));
spellings.extend(argument.short.iter().map(|short| format!("-{short}")));
spellings.extend(
argument
.short_aliases
.iter()
.map(|short| format!("-{short}")),
);
spellings.extend(argument.index.map(|index| format!("<positional {index}>")));
for spelling in spellings {
lines.insert(format!("{path} {spelling}"));
}
}
let parent = path.rsplit_once(' ').map_or("", |(parent, _)| parent);
for alias in &command.aliases {
lines.insert(format!("{parent} {alias} (alias of {path})"));
}
for subcommand in &command.subcommands {
if subcommand.name == "help" {
continue;
}
let child = format!("{path} {}", subcommand.name);
collect_surface(subcommand, &child, &globals, lines);
}
}
fn native_schema() -> CommandSchema {
let mut command = Cli::command();
command.build();
project_command(&command)
}
fn project_command(command: &Command) -> CommandSchema {
CommandSchema {
name: command.get_name().to_owned(),
aliases: command.get_all_aliases().map(str::to_owned).collect(),
subcommand_required: command.is_subcommand_required_set(),
arguments: command.get_arguments().map(project_argument).collect(),
subcommands: command.get_subcommands().map(project_command).collect(),
}
}
fn project_argument(argument: &Arg) -> ArgumentSchema {
let range = argument.get_num_args();
let action = argument.get_action();
ArgumentSchema {
id: argument.get_id().to_string(),
kind: if argument.get_index().is_some() {
"positional"
} else if range.is_some_and(|range| range.takes_values()) {
"option"
} else {
"flag"
},
long: argument.get_long().map(str::to_owned),
short: argument.get_short(),
long_aliases: argument
.get_all_aliases()
.unwrap_or_default()
.into_iter()
.map(str::to_owned)
.collect(),
short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
index: argument.get_index(),
required: argument.is_required_set(),
global: argument.is_global_set(),
trailing: argument.is_trailing_var_arg_set(),
action: match action {
ArgAction::Set => "set",
ArgAction::Append => "append",
ArgAction::SetTrue => "setTrue",
ArgAction::SetFalse => "setFalse",
ArgAction::Count => "count",
ArgAction::Help => "help",
ArgAction::HelpShort => "helpShort",
ArgAction::HelpLong => "helpLong",
ArgAction::Version => "version",
_ => "other",
},
min_values: range.map_or(0, |range| range.min_values()),
max_values: range
.and_then(|range| (range.max_values() != usize::MAX).then_some(range.max_values())),
value_names: argument.get_value_names().map_or_else(Vec::new, |names| {
names.iter().map(ToString::to_string).collect()
}),
possible_values: argument
.get_possible_values()
.iter()
.map(|value| value.get_name().to_owned())
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{error::ErrorKind, Parser};
fn find<'a>(schema: &'a CommandSchema, path: &[&str]) -> &'a CommandSchema {
path.iter().fold(schema, |parent, name| {
parent
.subcommands
.iter()
.find(|child| child.name == *name)
.expect("native command")
})
}
#[test]
fn nested_native_flags_come_from_the_parser_and_reject_a_fake_flag() {
let schema = native_schema();
let ladder = find(&schema, &["merge-captain", "ladder"]);
assert!(ladder
.arguments
.iter()
.any(|arg| arg.long.as_deref() == Some("report-out") && arg.min_values == 1));
assert!(ladder
.arguments
.iter()
.any(|arg| arg.long.as_deref() == Some("format")
&& arg.possible_values.contains(&"json".to_owned())));
let mut command = Cli::command();
let help = command
.find_subcommand_mut("merge-captain")
.expect("parent")
.find_subcommand_mut("ladder")
.expect("child")
.render_long_help()
.to_string();
assert!(help.contains("--report-out"));
let error = Cli::try_parse_from([
"harn",
"merge-captain",
"ladder",
"manifest.toml",
"--not-a-harn-flag",
])
.expect_err("fake flag must fail");
assert_eq!(error.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn surface_lists_every_spelling_a_caller_can_type() {
let listing = surface_listing(&native_schema());
let lines: Vec<&str> = listing.lines().collect();
for expected in [
"harn merge-captain ladder",
"harn merge-captain ladder --report-out",
"harn merge-captain ladder --format",
"harn run",
"harn run <positional 1>",
] {
assert!(lines.contains(&expected), "missing `{expected}`");
}
assert!(!lines.iter().any(|line| line.ends_with(" --help")));
assert!(!lines.iter().any(|line| line.starts_with("harn help")));
let body: Vec<&str> = lines
.iter()
.copied()
.filter(|line| !line.starts_with('#'))
.collect();
let mut sorted = body.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(body, sorted, "listing must be sorted and unique");
}
#[test]
fn schema_is_a_versioned_json_envelope() {
let value =
serde_json::to_value(JsonEnvelope::ok(SCHEMA_VERSION, native_schema())).unwrap();
assert_eq!(value["schemaVersion"], 1);
assert_eq!(value["ok"], true);
assert!(value["data"]["subcommands"].as_array().unwrap().len() > 20);
}
}