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