1use crate::config::schema::{ArgumentType, CommandDefinition, CommandsConfig};
38use colored::Colorize;
39
40pub trait HelpFormatter {
76 fn format_app(&self, config: &CommandsConfig) -> String;
80
81 fn format_command(&self, config: &CommandsConfig, command: &str) -> String;
87}
88
89#[derive(Debug, Default)]
114pub struct DefaultHelpFormatter;
115
116impl DefaultHelpFormatter {
117 pub fn new() -> Self {
119 Self
120 }
121
122 fn type_label(t: ArgumentType) -> &'static str {
128 t.as_str()
129 }
130
131 fn pad(s: &str, width: usize) -> String {
133 format!("{:<width$}", s, width = width)
134 }
135
136 fn find_command<'a>(config: &'a CommandsConfig, name: &str) -> Option<&'a CommandDefinition> {
138 config
139 .commands
140 .iter()
141 .find(|cmd| cmd.name == name || cmd.aliases.iter().any(|a| a == name))
142 }
143
144 fn format_arguments(cmd: &CommandDefinition) -> String {
146 if cmd.arguments.is_empty() {
147 return String::new();
148 }
149
150 let col_width = cmd
152 .arguments
153 .iter()
154 .map(|a| a.name.len())
155 .max()
156 .unwrap_or(0)
157 + 4; let mut out = format!("\n{}\n", "ARGUMENTS:".bold());
160 for arg in &cmd.arguments {
161 let req = if arg.required { "required" } else { "optional" };
162 let label = format!("({}, {req})", Self::type_label(arg.arg_type));
163 out.push_str(&format!(
164 " {} {} {}\n",
165 Self::pad(&arg.name, col_width).green(),
166 label.dimmed(),
167 arg.description
168 ));
169 }
170 out
171 }
172
173 fn format_options(cmd: &CommandDefinition) -> String {
175 if cmd.options.is_empty() {
176 return String::new();
177 }
178
179 let flags: Vec<String> = cmd
181 .options
182 .iter()
183 .map(|opt| {
184 let short = opt
185 .short
186 .as_deref()
187 .map(|s| format!("-{s}"))
188 .unwrap_or_default();
189 let long = opt
190 .long
191 .as_deref()
192 .map(|l| format!("--{l}"))
193 .unwrap_or_default();
194 match (short.is_empty(), long.is_empty()) {
195 (false, false) => format!("{short}, {long}"),
196 (false, true) => short,
197 (true, false) => long,
198 (true, true) => opt.name.clone(),
199 }
200 })
201 .collect();
202
203 let col_width = flags.iter().map(|f| f.len()).max().unwrap_or(0) + 4;
204
205 let mut out = format!("\n{}\n", "OPTIONS:".bold());
206 for (opt, flag) in cmd.options.iter().zip(flags.iter()) {
207 let type_label = format!("({})", Self::type_label(opt.option_type));
208 let default_note = opt
209 .default
210 .as_deref()
211 .map(|d| format!(" [default: {d}]"))
212 .unwrap_or_default();
213 out.push_str(&format!(
214 " {} {} {}{}\n",
215 Self::pad(flag, col_width).yellow(),
216 type_label.dimmed(),
217 opt.description,
218 default_note.dimmed()
219 ));
220 }
221 out
222 }
223
224 fn format_aliases(cmd: &CommandDefinition) -> String {
226 if cmd.aliases.is_empty() {
227 return String::new();
228 }
229 format!(
230 "\n{}\n {}\n",
231 "ALIASES:".bold(),
232 cmd.aliases.join(", ").italic()
233 )
234 }
235
236 fn usage_args(cmd: &CommandDefinition) -> String {
238 let args: String = cmd
239 .arguments
240 .iter()
241 .map(|a| {
242 if a.required {
243 format!("<{}>", a.name)
244 } else {
245 format!("[{}]", a.name)
246 }
247 })
248 .collect::<Vec<_>>()
249 .join(" ");
250
251 let opts = if cmd.options.is_empty() {
252 String::new()
253 } else {
254 " [options]".to_string()
255 };
256
257 format!("{args}{opts}")
258 }
259}
260
261impl HelpFormatter for DefaultHelpFormatter {
262 fn format_app(&self, config: &CommandsConfig) -> String {
279 let mut out = String::new();
280
281 out.push_str(&format!(
283 "{} {}\n",
284 config.metadata.prompt.bold().cyan(),
285 config.metadata.version.dimmed()
286 ));
287
288 out.push('\n');
290 out.push_str(&format!("{}\n", "USAGE:".bold()));
291 out.push_str(&format!(
292 " {} {} [arguments] [options]\n",
293 config.metadata.prompt,
294 "<command>".green()
295 ));
296
297 if !config.commands.is_empty() {
299 out.push('\n');
300 out.push_str(&format!("{}\n", "COMMANDS:".bold()));
301
302 let col_width = config
303 .commands
304 .iter()
305 .map(|c| c.name.len())
306 .max()
307 .unwrap_or(0)
308 + 4;
309
310 for cmd in &config.commands {
311 out.push_str(&format!(
312 " {} {}\n",
313 Self::pad(&cmd.name, col_width).green(),
314 cmd.description
315 ));
316 }
317 }
318
319 out.push('\n');
321 out.push_str(&format!(
322 "{} '{}' {}\n",
323 "Run".dimmed(),
324 format!("{} --help <command>", config.metadata.prompt).italic(),
325 "for more information on a command.".dimmed()
326 ));
327
328 out
329 }
330
331 fn format_command(&self, config: &CommandsConfig, command: &str) -> String {
353 let Some(cmd) = Self::find_command(config, command) else {
354 let available = config
356 .commands
357 .iter()
358 .map(|c| c.name.as_str())
359 .collect::<Vec<_>>()
360 .join(", ");
361 return format!(
362 "{} '{}'\n\nAvailable commands: {}\n",
363 "Unknown command:".red().bold(),
364 command,
365 available
366 );
367 };
368
369 let mut out = String::new();
370
371 out.push_str(&format!(
373 "{} — {}\n",
374 cmd.name.bold().cyan(),
375 cmd.description
376 ));
377
378 out.push('\n');
380 out.push_str(&format!("{}\n", "USAGE:".bold()));
381 out.push_str(&format!(
382 " {} {}\n",
383 cmd.name.green(),
384 Self::usage_args(cmd)
385 ));
386
387 out.push_str(&Self::format_arguments(cmd));
389 out.push_str(&Self::format_options(cmd));
390 out.push_str(&Self::format_aliases(cmd));
391
392 out
393 }
394}
395
396#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::config::schema::{
404 ArgumentDefinition, ArgumentType, CommandDefinition, Metadata, OptionDefinition,
405 };
406 use std::collections::HashMap;
407
408 fn no_color() {
410 colored::control::set_override(false);
411 }
412
413 fn make_config() -> CommandsConfig {
418 CommandsConfig {
419 metadata: Metadata {
420 version: "1.0.0".to_string(),
421 prompt: "myapp".to_string(),
422 prompt_suffix: " > ".to_string(),
423 },
424 commands: vec![
425 CommandDefinition {
426 name: "hello".to_string(),
427 aliases: vec!["hi".to_string(), "hey".to_string()],
428 description: "Say hello to someone".to_string(),
429 required: false,
430 arguments: vec![ArgumentDefinition {
431 name: "name".to_string(),
432 arg_type: ArgumentType::String,
433 required: true,
434 description: "Name to greet".to_string(),
435 validation: vec![],
436 secure: false,
437 }],
438 options: vec![OptionDefinition {
439 name: "loud".to_string(),
440 short: Some("l".to_string()),
441 long: Some("loud".to_string()),
442 option_type: ArgumentType::Bool,
443 required: false,
444 default: None,
445 description: "Use uppercase".to_string(),
446 choices: vec![],
447 repeatable: false,
448 option_parameters: HashMap::new(),
449 }],
450 implementation: "hello_handler".to_string(),
451 continue_on_failure: false,
452 requires_success: false,
453 },
454 CommandDefinition {
455 name: "process".to_string(),
456 aliases: vec![],
457 description: "Process data files".to_string(),
458 required: true,
459 arguments: vec![],
460 options: vec![],
461 implementation: "process_handler".to_string(),
462 continue_on_failure: false,
463 requires_success: false,
464 },
465 ],
466 global_options: vec![],
467 }
468 }
469
470 fn make_formatter() -> DefaultHelpFormatter {
471 DefaultHelpFormatter::new()
472 }
473
474 #[test]
479 #[allow(clippy::default_constructed_unit_structs)]
484 fn test_new_and_default_are_equivalent() {
485 let _a = DefaultHelpFormatter::new();
487 let _b = DefaultHelpFormatter::default();
488 }
489
490 #[test]
495 fn test_format_app_contains_prompt_and_version() {
496 no_color();
497 let config = make_config();
498 let out = make_formatter().format_app(&config);
499
500 assert!(out.contains("myapp"), "should contain prompt");
501 assert!(out.contains("1.0.0"), "should contain version");
502 }
503
504 #[test]
505 fn test_format_app_contains_all_commands() {
506 no_color();
507 let config = make_config();
508 let out = make_formatter().format_app(&config);
509
510 assert!(out.contains("hello"), "should list command 'hello'");
511 assert!(out.contains("process"), "should list command 'process'");
512 assert!(
513 out.contains("Say hello to someone"),
514 "should include description"
515 );
516 }
517
518 #[test]
519 fn test_format_app_contains_usage_and_footer() {
520 no_color();
521 let config = make_config();
522 let out = make_formatter().format_app(&config);
523
524 assert!(out.contains("USAGE:"), "should have USAGE section");
525 assert!(out.contains("COMMANDS:"), "should have COMMANDS section");
526 assert!(
527 out.contains("--help <command>"),
528 "should hint at per-command help"
529 );
530 }
531
532 #[test]
533 fn test_format_app_empty_commands() {
534 no_color();
535 let mut config = make_config();
536 config.commands.clear();
537 let out = make_formatter().format_app(&config);
538
539 assert!(out.contains("myapp"));
541 assert!(!out.contains("COMMANDS:"));
542 }
543
544 #[test]
549 fn test_format_command_by_name() {
550 no_color();
551 let config = make_config();
552 let out = make_formatter().format_command(&config, "hello");
553
554 assert!(out.contains("hello"), "should contain command name");
555 assert!(
556 out.contains("Say hello to someone"),
557 "should contain description"
558 );
559 }
560
561 #[test]
562 fn test_format_command_by_alias() {
563 no_color();
564 let config = make_config();
565 let out = make_formatter().format_command(&config, "hi");
567
568 assert!(out.contains("hello"));
570 assert!(out.contains("Say hello to someone"));
571 }
572
573 #[test]
574 fn test_format_command_shows_arguments() {
575 no_color();
576 let config = make_config();
577 let out = make_formatter().format_command(&config, "hello");
578
579 assert!(out.contains("ARGUMENTS:"), "should have ARGUMENTS section");
580 assert!(out.contains("name"), "should list argument name");
581 assert!(out.contains("string"), "should show argument type");
582 assert!(out.contains("required"), "should show required status");
583 assert!(out.contains("Name to greet"), "should show description");
584 }
585
586 #[test]
587 fn test_format_command_shows_options() {
588 no_color();
589 let config = make_config();
590 let out = make_formatter().format_command(&config, "hello");
591
592 assert!(out.contains("OPTIONS:"), "should have OPTIONS section");
593 assert!(out.contains("-l"), "should show short flag");
594 assert!(out.contains("--loud"), "should show long flag");
595 assert!(
596 out.contains("Use uppercase"),
597 "should show option description"
598 );
599 }
600
601 #[test]
602 fn test_format_command_shows_aliases() {
603 no_color();
604 let config = make_config();
605 let out = make_formatter().format_command(&config, "hello");
606
607 assert!(out.contains("ALIASES:"), "should have ALIASES section");
608 assert!(out.contains("hi"), "should list alias 'hi'");
609 assert!(out.contains("hey"), "should list alias 'hey'");
610 }
611
612 #[test]
613 fn test_format_command_no_aliases_section_when_empty() {
614 no_color();
615 let config = make_config();
616 let out = make_formatter().format_command(&config, "process");
618
619 assert!(!out.contains("ALIASES:"), "should omit ALIASES section");
620 }
621
622 #[test]
623 fn test_format_command_no_arguments_section_when_empty() {
624 no_color();
625 let config = make_config();
626 let out = make_formatter().format_command(&config, "process");
627
628 assert!(!out.contains("ARGUMENTS:"), "should omit ARGUMENTS section");
629 }
630
631 #[test]
632 fn test_format_command_no_options_section_when_empty() {
633 no_color();
634 let config = make_config();
635 let out = make_formatter().format_command(&config, "process");
636
637 assert!(!out.contains("OPTIONS:"), "should omit OPTIONS section");
638 }
639
640 #[test]
645 fn test_format_command_unknown_returns_error_string() {
646 no_color();
647 let config = make_config();
648 let out = make_formatter().format_command(&config, "nonexistent");
649
650 assert!(
651 out.contains("Unknown command"),
652 "should signal unknown command"
653 );
654 assert!(
655 out.contains("nonexistent"),
656 "should echo the unknown name back"
657 );
658 }
659
660 #[test]
661 fn test_format_command_unknown_lists_available() {
662 no_color();
663 let config = make_config();
664 let out = make_formatter().format_command(&config, "nonexistent");
665
666 assert!(
668 out.contains("hello"),
669 "should list available command 'hello'"
670 );
671 assert!(
672 out.contains("process"),
673 "should list available command 'process'"
674 );
675 }
676
677 #[test]
682 fn test_trait_is_dyn_compatible() {
683 no_color();
684 let formatter: Box<dyn HelpFormatter> = Box::new(DefaultHelpFormatter::new());
686 let config = make_config();
687 let _ = formatter.format_app(&config);
688 }
689
690 #[test]
695 fn test_format_command_shows_default_value() {
696 no_color();
697 let mut config = make_config();
698 config.commands[0].options[0].default = Some("false".to_string());
700 let out = make_formatter().format_command(&config, "hello");
701
702 assert!(out.contains("false"), "should show default value");
703 }
704
705 struct MinimalFormatter;
710
711 impl HelpFormatter for MinimalFormatter {
712 fn format_app(&self, config: &CommandsConfig) -> String {
713 config.metadata.prompt.clone()
714 }
715 fn format_command(&self, _config: &CommandsConfig, command: &str) -> String {
716 command.to_string()
717 }
718 }
719
720 #[test]
721 fn test_custom_formatter_via_trait_object() {
722 let config = make_config();
723 let f: Box<dyn HelpFormatter> = Box::new(MinimalFormatter);
724
725 assert_eq!(f.format_app(&config), "myapp");
726 assert_eq!(f.format_command(&config, "hello"), "hello");
727 }
728}