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