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