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