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 push
25  xbp deploy workers --plan
26  xbp workers list
27  xbp workers logs -f
28  xbp api health
29
30Discover:
31  xbp -h                    All top-level commands
32  xbp -h -q deploy          Search help for a keyword
33  xbp --query webhook       Same without -h
34  xbp <command> -h          Command help and examples
35  xbp <command> <sub> -h    Subcommand help
36  xbp --commands            Full alphabetical command tree
37  xbp config discord setup  Discord webhook notifications
38  xbp install               Browse installable targets";
39
40pub const CONFIG_AFTER_HELP: &str = "\
41Examples:
42  xbp config
43  xbp config --project
44  xbp config cloudflare
45  xbp config cloudflare status
46  xbp config openapi
47  xbp config openapi show
48  xbp config openrouter set-key
49  xbp config openapi setup";
50
51pub const VERSION_AFTER_HELP: &str = "\
52Examples:
53  xbp version
54  xbp version patch
55  xbp version 1.2.3
56  xbp version discover --dry-run
57  xbp version release --dry-run
58  xbp version release --publish --dry-run
59  xbp version workspace check
60  xbp version workspace sync --version 3.16.5 --write";
61
62pub const DIAG_AFTER_HELP: &str = "\
63Examples:
64  xbp diag
65  xbp diag --full
66  xbp diag --quick
67  xbp diag --json
68  xbp diag --http-probe
69  xbp diag --ports 80,443,3000
70  xbp diag --fix-nginx
71  xbp diag --codetime --cursor
72  xbp diag --nginx                 # Nginx-only focus mode";
73
74pub const NGINX_AFTER_HELP: &str = "\
75Examples:
76  xbp nginx list
77  xbp nginx enable api.example.com
78  xbp nginx disable api.example.com
79  xbp nginx upstream list";
80
81pub const COMMIT_AFTER_HELP: &str = "\
82Examples:
83  xbp commit
84  xbp commit --dry-run
85  xbp commit --push
86  xbp commit --scope cli
87  xbp push                 # sync+push only (no new commit; stash-safe rebase)";
88
89pub const LOGS_AFTER_HELP: &str = "\
90Examples:
91  xbp logs
92  xbp logs my-project
93  xbp logs --ssh-host bastion.example.com";
94
95pub const PUBLISH_AFTER_HELP: &str = "\
96Examples:
97  xbp publish --dry-run
98  xbp publish --target npm
99  xbp publish --service web
100  xbp publish --service api --include-prereqs
101  xbp publish --allow-dirty";
102
103pub const DOMAINS_AFTER_HELP: &str = "\
104Examples:
105  xbp domains list
106  xbp domains check --domain example.com
107  xbp domains search --query myapp --extension com";
108
109pub const LOGIN_AFTER_HELP: &str = "\
110Examples:
111  xbp login
112  xbp login status
113  xbp login logout
114  xbp whoami";
115
116pub const SSH_AFTER_HELP: &str = "\
117Examples:
118  xbp ssh
119  xbp ssh --host bastion.example.com
120  xbp ssh --host 10.0.0.5 --command \"uptime\"";
121
122pub const WORKTREE_WATCH_AFTER_HELP: &str = "\
123Examples:
124  xbp worktree-watch start
125  xbp worktree-watch start --detach
126  xbp worktree-watch start --repo athena --detach
127  xbp worktree-watch start --repo xylex-group/athena --detach
128  xbp worktree-watch start --parent C:\\Users\\floris\\Documents\\GitHub --detach
129  xbp worktree-watch stop
130  xbp worktree-watch sync
131  xbp worktree-watch sync --dry-run
132  xbp worktree-watch status
133  xbp worktree-watch status --stats
134  xbp worktree-watch status --stats --repo-activity
135  xbp worktree-watch status --stats --json
136  xbp worktree-watch status --records --record-limit 100
137  xbp worktree-watch status --stats --stats-gap-minutes 30
138  xbp worktree-watch tray
139  xbp worktree-watch tray --parent C:\\Users\\floris\\Documents\\GitHub
140  xbp worktree-watch tray --repos C:\\src\\a C:\\src\\b
141
142Local stats are written under ~/.xbp/mutations/<owner>/<repo>/<branch>/stats.json
143and repo rollups to ~/.xbp/mutations/<owner>/<repo>/repo-activity.json.
144
145Tray UI (Windows system tray + Linux StatusNotifier/GNOME AppIndicator) can start/stop
146detached watchers and show backlog/stats without keeping a terminal focused.";
147
148pub const WORKTREE_WATCH_STATUS_AFTER_HELP: &str = "\
149Examples:
150  xbp worktree-watch status
151  xbp worktree-watch status --stats
152  xbp worktree-watch status --stats --repo-activity
153  xbp worktree-watch status --stats --json
154  xbp worktree-watch status --records --record-limit 100
155  xbp worktree-watch status --stats --stats-gap-minutes 30
156  xbp worktree-watch status --parent C:\\Users\\floris\\Documents\\GitHub --stats
157
158--stats recomputes activity from local JSONL spools and writes stats.json.
159--repo-activity rolls up every spooled branch into repo-activity.json.";
160
161pub const WORKTREE_WATCH_TRAY_AFTER_HELP: &str = "\
162Examples:
163  xbp worktree-watch tray
164  xbp worktree-watch tray --parent C:\\Users\\floris\\Documents\\GitHub
165  xbp worktree-watch tray --repo C:\\Users\\floris\\Documents\\GitHub\\xbp
166  xbp worktree-watch tray --repos C:\\src\\alpha C:\\src\\beta
167  xbp worktree-watch tray --parent ~/src --sync-interval-seconds 120
168  xbp worktree-watch tray --foreground
169
170Opens a native tray icon (backgrounded by default):
171  Windows  — system tray NotifyIcon
172  Linux    — StatusNotifierItem (GNOME needs AppIndicator / tray extension)
173  macOS    — menu bar status item
174
175Menu actions: Start watching, Stop watching, Refresh status, Show stats, Quit.
176Start always launches detached watchers so the tray can keep running.
177Use --foreground to keep the tray attached to the terminal.";
178
179#[cfg(feature = "openapi-gen")]
180pub const GENERATE_AFTER_HELP: &str = "\
181Examples:
182  xbp generate config
183  xbp generate config --force
184  xbp generate openapi --all
185  xbp generate openapi --service api --format both
186  xbp generate openapi --all --check
187  xbp generate systemd";
188
189#[cfg(not(feature = "openapi-gen"))]
190pub const GENERATE_AFTER_HELP: &str = "\
191Examples:
192  xbp generate config
193  xbp generate config --force
194  xbp generate systemd";
195
196pub const DONE_AFTER_HELP: &str = "\
197Examples:
198  xbp done
199  xbp done --since \"7 days ago\"
200  xbp done --output report.md";
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum HelpScope {
204    Root,
205    Subcommand,
206    Catalog,
207    Auto,
208}
209
210pub fn emit_styled_help(raw: &str, scope: HelpScope) {
211    crate::cli::ui::configure_color_output();
212    let resolved_scope = match scope {
213        HelpScope::Auto => detect_help_scope(raw),
214        other => other,
215    };
216
217    let mut skip_about_line = None::<String>;
218    let mut skip_until_usage = resolved_scope == HelpScope::Catalog;
219    if resolved_scope == HelpScope::Root {
220        print_root_banner(raw);
221    } else if resolved_scope != HelpScope::Catalog {
222        if let Some((_, tagline)) = parse_subcommand_heading(raw) {
223            skip_about_line = Some(tagline);
224            print_subcommand_banner(raw);
225        }
226    }
227
228    let lines: Vec<&str> = raw.lines().collect();
229    let mut index = 0usize;
230    while index < lines.len() {
231        let line = lines[index];
232        let trimmed = line.trim();
233        if skip_until_usage {
234            if trimmed.starts_with("Usage:") {
235                skip_until_usage = false;
236            } else {
237                index += 1;
238                continue;
239            }
240        }
241        if let Some(about) = skip_about_line.as_deref() {
242            if trimmed == about {
243                skip_about_line = None;
244                index += 1;
245                continue;
246            }
247        }
248
249        if is_command_name_line(line)
250            && index + 1 < lines.len()
251            && is_indented_help_text(lines[index + 1], 4)
252        {
253            println!("{}", style_command_pair(line, lines[index + 1]));
254            index += 2;
255            continue;
256        }
257
258        if is_option_flags_line(line)
259            && index + 1 < lines.len()
260            && is_indented_help_text(lines[index + 1], 4)
261        {
262            println!("{}", style_option_pair(line, lines[index + 1]));
263            index += 2;
264            continue;
265        }
266
267        if is_option_entry(line)
268            && index + 1 < lines.len()
269            && is_indented_help_text(lines[index + 1], 8)
270        {
271            println!("{}", style_option_pair(line, lines[index + 1]));
272            index += 2;
273            continue;
274        }
275
276        println!("{}", style_help_line(line));
277        index += 1;
278    }
279    println!();
280}
281
282/// Print the complete Clap command tree with styled formatting.
283///
284/// Includes nested subcommands and their flags (for example
285/// `worktree-watch status --stats`), not only the root command list.
286pub fn print_command_catalog() {
287    crate::cli::ui::configure_color_output();
288    println!();
289    println!("{}", "xbp commands".bright_magenta().bold());
290    println!(
291        "{}",
292        "Complete command reference (nested flags included)".bright_black()
293    );
294    crate::cli::ui::divider(44);
295
296    let cmd = crate::cli::commands::Cli::command();
297    print_catalog_command_tree(&cmd, &[], true);
298    println!();
299}
300
301fn print_catalog_command_tree(cmd: &clap::Command, path: &[&str], is_root: bool) {
302    if !is_root {
303        let command_path = format!("xbp {}", path.join(" "));
304        println!();
305        println!("{}", command_path.bright_cyan().bold());
306        if let Some(about) = cmd.get_about().or_else(|| cmd.get_long_about()) {
307            println!("  {}", about.to_string().bright_black());
308        }
309
310        let mut args: Vec<_> = cmd
311            .get_arguments()
312            .filter(|arg| {
313                !arg.is_hide_set()
314                    && !arg.is_global_set()
315                    && arg.get_id() != "help"
316                    && arg.get_id() != "version"
317                    && arg.get_id() != "debug"
318            })
319            .collect();
320        args.sort_by_key(|arg| arg.get_id().to_string());
321
322        if !args.is_empty() {
323            println!(
324                "  {} {}",
325                "▸".bright_blue().bold(),
326                "Options:".bright_blue().bold()
327            );
328            for arg in args {
329                println!("    {}", style_catalog_arg(arg));
330            }
331        }
332
333        if let Some(after) = cmd.get_after_help().or_else(|| cmd.get_after_long_help()) {
334            let after = after.to_string();
335            let mut in_examples = false;
336            for line in after.lines() {
337                let trimmed = line.trim();
338                if trimmed.is_empty() {
339                    continue;
340                }
341                if trimmed == "Examples:" || trimmed.starts_with("Examples:") {
342                    in_examples = true;
343                    println!(
344                        "  {} {}",
345                        "◇".bright_green().bold(),
346                        "Examples:".bright_green().bold()
347                    );
348                    continue;
349                }
350                if in_examples && (trimmed.starts_with("xbp ") || trimmed.starts_with("xbp.exe ")) {
351                    println!("    {}", highlight_inline_flags(trimmed).dimmed());
352                    continue;
353                }
354                // Keep short notes under examples, stop at unrelated sections.
355                if in_examples && !trimmed.starts_with('-') && !trimmed.starts_with("Local ") {
356                    in_examples = false;
357                }
358                if in_examples {
359                    println!("    {}", trimmed.bright_black());
360                }
361            }
362        }
363    }
364
365    let mut subs: Vec<_> = cmd
366        .get_subcommands()
367        .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
368        .collect();
369    subs.sort_by_key(|sub| sub.get_name().to_string());
370
371    for sub in subs {
372        let mut next = path.to_vec();
373        next.push(sub.get_name());
374        print_catalog_command_tree(sub, &next, false);
375    }
376}
377
378fn style_catalog_arg(arg: &clap::Arg) -> String {
379    let mut flags = Vec::new();
380    if let Some(short) = arg.get_short() {
381        flags.push(format!("-{short}"));
382    }
383    if let Some(long) = arg.get_long() {
384        flags.push(format!("--{long}"));
385    }
386    if flags.is_empty() {
387        if let Some(name) = arg.get_value_names().and_then(|names| names.first()) {
388            flags.push(format!("<{name}>"));
389        } else {
390            flags.push(format!("<{}>", arg.get_id()));
391        }
392    } else if arg.get_action().takes_values() {
393        let value_name = arg
394            .get_value_names()
395            .and_then(|names| names.first())
396            .map(|name| name.to_string())
397            .unwrap_or_else(|| arg.get_id().to_string().to_ascii_uppercase());
398        if let Some(last) = flags.last_mut() {
399            *last = format!("{last} <{value_name}>");
400        }
401    }
402
403    let flags_text = flags.join(", ").bright_yellow().to_string();
404    let help = arg
405        .get_help()
406        .or_else(|| arg.get_long_help())
407        .map(|help| help.to_string())
408        .unwrap_or_default();
409    let default = arg
410        .get_default_values()
411        .first()
412        .and_then(|value| value.to_str())
413        .map(|value| format!(" [default: {value}]"))
414        .unwrap_or_default();
415
416    if help.is_empty() {
417        format!("{flags_text}{}", default.bright_black())
418    } else {
419        format!(
420            "{flags_text:<36} {}{}",
421            help.bright_black(),
422            default.bright_black()
423        )
424    }
425}
426
427pub fn emit_version_line(version: &str) {
428    emit_version_info(version);
429}
430
431/// Rich `xbp -v` / `--version` output: version, install source, features.
432pub fn emit_version_info(version: &str) {
433    crate::cli::ui::configure_color_output();
434
435    let kind = option_env!("XBP_INSTALL_SOURCE_KIND").unwrap_or("unknown");
436    let detail = option_env!("XBP_INSTALL_SOURCE_DETAIL").unwrap_or("");
437    let git_url = option_env!("XBP_INSTALL_GIT_URL").unwrap_or("");
438    let git_rev = option_env!("XBP_INSTALL_GIT_REV").unwrap_or("");
439    let features_plus_owned;
440    let features_plus = if let Some(from_build) = option_env!("XBP_ENABLED_FEATURES_PLUS") {
441        from_build
442    } else {
443        // Fallback when build.rs env is missing (e.g. odd IDE builds).
444        features_plus_owned = runtime_features_plus();
445        features_plus_owned.as_str()
446    };
447    let repository = option_env!("XBP_PKG_REPOSITORY")
448        .or(option_env!("CARGO_PKG_REPOSITORY"))
449        .unwrap_or("https://github.com/xylex-group/xbp");
450
451    println!(
452        "{} {}",
453        "xbp".bright_magenta().bold(),
454        version.bright_white().bold()
455    );
456
457    let (source_label, source_value) = match kind {
458        "crates.io" | "crates" | "registry" => (
459            "crates.io",
460            if detail.is_empty() {
461                format!("xbp@{version}")
462            } else {
463                detail.to_string()
464            },
465        ),
466        "git" => (
467            "git",
468            if detail.is_empty() {
469                if git_url.is_empty() {
470                    repository.to_string()
471                } else if git_rev.is_empty() {
472                    git_url.to_string()
473                } else {
474                    format!("{git_url}#{git_rev}")
475                }
476            } else {
477                detail.to_string()
478            },
479        ),
480        "path" => (
481            "path",
482            if detail.is_empty() {
483                "local checkout".to_string()
484            } else {
485                detail.to_string()
486            },
487        ),
488        other => (
489            other,
490            if detail.is_empty() {
491                other.to_string()
492            } else {
493                detail.to_string()
494            },
495        ),
496    };
497
498    let source_colored = match kind {
499        "crates.io" | "crates" | "registry" => source_label.bright_green().bold(),
500        "git" => source_label.bright_cyan().bold(),
501        "path" => source_label.bright_yellow().bold(),
502        _ => source_label.bright_white().bold(),
503    };
504
505    println!(
506        "  {}  {}  {}",
507        "source".dimmed(),
508        source_colored,
509        source_value.bright_white()
510    );
511
512    if kind == "git" || (kind == "path" && !git_url.is_empty()) {
513        if !git_url.is_empty() && !source_value.contains(git_url) {
514            println!(
515                "  {}  {}",
516                "repo  ".dimmed(),
517                git_url.bright_cyan()
518            );
519        }
520        if !git_rev.is_empty() && !source_value.contains(git_rev) {
521            println!(
522                "  {}  {}",
523                "commit".dimmed(),
524                git_rev.bright_yellow()
525            );
526        }
527    }
528
529    // Features: base (default) dim/white, optional +flags in green
530    print!("  {}  ", "features".dimmed());
531    print_features_colored(features_plus);
532    println!();
533
534    if kind == "crates.io" || kind == "crates" || kind == "registry" {
535        println!(
536            "  {}  {}",
537            "install".dimmed(),
538            format!("cargo install xbp --version {version}").bright_black()
539        );
540    } else if kind == "git" {
541        let url = if git_url.is_empty() { repository } else { git_url };
542        let mut cmd = format!("cargo install --git {url} xbp");
543        if let Some(extras) = optional_feature_flags(features_plus) {
544            cmd.push_str(" --features ");
545            cmd.push_str(&extras);
546        }
547        println!(
548            "  {}  {}",
549            "install".dimmed(),
550            cmd.bright_black()
551        );
552    } else if kind == "path" {
553        println!(
554            "  {}  {}",
555            "install".dimmed(),
556            "cargo install --path crates/cli".bright_black()
557        );
558    }
559}
560
561fn print_features_colored(features_plus: &str) {
562    if features_plus.is_empty() || features_plus == "(none)" {
563        print!("{}", "(none)".bright_black());
564        return;
565    }
566    let mut first = true;
567    for token in features_plus.split_whitespace() {
568        if !first {
569            print!(" ");
570        }
571        first = false;
572        if let Some(feature) = token.strip_prefix('+') {
573            print!(
574                "{}{}",
575                "+".bright_green().bold(),
576                feature.bright_green().bold()
577            );
578        } else {
579            // default-set features (comma-separated chunk)
580            let parts: Vec<&str> = token.split(',').filter(|p| !p.is_empty()).collect();
581            for (i, part) in parts.iter().enumerate() {
582                if i > 0 {
583                    print!("{}", ",".bright_black());
584                }
585                print!("{}", part.bright_white());
586            }
587        }
588    }
589}
590
591fn optional_feature_flags(features_plus: &str) -> Option<String> {
592    let extras: Vec<&str> = features_plus
593        .split_whitespace()
594        .filter_map(|t| t.strip_prefix('+'))
595        .collect();
596    if extras.is_empty() {
597        None
598    } else {
599        Some(extras.join(","))
600    }
601}
602
603fn runtime_features_plus() -> String {
604    // Keep in sync with `crates/cli/Cargo.toml` features + build.rs KNOWN_FEATURES.
605    let mut base = Vec::new();
606    let mut extra = Vec::new();
607    macro_rules! feat {
608        ($name:literal) => {
609            if cfg!(feature = $name) {
610                if matches!($name, "secrets" | "linear") {
611                    base.push($name);
612                } else {
613                    extra.push(format!(concat!("+", $name)));
614                }
615            }
616        };
617    }
618    feat!("secrets");
619    feat!("linear");
620    feat!("athena");
621    feat!("docker");
622    feat!("kafka");
623    feat!("kubernetes");
624    feat!("monitoring");
625    feat!("nordvpn");
626    feat!("openapi-gen");
627    feat!("systemd");
628    let mut out = base.join(",");
629    if !extra.is_empty() {
630        if !out.is_empty() {
631            out.push(' ');
632        }
633        out.push_str(&extra.join(" "));
634    }
635    if out.is_empty() {
636        "(none)".to_string()
637    } else {
638        out
639    }
640}
641
642pub fn is_root_help_text(raw: &str) -> bool {
643    raw.lines().any(|line| {
644        let trimmed = line.trim();
645        trimmed == "Usage: xbp [OPTIONS] [COMMAND]"
646            || trimmed == "Usage: xbp.exe [OPTIONS] [COMMAND]"
647            || trimmed.starts_with("Usage: xbp [OPTIONS] [COMMAND]")
648            || trimmed.starts_with("Usage: xbp.exe [OPTIONS] [COMMAND]")
649    })
650}
651
652fn detect_help_scope(raw: &str) -> HelpScope {
653    if is_root_help_text(raw) {
654        return HelpScope::Root;
655    }
656    let first_line = raw.lines().next().unwrap_or_default().trim();
657    if first_line.starts_with("xbp ") && first_line.chars().any(|ch| ch.is_ascii_digit()) {
658        return HelpScope::Root;
659    }
660    HelpScope::Subcommand
661}
662
663fn print_root_banner(raw: &str) {
664    let version = parse_version_line(raw).unwrap_or(env!("CARGO_PKG_VERSION"));
665    println!();
666    println!(
667        "{}  {}",
668        "XBP".bright_magenta().bold(),
669        format!("v{version}").bright_white()
670    );
671    println!("{}", "Deploy · operate · debug · ship".bright_black());
672    crate::cli::ui::divider(44);
673}
674
675fn print_subcommand_banner(raw: &str) {
676    let Some((command_path, tagline)) = parse_subcommand_heading(raw) else {
677        return;
678    };
679    println!();
680    println!("{}", command_path.bright_magenta().bold());
681    if !tagline.is_empty() {
682        println!("{}", tagline.bright_black());
683    }
684    crate::cli::ui::divider(command_path.len().max(28));
685}
686
687fn parse_version_line(raw: &str) -> Option<&str> {
688    let first = raw.lines().next()?.trim();
689    let rest = first.strip_prefix("xbp")?.trim();
690    if rest.is_empty() {
691        None
692    } else {
693        Some(rest)
694    }
695}
696
697fn parse_subcommand_heading(raw: &str) -> Option<(String, String)> {
698    let about = raw
699        .lines()
700        .map(str::trim)
701        .find(|line| !line.is_empty() && !line.starts_with("Usage:"))?;
702
703    let usage = raw
704        .lines()
705        .map(str::trim)
706        .find(|line| line.starts_with("Usage:"))?;
707    let command_path = extract_command_path_from_usage(usage)?;
708
709    Some((command_path, about.to_string()))
710}
711
712fn extract_command_path_from_usage(usage: &str) -> Option<String> {
713    let rest = usage.split_once(':')?.1.trim();
714    let path = rest.split('[').next()?.trim();
715    let normalized = path.replace(".exe", "");
716    if normalized.is_empty() {
717        None
718    } else {
719        Some(normalized)
720    }
721}
722
723fn style_help_line(line: &str) -> String {
724    let trimmed = line.trim_start();
725    if trimmed.is_empty() {
726        return String::new();
727    }
728
729    if matches!(
730        trimmed,
731        "Commands:" | "Options:" | "Arguments:" | "Subcommands:"
732    ) {
733        return format!(
734            "\n{} {}",
735            "▸".bright_blue().bold(),
736            trimmed.bright_blue().bold()
737        );
738    }
739
740    if trimmed.starts_with("Usage:") {
741        return style_usage_line(line);
742    }
743
744    if trimmed == "Discover:" {
745        return format!(
746            "\n{} {}",
747            "◇".bright_green().bold(),
748            trimmed.bright_green().bold()
749        );
750    }
751
752    if is_example_section_header(trimmed) {
753        return format!(
754            "\n{} {}",
755            "◇".bright_green().bold(),
756            trimmed.bright_green().bold()
757        );
758    }
759
760    if is_note_section_header(trimmed) {
761        return format!(
762            "\n{} {}",
763            "◇".bright_yellow().bold(),
764            trimmed.bright_yellow().bold()
765        );
766    }
767
768    if is_command_entry(line) {
769        return style_command_entry(line);
770    }
771
772    if is_option_flags_line(line) {
773        return format!("  {}", line.trim().bright_yellow());
774    }
775
776    if is_option_entry(line) {
777        return style_option_entry(line);
778    }
779
780    if is_command_name_line(line) {
781        return format!("  {}", line.trim().bright_cyan().bold());
782    }
783
784    if is_indented_help_text(line, 4) || is_indented_help_text(line, 8) {
785        return format!("      {}", trimmed.bright_black());
786    }
787
788    if is_example_command_line(trimmed) {
789        return format!("  {}", highlight_inline_flags(trimmed).dimmed());
790    }
791
792    if trimmed.starts_with("Run `") || trimmed.starts_with("Use `") || trimmed.starts_with("Pass `")
793    {
794        return trimmed.bright_black().to_string();
795    }
796
797    line.to_string()
798}
799
800fn style_usage_line(line: &str) -> String {
801    let (prefix, rest) = line.split_once(':').unwrap_or((line, ""));
802    format!(
803        "{} {}",
804        format!("{prefix}:").bright_cyan().bold(),
805        highlight_inline_flags(rest.trim()).bright_white()
806    )
807}
808
809fn is_example_section_header(line: &str) -> bool {
810    matches!(
811        line,
812        "Examples:"
813            | "Quick start:"
814            | "Discover:"
815            | "Available targets:"
816            | "List installable targets:"
817    ) || line.starts_with("Quick start")
818        || line.starts_with("List installable")
819}
820
821fn is_note_section_header(line: &str) -> bool {
822    line == "Notes:"
823        || line.starts_with("Tip:")
824        || line.starts_with("More info:")
825        || line.starts_with("Hint:")
826}
827
828fn is_command_entry(line: &str) -> bool {
829    if !line.starts_with("  ") || line.starts_with("    ") {
830        return false;
831    }
832    let trimmed = line.trim_start();
833    let Some((name, _)) = trimmed.split_once("  ") else {
834        return false;
835    };
836    !name.starts_with('-') && !name.contains('<') && name.len() <= 24
837}
838
839fn style_command_entry(line: &str) -> String {
840    let trimmed = line.trim_start();
841    let mut parts = trimmed.splitn(2, "  ");
842    let name = parts.next().unwrap_or_default();
843    let description = parts.next().unwrap_or_default().trim();
844    let alias = extract_alias_suffix(description);
845    let base_description = description
846        .split_once('[')
847        .map(|(left, _)| left.trim())
848        .unwrap_or(description);
849
850    let name = name.bright_cyan().bold();
851    if alias.is_empty() {
852        format!("  {name:<22} {}", base_description.bright_black())
853    } else {
854        format!(
855            "  {name:<22} {} {}",
856            base_description.bright_black(),
857            alias.bright_black()
858        )
859    }
860}
861
862fn extract_alias_suffix(description: &str) -> String {
863    let Some(start) = description.find('[') else {
864        return String::new();
865    };
866    description[start..].to_string()
867}
868
869fn is_option_flags_line(line: &str) -> bool {
870    let trimmed = line.trim_start();
871    line.starts_with("  ")
872        && !line.starts_with("    ")
873        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
874}
875
876fn is_option_entry(line: &str) -> bool {
877    let trimmed = line.trim_start();
878    line.starts_with("      ")
879        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
880        && !trimmed.starts_with("Usage:")
881}
882
883fn is_command_name_line(line: &str) -> bool {
884    if !line.starts_with("  ") || line.starts_with("    ") {
885        return false;
886    }
887    let trimmed = line.trim();
888    !trimmed.is_empty()
889        && !trimmed.ends_with(':')
890        && !trimmed.starts_with('-')
891        && !trimmed.contains(' ')
892        && trimmed.len() <= 24
893}
894
895fn is_indented_help_text(line: &str, min_spaces: usize) -> bool {
896    let leading = line.chars().take_while(|ch| *ch == ' ').count();
897    leading >= min_spaces && !line.trim_start().starts_with('-') && !line.trim().is_empty()
898}
899
900fn style_command_pair(name_line: &str, description_line: &str) -> String {
901    let name = name_line.trim().bright_cyan().bold();
902    let description = description_line.trim();
903    let alias = extract_alias_suffix(description);
904    let base_description = description
905        .split_once('[')
906        .map(|(left, _)| left.trim())
907        .unwrap_or(description);
908
909    if alias.is_empty() {
910        format!("  {name:<22} {}", base_description.bright_black())
911    } else {
912        format!(
913            "  {name:<22} {} {}",
914            base_description.bright_black(),
915            alias.bright_black()
916        )
917    }
918}
919
920fn style_option_pair(flags_line: &str, description_line: &str) -> String {
921    let flags = flags_line.trim();
922    let styled_flags = flags
923        .split(", ")
924        .map(|flag| flag.bright_yellow().to_string())
925        .collect::<Vec<_>>()
926        .join(", ");
927    format!(
928        "      {styled_flags:<28} {}",
929        description_line.trim().bright_black()
930    )
931}
932
933fn style_option_entry(line: &str) -> String {
934    let trimmed = line.trim_start();
935    let mut parts = trimmed.splitn(2, "  ");
936    let flags = parts.next().unwrap_or_default();
937    let description = parts.next().unwrap_or_default().trim();
938
939    let styled_flags = flags
940        .split(", ")
941        .map(|flag| flag.bright_yellow().to_string())
942        .collect::<Vec<_>>()
943        .join(", ");
944
945    if description.is_empty() {
946        format!("      {styled_flags}")
947    } else {
948        format!("      {styled_flags:<28} {}", description.bright_black())
949    }
950}
951
952fn is_example_command_line(line: &str) -> bool {
953    line.starts_with("xbp ") || line.starts_with("cargo ") || line.starts_with("git ")
954}
955
956fn highlight_inline_flags(text: &str) -> String {
957    let mut output = String::new();
958    let mut current = String::new();
959    let mut chars = text.chars().peekable();
960
961    while let Some(ch) = chars.next() {
962        if ch == '-' && matches!(chars.peek(), Some('-' | 'f' | 'h' | 'l' | 'p' | 'v' | 'n')) {
963            if !current.is_empty() {
964                output.push_str(&current);
965                current.clear();
966            }
967            let mut flag = String::from('-');
968            if chars.peek() == Some(&'-') {
969                flag.push(chars.next().expect("dash"));
970            }
971            while let Some(&next) = chars.peek() {
972                if next.is_ascii_alphanumeric() || next == '-' {
973                    flag.push(chars.next().expect("flag char"));
974                } else {
975                    break;
976                }
977            }
978            output.push_str(&flag.bright_yellow().to_string());
979            continue;
980        }
981
982        if ch == '<' {
983            if !current.is_empty() {
984                output.push_str(&current);
985                current.clear();
986            }
987            let mut placeholder = String::from('<');
988            for next in chars.by_ref() {
989                placeholder.push(next);
990                if next == '>' {
991                    break;
992                }
993            }
994            output.push_str(&placeholder.bright_green().to_string());
995            continue;
996        }
997
998        current.push(ch);
999    }
1000
1001    if !current.is_empty() {
1002        output.push_str(&current);
1003    }
1004    output
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    #[test]
1012    fn detects_root_help_scope() {
1013        let raw = "xbp 10.30.3\n\nAbout\nUsage: xbp [OPTIONS] [COMMAND]";
1014        assert_eq!(detect_help_scope(raw), HelpScope::Root);
1015    }
1016
1017    #[test]
1018    fn workers_help_is_not_root() {
1019        let raw = "Manage workers\nUsage: xbp.exe workers [OPTIONS] <COMMAND>";
1020        assert!(!is_root_help_text(raw));
1021    }
1022
1023    #[test]
1024    fn styles_usage_line_with_flags() {
1025        let styled = style_help_line("Usage: xbp workers logs [OPTIONS]");
1026        assert!(styled.contains("Usage:"));
1027        assert!(styled.contains("workers"));
1028    }
1029
1030    #[test]
1031    fn styles_command_entry_line() {
1032        let styled = style_help_line("  list      List workers [aliases: ls]");
1033        assert!(styled.contains("list"));
1034    }
1035
1036    #[test]
1037    fn catalog_scope_skips_duplicate_about() {
1038        let raw = "Deploy services\nUsage: xbp [OPTIONS] [COMMAND]\n\nCommands:\n  diag\n      Run diagnostics";
1039        emit_styled_help(raw, HelpScope::Catalog);
1040    }
1041}