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