1use clap::CommandFactory;
4use colored::Colorize;
5
6pub const XBP_HELP_TEMPLATE: &str = "\
8{about-with-newline}\
9Usage: {usage}\n\n\
10{all-args}\
11{after-help}";
12
13pub 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
270pub 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 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(¤t);
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(¤t);
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(¤t);
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}