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