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 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
264pub 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 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(¤t);
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(¤t);
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(¤t);
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}