use indexmap::IndexMap;
use serde::Deserialize;
use strsim::jaro_winkler;
use thiserror::Error;
const SIMILARITY_THRESHOLD: f64 = 0.85;
#[derive(Debug, Error)]
pub enum HelpError {
#[error("parse help.yaml: {0}")]
Parse(#[from] serde_yaml::Error),
}
pub type Result<T> = std::result::Result<T, HelpError>;
#[derive(Debug, Clone, Deserialize)]
pub struct HelpConfig {
pub name: String,
pub tagline: String,
pub usage: String,
#[serde(default)]
pub commands: IndexMap<String, CommandDef>,
#[serde(default = "default_suggest")]
pub suggest: bool,
}
fn default_suggest() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommandDef {
pub description: String,
#[serde(default)]
pub flags: Vec<FlagDef>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub examples: Vec<Example>,
#[serde(default)]
pub subcommands: Option<IndexMap<String, CommandDef>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FlagDef {
pub name: String,
#[serde(default)]
pub short: Option<char>,
#[serde(default)]
pub type_hint: Option<String>,
#[serde(default)]
pub default: Option<String>,
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Example {
pub cmd: String,
#[serde(default)]
pub note: Option<String>,
}
pub fn load_help(yaml: &str) -> Result<HelpConfig> {
let config: HelpConfig = serde_yaml::from_str(yaml)?;
Ok(config)
}
pub fn render_help(config: &HelpConfig, subcommand: Option<&str>) -> String {
match subcommand {
None => render_top_level(config),
Some(path) => render_subcommand(config, path),
}
}
fn render_top_level(config: &HelpConfig) -> String {
let mut out = String::new();
out.push_str(&format!("{} — {}\n", config.name, config.tagline));
out.push('\n');
out.push_str("USAGE:\n");
out.push_str(&format!(" {}\n", config.usage));
out.push('\n');
if !config.commands.is_empty() {
out.push_str("COMMANDS:\n");
let name_width = config
.commands
.keys()
.map(|k| k.len())
.max()
.unwrap_or(0)
.max(4);
for (name, cmd) in &config.commands {
out.push_str(&format!(
" {:width$} {}\n",
name,
cmd.description,
width = name_width
));
}
out.push('\n');
}
out.push_str("OPTIONS:\n");
out.push_str(" -h, --help Print this help\n");
out.push_str(" -V, --version Print version\n");
out.push('\n');
out.push_str(&format!(
"Run '{} <COMMAND> --help' for command-specific help.\n",
config.name
));
out
}
fn render_subcommand(config: &HelpConfig, path: &str) -> String {
let parts: Vec<&str> = path.split_whitespace().collect();
if parts.is_empty() {
return render_top_level(config);
}
let mut commands_map: &IndexMap<String, CommandDef> = &config.commands;
let mut current: Option<&CommandDef> = None;
let mut resolved_path: Vec<String> = Vec::with_capacity(parts.len());
for part in &parts {
match commands_map.get(*part) {
Some(cmd) => {
current = Some(cmd);
resolved_path.push((*part).to_string());
if let Some(subs) = &cmd.subcommands {
commands_map = subs;
} else {
commands_map = &EMPTY_MAP;
}
}
None => {
return format!("unknown command: {}\n", parts.join(" "));
}
}
}
let Some(cmd) = current else {
return format!("unknown command: {}\n", parts.join(" "));
};
let full_name = format!("{} {}", config.name, resolved_path.join(" "));
let mut out = String::new();
out.push_str(&format!("{full_name} — {}\n\n", cmd.description));
out.push_str("USAGE:\n");
let mut usage_line = format!(" {full_name}");
for arg in &cmd.args {
usage_line.push_str(&format!(" <{}>", arg.to_uppercase()));
}
if !cmd.flags.is_empty() {
usage_line.push_str(" [OPTIONS]");
}
if cmd.subcommands.is_some() {
usage_line.push_str(" <SUBCOMMAND>");
}
out.push_str(&usage_line);
out.push('\n');
out.push('\n');
if let Some(subs) = &cmd.subcommands
&& !subs.is_empty()
{
out.push_str("SUBCOMMANDS:\n");
let name_width = subs.keys().map(|k| k.len()).max().unwrap_or(0).max(4);
for (n, sub) in subs {
out.push_str(&format!(
" {:width$} {}\n",
n,
sub.description,
width = name_width
));
}
out.push('\n');
}
if !cmd.flags.is_empty() {
out.push_str("OPTIONS:\n");
for flag in &cmd.flags {
let mut left = String::new();
if let Some(short) = flag.short {
left.push_str(&format!("-{short}, "));
} else {
left.push_str(" ");
}
left.push_str(&format!("--{}", flag.name));
if let Some(hint) = &flag.type_hint {
left.push_str(&format!(" <{hint}>"));
}
let mut right = flag.description.clone();
if let Some(def) = &flag.default {
right.push_str(&format!(" [default: {def}])"));
}
out.push_str(&format!(" {left:<32}{right}\n"));
}
out.push('\n');
}
if !cmd.examples.is_empty() {
out.push_str("EXAMPLES:\n");
for ex in &cmd.examples {
if let Some(note) = &ex.note {
out.push_str(&format!(" # {note}\n"));
}
out.push_str(&format!(" {}\n", ex.cmd));
}
out.push('\n');
}
out
}
static EMPTY_MAP: std::sync::LazyLock<IndexMap<String, CommandDef>> =
std::sync::LazyLock::new(IndexMap::new);
pub fn suggest(input: &str, config: &HelpConfig) -> Option<String> {
if !config.suggest {
return None;
}
let input_lc = input.to_lowercase();
let mut best: Option<(f64, &str)> = None;
for name in config.commands.keys() {
let score = jaro_winkler(&input_lc, &name.to_lowercase());
match best {
Some((b, _)) if b >= score => {}
_ => best = Some((score, name.as_str())),
}
}
best.and_then(|(score, name)| {
if score > SIMILARITY_THRESHOLD {
Some(format!("Did you mean: {name}?"))
} else {
None
}
})
}
pub fn extract_unknown_subcommand(argv: &[String]) -> Option<&str> {
let mut iter = argv.iter().enumerate();
let _ = iter.next();
while let Some((_, tok)) = iter.next() {
if let Some(stripped) = tok.strip_prefix("--") {
if !stripped.contains('=') {
let _ = iter.next();
}
continue;
}
if tok.starts_with('-') && tok.len() > 1 {
let _ = iter.next();
continue;
}
return Some(tok.as_str());
}
None
}
pub fn print_suggestion_hint(argv: &[String], config: &HelpConfig) {
if let Some(unknown) = extract_unknown_subcommand(argv)
&& let Some(hint) = suggest(unknown, config)
{
eprintln!(" {hint}");
}
eprintln!(" Run '{} --help' for available commands.", config.name);
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_YAML: &str = r#"
name: trusty-search
tagline: Hybrid BM25 + semantic + KG search daemon and MCP server
usage: trusty-search <COMMAND> [OPTIONS]
commands:
start:
description: Start the HTTP daemon and MCP server
flags:
- name: port
short: p
type_hint: PORT
default: "7878"
description: Port to listen on
examples:
- cmd: trusty-search start
- cmd: trusty-search start --port 8080
note: bind to a non-default port
query:
description: Query a search index
args: [query]
flags:
- name: index
type_hint: NAME
description: Override auto-detected index
service:
description: Manage the launchd service
subcommands:
install:
description: Install the launchd plist
uninstall:
description: Remove the launchd plist
"#;
#[test]
fn load_help_parses_yaml() {
let config = load_help(SAMPLE_YAML).expect("yaml must parse");
assert_eq!(config.name, "trusty-search");
assert!(config.tagline.starts_with("Hybrid"));
assert_eq!(config.commands.len(), 3);
let start = config.commands.get("start").expect("start defined");
assert_eq!(start.flags.len(), 1);
assert_eq!(start.flags[0].name, "port");
assert_eq!(start.flags[0].short, Some('p'));
assert_eq!(start.flags[0].default.as_deref(), Some("7878"));
assert_eq!(start.examples.len(), 2);
let service = config.commands.get("service").expect("service defined");
let subs = service.subcommands.as_ref().expect("subcommands present");
assert!(subs.contains_key("install"));
assert!(subs.contains_key("uninstall"));
}
#[test]
fn load_help_defaults_suggest_to_true() {
let yaml = "name: x\ntagline: y\nusage: x\ncommands: {}\n";
let config = load_help(yaml).unwrap();
assert!(
config.suggest,
"suggest should default to true when omitted"
);
}
#[test]
fn load_help_returns_err_on_malformed_yaml() {
let yaml = "name: trusty-search\ntagline: [unterminated";
let err = load_help(yaml).unwrap_err();
assert!(matches!(err, HelpError::Parse(_)));
}
#[test]
fn render_help_top_level() {
let config = load_help(SAMPLE_YAML).unwrap();
let out = render_help(&config, None);
assert!(
out.starts_with("trusty-search — Hybrid"),
"header missing or wrong: {out}"
);
assert!(out.contains("USAGE:"));
assert!(out.contains("trusty-search <COMMAND> [OPTIONS]"));
assert!(out.contains("COMMANDS:"));
let start_idx = out.find(" start").expect("start listed");
let query_idx = out.find(" query").expect("query listed");
let service_idx = out.find(" service").expect("service listed");
assert!(start_idx < query_idx);
assert!(query_idx < service_idx);
assert!(out.contains("OPTIONS:"));
assert!(out.contains("--help"));
assert!(out.contains("--version"));
assert!(out.contains("Run 'trusty-search <COMMAND> --help'"));
}
#[test]
fn render_help_subcommand() {
let config = load_help(SAMPLE_YAML).unwrap();
let out = render_help(&config, Some("start"));
assert!(out.contains("trusty-search start"));
assert!(out.contains("Start the HTTP daemon"));
assert!(out.contains("USAGE:"));
assert!(out.contains("OPTIONS:"));
assert!(out.contains("--port"));
assert!(out.contains("-p"));
assert!(out.contains("[default: 7878"));
assert!(out.contains("EXAMPLES:"));
assert!(out.contains("trusty-search start --port 8080"));
assert!(out.contains("# bind to a non-default port"));
}
#[test]
fn render_help_subcommand_with_positional_args() {
let config = load_help(SAMPLE_YAML).unwrap();
let out = render_help(&config, Some("query"));
assert!(
out.contains("<QUERY>"),
"positional arg missing from query usage: {out}"
);
}
#[test]
fn render_help_subcommand_with_nested_subcommands() {
let config = load_help(SAMPLE_YAML).unwrap();
let out = render_help(&config, Some("service"));
assert!(out.contains("SUBCOMMANDS:"));
assert!(out.contains("install"));
assert!(out.contains("uninstall"));
assert!(out.contains("<SUBCOMMAND>"));
}
#[test]
fn render_help_subcommand_unknown() {
let config = load_help(SAMPLE_YAML).unwrap();
let out = render_help(&config, Some("nope"));
assert!(out.starts_with("unknown command:"));
}
#[test]
fn suggest_returns_closest_match() {
let config = load_help(SAMPLE_YAML).unwrap();
let s = suggest("quer", &config).expect("should suggest for typo");
assert!(s.contains("Did you mean"));
assert!(s.contains("query"));
}
#[test]
fn suggest_returns_none_when_no_match() {
let config = load_help(SAMPLE_YAML).unwrap();
let s = suggest("xyzzy", &config);
assert!(s.is_none(), "expected None for unrelated input, got {s:?}");
}
#[test]
fn suggest_is_case_insensitive() {
let config = load_help(SAMPLE_YAML).unwrap();
let s = suggest("START", &config).expect("uppercase should still match");
assert!(s.contains("start"));
}
#[test]
fn extract_unknown_subcommand_finds_first_positional() {
let argv: Vec<String> = ["trusty-search", "qury", "fn auth"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(extract_unknown_subcommand(&argv), Some("qury"));
}
#[test]
fn extract_unknown_subcommand_skips_leading_flags() {
let argv: Vec<String> = ["trusty-search", "--verbose", "satus"]
.iter()
.map(|s| s.to_string())
.collect();
let got = extract_unknown_subcommand(&argv);
assert_ne!(got, Some("--verbose"));
}
#[test]
fn extract_unknown_subcommand_returns_none_when_no_positional() {
let argv: Vec<String> = ["trusty-search", "--verbose"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(extract_unknown_subcommand(&argv).is_none());
}
#[test]
fn extract_unknown_subcommand_handles_equals_form() {
let argv: Vec<String> = ["trusty-search", "--index=foo", "satus"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(extract_unknown_subcommand(&argv), Some("satus"));
}
#[test]
fn suggest_respects_suggest_false() {
let yaml =
"name: x\ntagline: y\nusage: x\nsuggest: false\ncommands:\n query: {description: q}\n";
let config = load_help(yaml).unwrap();
assert!(suggest("quer", &config).is_none());
}
}