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