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