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 linear select-initiative";
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
123Local stats are written under ~/.xbp/mutations/<owner>/<repo>/<branch>/stats.json
124and repo rollups to ~/.xbp/mutations/<owner>/<repo>/repo-activity.json.";
125
126pub const WORKTREE_WATCH_STATUS_AFTER_HELP: &str = "\
127Examples:
128 xbp worktree-watch status
129 xbp worktree-watch status --stats
130 xbp worktree-watch status --stats --repo-activity
131 xbp worktree-watch status --stats --json
132 xbp worktree-watch status --records --record-limit 100
133 xbp worktree-watch status --stats --stats-gap-minutes 30
134 xbp worktree-watch status --parent C:\\Users\\floris\\Documents\\GitHub --stats
135
136--stats recomputes activity from local JSONL spools and writes stats.json.
137--repo-activity rolls up every spooled branch into repo-activity.json.";
138
139#[cfg(feature = "openapi-gen")]
140pub const GENERATE_AFTER_HELP: &str = "\
141Examples:
142 xbp generate config
143 xbp generate config --force
144 xbp generate openapi --all
145 xbp generate openapi --service api --format both
146 xbp generate openapi --all --check
147 xbp generate systemd";
148
149#[cfg(not(feature = "openapi-gen"))]
150pub const GENERATE_AFTER_HELP: &str = "\
151Examples:
152 xbp generate config
153 xbp generate config --force
154 xbp generate systemd";
155
156pub const DONE_AFTER_HELP: &str = "\
157Examples:
158 xbp done
159 xbp done --since \"7 days ago\"
160 xbp done --output report.md";
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum HelpScope {
164 Root,
165 Subcommand,
166 Catalog,
167 Auto,
168}
169
170pub fn emit_styled_help(raw: &str, scope: HelpScope) {
171 crate::cli::ui::configure_color_output();
172 let resolved_scope = match scope {
173 HelpScope::Auto => detect_help_scope(raw),
174 other => other,
175 };
176
177 let mut skip_about_line = None::<String>;
178 let mut skip_until_usage = resolved_scope == HelpScope::Catalog;
179 if resolved_scope == HelpScope::Root {
180 print_root_banner(raw);
181 } else if resolved_scope != HelpScope::Catalog {
182 if let Some((_, tagline)) = parse_subcommand_heading(raw) {
183 skip_about_line = Some(tagline);
184 print_subcommand_banner(raw);
185 }
186 }
187
188 let lines: Vec<&str> = raw.lines().collect();
189 let mut index = 0usize;
190 while index < lines.len() {
191 let line = lines[index];
192 let trimmed = line.trim();
193 if skip_until_usage {
194 if trimmed.starts_with("Usage:") {
195 skip_until_usage = false;
196 } else {
197 index += 1;
198 continue;
199 }
200 }
201 if let Some(about) = skip_about_line.as_deref() {
202 if trimmed == about {
203 skip_about_line = None;
204 index += 1;
205 continue;
206 }
207 }
208
209 if is_command_name_line(line)
210 && index + 1 < lines.len()
211 && is_indented_help_text(lines[index + 1], 4)
212 {
213 println!("{}", style_command_pair(line, lines[index + 1]));
214 index += 2;
215 continue;
216 }
217
218 if is_option_flags_line(line)
219 && index + 1 < lines.len()
220 && is_indented_help_text(lines[index + 1], 4)
221 {
222 println!("{}", style_option_pair(line, lines[index + 1]));
223 index += 2;
224 continue;
225 }
226
227 if is_option_entry(line)
228 && index + 1 < lines.len()
229 && is_indented_help_text(lines[index + 1], 8)
230 {
231 println!("{}", style_option_pair(line, lines[index + 1]));
232 index += 2;
233 continue;
234 }
235
236 println!("{}", style_help_line(line));
237 index += 1;
238 }
239 println!();
240}
241
242pub fn print_command_catalog() {
247 crate::cli::ui::configure_color_output();
248 println!();
249 println!("{}", "xbp commands".bright_magenta().bold());
250 println!(
251 "{}",
252 "Complete command reference (nested flags included)".bright_black()
253 );
254 crate::cli::ui::divider(44);
255
256 let cmd = crate::cli::commands::Cli::command();
257 print_catalog_command_tree(&cmd, &[], true);
258 println!();
259}
260
261fn print_catalog_command_tree(cmd: &clap::Command, path: &[&str], is_root: bool) {
262 if !is_root {
263 let command_path = format!("xbp {}", path.join(" "));
264 println!();
265 println!("{}", command_path.bright_cyan().bold());
266 if let Some(about) = cmd.get_about().or_else(|| cmd.get_long_about()) {
267 println!(" {}", about.to_string().bright_black());
268 }
269
270 let mut args: Vec<_> = cmd
271 .get_arguments()
272 .filter(|arg| {
273 !arg.is_hide_set()
274 && !arg.is_global_set()
275 && arg.get_id() != "help"
276 && arg.get_id() != "version"
277 && arg.get_id() != "debug"
278 })
279 .collect();
280 args.sort_by_key(|arg| arg.get_id().to_string());
281
282 if !args.is_empty() {
283 println!(
284 " {} {}",
285 "▸".bright_blue().bold(),
286 "Options:".bright_blue().bold()
287 );
288 for arg in args {
289 println!(" {}", style_catalog_arg(arg));
290 }
291 }
292
293 if let Some(after) = cmd.get_after_help().or_else(|| cmd.get_after_long_help()) {
294 let after = after.to_string();
295 let mut in_examples = false;
296 for line in after.lines() {
297 let trimmed = line.trim();
298 if trimmed.is_empty() {
299 continue;
300 }
301 if trimmed == "Examples:" || trimmed.starts_with("Examples:") {
302 in_examples = true;
303 println!(
304 " {} {}",
305 "◇".bright_green().bold(),
306 "Examples:".bright_green().bold()
307 );
308 continue;
309 }
310 if in_examples && (trimmed.starts_with("xbp ") || trimmed.starts_with("xbp.exe ")) {
311 println!(" {}", highlight_inline_flags(trimmed).dimmed());
312 continue;
313 }
314 if in_examples && !trimmed.starts_with('-') && !trimmed.starts_with("Local ") {
316 in_examples = false;
317 }
318 if in_examples {
319 println!(" {}", trimmed.bright_black());
320 }
321 }
322 }
323 }
324
325 let mut subs: Vec<_> = cmd
326 .get_subcommands()
327 .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
328 .collect();
329 subs.sort_by_key(|sub| sub.get_name().to_string());
330
331 for sub in subs {
332 let mut next = path.to_vec();
333 next.push(sub.get_name());
334 print_catalog_command_tree(sub, &next, false);
335 }
336}
337
338fn style_catalog_arg(arg: &clap::Arg) -> String {
339 let mut flags = Vec::new();
340 if let Some(short) = arg.get_short() {
341 flags.push(format!("-{short}"));
342 }
343 if let Some(long) = arg.get_long() {
344 flags.push(format!("--{long}"));
345 }
346 if flags.is_empty() {
347 if let Some(name) = arg.get_value_names().and_then(|names| names.first()) {
348 flags.push(format!("<{name}>"));
349 } else {
350 flags.push(format!("<{}>", arg.get_id()));
351 }
352 } else if arg.get_action().takes_values() {
353 let value_name = arg
354 .get_value_names()
355 .and_then(|names| names.first())
356 .map(|name| name.to_string())
357 .unwrap_or_else(|| arg.get_id().to_string().to_ascii_uppercase());
358 if let Some(last) = flags.last_mut() {
359 *last = format!("{last} <{value_name}>");
360 }
361 }
362
363 let flags_text = flags.join(", ").bright_yellow().to_string();
364 let help = arg
365 .get_help()
366 .or_else(|| arg.get_long_help())
367 .map(|help| help.to_string())
368 .unwrap_or_default();
369 let default = arg
370 .get_default_values()
371 .first()
372 .and_then(|value| value.to_str())
373 .map(|value| format!(" [default: {value}]"))
374 .unwrap_or_default();
375
376 if help.is_empty() {
377 format!("{flags_text}{}", default.bright_black())
378 } else {
379 format!(
380 "{flags_text:<36} {}{}",
381 help.bright_black(),
382 default.bright_black()
383 )
384 }
385}
386
387pub fn emit_version_line(version: &str) {
388 crate::cli::ui::configure_color_output();
389 println!(
390 "{} {}",
391 "xbp".bright_magenta().bold(),
392 version.bright_white().bold()
393 );
394}
395
396pub fn is_root_help_text(raw: &str) -> bool {
397 raw.lines().any(|line| {
398 let trimmed = line.trim();
399 trimmed == "Usage: xbp [OPTIONS] [COMMAND]"
400 || trimmed == "Usage: xbp.exe [OPTIONS] [COMMAND]"
401 || trimmed.starts_with("Usage: xbp [OPTIONS] [COMMAND]")
402 || trimmed.starts_with("Usage: xbp.exe [OPTIONS] [COMMAND]")
403 })
404}
405
406fn detect_help_scope(raw: &str) -> HelpScope {
407 if is_root_help_text(raw) {
408 return HelpScope::Root;
409 }
410 let first_line = raw.lines().next().unwrap_or_default().trim();
411 if first_line.starts_with("xbp ") && first_line.chars().any(|ch| ch.is_ascii_digit()) {
412 return HelpScope::Root;
413 }
414 HelpScope::Subcommand
415}
416
417fn print_root_banner(raw: &str) {
418 let version = parse_version_line(raw).unwrap_or(env!("CARGO_PKG_VERSION"));
419 println!();
420 println!(
421 "{} {}",
422 "XBP".bright_magenta().bold(),
423 format!("v{version}").bright_white()
424 );
425 println!("{}", "Deploy · operate · debug · ship".bright_black());
426 crate::cli::ui::divider(44);
427}
428
429fn print_subcommand_banner(raw: &str) {
430 let Some((command_path, tagline)) = parse_subcommand_heading(raw) else {
431 return;
432 };
433 println!();
434 println!("{}", command_path.bright_magenta().bold());
435 if !tagline.is_empty() {
436 println!("{}", tagline.bright_black());
437 }
438 crate::cli::ui::divider(command_path.len().max(28));
439}
440
441fn parse_version_line(raw: &str) -> Option<&str> {
442 let first = raw.lines().next()?.trim();
443 let rest = first.strip_prefix("xbp")?.trim();
444 if rest.is_empty() {
445 None
446 } else {
447 Some(rest)
448 }
449}
450
451fn parse_subcommand_heading(raw: &str) -> Option<(String, String)> {
452 let about = raw
453 .lines()
454 .map(str::trim)
455 .find(|line| !line.is_empty() && !line.starts_with("Usage:"))?;
456
457 let usage = raw
458 .lines()
459 .map(str::trim)
460 .find(|line| line.starts_with("Usage:"))?;
461 let command_path = extract_command_path_from_usage(usage)?;
462
463 Some((command_path, about.to_string()))
464}
465
466fn extract_command_path_from_usage(usage: &str) -> Option<String> {
467 let rest = usage.split_once(':')?.1.trim();
468 let path = rest.split('[').next()?.trim();
469 let normalized = path.replace(".exe", "");
470 if normalized.is_empty() {
471 None
472 } else {
473 Some(normalized)
474 }
475}
476
477fn style_help_line(line: &str) -> String {
478 let trimmed = line.trim_start();
479 if trimmed.is_empty() {
480 return String::new();
481 }
482
483 if matches!(
484 trimmed,
485 "Commands:" | "Options:" | "Arguments:" | "Subcommands:"
486 ) {
487 return format!(
488 "\n{} {}",
489 "▸".bright_blue().bold(),
490 trimmed.bright_blue().bold()
491 );
492 }
493
494 if trimmed.starts_with("Usage:") {
495 return style_usage_line(line);
496 }
497
498 if trimmed == "Discover:" {
499 return format!(
500 "\n{} {}",
501 "◇".bright_green().bold(),
502 trimmed.bright_green().bold()
503 );
504 }
505
506 if is_example_section_header(trimmed) {
507 return format!(
508 "\n{} {}",
509 "◇".bright_green().bold(),
510 trimmed.bright_green().bold()
511 );
512 }
513
514 if is_note_section_header(trimmed) {
515 return format!(
516 "\n{} {}",
517 "◇".bright_yellow().bold(),
518 trimmed.bright_yellow().bold()
519 );
520 }
521
522 if is_command_entry(line) {
523 return style_command_entry(line);
524 }
525
526 if is_option_flags_line(line) {
527 return format!(" {}", line.trim().bright_yellow());
528 }
529
530 if is_option_entry(line) {
531 return style_option_entry(line);
532 }
533
534 if is_command_name_line(line) {
535 return format!(" {}", line.trim().bright_cyan().bold());
536 }
537
538 if is_indented_help_text(line, 4) || is_indented_help_text(line, 8) {
539 return format!(" {}", trimmed.bright_black());
540 }
541
542 if is_example_command_line(trimmed) {
543 return format!(" {}", highlight_inline_flags(trimmed).dimmed());
544 }
545
546 if trimmed.starts_with("Run `") || trimmed.starts_with("Use `") || trimmed.starts_with("Pass `")
547 {
548 return trimmed.bright_black().to_string();
549 }
550
551 line.to_string()
552}
553
554fn style_usage_line(line: &str) -> String {
555 let (prefix, rest) = line.split_once(':').unwrap_or((line, ""));
556 format!(
557 "{} {}",
558 format!("{prefix}:").bright_cyan().bold(),
559 highlight_inline_flags(rest.trim()).bright_white()
560 )
561}
562
563fn is_example_section_header(line: &str) -> bool {
564 matches!(
565 line,
566 "Examples:"
567 | "Quick start:"
568 | "Discover:"
569 | "Available targets:"
570 | "List installable targets:"
571 ) || line.starts_with("Quick start")
572 || line.starts_with("List installable")
573}
574
575fn is_note_section_header(line: &str) -> bool {
576 line == "Notes:"
577 || line.starts_with("Tip:")
578 || line.starts_with("More info:")
579 || line.starts_with("Hint:")
580}
581
582fn is_command_entry(line: &str) -> bool {
583 if !line.starts_with(" ") || line.starts_with(" ") {
584 return false;
585 }
586 let trimmed = line.trim_start();
587 let Some((name, _)) = trimmed.split_once(" ") else {
588 return false;
589 };
590 !name.starts_with('-') && !name.contains('<') && name.len() <= 24
591}
592
593fn style_command_entry(line: &str) -> String {
594 let trimmed = line.trim_start();
595 let mut parts = trimmed.splitn(2, " ");
596 let name = parts.next().unwrap_or_default();
597 let description = parts.next().unwrap_or_default().trim();
598 let alias = extract_alias_suffix(description);
599 let base_description = description
600 .split_once('[')
601 .map(|(left, _)| left.trim())
602 .unwrap_or(description);
603
604 let name = name.bright_cyan().bold();
605 if alias.is_empty() {
606 format!(" {name:<22} {}", base_description.bright_black())
607 } else {
608 format!(
609 " {name:<22} {} {}",
610 base_description.bright_black(),
611 alias.bright_black()
612 )
613 }
614}
615
616fn extract_alias_suffix(description: &str) -> String {
617 let Some(start) = description.find('[') else {
618 return String::new();
619 };
620 description[start..].to_string()
621}
622
623fn is_option_flags_line(line: &str) -> bool {
624 let trimmed = line.trim_start();
625 line.starts_with(" ")
626 && !line.starts_with(" ")
627 && (trimmed.starts_with("--") || trimmed.starts_with("-"))
628}
629
630fn is_option_entry(line: &str) -> bool {
631 let trimmed = line.trim_start();
632 line.starts_with(" ")
633 && (trimmed.starts_with("--") || trimmed.starts_with("-"))
634 && !trimmed.starts_with("Usage:")
635}
636
637fn is_command_name_line(line: &str) -> bool {
638 if !line.starts_with(" ") || line.starts_with(" ") {
639 return false;
640 }
641 let trimmed = line.trim();
642 !trimmed.is_empty()
643 && !trimmed.ends_with(':')
644 && !trimmed.starts_with('-')
645 && !trimmed.contains(' ')
646 && trimmed.len() <= 24
647}
648
649fn is_indented_help_text(line: &str, min_spaces: usize) -> bool {
650 let leading = line.chars().take_while(|ch| *ch == ' ').count();
651 leading >= min_spaces && !line.trim_start().starts_with('-') && !line.trim().is_empty()
652}
653
654fn style_command_pair(name_line: &str, description_line: &str) -> String {
655 let name = name_line.trim().bright_cyan().bold();
656 let description = description_line.trim();
657 let alias = extract_alias_suffix(description);
658 let base_description = description
659 .split_once('[')
660 .map(|(left, _)| left.trim())
661 .unwrap_or(description);
662
663 if alias.is_empty() {
664 format!(" {name:<22} {}", base_description.bright_black())
665 } else {
666 format!(
667 " {name:<22} {} {}",
668 base_description.bright_black(),
669 alias.bright_black()
670 )
671 }
672}
673
674fn style_option_pair(flags_line: &str, description_line: &str) -> String {
675 let flags = flags_line.trim();
676 let styled_flags = flags
677 .split(", ")
678 .map(|flag| flag.bright_yellow().to_string())
679 .collect::<Vec<_>>()
680 .join(", ");
681 format!(
682 " {styled_flags:<28} {}",
683 description_line.trim().bright_black()
684 )
685}
686
687fn style_option_entry(line: &str) -> String {
688 let trimmed = line.trim_start();
689 let mut parts = trimmed.splitn(2, " ");
690 let flags = parts.next().unwrap_or_default();
691 let description = parts.next().unwrap_or_default().trim();
692
693 let styled_flags = flags
694 .split(", ")
695 .map(|flag| flag.bright_yellow().to_string())
696 .collect::<Vec<_>>()
697 .join(", ");
698
699 if description.is_empty() {
700 format!(" {styled_flags}")
701 } else {
702 format!(" {styled_flags:<28} {}", description.bright_black())
703 }
704}
705
706fn is_example_command_line(line: &str) -> bool {
707 line.starts_with("xbp ") || line.starts_with("cargo ") || line.starts_with("git ")
708}
709
710fn highlight_inline_flags(text: &str) -> String {
711 let mut output = String::new();
712 let mut current = String::new();
713 let mut chars = text.chars().peekable();
714
715 while let Some(ch) = chars.next() {
716 if ch == '-' && matches!(chars.peek(), Some('-' | 'f' | 'h' | 'l' | 'p' | 'v' | 'n')) {
717 if !current.is_empty() {
718 output.push_str(¤t);
719 current.clear();
720 }
721 let mut flag = String::from('-');
722 if chars.peek() == Some(&'-') {
723 flag.push(chars.next().expect("dash"));
724 }
725 while let Some(&next) = chars.peek() {
726 if next.is_ascii_alphanumeric() || next == '-' {
727 flag.push(chars.next().expect("flag char"));
728 } else {
729 break;
730 }
731 }
732 output.push_str(&flag.bright_yellow().to_string());
733 continue;
734 }
735
736 if ch == '<' {
737 if !current.is_empty() {
738 output.push_str(¤t);
739 current.clear();
740 }
741 let mut placeholder = String::from('<');
742 for next in chars.by_ref() {
743 placeholder.push(next);
744 if next == '>' {
745 break;
746 }
747 }
748 output.push_str(&placeholder.bright_green().to_string());
749 continue;
750 }
751
752 current.push(ch);
753 }
754
755 if !current.is_empty() {
756 output.push_str(¤t);
757 }
758 output
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn detects_root_help_scope() {
767 let raw = "xbp 10.30.3\n\nAbout\nUsage: xbp [OPTIONS] [COMMAND]";
768 assert_eq!(detect_help_scope(raw), HelpScope::Root);
769 }
770
771 #[test]
772 fn workers_help_is_not_root() {
773 let raw = "Manage workers\nUsage: xbp.exe workers [OPTIONS] <COMMAND>";
774 assert!(!is_root_help_text(raw));
775 }
776
777 #[test]
778 fn styles_usage_line_with_flags() {
779 let styled = style_help_line("Usage: xbp workers logs [OPTIONS]");
780 assert!(styled.contains("Usage:"));
781 assert!(styled.contains("workers"));
782 }
783
784 #[test]
785 fn styles_command_entry_line() {
786 let styled = style_help_line(" list List workers [aliases: ls]");
787 assert!(styled.contains("list"));
788 }
789
790 #[test]
791 fn catalog_scope_skips_duplicate_about() {
792 let raw = "Deploy services\nUsage: xbp [OPTIONS] [COMMAND]\n\nCommands:\n diag\n Run diagnostics";
793 emit_styled_help(raw, HelpScope::Catalog);
794 }
795}