xbp_cli/cli/
help_render.rs1use 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 --allow-dirty";
84
85pub const DOMAINS_AFTER_HELP: &str = "\
86Examples:
87 xbp domains list
88 xbp domains check --domain example.com
89 xbp domains search --query myapp --extension com";
90
91pub const LOGIN_AFTER_HELP: &str = "\
92Examples:
93 xbp login
94 xbp login status
95 xbp login logout";
96
97pub const SSH_AFTER_HELP: &str = "\
98Examples:
99 xbp ssh
100 xbp ssh --host bastion.example.com
101 xbp ssh --host 10.0.0.5 --command \"uptime\"";
102
103pub const GENERATE_AFTER_HELP: &str = "\
104Examples:
105 xbp generate config
106 xbp generate config --force
107 xbp generate systemd";
108
109pub const DONE_AFTER_HELP: &str = "\
110Examples:
111 xbp done
112 xbp done --since \"7 days ago\"
113 xbp done --output report.md";
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum HelpScope {
117 Root,
118 Subcommand,
119 Catalog,
120 Auto,
121}
122
123pub fn emit_styled_help(raw: &str, scope: HelpScope) {
124 crate::cli::ui::configure_color_output();
125 let resolved_scope = match scope {
126 HelpScope::Auto => detect_help_scope(raw),
127 other => other,
128 };
129
130 let mut skip_about_line = None::<String>;
131 let mut skip_until_usage = resolved_scope == HelpScope::Catalog;
132 if resolved_scope == HelpScope::Root {
133 print_root_banner(raw);
134 } else if resolved_scope != HelpScope::Catalog {
135 if let Some((_, tagline)) = parse_subcommand_heading(raw) {
136 skip_about_line = Some(tagline);
137 print_subcommand_banner(raw);
138 }
139 }
140
141 let lines: Vec<&str> = raw.lines().collect();
142 let mut index = 0usize;
143 while index < lines.len() {
144 let line = lines[index];
145 let trimmed = line.trim();
146 if skip_until_usage {
147 if trimmed.starts_with("Usage:") {
148 skip_until_usage = false;
149 } else {
150 index += 1;
151 continue;
152 }
153 }
154 if let Some(about) = skip_about_line.as_deref() {
155 if trimmed == about {
156 skip_about_line = None;
157 index += 1;
158 continue;
159 }
160 }
161
162 if is_command_name_line(line)
163 && index + 1 < lines.len()
164 && is_indented_help_text(lines[index + 1], 4)
165 {
166 println!("{}", style_command_pair(line, lines[index + 1]));
167 index += 2;
168 continue;
169 }
170
171 if is_option_flags_line(line)
172 && index + 1 < lines.len()
173 && is_indented_help_text(lines[index + 1], 4)
174 {
175 println!("{}", style_option_pair(line, lines[index + 1]));
176 index += 2;
177 continue;
178 }
179
180 if is_option_entry(line)
181 && index + 1 < lines.len()
182 && is_indented_help_text(lines[index + 1], 8)
183 {
184 println!("{}", style_option_pair(line, lines[index + 1]));
185 index += 2;
186 continue;
187 }
188
189 println!("{}", style_help_line(line));
190 index += 1;
191 }
192 println!();
193}
194
195pub fn print_command_catalog() {
197 crate::cli::ui::configure_color_output();
198 println!();
199 println!("{}", "xbp commands".bright_magenta().bold());
200 println!("{}", "Complete command reference".bright_black());
201 crate::cli::ui::divider(32);
202
203 let help = crate::cli::commands::Cli::command()
204 .render_long_help()
205 .to_string();
206 emit_styled_help(&help, HelpScope::Catalog);
207}
208
209pub fn emit_version_line(version: &str) {
210 crate::cli::ui::configure_color_output();
211 println!(
212 "{} {}",
213 "xbp".bright_magenta().bold(),
214 version.bright_white().bold()
215 );
216}
217
218pub fn is_root_help_text(raw: &str) -> bool {
219 raw.lines().any(|line| {
220 let trimmed = line.trim();
221 trimmed == "Usage: xbp [OPTIONS] [COMMAND]"
222 || trimmed == "Usage: xbp.exe [OPTIONS] [COMMAND]"
223 || trimmed.starts_with("Usage: xbp [OPTIONS] [COMMAND]")
224 || trimmed.starts_with("Usage: xbp.exe [OPTIONS] [COMMAND]")
225 })
226}
227
228fn detect_help_scope(raw: &str) -> HelpScope {
229 if is_root_help_text(raw) {
230 return HelpScope::Root;
231 }
232 let first_line = raw.lines().next().unwrap_or_default().trim();
233 if first_line.starts_with("xbp ") && first_line.chars().any(|ch| ch.is_ascii_digit()) {
234 return HelpScope::Root;
235 }
236 HelpScope::Subcommand
237}
238
239fn print_root_banner(raw: &str) {
240 let version = parse_version_line(raw).unwrap_or(env!("CARGO_PKG_VERSION"));
241 println!();
242 println!(
243 "{} {}",
244 "XBP".bright_magenta().bold(),
245 format!("v{version}").bright_white()
246 );
247 println!("{}", "Deploy · operate · debug · ship".bright_black());
248 crate::cli::ui::divider(44);
249}
250
251fn print_subcommand_banner(raw: &str) {
252 let Some((command_path, tagline)) = parse_subcommand_heading(raw) else {
253 return;
254 };
255 println!();
256 println!("{}", command_path.bright_magenta().bold());
257 if !tagline.is_empty() {
258 println!("{}", tagline.bright_black());
259 }
260 crate::cli::ui::divider(command_path.len().max(28));
261}
262
263fn parse_version_line(raw: &str) -> Option<&str> {
264 let first = raw.lines().next()?.trim();
265 let rest = first.strip_prefix("xbp")?.trim();
266 if rest.is_empty() {
267 None
268 } else {
269 Some(rest)
270 }
271}
272
273fn parse_subcommand_heading(raw: &str) -> Option<(String, String)> {
274 let about = raw
275 .lines()
276 .map(str::trim)
277 .find(|line| !line.is_empty() && !line.starts_with("Usage:"))?;
278
279 let usage = raw
280 .lines()
281 .map(str::trim)
282 .find(|line| line.starts_with("Usage:"))?;
283 let command_path = extract_command_path_from_usage(usage)?;
284
285 Some((command_path, about.to_string()))
286}
287
288fn extract_command_path_from_usage(usage: &str) -> Option<String> {
289 let rest = usage.split_once(':')?.1.trim();
290 let path = rest.split('[').next()?.trim();
291 let normalized = path.replace(".exe", "");
292 if normalized.is_empty() {
293 None
294 } else {
295 Some(normalized)
296 }
297}
298
299fn style_help_line(line: &str) -> String {
300 let trimmed = line.trim_start();
301 if trimmed.is_empty() {
302 return String::new();
303 }
304
305 if matches!(
306 trimmed,
307 "Commands:" | "Options:" | "Arguments:" | "Subcommands:"
308 ) {
309 return format!(
310 "\n{} {}",
311 "▸".bright_blue().bold(),
312 trimmed.bright_blue().bold()
313 );
314 }
315
316 if trimmed.starts_with("Usage:") {
317 return style_usage_line(line);
318 }
319
320 if trimmed == "Discover:" {
321 return format!(
322 "\n{} {}",
323 "◇".bright_green().bold(),
324 trimmed.bright_green().bold()
325 );
326 }
327
328 if is_example_section_header(trimmed) {
329 return format!(
330 "\n{} {}",
331 "◇".bright_green().bold(),
332 trimmed.bright_green().bold()
333 );
334 }
335
336 if is_note_section_header(trimmed) {
337 return format!(
338 "\n{} {}",
339 "◇".bright_yellow().bold(),
340 trimmed.bright_yellow().bold()
341 );
342 }
343
344 if is_command_entry(line) {
345 return style_command_entry(line);
346 }
347
348 if is_option_flags_line(line) {
349 return format!(" {}", line.trim().bright_yellow());
350 }
351
352 if is_option_entry(line) {
353 return style_option_entry(line);
354 }
355
356 if is_command_name_line(line) {
357 return format!(" {}", line.trim().bright_cyan().bold());
358 }
359
360 if is_indented_help_text(line, 4) || is_indented_help_text(line, 8) {
361 return format!(" {}", trimmed.bright_black());
362 }
363
364 if is_example_command_line(trimmed) {
365 return format!(" {}", highlight_inline_flags(trimmed).dimmed());
366 }
367
368 if trimmed.starts_with("Run `") || trimmed.starts_with("Use `") || trimmed.starts_with("Pass `")
369 {
370 return trimmed.bright_black().to_string();
371 }
372
373 line.to_string()
374}
375
376fn style_usage_line(line: &str) -> String {
377 let (prefix, rest) = line.split_once(':').unwrap_or((line, ""));
378 format!(
379 "{} {}",
380 format!("{prefix}:").bright_cyan().bold(),
381 highlight_inline_flags(rest.trim()).bright_white()
382 )
383}
384
385fn is_example_section_header(line: &str) -> bool {
386 matches!(
387 line,
388 "Examples:"
389 | "Quick start:"
390 | "Discover:"
391 | "Available targets:"
392 | "List installable targets:"
393 ) || line.starts_with("Quick start")
394 || line.starts_with("List installable")
395}
396
397fn is_note_section_header(line: &str) -> bool {
398 line == "Notes:"
399 || line.starts_with("Tip:")
400 || line.starts_with("More info:")
401 || line.starts_with("Hint:")
402}
403
404fn is_command_entry(line: &str) -> bool {
405 if !line.starts_with(" ") || line.starts_with(" ") {
406 return false;
407 }
408 let trimmed = line.trim_start();
409 let Some((name, _)) = trimmed.split_once(" ") else {
410 return false;
411 };
412 !name.starts_with('-') && !name.contains('<') && name.len() <= 24
413}
414
415fn style_command_entry(line: &str) -> String {
416 let trimmed = line.trim_start();
417 let mut parts = trimmed.splitn(2, " ");
418 let name = parts.next().unwrap_or_default();
419 let description = parts.next().unwrap_or_default().trim();
420 let alias = extract_alias_suffix(description);
421 let base_description = description
422 .split_once('[')
423 .map(|(left, _)| left.trim())
424 .unwrap_or(description);
425
426 let name = name.bright_cyan().bold();
427 if alias.is_empty() {
428 format!(" {name:<22} {}", base_description.bright_black())
429 } else {
430 format!(
431 " {name:<22} {} {}",
432 base_description.bright_black(),
433 alias.bright_black()
434 )
435 }
436}
437
438fn extract_alias_suffix(description: &str) -> String {
439 let Some(start) = description.find('[') else {
440 return String::new();
441 };
442 description[start..].to_string()
443}
444
445fn is_option_flags_line(line: &str) -> bool {
446 let trimmed = line.trim_start();
447 line.starts_with(" ")
448 && !line.starts_with(" ")
449 && (trimmed.starts_with("--") || trimmed.starts_with("-"))
450}
451
452fn is_option_entry(line: &str) -> bool {
453 let trimmed = line.trim_start();
454 line.starts_with(" ")
455 && (trimmed.starts_with("--") || trimmed.starts_with("-"))
456 && !trimmed.starts_with("Usage:")
457}
458
459fn is_command_name_line(line: &str) -> bool {
460 if !line.starts_with(" ") || line.starts_with(" ") {
461 return false;
462 }
463 let trimmed = line.trim();
464 !trimmed.is_empty()
465 && !trimmed.ends_with(':')
466 && !trimmed.starts_with('-')
467 && !trimmed.contains(' ')
468 && trimmed.len() <= 24
469}
470
471fn is_indented_help_text(line: &str, min_spaces: usize) -> bool {
472 let leading = line.chars().take_while(|ch| *ch == ' ').count();
473 leading >= min_spaces && !line.trim_start().starts_with('-') && !line.trim().is_empty()
474}
475
476fn style_command_pair(name_line: &str, description_line: &str) -> String {
477 let name = name_line.trim().bright_cyan().bold();
478 let description = description_line.trim();
479 let alias = extract_alias_suffix(description);
480 let base_description = description
481 .split_once('[')
482 .map(|(left, _)| left.trim())
483 .unwrap_or(description);
484
485 if alias.is_empty() {
486 format!(" {name:<22} {}", base_description.bright_black())
487 } else {
488 format!(
489 " {name:<22} {} {}",
490 base_description.bright_black(),
491 alias.bright_black()
492 )
493 }
494}
495
496fn style_option_pair(flags_line: &str, description_line: &str) -> String {
497 let flags = flags_line.trim();
498 let styled_flags = flags
499 .split(", ")
500 .map(|flag| flag.bright_yellow().to_string())
501 .collect::<Vec<_>>()
502 .join(", ");
503 format!(
504 " {styled_flags:<28} {}",
505 description_line.trim().bright_black()
506 )
507}
508
509fn style_option_entry(line: &str) -> String {
510 let trimmed = line.trim_start();
511 let mut parts = trimmed.splitn(2, " ");
512 let flags = parts.next().unwrap_or_default();
513 let description = parts.next().unwrap_or_default().trim();
514
515 let styled_flags = flags
516 .split(", ")
517 .map(|flag| flag.bright_yellow().to_string())
518 .collect::<Vec<_>>()
519 .join(", ");
520
521 if description.is_empty() {
522 format!(" {styled_flags}")
523 } else {
524 format!(" {styled_flags:<28} {}", description.bright_black())
525 }
526}
527
528fn is_example_command_line(line: &str) -> bool {
529 line.starts_with("xbp ") || line.starts_with("cargo ") || line.starts_with("git ")
530}
531
532fn highlight_inline_flags(text: &str) -> String {
533 let mut output = String::new();
534 let mut current = String::new();
535 let mut chars = text.chars().peekable();
536
537 while let Some(ch) = chars.next() {
538 if ch == '-' && matches!(chars.peek(), Some('-' | 'f' | 'h' | 'l' | 'p' | 'v' | 'n')) {
539 if !current.is_empty() {
540 output.push_str(¤t);
541 current.clear();
542 }
543 let mut flag = String::from('-');
544 if chars.peek() == Some(&'-') {
545 flag.push(chars.next().expect("dash"));
546 }
547 while let Some(&next) = chars.peek() {
548 if next.is_ascii_alphanumeric() || next == '-' {
549 flag.push(chars.next().expect("flag char"));
550 } else {
551 break;
552 }
553 }
554 output.push_str(&flag.bright_yellow().to_string());
555 continue;
556 }
557
558 if ch == '<' {
559 if !current.is_empty() {
560 output.push_str(¤t);
561 current.clear();
562 }
563 let mut placeholder = String::from('<');
564 while let Some(next) = chars.next() {
565 placeholder.push(next);
566 if next == '>' {
567 break;
568 }
569 }
570 output.push_str(&placeholder.bright_green().to_string());
571 continue;
572 }
573
574 current.push(ch);
575 }
576
577 if !current.is_empty() {
578 output.push_str(¤t);
579 }
580 output
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn detects_root_help_scope() {
589 let raw = "xbp 10.30.3\n\nAbout\nUsage: xbp [OPTIONS] [COMMAND]";
590 assert_eq!(detect_help_scope(raw), HelpScope::Root);
591 }
592
593 #[test]
594 fn workers_help_is_not_root() {
595 let raw = "Manage workers\nUsage: xbp.exe workers [OPTIONS] <COMMAND>";
596 assert!(!is_root_help_text(raw));
597 }
598
599 #[test]
600 fn styles_usage_line_with_flags() {
601 let styled = style_help_line("Usage: xbp workers logs [OPTIONS]");
602 assert!(styled.contains("Usage:"));
603 assert!(styled.contains("workers"));
604 }
605
606 #[test]
607 fn styles_command_entry_line() {
608 let styled = style_help_line(" list List workers [aliases: ls]");
609 assert!(styled.contains("list"));
610 }
611
612 #[test]
613 fn catalog_scope_skips_duplicate_about() {
614 let raw = "Deploy services\nUsage: xbp [OPTIONS] [COMMAND]\n\nCommands:\n diag\n Run diagnostics";
615 emit_styled_help(raw, HelpScope::Catalog);
616 }
617}