Skip to main content

xbp_cli/cli/
help_render.rs

1//! Styled help rendering for Clap-generated usage text.
2
3use clap::CommandFactory;
4use colored::Colorize;
5
6/// Shared help layout for subcommands (no duplicate name/version header).
7pub const XBP_HELP_TEMPLATE: &str = "\
8{about-with-newline}\
9Usage: {usage}\n\n\
10{all-args}\
11{after-help}";
12
13/// Root help layout; the renderer adds a branded banner above Clap output.
14pub const XBP_ROOT_HELP_TEMPLATE: &str = "\
15{about-with-newline}\
16Usage: {usage}\n\n\
17{all-args}\
18{after-help}";
19
20pub const XBP_ROOT_AFTER_HELP: &str = "\
21Quick start:
22  xbp diag
23  xbp services
24  xbp workers list
25  xbp workers logs -f
26  xbp api health
27
28Discover:
29  xbp <command> -h          Command help and examples
30  xbp <command> <sub> -h    Subcommand help
31  xbp --commands            Full alphabetical command tree
32  xbp install               Browse installable targets";
33
34pub const CONFIG_AFTER_HELP: &str = "\
35Examples:
36  xbp config
37  xbp config --project
38  xbp config cloudflare
39  xbp config cloudflare status
40  xbp config openapi
41  xbp config openapi show
42  xbp config openrouter set-key
43  xbp config openapi setup";
44
45pub const VERSION_AFTER_HELP: &str = "\
46Examples:
47  xbp version
48  xbp version patch
49  xbp version 1.2.3
50  xbp version discover --dry-run
51  xbp version release --dry-run
52  xbp version release --publish --dry-run
53  xbp version workspace check
54  xbp version workspace sync --version 3.16.5 --write";
55
56pub const DIAG_AFTER_HELP: &str = "\
57Examples:
58  xbp diag
59  xbp diag --nginx
60  xbp diag --ports 80,443
61  xbp diag --codetime --cursor";
62
63pub const NGINX_AFTER_HELP: &str = "\
64Examples:
65  xbp nginx list
66  xbp nginx enable api.example.com
67  xbp nginx disable api.example.com
68  xbp nginx upstream list";
69
70pub const COMMIT_AFTER_HELP: &str = "\
71Examples:
72  xbp commit
73  xbp commit --dry-run
74  xbp commit --push
75  xbp commit --scope cli";
76
77pub const LOGS_AFTER_HELP: &str = "\
78Examples:
79  xbp logs
80  xbp logs my-project
81  xbp logs --ssh-host bastion.example.com";
82
83pub const PUBLISH_AFTER_HELP: &str = "\
84Examples:
85  xbp publish --dry-run
86  xbp publish --target npm
87  xbp publish --service web
88  xbp publish --service api --include-prereqs
89  xbp publish --allow-dirty";
90
91pub const DOMAINS_AFTER_HELP: &str = "\
92Examples:
93  xbp domains list
94  xbp domains check --domain example.com
95  xbp domains search --query myapp --extension com";
96
97pub const LOGIN_AFTER_HELP: &str = "\
98Examples:
99  xbp login
100  xbp login status
101  xbp login logout
102  xbp whoami";
103
104pub const SSH_AFTER_HELP: &str = "\
105Examples:
106  xbp ssh
107  xbp ssh --host bastion.example.com
108  xbp ssh --host 10.0.0.5 --command \"uptime\"";
109
110pub const WORKTREE_WATCH_AFTER_HELP: &str = "\
111Examples:
112  xbp worktree-watch start
113  xbp worktree-watch start --detach
114  xbp worktree-watch start --repo athena --detach
115  xbp worktree-watch start --repo xylex-group/athena --detach
116  xbp worktree-watch start --parent C:\\Users\\floris\\Documents\\GitHub --detach
117  xbp worktree-watch stop
118  xbp worktree-watch sync
119  xbp worktree-watch sync --dry-run
120  xbp worktree-watch status
121  xbp worktree-watch status --stats
122  xbp worktree-watch status --stats --repo-activity
123  xbp worktree-watch status --stats --json
124  xbp worktree-watch status --records --record-limit 100
125  xbp worktree-watch status --stats --stats-gap-minutes 30
126  xbp worktree-watch tray
127  xbp worktree-watch tray --parent C:\\Users\\floris\\Documents\\GitHub
128  xbp worktree-watch tray --repos C:\\src\\a C:\\src\\b
129
130Local stats are written under ~/.xbp/mutations/<owner>/<repo>/<branch>/stats.json
131and repo rollups to ~/.xbp/mutations/<owner>/<repo>/repo-activity.json.
132
133Tray UI (Windows system tray + Linux StatusNotifier/GNOME AppIndicator) can start/stop
134detached watchers and show backlog/stats without keeping a terminal focused.";
135
136pub const WORKTREE_WATCH_STATUS_AFTER_HELP: &str = "\
137Examples:
138  xbp worktree-watch status
139  xbp worktree-watch status --stats
140  xbp worktree-watch status --stats --repo-activity
141  xbp worktree-watch status --stats --json
142  xbp worktree-watch status --records --record-limit 100
143  xbp worktree-watch status --stats --stats-gap-minutes 30
144  xbp worktree-watch status --parent C:\\Users\\floris\\Documents\\GitHub --stats
145
146--stats recomputes activity from local JSONL spools and writes stats.json.
147--repo-activity rolls up every spooled branch into repo-activity.json.";
148
149pub const WORKTREE_WATCH_TRAY_AFTER_HELP: &str = "\
150Examples:
151  xbp worktree-watch tray
152  xbp worktree-watch tray --parent C:\\Users\\floris\\Documents\\GitHub
153  xbp worktree-watch tray --repo C:\\Users\\floris\\Documents\\GitHub\\xbp
154  xbp worktree-watch tray --repos C:\\src\\alpha C:\\src\\beta
155  xbp worktree-watch tray --parent ~/src --sync-interval-seconds 120
156  xbp worktree-watch tray --foreground
157
158Opens a native tray icon (backgrounded by default):
159  Windows  — system tray NotifyIcon
160  Linux    — StatusNotifierItem (GNOME needs AppIndicator / tray extension)
161  macOS    — menu bar status item
162
163Menu actions: Start watching, Stop watching, Refresh status, Show stats, Quit.
164Start always launches detached watchers so the tray can keep running.
165Use --foreground to keep the tray attached to the terminal.";
166
167#[cfg(feature = "openapi-gen")]
168pub const GENERATE_AFTER_HELP: &str = "\
169Examples:
170  xbp generate config
171  xbp generate config --force
172  xbp generate openapi --all
173  xbp generate openapi --service api --format both
174  xbp generate openapi --all --check
175  xbp generate systemd";
176
177#[cfg(not(feature = "openapi-gen"))]
178pub const GENERATE_AFTER_HELP: &str = "\
179Examples:
180  xbp generate config
181  xbp generate config --force
182  xbp generate systemd";
183
184pub const DONE_AFTER_HELP: &str = "\
185Examples:
186  xbp done
187  xbp done --since \"7 days ago\"
188  xbp done --output report.md";
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum HelpScope {
192    Root,
193    Subcommand,
194    Catalog,
195    Auto,
196}
197
198pub fn emit_styled_help(raw: &str, scope: HelpScope) {
199    crate::cli::ui::configure_color_output();
200    let resolved_scope = match scope {
201        HelpScope::Auto => detect_help_scope(raw),
202        other => other,
203    };
204
205    let mut skip_about_line = None::<String>;
206    let mut skip_until_usage = resolved_scope == HelpScope::Catalog;
207    if resolved_scope == HelpScope::Root {
208        print_root_banner(raw);
209    } else if resolved_scope != HelpScope::Catalog {
210        if let Some((_, tagline)) = parse_subcommand_heading(raw) {
211            skip_about_line = Some(tagline);
212            print_subcommand_banner(raw);
213        }
214    }
215
216    let lines: Vec<&str> = raw.lines().collect();
217    let mut index = 0usize;
218    while index < lines.len() {
219        let line = lines[index];
220        let trimmed = line.trim();
221        if skip_until_usage {
222            if trimmed.starts_with("Usage:") {
223                skip_until_usage = false;
224            } else {
225                index += 1;
226                continue;
227            }
228        }
229        if let Some(about) = skip_about_line.as_deref() {
230            if trimmed == about {
231                skip_about_line = None;
232                index += 1;
233                continue;
234            }
235        }
236
237        if is_command_name_line(line)
238            && index + 1 < lines.len()
239            && is_indented_help_text(lines[index + 1], 4)
240        {
241            println!("{}", style_command_pair(line, lines[index + 1]));
242            index += 2;
243            continue;
244        }
245
246        if is_option_flags_line(line)
247            && index + 1 < lines.len()
248            && is_indented_help_text(lines[index + 1], 4)
249        {
250            println!("{}", style_option_pair(line, lines[index + 1]));
251            index += 2;
252            continue;
253        }
254
255        if is_option_entry(line)
256            && index + 1 < lines.len()
257            && is_indented_help_text(lines[index + 1], 8)
258        {
259            println!("{}", style_option_pair(line, lines[index + 1]));
260            index += 2;
261            continue;
262        }
263
264        println!("{}", style_help_line(line));
265        index += 1;
266    }
267    println!();
268}
269
270/// Print the complete Clap command tree with styled formatting.
271///
272/// Includes nested subcommands and their flags (for example
273/// `worktree-watch status --stats`), not only the root command list.
274pub fn print_command_catalog() {
275    crate::cli::ui::configure_color_output();
276    println!();
277    println!("{}", "xbp commands".bright_magenta().bold());
278    println!(
279        "{}",
280        "Complete command reference (nested flags included)".bright_black()
281    );
282    crate::cli::ui::divider(44);
283
284    let cmd = crate::cli::commands::Cli::command();
285    print_catalog_command_tree(&cmd, &[], true);
286    println!();
287}
288
289fn print_catalog_command_tree(cmd: &clap::Command, path: &[&str], is_root: bool) {
290    if !is_root {
291        let command_path = format!("xbp {}", path.join(" "));
292        println!();
293        println!("{}", command_path.bright_cyan().bold());
294        if let Some(about) = cmd.get_about().or_else(|| cmd.get_long_about()) {
295            println!("  {}", about.to_string().bright_black());
296        }
297
298        let mut args: Vec<_> = cmd
299            .get_arguments()
300            .filter(|arg| {
301                !arg.is_hide_set()
302                    && !arg.is_global_set()
303                    && arg.get_id() != "help"
304                    && arg.get_id() != "version"
305                    && arg.get_id() != "debug"
306            })
307            .collect();
308        args.sort_by_key(|arg| arg.get_id().to_string());
309
310        if !args.is_empty() {
311            println!(
312                "  {} {}",
313                "▸".bright_blue().bold(),
314                "Options:".bright_blue().bold()
315            );
316            for arg in args {
317                println!("    {}", style_catalog_arg(arg));
318            }
319        }
320
321        if let Some(after) = cmd.get_after_help().or_else(|| cmd.get_after_long_help()) {
322            let after = after.to_string();
323            let mut in_examples = false;
324            for line in after.lines() {
325                let trimmed = line.trim();
326                if trimmed.is_empty() {
327                    continue;
328                }
329                if trimmed == "Examples:" || trimmed.starts_with("Examples:") {
330                    in_examples = true;
331                    println!(
332                        "  {} {}",
333                        "◇".bright_green().bold(),
334                        "Examples:".bright_green().bold()
335                    );
336                    continue;
337                }
338                if in_examples && (trimmed.starts_with("xbp ") || trimmed.starts_with("xbp.exe ")) {
339                    println!("    {}", highlight_inline_flags(trimmed).dimmed());
340                    continue;
341                }
342                // Keep short notes under examples, stop at unrelated sections.
343                if in_examples && !trimmed.starts_with('-') && !trimmed.starts_with("Local ") {
344                    in_examples = false;
345                }
346                if in_examples {
347                    println!("    {}", trimmed.bright_black());
348                }
349            }
350        }
351    }
352
353    let mut subs: Vec<_> = cmd
354        .get_subcommands()
355        .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
356        .collect();
357    subs.sort_by_key(|sub| sub.get_name().to_string());
358
359    for sub in subs {
360        let mut next = path.to_vec();
361        next.push(sub.get_name());
362        print_catalog_command_tree(sub, &next, false);
363    }
364}
365
366fn style_catalog_arg(arg: &clap::Arg) -> String {
367    let mut flags = Vec::new();
368    if let Some(short) = arg.get_short() {
369        flags.push(format!("-{short}"));
370    }
371    if let Some(long) = arg.get_long() {
372        flags.push(format!("--{long}"));
373    }
374    if flags.is_empty() {
375        if let Some(name) = arg.get_value_names().and_then(|names| names.first()) {
376            flags.push(format!("<{name}>"));
377        } else {
378            flags.push(format!("<{}>", arg.get_id()));
379        }
380    } else if arg.get_action().takes_values() {
381        let value_name = arg
382            .get_value_names()
383            .and_then(|names| names.first())
384            .map(|name| name.to_string())
385            .unwrap_or_else(|| arg.get_id().to_string().to_ascii_uppercase());
386        if let Some(last) = flags.last_mut() {
387            *last = format!("{last} <{value_name}>");
388        }
389    }
390
391    let flags_text = flags.join(", ").bright_yellow().to_string();
392    let help = arg
393        .get_help()
394        .or_else(|| arg.get_long_help())
395        .map(|help| help.to_string())
396        .unwrap_or_default();
397    let default = arg
398        .get_default_values()
399        .first()
400        .and_then(|value| value.to_str())
401        .map(|value| format!(" [default: {value}]"))
402        .unwrap_or_default();
403
404    if help.is_empty() {
405        format!("{flags_text}{}", default.bright_black())
406    } else {
407        format!(
408            "{flags_text:<36} {}{}",
409            help.bright_black(),
410            default.bright_black()
411        )
412    }
413}
414
415pub fn emit_version_line(version: &str) {
416    crate::cli::ui::configure_color_output();
417    println!(
418        "{} {}",
419        "xbp".bright_magenta().bold(),
420        version.bright_white().bold()
421    );
422}
423
424pub fn is_root_help_text(raw: &str) -> bool {
425    raw.lines().any(|line| {
426        let trimmed = line.trim();
427        trimmed == "Usage: xbp [OPTIONS] [COMMAND]"
428            || trimmed == "Usage: xbp.exe [OPTIONS] [COMMAND]"
429            || trimmed.starts_with("Usage: xbp [OPTIONS] [COMMAND]")
430            || trimmed.starts_with("Usage: xbp.exe [OPTIONS] [COMMAND]")
431    })
432}
433
434fn detect_help_scope(raw: &str) -> HelpScope {
435    if is_root_help_text(raw) {
436        return HelpScope::Root;
437    }
438    let first_line = raw.lines().next().unwrap_or_default().trim();
439    if first_line.starts_with("xbp ") && first_line.chars().any(|ch| ch.is_ascii_digit()) {
440        return HelpScope::Root;
441    }
442    HelpScope::Subcommand
443}
444
445fn print_root_banner(raw: &str) {
446    let version = parse_version_line(raw).unwrap_or(env!("CARGO_PKG_VERSION"));
447    println!();
448    println!(
449        "{}  {}",
450        "XBP".bright_magenta().bold(),
451        format!("v{version}").bright_white()
452    );
453    println!("{}", "Deploy · operate · debug · ship".bright_black());
454    crate::cli::ui::divider(44);
455}
456
457fn print_subcommand_banner(raw: &str) {
458    let Some((command_path, tagline)) = parse_subcommand_heading(raw) else {
459        return;
460    };
461    println!();
462    println!("{}", command_path.bright_magenta().bold());
463    if !tagline.is_empty() {
464        println!("{}", tagline.bright_black());
465    }
466    crate::cli::ui::divider(command_path.len().max(28));
467}
468
469fn parse_version_line(raw: &str) -> Option<&str> {
470    let first = raw.lines().next()?.trim();
471    let rest = first.strip_prefix("xbp")?.trim();
472    if rest.is_empty() {
473        None
474    } else {
475        Some(rest)
476    }
477}
478
479fn parse_subcommand_heading(raw: &str) -> Option<(String, String)> {
480    let about = raw
481        .lines()
482        .map(str::trim)
483        .find(|line| !line.is_empty() && !line.starts_with("Usage:"))?;
484
485    let usage = raw
486        .lines()
487        .map(str::trim)
488        .find(|line| line.starts_with("Usage:"))?;
489    let command_path = extract_command_path_from_usage(usage)?;
490
491    Some((command_path, about.to_string()))
492}
493
494fn extract_command_path_from_usage(usage: &str) -> Option<String> {
495    let rest = usage.split_once(':')?.1.trim();
496    let path = rest.split('[').next()?.trim();
497    let normalized = path.replace(".exe", "");
498    if normalized.is_empty() {
499        None
500    } else {
501        Some(normalized)
502    }
503}
504
505fn style_help_line(line: &str) -> String {
506    let trimmed = line.trim_start();
507    if trimmed.is_empty() {
508        return String::new();
509    }
510
511    if matches!(
512        trimmed,
513        "Commands:" | "Options:" | "Arguments:" | "Subcommands:"
514    ) {
515        return format!(
516            "\n{} {}",
517            "▸".bright_blue().bold(),
518            trimmed.bright_blue().bold()
519        );
520    }
521
522    if trimmed.starts_with("Usage:") {
523        return style_usage_line(line);
524    }
525
526    if trimmed == "Discover:" {
527        return format!(
528            "\n{} {}",
529            "◇".bright_green().bold(),
530            trimmed.bright_green().bold()
531        );
532    }
533
534    if is_example_section_header(trimmed) {
535        return format!(
536            "\n{} {}",
537            "◇".bright_green().bold(),
538            trimmed.bright_green().bold()
539        );
540    }
541
542    if is_note_section_header(trimmed) {
543        return format!(
544            "\n{} {}",
545            "◇".bright_yellow().bold(),
546            trimmed.bright_yellow().bold()
547        );
548    }
549
550    if is_command_entry(line) {
551        return style_command_entry(line);
552    }
553
554    if is_option_flags_line(line) {
555        return format!("  {}", line.trim().bright_yellow());
556    }
557
558    if is_option_entry(line) {
559        return style_option_entry(line);
560    }
561
562    if is_command_name_line(line) {
563        return format!("  {}", line.trim().bright_cyan().bold());
564    }
565
566    if is_indented_help_text(line, 4) || is_indented_help_text(line, 8) {
567        return format!("      {}", trimmed.bright_black());
568    }
569
570    if is_example_command_line(trimmed) {
571        return format!("  {}", highlight_inline_flags(trimmed).dimmed());
572    }
573
574    if trimmed.starts_with("Run `") || trimmed.starts_with("Use `") || trimmed.starts_with("Pass `")
575    {
576        return trimmed.bright_black().to_string();
577    }
578
579    line.to_string()
580}
581
582fn style_usage_line(line: &str) -> String {
583    let (prefix, rest) = line.split_once(':').unwrap_or((line, ""));
584    format!(
585        "{} {}",
586        format!("{prefix}:").bright_cyan().bold(),
587        highlight_inline_flags(rest.trim()).bright_white()
588    )
589}
590
591fn is_example_section_header(line: &str) -> bool {
592    matches!(
593        line,
594        "Examples:"
595            | "Quick start:"
596            | "Discover:"
597            | "Available targets:"
598            | "List installable targets:"
599    ) || line.starts_with("Quick start")
600        || line.starts_with("List installable")
601}
602
603fn is_note_section_header(line: &str) -> bool {
604    line == "Notes:"
605        || line.starts_with("Tip:")
606        || line.starts_with("More info:")
607        || line.starts_with("Hint:")
608}
609
610fn is_command_entry(line: &str) -> bool {
611    if !line.starts_with("  ") || line.starts_with("    ") {
612        return false;
613    }
614    let trimmed = line.trim_start();
615    let Some((name, _)) = trimmed.split_once("  ") else {
616        return false;
617    };
618    !name.starts_with('-') && !name.contains('<') && name.len() <= 24
619}
620
621fn style_command_entry(line: &str) -> String {
622    let trimmed = line.trim_start();
623    let mut parts = trimmed.splitn(2, "  ");
624    let name = parts.next().unwrap_or_default();
625    let description = parts.next().unwrap_or_default().trim();
626    let alias = extract_alias_suffix(description);
627    let base_description = description
628        .split_once('[')
629        .map(|(left, _)| left.trim())
630        .unwrap_or(description);
631
632    let name = name.bright_cyan().bold();
633    if alias.is_empty() {
634        format!("  {name:<22} {}", base_description.bright_black())
635    } else {
636        format!(
637            "  {name:<22} {} {}",
638            base_description.bright_black(),
639            alias.bright_black()
640        )
641    }
642}
643
644fn extract_alias_suffix(description: &str) -> String {
645    let Some(start) = description.find('[') else {
646        return String::new();
647    };
648    description[start..].to_string()
649}
650
651fn is_option_flags_line(line: &str) -> bool {
652    let trimmed = line.trim_start();
653    line.starts_with("  ")
654        && !line.starts_with("    ")
655        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
656}
657
658fn is_option_entry(line: &str) -> bool {
659    let trimmed = line.trim_start();
660    line.starts_with("      ")
661        && (trimmed.starts_with("--") || trimmed.starts_with("-"))
662        && !trimmed.starts_with("Usage:")
663}
664
665fn is_command_name_line(line: &str) -> bool {
666    if !line.starts_with("  ") || line.starts_with("    ") {
667        return false;
668    }
669    let trimmed = line.trim();
670    !trimmed.is_empty()
671        && !trimmed.ends_with(':')
672        && !trimmed.starts_with('-')
673        && !trimmed.contains(' ')
674        && trimmed.len() <= 24
675}
676
677fn is_indented_help_text(line: &str, min_spaces: usize) -> bool {
678    let leading = line.chars().take_while(|ch| *ch == ' ').count();
679    leading >= min_spaces && !line.trim_start().starts_with('-') && !line.trim().is_empty()
680}
681
682fn style_command_pair(name_line: &str, description_line: &str) -> String {
683    let name = name_line.trim().bright_cyan().bold();
684    let description = description_line.trim();
685    let alias = extract_alias_suffix(description);
686    let base_description = description
687        .split_once('[')
688        .map(|(left, _)| left.trim())
689        .unwrap_or(description);
690
691    if alias.is_empty() {
692        format!("  {name:<22} {}", base_description.bright_black())
693    } else {
694        format!(
695            "  {name:<22} {} {}",
696            base_description.bright_black(),
697            alias.bright_black()
698        )
699    }
700}
701
702fn style_option_pair(flags_line: &str, description_line: &str) -> String {
703    let flags = flags_line.trim();
704    let styled_flags = flags
705        .split(", ")
706        .map(|flag| flag.bright_yellow().to_string())
707        .collect::<Vec<_>>()
708        .join(", ");
709    format!(
710        "      {styled_flags:<28} {}",
711        description_line.trim().bright_black()
712    )
713}
714
715fn style_option_entry(line: &str) -> String {
716    let trimmed = line.trim_start();
717    let mut parts = trimmed.splitn(2, "  ");
718    let flags = parts.next().unwrap_or_default();
719    let description = parts.next().unwrap_or_default().trim();
720
721    let styled_flags = flags
722        .split(", ")
723        .map(|flag| flag.bright_yellow().to_string())
724        .collect::<Vec<_>>()
725        .join(", ");
726
727    if description.is_empty() {
728        format!("      {styled_flags}")
729    } else {
730        format!("      {styled_flags:<28} {}", description.bright_black())
731    }
732}
733
734fn is_example_command_line(line: &str) -> bool {
735    line.starts_with("xbp ") || line.starts_with("cargo ") || line.starts_with("git ")
736}
737
738fn highlight_inline_flags(text: &str) -> String {
739    let mut output = String::new();
740    let mut current = String::new();
741    let mut chars = text.chars().peekable();
742
743    while let Some(ch) = chars.next() {
744        if ch == '-' && matches!(chars.peek(), Some('-' | 'f' | 'h' | 'l' | 'p' | 'v' | 'n')) {
745            if !current.is_empty() {
746                output.push_str(&current);
747                current.clear();
748            }
749            let mut flag = String::from('-');
750            if chars.peek() == Some(&'-') {
751                flag.push(chars.next().expect("dash"));
752            }
753            while let Some(&next) = chars.peek() {
754                if next.is_ascii_alphanumeric() || next == '-' {
755                    flag.push(chars.next().expect("flag char"));
756                } else {
757                    break;
758                }
759            }
760            output.push_str(&flag.bright_yellow().to_string());
761            continue;
762        }
763
764        if ch == '<' {
765            if !current.is_empty() {
766                output.push_str(&current);
767                current.clear();
768            }
769            let mut placeholder = String::from('<');
770            for next in chars.by_ref() {
771                placeholder.push(next);
772                if next == '>' {
773                    break;
774                }
775            }
776            output.push_str(&placeholder.bright_green().to_string());
777            continue;
778        }
779
780        current.push(ch);
781    }
782
783    if !current.is_empty() {
784        output.push_str(&current);
785    }
786    output
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn detects_root_help_scope() {
795        let raw = "xbp 10.30.3\n\nAbout\nUsage: xbp [OPTIONS] [COMMAND]";
796        assert_eq!(detect_help_scope(raw), HelpScope::Root);
797    }
798
799    #[test]
800    fn workers_help_is_not_root() {
801        let raw = "Manage workers\nUsage: xbp.exe workers [OPTIONS] <COMMAND>";
802        assert!(!is_root_help_text(raw));
803    }
804
805    #[test]
806    fn styles_usage_line_with_flags() {
807        let styled = style_help_line("Usage: xbp workers logs [OPTIONS]");
808        assert!(styled.contains("Usage:"));
809        assert!(styled.contains("workers"));
810    }
811
812    #[test]
813    fn styles_command_entry_line() {
814        let styled = style_help_line("  list      List workers [aliases: ls]");
815        assert!(styled.contains("list"));
816    }
817
818    #[test]
819    fn catalog_scope_skips_duplicate_about() {
820        let raw = "Deploy services\nUsage: xbp [OPTIONS] [COMMAND]\n\nCommands:\n  diag\n      Run diagnostics";
821        emit_styled_help(raw, HelpScope::Catalog);
822    }
823}