use clap::CommandFactory;
use colored::Colorize;
use crate::cli::commands::Cli;
use crate::cli::ui;
use crate::commands::service::{is_xbp_project, load_xbp_config};
use crate::strategies::get_all_services;
pub async fn print_help() {
ui::configure_color_output();
print_banner();
ui::section("Usage");
println!(" {}", "xbp <command> [options]".bright_white());
print_all_top_level_commands();
ui::section("Global flags");
print_flag_line("--debug", "Enable verbose debugging output");
print_flag_line("--help, -h", "Show this guide (all top-level commands)");
print_flag_line("-q, --query <Q>", "Search commands/flags (with or without -h)");
print_flag_line("--commands", "Full nested command tree with flags");
print_flag_line("--logs", "Open the logs directory");
print_flag_line("-v, --version", "Print CLI version");
ui::section("Discover");
print_example("xbp workers -h Subcommand help and examples");
print_example("xbp help workers Same via clap help subcommand");
print_example("xbp -h -q deploy Search help for \"deploy\"");
print_example("xbp --query webhook Search the full command tree");
print_example("xbp --commands Full nested tree + flags");
print_example("xbp mcp export-tools MCP tool catalog from clap");
ui::section("Examples");
print_example("xbp diag");
print_example("xbp workers list");
print_example("xbp workers logs -f");
print_example("xbp deploy athena --env production --plan");
print_example("xbp api health");
print_example("xbp mcp serve --detach");
print_example("xbp install docker");
if is_xbp_project().await {
ui::section("This project");
println!(" {}", "services".bright_cyan());
println!(
" {} {}",
"service <cmd> <name>".bright_cyan(),
"build · install · start · dev".bright_black()
);
if let Ok(config) = load_xbp_config().await {
let services = get_all_services(&config);
if !services.is_empty() {
ui::section("Services here");
for service in services.iter().take(6) {
println!(
" {} {}:{}",
service.name.bright_green(),
service.target.dimmed(),
service.port.to_string().yellow()
);
}
if services.len() > 6 {
println!(
" {} {} more",
"…".dimmed(),
(services.len() - 6).to_string().dimmed()
);
}
}
}
}
println!(
"\n{} {}",
"Docs".bright_blue().bold(),
"apps/docs (generated) · https://github.com/xylex-group/xbp".cyan().underline()
);
println!();
}
fn print_banner() {
println!();
println!(
"{} {}",
"XBP".bright_magenta().bold(),
format!("v{}", env!("CARGO_PKG_VERSION")).bright_white()
);
println!("{}", "Deploy · operate · debug · ship".bright_black());
ui::divider(44);
}
fn print_all_top_level_commands() {
ui::section("Commands");
let root = Cli::command();
let mut entries: Vec<(String, String, Vec<String>)> = root
.get_subcommands()
.filter(|cmd| !cmd.is_hide_set())
.filter(|cmd| cmd.get_name() != "help")
.map(|cmd| {
let name = cmd.get_name().to_string();
let about = cmd
.get_about()
.or_else(|| cmd.get_long_about())
.map(|s| first_line(&s.to_string()))
.unwrap_or_default();
let aliases: Vec<String> = cmd
.get_visible_aliases()
.map(str::to_string)
.chain(cmd.get_all_aliases().map(str::to_string))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
(name, about, aliases)
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let width = entries
.iter()
.map(|(name, _, aliases)| {
let alias_extra = if aliases.is_empty() {
0
} else {
3 + aliases.iter().map(|a| a.len()).sum::<usize>() + (aliases.len().saturating_sub(1)) * 2
};
name.len() + alias_extra
})
.max()
.unwrap_or(14)
.clamp(14, 28);
for (name, about, aliases) in entries {
let label = if aliases.is_empty() {
name.clone()
} else {
format!("{name} ({})", aliases.join(", "))
};
println!(
" {:width$} {}",
label.bright_cyan().bold(),
about.bright_black(),
width = width
);
}
println!(
"\n {} {}",
"Tip:".bright_black(),
"use `xbp <command> -h` or `xbp --commands` for nested flags.".bright_black()
);
}
fn first_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
.to_string()
}
fn print_flag_line(flag: &str, description: &str) {
println!(
" {:<16} {}",
flag.bright_yellow(),
description.bright_black()
);
}
fn print_example(command: &str) {
println!(" {}", command.dimmed());
}
pub fn print_help_query(query: &str) {
ui::configure_color_output();
let q = query.trim();
if q.is_empty() {
println!("{}", "Empty search query. Example: xbp -h -q deploy".yellow());
return;
}
let root = Cli::command();
let terms: Vec<String> = q
.split_whitespace()
.map(|t| t.to_ascii_lowercase())
.filter(|t| !t.is_empty())
.collect();
if terms.is_empty() {
println!("{}", "Empty search query. Example: xbp -q webhook".yellow());
return;
}
let mut hits: Vec<SearchHit> = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
print_banner();
ui::section(&format!("Search: {q}"));
if hits.is_empty() {
println!(
" {} {}",
"No matches.".yellow().bold(),
"Try another keyword or `xbp --commands`.".bright_black()
);
println!();
return;
}
let shown = hits.len().min(40);
println!(
" {} {}",
format!("{shown}").bright_green().bold(),
format!(
"match{} (of {})",
if shown == 1 { "" } else { "es" },
hits.len()
)
.bright_black()
);
println!();
for hit in hits.into_iter().take(shown) {
let cmd_line = format!("xbp {}", hit.path.join(" "));
println!(" {}", cmd_line.bright_cyan().bold());
if !hit.about.is_empty() {
println!(" {}", hit.about.bright_black());
}
if !hit.matched.is_empty() {
println!(
" {} {}",
"matched:".dimmed(),
hit.matched.join(", ").yellow()
);
}
if !hit.flags.is_empty() {
let preview: Vec<&str> = hit.flags.iter().take(6).map(String::as_str).collect();
let more = if hit.flags.len() > 6 {
format!(" (+{} more)", hit.flags.len() - 6)
} else {
String::new()
};
println!(
" {} {}{}",
"flags:".dimmed(),
preview.join(" ").bright_white(),
more.dimmed()
);
}
println!(
" {} {}",
"→".bright_blue(),
format!("xbp {} -h", hit.path.join(" ")).dimmed()
);
println!();
}
println!(
" {} {}",
"Tip:".bright_black(),
"refine with more words, or open nested help with `xbp <cmd> -h`.".bright_black()
);
println!();
}
#[derive(Debug, Clone)]
struct SearchHit {
path: Vec<String>,
about: String,
score: i32,
matched: Vec<String>,
flags: Vec<String>,
}
fn walk_search(
cmd: &clap::Command,
path: &[&str],
terms: &[String],
out: &mut Vec<SearchHit>,
) {
let subs: Vec<&clap::Command> = cmd
.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
.collect();
if !path.is_empty() {
if let Some(hit) = score_command(cmd, path, terms) {
out.push(hit);
}
}
for sub in subs {
let mut next = path.to_vec();
next.push(sub.get_name());
walk_search(sub, &next, terms, out);
}
}
fn score_command(cmd: &clap::Command, path: &[&str], terms: &[String]) -> Option<SearchHit> {
let name = path.join(" ");
let about = cmd
.get_about()
.or_else(|| cmd.get_long_about())
.map(|s| first_line(&s.to_string()))
.unwrap_or_default();
let aliases: Vec<String> = cmd
.get_visible_aliases()
.chain(cmd.get_all_aliases())
.map(str::to_string)
.collect();
let mut flags: Vec<String> = Vec::new();
let mut flag_help_blob = String::new();
for arg in cmd.get_arguments().filter(|a| {
!a.is_hide_set()
&& !a.is_global_set()
&& a.get_id() != "help"
&& a.get_id() != "version"
&& a.get_id() != "debug"
}) {
let long = arg
.get_long()
.map(|s| format!("--{s}"))
.or_else(|| arg.get_short().map(|c| format!("-{c}")))
.unwrap_or_else(|| format!("--{}", arg.get_id()));
flags.push(long.clone());
flag_help_blob.push_str(&long);
flag_help_blob.push(' ');
if let Some(h) = arg.get_help().or_else(|| arg.get_long_help()) {
flag_help_blob.push_str(&h.to_string());
flag_help_blob.push(' ');
}
}
let hay_name = name.to_ascii_lowercase();
let hay_about = about.to_ascii_lowercase();
let hay_alias = aliases.join(" ").to_ascii_lowercase();
let hay_flags = flag_help_blob.to_ascii_lowercase();
let leaf = path.last().copied().unwrap_or("").to_ascii_lowercase();
let mut score = 0i32;
let mut matched = Vec::new();
for term in terms {
let mut term_score = 0i32;
if leaf == *term {
term_score += 100;
matched.push(format!("name={term}"));
} else if leaf.contains(term) || hay_name.contains(term) {
term_score += 60;
matched.push(format!("path~{term}"));
}
if hay_alias.split_whitespace().any(|a| a == term || a.contains(term)) {
term_score += 40;
matched.push(format!("alias~{term}"));
}
if hay_about.contains(term) {
term_score += 25;
matched.push(format!("about~{term}"));
}
if hay_flags.contains(term) {
term_score += 15;
matched.push(format!("flag~{term}"));
}
if term_score == 0 {
return None;
}
score += term_score;
}
score += path.len() as i32;
Some(SearchHit {
path: path.iter().map(|s| (*s).to_string()).collect(),
about,
score,
matched,
flags,
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn help_lists_every_top_level_clap_command() {
let root = Cli::command();
let clap_names: std::collections::BTreeSet<String> = root
.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
.map(|c| c.get_name().to_string())
.collect();
assert!(
clap_names.len() > 30,
"expected broad top-level surface, got {}",
clap_names.len()
);
for name in ["deploy", "workers", "mcp", "api", "commit"] {
assert!(
clap_names.iter().any(|n| n == name),
"missing expected top-level `{name}`"
);
}
}
#[test]
fn help_query_finds_deploy() {
let root = Cli::command();
let terms = vec!["deploy".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(
hits.iter().any(|h| h.path == ["deploy"]),
"expected deploy hit, got {:?}",
hits.iter().map(|h| h.path.join(" ")).collect::<Vec<_>>()
);
}
#[test]
fn help_query_finds_discord_config() {
let root = Cli::command();
let terms = vec!["discord".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(
hits.iter().any(|h| h.path.iter().any(|p| p.contains("discord"))),
"expected discord-related hit"
);
}
#[test]
fn help_query_finds_workers_and_diag() {
let root = Cli::command();
for term in ["workers", "diag", "version", "ports"] {
let terms = vec![term.to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(
!hits.is_empty(),
"expected hits for `{term}`"
);
assert!(
hits.iter().any(|h| h.path.iter().any(|p| p.contains(term))),
"expected path containing `{term}`, got {:?}",
hits.iter().map(|h| h.path.join(" ")).collect::<Vec<_>>()
);
}
}
#[test]
fn help_query_multi_term_requires_all() {
let root = Cli::command();
let terms = vec!["config".to_string(), "discord".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(!hits.is_empty());
for hit in &hits {
let joined = hit.path.join(" ");
assert!(
joined.contains("config") || hit.about.to_ascii_lowercase().contains("config") || hit.matched.iter().any(|m| m.contains("config")),
"hit should relate to config: {joined}"
);
}
}
#[test]
fn help_query_unknown_returns_empty() {
let root = Cli::command();
let terms = vec!["zzzxbpnonexistent999".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(hits.is_empty());
}
#[test]
fn help_query_scores_exact_leaf_higher_than_partial() {
let root = Cli::command();
let terms = vec!["list".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(!hits.is_empty());
let exact = hits.iter().find(|h| h.path.last().map(|s| s.as_str()) == Some("list"));
let partial = hits.iter().find(|h| {
h.path.last().map(|s| s.as_str()) != Some("list")
&& h.path.iter().any(|p| p.contains("list"))
});
if let (Some(exact), Some(partial)) = (exact, partial) {
assert!(
exact.score >= partial.score,
"exact leaf score {} < partial {}",
exact.score,
partial.score
);
}
}
#[test]
fn help_query_case_insensitive_via_lowercase_terms() {
let root = Cli::command();
let terms = vec!["deploy".to_string()];
let mut hits = Vec::new();
walk_search(&root, &[], &terms, &mut hits);
assert!(hits.iter().any(|h| h.path == ["deploy"]));
}
#[test]
fn first_line_helper() {
assert_eq!(first_line("hello\nworld"), "hello");
assert_eq!(first_line("\n \nfoo"), "foo");
assert_eq!(first_line(""), "");
}
#[test]
fn help_lists_more_than_thirty_tops() {
let root = Cli::command();
let n = root
.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
.count();
assert!(n > 30, "got {n}");
}
}