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