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