Skip to main content

xbp_cli/cli/
help_render.rs

1//! Styled help rendering for Clap-generated usage text.
2
3use clap::CommandFactory;
4use colored::Colorize;
5
6/// Shared help layout for subcommands (no duplicate name/version header).
7pub const XBP_HELP_TEMPLATE: &str = "\
8{about-with-newline}\
9Usage: {usage}\n\n\
10{all-args}\
11{after-help}";
12
13/// Root help layout; the renderer adds a branded banner above Clap output.
14pub const XBP_ROOT_HELP_TEMPLATE: &str = "\
15{about-with-newline}\
16Usage: {usage}\n\n\
17{all-args}\
18{after-help}";
19
20pub const XBP_ROOT_AFTER_HELP: &str = "\
21Quick start:
22  xbp diag
23  xbp services
24  xbp workers list
25  xbp workers logs -f
26  xbp api health
27
28Discover:
29  xbp <command> -h          Command help and examples
30  xbp <command> <sub> -h    Subcommand help
31  xbp --commands            Full alphabetical command tree
32  xbp install               Browse installable targets";
33
34pub const CONFIG_AFTER_HELP: &str = "\
35Examples:
36  xbp config
37  xbp config --project
38  xbp config cloudflare
39  xbp config cloudflare status
40  xbp config openrouter set-key
41  xbp config linear select-initiative";
42
43pub const VERSION_AFTER_HELP: &str = "\
44Examples:
45  xbp version
46  xbp version patch
47  xbp version 1.2.3
48  xbp version release
49  xbp version workspace check
50  xbp version workspace sync --version 3.16.5 --write";
51
52pub const DIAG_AFTER_HELP: &str = "\
53Examples:
54  xbp diag
55  xbp diag --nginx
56  xbp diag --ports 80,443
57  xbp diag --codetime --cursor";
58
59pub const NGINX_AFTER_HELP: &str = "\
60Examples:
61  xbp nginx list
62  xbp nginx enable api.example.com
63  xbp nginx disable api.example.com
64  xbp nginx upstream list";
65
66pub const COMMIT_AFTER_HELP: &str = "\
67Examples:
68  xbp commit
69  xbp commit --dry-run
70  xbp commit --push
71  xbp commit --scope cli";
72
73pub const LOGS_AFTER_HELP: &str = "\
74Examples:
75  xbp logs
76  xbp logs my-project
77  xbp logs --ssh-host bastion.example.com";
78
79pub const PUBLISH_AFTER_HELP: &str = "\
80Examples:
81  xbp publish --dry-run
82  xbp publish --target npm
83  xbp publish --service web
84  xbp publish --service api --include-prereqs
85  xbp publish --allow-dirty";
86
87pub const DOMAINS_AFTER_HELP: &str = "\
88Examples:
89  xbp domains list
90  xbp domains check --domain example.com
91  xbp domains search --query myapp --extension com";
92
93pub const LOGIN_AFTER_HELP: &str = "\
94Examples:
95  xbp login
96  xbp login status
97  xbp login logout
98  xbp whoami";
99
100pub const SSH_AFTER_HELP: &str = "\
101Examples:
102  xbp ssh
103  xbp ssh --host bastion.example.com
104  xbp ssh --host 10.0.0.5 --command \"uptime\"";
105
106pub const GENERATE_AFTER_HELP: &str = "\
107Examples:
108  xbp generate config
109  xbp generate config --force
110  xbp generate systemd";
111
112pub const DONE_AFTER_HELP: &str = "\
113Examples:
114  xbp done
115  xbp done --since \"7 days ago\"
116  xbp done --output report.md";
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum HelpScope {
120    Root,
121    Subcommand,
122    Catalog,
123    Auto,
124}
125
126pub fn emit_styled_help(raw: &str, scope: HelpScope) {
127    crate::cli::ui::configure_color_output();
128    let resolved_scope = match scope {
129        HelpScope::Auto => detect_help_scope(raw),
130        other => other,
131    };
132
133    let mut skip_about_line = None::<String>;
134    let mut skip_until_usage = resolved_scope == HelpScope::Catalog;
135    if resolved_scope == HelpScope::Root {
136        print_root_banner(raw);
137    } else if resolved_scope != HelpScope::Catalog {
138        if let Some((_, tagline)) = parse_subcommand_heading(raw) {
139            skip_about_line = Some(tagline);
140            print_subcommand_banner(raw);
141        }
142    }
143
144    let lines: Vec<&str> = raw.lines().collect();
145    let mut index = 0usize;
146    while index < lines.len() {
147        let line = lines[index];
148        let trimmed = line.trim();
149        if skip_until_usage {
150            if trimmed.starts_with("Usage:") {
151                skip_until_usage = false;
152            } else {
153                index += 1;
154                continue;
155            }
156        }
157        if let Some(about) = skip_about_line.as_deref() {
158            if trimmed == about {
159                skip_about_line = None;
160                index += 1;
161                continue;
162            }
163        }
164
165        if is_command_name_line(line)
166            && index + 1 < lines.len()
167            && is_indented_help_text(lines[index + 1], 4)
168        {
169            println!("{}", style_command_pair(line, lines[index + 1]));
170            index += 2;
171            continue;
172        }
173
174        if is_option_flags_line(line)
175            && index + 1 < lines.len()
176            && is_indented_help_text(lines[index + 1], 4)
177        {
178            println!("{}", style_option_pair(line, lines[index + 1]));
179            index += 2;
180            continue;
181        }
182
183        if is_option_entry(line)
184            && index + 1 < lines.len()
185            && is_indented_help_text(lines[index + 1], 8)
186        {
187            println!("{}", style_option_pair(line, lines[index + 1]));
188            index += 2;
189            continue;
190        }
191
192        println!("{}", style_help_line(line));
193        index += 1;
194    }
195    println!();
196}
197
198/// Print the complete Clap command tree with styled formatting.
199pub fn print_command_catalog() {
200    crate::cli::ui::configure_color_output();
201    println!();
202    println!("{}", "xbp commands".bright_magenta().bold());
203    println!("{}", "Complete command reference".bright_black());
204    crate::cli::ui::divider(32);
205
206    let help = crate::cli::commands::Cli::command()
207        .render_long_help()
208        .to_string();
209    emit_styled_help(&help, HelpScope::Catalog);
210}
211
212pub fn emit_version_line(version: &str) {
213    crate::cli::ui::configure_color_output();
214    println!(
215        "{} {}",
216        "xbp".bright_magenta().bold(),
217        version.bright_white().bold()
218    );
219}
220
221pub fn is_root_help_text(raw: &str) -> bool {
222    raw.lines().any(|line| {
223        let trimmed = line.trim();
224        trimmed == "Usage: xbp [OPTIONS] [COMMAND]"
225            || trimmed == "Usage: xbp.exe [OPTIONS] [COMMAND]"
226            || trimmed.starts_with("Usage: xbp [OPTIONS] [COMMAND]")
227            || trimmed.starts_with("Usage: xbp.exe [OPTIONS] [COMMAND]")
228    })
229}
230
231fn detect_help_scope(raw: &str) -> HelpScope {
232    if is_root_help_text(raw) {
233        return HelpScope::Root;
234    }
235    let first_line = raw.lines().next().unwrap_or_default().trim();
236    if first_line.starts_with("xbp ") && first_line.chars().any(|ch| ch.is_ascii_digit()) {
237        return HelpScope::Root;
238    }
239    HelpScope::Subcommand
240}
241
242fn print_root_banner(raw: &str) {
243    let version = parse_version_line(raw).unwrap_or(env!("CARGO_PKG_VERSION"));
244    println!();
245    println!(
246        "{}  {}",
247        "XBP".bright_magenta().bold(),
248        format!("v{version}").bright_white()
249    );
250    println!("{}", "Deploy · operate · debug · ship".bright_black());
251    crate::cli::ui::divider(44);
252}
253
254fn print_subcommand_banner(raw: &str) {
255    let Some((command_path, tagline)) = parse_subcommand_heading(raw) else {
256        return;
257    };
258    println!();
259    println!("{}", command_path.bright_magenta().bold());
260    if !tagline.is_empty() {
261        println!("{}", tagline.bright_black());
262    }
263    crate::cli::ui::divider(command_path.len().max(28));
264}
265
266fn parse_version_line(raw: &str) -> Option<&str> {
267    let first = raw.lines().next()?.trim();
268    let rest = first.strip_prefix("xbp")?.trim();
269    if rest.is_empty() {
270        None
271    } else {
272        Some(rest)
273    }
274}
275
276fn parse_subcommand_heading(raw: &str) -> Option<(String, String)> {
277    let about = raw
278        .lines()
279        .map(str::trim)
280        .find(|line| !line.is_empty() && !line.starts_with("Usage:"))?;
281
282    let usage = raw
283        .lines()
284        .map(str::trim)
285        .find(|line| line.starts_with("Usage:"))?;
286    let command_path = extract_command_path_from_usage(usage)?;
287
288    Some((command_path, about.to_string()))
289}
290
291fn extract_command_path_from_usage(usage: &str) -> Option<String> {
292    let rest = usage.split_once(':')?.1.trim();
293    let path = rest.split('[').next()?.trim();
294    let normalized = path.replace(".exe", "");
295    if normalized.is_empty() {
296        None
297    } else {
298        Some(normalized)
299    }
300}
301
302fn style_help_line(line: &str) -> String {
303    let trimmed = line.trim_start();
304    if trimmed.is_empty() {
305        return String::new();
306    }
307
308    if matches!(
309        trimmed,
310        "Commands:" | "Options:" | "Arguments:" | "Subcommands:"
311    ) {
312        return format!(
313            "\n{} {}",
314            "▸".bright_blue().bold(),
315            trimmed.bright_blue().bold()
316        );
317    }
318
319    if trimmed.starts_with("Usage:") {
320        return style_usage_line(line);
321    }
322
323    if trimmed == "Discover:" {
324        return format!(
325            "\n{} {}",
326            "◇".bright_green().bold(),
327            trimmed.bright_green().bold()
328        );
329    }
330
331    if is_example_section_header(trimmed) {
332        return format!(
333            "\n{} {}",
334            "◇".bright_green().bold(),
335            trimmed.bright_green().bold()
336        );
337    }
338
339    if is_note_section_header(trimmed) {
340        return format!(
341            "\n{} {}",
342            "◇".bright_yellow().bold(),
343            trimmed.bright_yellow().bold()
344        );
345    }
346
347    if is_command_entry(line) {
348        return style_command_entry(line);
349    }
350
351    if is_option_flags_line(line) {
352        return format!("  {}", line.trim().bright_yellow());
353    }
354
355    if is_option_entry(line) {
356        return style_option_entry(line);
357    }
358
359    if is_command_name_line(line) {
360        return format!("  {}", line.trim().bright_cyan().bold());
361    }
362
363    if is_indented_help_text(line, 4) || is_indented_help_text(line, 8) {
364        return format!("      {}", trimmed.bright_black());
365    }
366
367    if is_example_command_line(trimmed) {
368        return format!("  {}", highlight_inline_flags(trimmed).dimmed());
369    }
370
371    if trimmed.starts_with("Run `") || trimmed.starts_with("Use `") || trimmed.starts_with("Pass `")
372    {
373        return trimmed.bright_black().to_string();
374    }
375
376    line.to_string()
377}
378
379fn style_usage_line(line: &str) -> String {
380    let (prefix, rest) = line.split_once(':').unwrap_or((line, ""));
381    format!(
382        "{} {}",
383        format!("{prefix}:").bright_cyan().bold(),
384        highlight_inline_flags(rest.trim()).bright_white()
385    )
386}
387
388fn is_example_section_header(line: &str) -> bool {
389    matches!(
390        line,
391        "Examples:"
392            | "Quick start:"
393            | "Discover:"
394            | "Available targets:"
395            | "List installable targets:"
396    ) || line.starts_with("Quick start")
397        || line.starts_with("List installable")
398}
399
400fn is_note_section_header(line: &str) -> bool {
401    line == "Notes:"
402        || line.starts_with("Tip:")
403        || line.starts_with("More info:")
404        || line.starts_with("Hint:")
405}
406
407fn is_command_entry(line: &str) -> bool {
408    if !line.starts_with("  ") || line.starts_with("    ") {
409        return false;
410    }
411    let trimmed = line.trim_start();
412    let Some((name, _)) = trimmed.split_once("  ") else {
413        return false;
414    };
415    !name.starts_with('-') && !name.contains('<') && name.len() <= 24
416}
417
418fn style_command_entry(line: &str) -> String {
419    let trimmed = line.trim_start();
420    let mut parts = trimmed.splitn(2, "  ");
421    let name = parts.next().unwrap_or_default();
422    let description = parts.next().unwrap_or_default().trim();
423    let alias = extract_alias_suffix(description);
424    let base_description = description
425        .split_once('[')
426        .map(|(left, _)| left.trim())
427        .unwrap_or(description);
428
429    let name = name.bright_cyan().bold();
430    if alias.is_empty() {
431        format!("  {name:<22} {}", base_description.bright_black())
432    } else {
433        format!(
434            "  {name:<22} {} {}",
435            base_description.bright_black(),
436            alias.bright_black()
437        )
438    }
439}
440
441fn extract_alias_suffix(description: &str) -> String {
442    let Some(start) = description.find('[') else {
443        return String::new();
444    };
445    description[start..].to_string()
446}
447
448fn is_option_flags_line(line: &str) -> bool {
449    let trimmed = line.trim_start();
450    line.starts_with("  ")
451        && !line.starts_with("    ")
452        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
453}
454
455fn is_option_entry(line: &str) -> bool {
456    let trimmed = line.trim_start();
457    line.starts_with("      ")
458        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
459        && !trimmed.starts_with("Usage:")
460}
461
462fn is_command_name_line(line: &str) -> bool {
463    if !line.starts_with("  ") || line.starts_with("    ") {
464        return false;
465    }
466    let trimmed = line.trim();
467    !trimmed.is_empty()
468        && !trimmed.ends_with(':')
469        && !trimmed.starts_with('-')
470        && !trimmed.contains(' ')
471        && trimmed.len() <= 24
472}
473
474fn is_indented_help_text(line: &str, min_spaces: usize) -> bool {
475    let leading = line.chars().take_while(|ch| *ch == ' ').count();
476    leading >= min_spaces && !line.trim_start().starts_with('-') && !line.trim().is_empty()
477}
478
479fn style_command_pair(name_line: &str, description_line: &str) -> String {
480    let name = name_line.trim().bright_cyan().bold();
481    let description = description_line.trim();
482    let alias = extract_alias_suffix(description);
483    let base_description = description
484        .split_once('[')
485        .map(|(left, _)| left.trim())
486        .unwrap_or(description);
487
488    if alias.is_empty() {
489        format!("  {name:<22} {}", base_description.bright_black())
490    } else {
491        format!(
492            "  {name:<22} {} {}",
493            base_description.bright_black(),
494            alias.bright_black()
495        )
496    }
497}
498
499fn style_option_pair(flags_line: &str, description_line: &str) -> String {
500    let flags = flags_line.trim();
501    let styled_flags = flags
502        .split(", ")
503        .map(|flag| flag.bright_yellow().to_string())
504        .collect::<Vec<_>>()
505        .join(", ");
506    format!(
507        "      {styled_flags:<28} {}",
508        description_line.trim().bright_black()
509    )
510}
511
512fn style_option_entry(line: &str) -> String {
513    let trimmed = line.trim_start();
514    let mut parts = trimmed.splitn(2, "  ");
515    let flags = parts.next().unwrap_or_default();
516    let description = parts.next().unwrap_or_default().trim();
517
518    let styled_flags = flags
519        .split(", ")
520        .map(|flag| flag.bright_yellow().to_string())
521        .collect::<Vec<_>>()
522        .join(", ");
523
524    if description.is_empty() {
525        format!("      {styled_flags}")
526    } else {
527        format!("      {styled_flags:<28} {}", description.bright_black())
528    }
529}
530
531fn is_example_command_line(line: &str) -> bool {
532    line.starts_with("xbp ") || line.starts_with("cargo ") || line.starts_with("git ")
533}
534
535fn highlight_inline_flags(text: &str) -> String {
536    let mut output = String::new();
537    let mut current = String::new();
538    let mut chars = text.chars().peekable();
539
540    while let Some(ch) = chars.next() {
541        if ch == '-' && matches!(chars.peek(), Some('-' | 'f' | 'h' | 'l' | 'p' | 'v' | 'n')) {
542            if !current.is_empty() {
543                output.push_str(&current);
544                current.clear();
545            }
546            let mut flag = String::from('-');
547            if chars.peek() == Some(&'-') {
548                flag.push(chars.next().expect("dash"));
549            }
550            while let Some(&next) = chars.peek() {
551                if next.is_ascii_alphanumeric() || next == '-' {
552                    flag.push(chars.next().expect("flag char"));
553                } else {
554                    break;
555                }
556            }
557            output.push_str(&flag.bright_yellow().to_string());
558            continue;
559        }
560
561        if ch == '<' {
562            if !current.is_empty() {
563                output.push_str(&current);
564                current.clear();
565            }
566            let mut placeholder = String::from('<');
567            for next in chars.by_ref() {
568                placeholder.push(next);
569                if next == '>' {
570                    break;
571                }
572            }
573            output.push_str(&placeholder.bright_green().to_string());
574            continue;
575        }
576
577        current.push(ch);
578    }
579
580    if !current.is_empty() {
581        output.push_str(&current);
582    }
583    output
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn detects_root_help_scope() {
592        let raw = "xbp 10.30.3\n\nAbout\nUsage: xbp [OPTIONS] [COMMAND]";
593        assert_eq!(detect_help_scope(raw), HelpScope::Root);
594    }
595
596    #[test]
597    fn workers_help_is_not_root() {
598        let raw = "Manage workers\nUsage: xbp.exe workers [OPTIONS] <COMMAND>";
599        assert!(!is_root_help_text(raw));
600    }
601
602    #[test]
603    fn styles_usage_line_with_flags() {
604        let styled = style_help_line("Usage: xbp workers logs [OPTIONS]");
605        assert!(styled.contains("Usage:"));
606        assert!(styled.contains("workers"));
607    }
608
609    #[test]
610    fn styles_command_entry_line() {
611        let styled = style_help_line("  list      List workers [aliases: ls]");
612        assert!(styled.contains("list"));
613    }
614
615    #[test]
616    fn catalog_scope_skips_duplicate_about() {
617        let raw = "Deploy services\nUsage: xbp [OPTIONS] [COMMAND]\n\nCommands:\n  diag\n      Run diagnostics";
618        emit_styled_help(raw, HelpScope::Catalog);
619    }
620}