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