1use crate::driver::HelpListMode;
7use crate::missing::normalize_program_name;
8use crate::schema::{ArgLevelSchema, ArgSchema, Schema, Subcommand};
9use facet_core::Facet;
10use owo_colors::OwoColorize;
11use owo_colors::Stream::Stdout;
12use std::fmt;
13use std::string::String;
14use std::sync::Arc;
15use std::vec::Vec;
16
17pub fn generate_help<T: Facet<'static>>(config: &HelpConfig) -> String {
22 generate_help_for_shape(T::SHAPE, config)
23}
24
25pub fn generate_help_for_shape(shape: &'static facet_core::Shape, config: &HelpConfig) -> String {
30 let schema = match Schema::from_shape(shape) {
31 Ok(s) => s,
32 Err(_) => {
33 let program_name = config
35 .program_name
36 .clone()
37 .or_else(|| {
38 std::env::args()
39 .next()
40 .map(|path| normalize_program_name(&path))
41 })
42 .unwrap_or_else(|| "program".to_string());
43 return format!(
44 "{}\n\n(Schema could not be built for this type)\n",
45 program_name
46 );
47 }
48 };
49
50 generate_help_for_subcommand(&schema, &[], config)
51}
52
53#[derive(Clone)]
55pub struct HelpConfig {
56 pub program_name: Option<String>,
58 pub version: Option<String>,
60 pub description: Option<String>,
62 pub width: usize,
64 pub include_implementation_source_file: bool,
66 pub implementation_url: Option<Arc<dyn Fn(&str) -> String + Send + Sync>>,
68}
69
70impl fmt::Debug for HelpConfig {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.debug_struct("HelpConfig")
73 .field("program_name", &self.program_name)
74 .field("version", &self.version)
75 .field("description", &self.description)
76 .field("width", &self.width)
77 .field(
78 "include_implementation_source_file",
79 &self.include_implementation_source_file,
80 )
81 .field(
82 "implementation_url",
83 &self.implementation_url.as_ref().map(|_| "<fn>"),
84 )
85 .finish()
86 }
87}
88
89impl Default for HelpConfig {
90 fn default() -> Self {
91 Self {
92 program_name: None,
93 version: None,
94 description: None,
95 width: 80,
96 include_implementation_source_file: false,
97 implementation_url: None,
98 }
99 }
100}
101
102pub(crate) fn implementation_source_for_subcommand_path(
107 root_shape: &'static facet_core::Shape,
108 subcommand_path: &[String],
109) -> Option<&'static str> {
110 let mut current_shape = root_shape;
111
112 if subcommand_path.is_empty() {
113 return current_shape.source_file;
114 }
115
116 for segment in subcommand_path {
117 let next_shape = next_subcommand_shape(current_shape, segment)?;
118 current_shape = next_shape;
119 }
120
121 current_shape.source_file
122}
123
124fn next_subcommand_shape(
125 shape: &'static facet_core::Shape,
126 target_effective_name: &str,
127) -> Option<&'static facet_core::Shape> {
128 let fields = match shape.ty {
129 facet_core::Type::User(facet_core::UserType::Struct(s)) => s.fields,
130 _ => return None,
131 };
132
133 let subcommand_field = fields
134 .iter()
135 .find(|field| field.has_attr(Some("args"), "subcommand"))?;
136
137 let enum_shape = unwrap_option_shape(subcommand_field.shape());
138 let variants = match enum_shape.ty {
139 facet_core::Type::User(facet_core::UserType::Enum(e)) => e.variants,
140 _ => return None,
141 };
142
143 let variant = variants
144 .iter()
145 .find(|variant| variant.effective_name() == target_effective_name)?;
146
147 if variant.data.fields.is_empty() {
148 return Some(enum_shape);
149 }
150
151 let has_direct_subcommand = variant
152 .data
153 .fields
154 .iter()
155 .any(|field| field.has_attr(Some("args"), "subcommand"));
156
157 if has_direct_subcommand {
158 return Some(enum_shape);
159 }
160
161 if variant.data.fields.len() == 1 {
162 return Some(unwrap_option_shape(variant.data.fields[0].shape()));
163 }
164
165 Some(enum_shape)
166}
167
168fn unwrap_option_shape(mut shape: &'static facet_core::Shape) -> &'static facet_core::Shape {
169 while let facet_core::Def::Option(option_def) = shape.def {
170 shape = option_def.t;
171 }
172 shape
173}
174
175pub fn generate_help_for_subcommand(
180 schema: &Schema,
181 subcommand_path: &[String],
182 config: &HelpConfig,
183) -> String {
184 let program_name = config
185 .program_name
186 .clone()
187 .or_else(|| {
188 std::env::args()
189 .next()
190 .map(|path| normalize_program_name(&path))
191 })
192 .unwrap_or_else(|| "program".to_string());
193
194 if subcommand_path.is_empty() {
195 return generate_help_from_schema(schema, &program_name, config);
196 }
197
198 let mut current_args = schema.args();
200 let mut command_path = vec![program_name.clone()];
201
202 for name in subcommand_path {
203 let sub = current_args
206 .subcommands()
207 .values()
208 .find(|s| s.effective_name() == name);
209
210 if let Some(sub) = sub {
211 command_path.push(sub.cli_name().to_string());
212 current_args = sub.args();
213 } else {
214 return generate_help_from_schema(schema, &program_name, config);
216 }
217 }
218
219 let mut final_sub: Option<&Subcommand> = None;
221 let mut args = schema.args();
222
223 for name in subcommand_path {
224 let sub = args
225 .subcommands()
226 .values()
227 .find(|s| s.effective_name() == name);
228 if let Some(sub) = sub {
229 final_sub = Some(sub);
230 args = sub.args();
231 }
232 }
233
234 generate_help_for_subcommand_level(current_args, final_sub, &command_path.join(" "), config)
235}
236
237pub(crate) fn generate_help_list_for_subcommand(
244 schema: &Schema,
245 subcommand_path: &[String],
246 config: &HelpConfig,
247 mode: HelpListMode,
248) -> String {
249 let program_name = config
250 .program_name
251 .clone()
252 .or_else(|| {
253 std::env::args()
254 .next()
255 .map(|path| normalize_program_name(&path))
256 })
257 .unwrap_or_else(|| "program".to_string());
258
259 let mut current_args = schema.args();
260 let mut resolved_path = Vec::new();
261
262 for name in subcommand_path {
263 let sub = current_args
264 .subcommands()
265 .values()
266 .find(|s| s.effective_name() == name);
267
268 let Some(sub) = sub else {
269 return generate_help_for_subcommand(schema, &[], config);
271 };
272
273 resolved_path.push(sub.effective_name().to_string());
274 current_args = sub.args();
275 }
276
277 if !current_args.has_subcommands() {
278 let command_display = if resolved_path.is_empty() {
279 program_name
280 } else {
281 let cli_chain = resolve_cli_chain(schema, &resolved_path);
282 if cli_chain.is_empty() {
283 program_name
284 } else {
285 format!("{} {}", program_name, cli_chain.join(" "))
286 }
287 };
288 return format!("No subcommands available for {command_display}.");
289 }
290
291 match mode {
292 HelpListMode::Short => {
293 let mut cli_chain = if resolved_path.is_empty() {
294 Vec::new()
295 } else {
296 resolve_cli_chain(schema, &resolved_path)
297 };
298 let mut commands = Vec::new();
299 collect_short_help_commands(
300 &mut commands,
301 program_name.as_str(),
302 &mut cli_chain,
303 current_args,
304 );
305 commands.join("\n")
306 }
307 HelpListMode::Full => {
308 let mut sections = Vec::new();
309 let mut leaf_paths = Vec::new();
310 let mut working_path = resolved_path.clone();
311 collect_leaf_subcommand_paths(&mut leaf_paths, &mut working_path, current_args);
312
313 for child_path in leaf_paths {
314 sections.push(generate_help_for_subcommand(schema, &child_path, config));
315 }
316 sections.join("\n\n")
317 }
318 }
319}
320
321fn collect_leaf_subcommand_paths(
322 leaf_paths: &mut Vec<Vec<String>>,
323 current_path: &mut Vec<String>,
324 args: &ArgLevelSchema,
325) {
326 if !args.has_subcommands() {
327 if !current_path.is_empty() {
328 leaf_paths.push(current_path.clone());
329 }
330 return;
331 }
332
333 for sub in args.subcommands().values() {
334 current_path.push(sub.effective_name().to_string());
335 collect_leaf_subcommand_paths(leaf_paths, current_path, sub.args());
336 current_path.pop();
337 }
338}
339
340fn collect_short_help_commands(
341 commands: &mut Vec<String>,
342 program_name: &str,
343 cli_chain: &mut Vec<String>,
344 args: &ArgLevelSchema,
345) {
346 if args.subcommand_optional() {
347 if cli_chain.is_empty() {
348 commands.push(program_name.to_string());
349 } else {
350 commands.push(format!("{} {}", program_name, cli_chain.join(" ")));
351 }
352 }
353
354 if !args.has_subcommands() {
355 if !cli_chain.is_empty() {
356 commands.push(format!("{} {}", program_name, cli_chain.join(" ")));
357 }
358 return;
359 }
360
361 for sub in args.subcommands().values() {
362 cli_chain.push(sub.cli_name().to_string());
363 collect_short_help_commands(commands, program_name, cli_chain, sub.args());
364 cli_chain.pop();
365 }
366}
367
368fn resolve_cli_chain(schema: &Schema, subcommand_path: &[String]) -> Vec<String> {
369 let mut current_args = schema.args();
370 let mut cli_path = Vec::new();
371
372 for name in subcommand_path {
373 let sub = current_args
374 .subcommands()
375 .values()
376 .find(|s| s.effective_name() == name);
377 let Some(sub) = sub else {
378 break;
379 };
380 cli_path.push(sub.cli_name().to_string());
381 current_args = sub.args();
382 }
383
384 cli_path
385}
386
387fn generate_help_from_schema(schema: &Schema, program_name: &str, config: &HelpConfig) -> String {
389 let mut out = String::new();
390
391 if let Some(version) = &config.version {
393 out.push_str(&format!("{program_name} {version}\n"));
394 } else {
395 out.push_str(&format!("{program_name}\n"));
396 }
397
398 if let Some(summary) = schema.docs().summary() {
400 out.push('\n');
401 out.push_str(summary.trim());
402 out.push('\n');
403 }
404 if let Some(details) = schema.docs().details() {
405 for line in details.lines() {
406 out.push_str(line.trim());
407 out.push('\n');
408 }
409 }
410
411 if let Some(desc) = &config.description {
413 out.push('\n');
414 out.push_str(desc);
415 out.push('\n');
416 }
417
418 out.push('\n');
419
420 generate_arg_level_help(&mut out, schema.args(), program_name);
421
422 out
423}
424
425fn generate_help_for_subcommand_level(
427 args: &ArgLevelSchema,
428 subcommand: Option<&Subcommand>,
429 full_command: &str,
430 config: &HelpConfig,
431) -> String {
432 let mut out = String::new();
433
434 out.push_str(&format!("{full_command}\n"));
436
437 if let Some(sub) = subcommand {
439 if let Some(summary) = sub.docs().summary() {
440 out.push('\n');
441 out.push_str(summary.trim());
442 out.push('\n');
443 }
444 if let Some(details) = sub.docs().details() {
445 for line in details.lines() {
446 out.push_str(line.trim());
447 out.push('\n');
448 }
449 }
450 }
451
452 if let Some(desc) = &config.description {
454 out.push('\n');
455 out.push_str(desc);
456 out.push('\n');
457 }
458
459 out.push('\n');
460
461 generate_arg_level_help(&mut out, args, full_command);
462
463 out
464}
465
466fn generate_arg_level_help(out: &mut String, args: &ArgLevelSchema, program_name: &str) {
468 let mut positionals: Vec<&ArgSchema> = Vec::new();
470 let mut flags: Vec<&ArgSchema> = Vec::new();
471
472 for (_name, arg) in args.args().iter() {
473 if arg.kind().is_positional() {
474 positionals.push(arg);
475 } else {
476 flags.push(arg);
477 }
478 }
479
480 out.push_str(&format!("{}:\n ", "USAGE".yellow().bold()));
482 out.push_str(program_name);
483
484 if !flags.is_empty() {
485 out.push_str(" [OPTIONS]");
486 }
487
488 for pos in &positionals {
489 let name = pos.name().to_uppercase();
490 if pos.required() {
491 out.push_str(&format!(" <{name}>"));
492 } else {
493 out.push_str(&format!(" [{name}]"));
494 }
495 }
496
497 if args.has_subcommands() {
498 if args.subcommand_optional() {
499 out.push_str(" [COMMAND]");
500 } else {
501 out.push_str(" <COMMAND>");
502 }
503 }
504
505 out.push_str("\n\n");
506
507 if !positionals.is_empty() {
509 out.push_str(&format!("{}:\n", "ARGUMENTS".yellow().bold()));
510 for arg in &positionals {
511 write_arg_help(out, arg);
512 }
513 out.push('\n');
514 }
515
516 if !flags.is_empty() {
518 out.push_str(&format!("{}:\n", "OPTIONS".yellow().bold()));
519 for arg in &flags {
520 write_arg_help(out, arg);
521 }
522 out.push('\n');
523 }
524
525 if args.has_subcommands() {
527 out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
528 for sub in args.subcommands().values() {
529 write_subcommand_help(out, sub);
530 }
531 out.push('\n');
532 }
533}
534
535fn write_arg_help(out: &mut String, arg: &ArgSchema) {
537 out.push_str(" ");
538
539 let is_positional = arg.kind().is_positional();
540
541 if let Some(c) = arg.kind().short() {
543 out.push_str(&format!(
544 "{}, ",
545 format!("-{c}").if_supports_color(Stdout, |text| text.green())
546 ));
547 } else {
548 out.push_str(" ");
550 }
551
552 let name = arg.name();
554 let is_counted = arg.kind().is_counted();
555
556 if is_positional {
557 out.push_str(&format!(
558 "{}",
559 format!("<{}>", name.to_uppercase()).if_supports_color(Stdout, |text| text.green())
560 ));
561 } else {
562 out.push_str(&format!(
563 "{}",
564 format!("--{name}").if_supports_color(Stdout, |text| text.green())
565 ));
566
567 if !is_counted && !arg.value().is_bool() {
569 let placeholder = if let Some(desc) = arg.label() {
570 desc.to_uppercase()
571 } else if let Some(variants) = arg.value().inner_if_option().enum_variants() {
572 variants.join(",")
573 } else {
574 arg.value().type_identifier().to_uppercase()
575 };
576 out.push_str(&format!(" <{}>", placeholder));
577 }
578 }
579
580 if let Some(summary) = arg.docs().summary() {
582 out.push_str("\n ");
583 out.push_str(summary.trim());
584 }
585
586 if is_counted {
587 out.push_str("\n ");
588 out.push_str("[can be repeated]");
589 }
590
591 out.push('\n');
592}
593
594fn write_subcommand_help(out: &mut String, sub: &Subcommand) {
596 out.push_str(" ");
597
598 out.push_str(&format!(
599 "{}",
600 sub.cli_name()
601 .if_supports_color(Stdout, |text| text.green())
602 ));
603
604 if let Some(summary) = sub.docs().summary() {
606 out.push_str("\n ");
607 out.push_str(summary.trim());
608 }
609
610 out.push('\n');
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use facet::Facet;
617 use figue_attrs as args;
618
619 #[derive(Facet)]
621 struct CommonArgs {
622 #[facet(args::named, crate::short = 'v')]
624 verbose: bool,
625
626 #[facet(args::named, crate::short = 'q')]
628 quiet: bool,
629 }
630
631 #[derive(Facet)]
633 struct ArgsWithFlatten {
634 #[facet(args::positional)]
636 input: String,
637
638 #[facet(flatten)]
640 common: CommonArgs,
641 }
642
643 #[test]
644 fn test_flatten_args_appear_in_help() {
645 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
646 let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
647
648 assert!(
650 help.contains("--verbose"),
651 "help should contain --verbose from flattened CommonArgs"
652 );
653 assert!(help.contains("-v"), "help should contain -v short flag");
654 assert!(
655 help.contains("--quiet"),
656 "help should contain --quiet from flattened CommonArgs"
657 );
658 assert!(help.contains("-q"), "help should contain -q short flag");
659
660 assert!(
662 !help.contains("--common"),
663 "help should not show --common as a flag"
664 );
665 }
666
667 #[test]
668 fn test_flatten_docs_preserved() {
669 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
670 let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
671
672 assert!(
674 help.contains("verbose output"),
675 "help should contain verbose field doc"
676 );
677 assert!(
678 help.contains("quiet mode"),
679 "help should contain quiet field doc"
680 );
681 }
682
683 #[derive(Facet)]
685 struct ServeArgs {
686 #[facet(args::named)]
688 port: u16,
689
690 #[facet(args::named)]
692 host: String,
693 }
694
695 #[derive(Facet)]
697 struct TupleVariantArgs {
698 #[facet(args::subcommand)]
700 command: Option<TupleVariantCommand>,
701 }
702
703 #[derive(Facet)]
705 #[repr(u8)]
706 #[allow(dead_code)]
707 enum TupleVariantCommand {
708 Serve(ServeArgs),
710 }
711
712 #[test]
713 fn test_label_overrides_placeholder() {
714 #[derive(Facet)]
715 struct TDArgs {
716 #[facet(args::named, args::label = "PATH")]
718 input: std::path::PathBuf,
719 }
720 let schema = Schema::from_shape(TDArgs::SHAPE).unwrap();
721 let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
722 assert!(
724 help.contains("<PATH>"),
725 "help should use custom label placeholder"
726 );
727 }
728
729 #[test]
730 fn test_tuple_variant_fields_not_shown_as_option() {
731 let schema = Schema::from_shape(TupleVariantArgs::SHAPE).unwrap();
732 let help =
734 generate_help_for_subcommand(&schema, &["Serve".to_string()], &HelpConfig::default());
735
736 assert!(
738 help.contains("--port"),
739 "help should contain --port from ServeArgs"
740 );
741 assert!(
742 help.contains("--host"),
743 "help should contain --host from ServeArgs"
744 );
745
746 assert!(
748 !help.contains("--0"),
749 "help should NOT show --0 for tuple variant wrapper field"
750 );
751 assert!(
752 !help.contains("SERVEARGS"),
753 "help should NOT show SERVEARGS as an option value"
754 );
755 }
756
757 #[derive(Facet)]
758 struct NestedRootArgs {
759 #[facet(args::subcommand)]
760 command: NestedRootCommand,
761 }
762
763 #[derive(Facet)]
764 #[repr(u8)]
765 #[allow(dead_code)]
766 enum NestedRootCommand {
767 Home(NestedHomeArgs),
768 Cache(NestedCacheArgs),
769 }
770
771 #[derive(Facet)]
772 struct NestedHomeArgs {
773 #[facet(args::subcommand)]
774 command: NestedHomeCommand,
775 }
776
777 #[derive(Facet)]
778 #[repr(u8)]
779 #[allow(dead_code)]
780 enum NestedHomeCommand {
781 Open,
782 Show,
783 }
784
785 #[derive(Facet)]
786 struct NestedCacheArgs {
787 #[facet(args::subcommand)]
788 command: NestedCacheCommand,
789 }
790
791 #[derive(Facet)]
792 #[repr(u8)]
793 #[allow(dead_code)]
794 enum NestedCacheCommand {
795 Open,
796 Show,
797 }
798
799 #[test]
800 fn test_help_list_short_is_recursive_with_full_command_paths() {
801 let schema = Schema::from_shape(NestedRootArgs::SHAPE).unwrap();
802 let output = generate_help_list_for_subcommand(
803 &schema,
804 &[],
805 &HelpConfig {
806 program_name: Some("myapp".to_string()),
807 ..HelpConfig::default()
808 },
809 HelpListMode::Short,
810 );
811
812 let lines: Vec<&str> = output.lines().collect();
813 assert_eq!(
814 lines,
815 vec![
816 "myapp home open",
817 "myapp home show",
818 "myapp cache open",
819 "myapp cache show"
820 ]
821 );
822 }
823
824 #[test]
825 fn test_help_list_full_is_recursive_for_leaf_subcommands() {
826 let schema = Schema::from_shape(NestedRootArgs::SHAPE).unwrap();
827 let output = generate_help_list_for_subcommand(
828 &schema,
829 &[],
830 &HelpConfig {
831 program_name: Some("myapp".to_string()),
832 ..HelpConfig::default()
833 },
834 HelpListMode::Full,
835 );
836
837 assert!(output.contains("myapp home open"));
838 assert!(output.contains("myapp home show"));
839 assert!(output.contains("myapp cache open"));
840 assert!(output.contains("myapp cache show"));
841 assert!(!output.contains("myapp home\n\n"));
842 assert!(!output.contains("myapp cache\n\n"));
843 }
844}