Skip to main content

figue/
help.rs

1//! Help text generation for command-line interfaces.
2//!
3//! This module provides utilities to generate help text from Schema,
4//! including doc comments, field names, and attribute information.
5
6use 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
17/// Generate help text for a Facet type.
18///
19/// This is a convenience function that builds a Schema internally.
20/// If you already have a Schema, use `generate_help_for_subcommand` instead.
21pub fn generate_help<T: Facet<'static>>(config: &HelpConfig) -> String {
22    generate_help_for_shape(T::SHAPE, config)
23}
24
25/// Generate help text from a Shape.
26///
27/// This is a convenience function that builds a Schema internally.
28/// If you already have a Schema, use `generate_help_for_subcommand` instead.
29pub 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            // Fall back to a minimal help message
34            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/// Configuration for help text generation.
54#[derive(Clone)]
55pub struct HelpConfig {
56    /// Program name (defaults to executable name)
57    pub program_name: Option<String>,
58    /// Program version
59    pub version: Option<String>,
60    /// Additional description to show after the auto-generated one
61    pub description: Option<String>,
62    /// Width for wrapping text (0 = no wrapping)
63    pub width: usize,
64    /// Whether to include implementation source file information in help output.
65    pub include_implementation_source_file: bool,
66    /// Optional callback to render an implementation URL from a source file path.
67    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
102/// Resolve implementation source file for a subcommand path from a root shape.
103///
104/// The `subcommand_path` should contain effective subcommand names (as emitted by
105/// `ConfigValue::extract_subcommand_path`). An empty path resolves to the root shape.
106pub(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
175/// Generate help text for a specific subcommand path from a Schema.
176///
177/// `subcommand_path` is a list of subcommand names (e.g., `["repo", "clone"]` for `myapp repo clone --help`).
178/// This navigates through the schema to find the target subcommand and generates help for it.
179pub 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    // Navigate to the subcommand
199    let mut current_args = schema.args();
200    let mut command_path = vec![program_name.clone()];
201    let mut inherited_flags = Vec::<ArgSchema>::new();
202
203    for name in subcommand_path {
204        merge_named_flags(&mut inherited_flags, current_args);
205
206        // The path contains effective names (e.g., "Clone", "rm") from ConfigValue.
207        // Look up by effective_name since that's what's stored in the path.
208        let sub = current_args
209            .subcommands()
210            .values()
211            .find(|s| s.effective_name() == name);
212
213        if let Some(sub) = sub {
214            command_path.push(sub.cli_name().to_string());
215            current_args = sub.args();
216        } else {
217            // Subcommand not found, fall back to root help
218            return generate_help_from_schema(schema, &program_name, config);
219        }
220    }
221
222    remove_shadowed_named_flags(&mut inherited_flags, current_args);
223
224    // Find the final subcommand to get its docs
225    let mut final_sub: Option<&Subcommand> = None;
226    let mut args = schema.args();
227
228    for name in subcommand_path {
229        let sub = args
230            .subcommands()
231            .values()
232            .find(|s| s.effective_name() == name);
233        if let Some(sub) = sub {
234            final_sub = Some(sub);
235            args = sub.args();
236        }
237    }
238
239    generate_help_for_subcommand_level(
240        current_args,
241        final_sub,
242        &command_path.join(" "),
243        &inherited_flags,
244        config,
245    )
246}
247
248/// Generate help-list output for subcommands at the current command level.
249///
250/// In [`HelpListMode::Short`], this returns one full CLI command path per line,
251/// recursively listing all reachable leaf commands.
252/// In [`HelpListMode::Full`], this returns concatenated help output for each
253/// reachable leaf subcommand under the current command path.
254pub(crate) fn generate_help_list_for_subcommand(
255    schema: &Schema,
256    subcommand_path: &[String],
257    config: &HelpConfig,
258    mode: HelpListMode,
259) -> String {
260    let program_name = config
261        .program_name
262        .clone()
263        .or_else(|| {
264            std::env::args()
265                .next()
266                .map(|path| normalize_program_name(&path))
267        })
268        .unwrap_or_else(|| "program".to_string());
269
270    let mut current_args = schema.args();
271    let mut resolved_path = Vec::new();
272
273    for name in subcommand_path {
274        let sub = current_args
275            .subcommands()
276            .values()
277            .find(|s| s.effective_name() == name);
278
279        let Some(sub) = sub else {
280            // Fall back to regular root help if the path cannot be resolved.
281            return generate_help_for_subcommand(schema, &[], config);
282        };
283
284        resolved_path.push(sub.effective_name().to_string());
285        current_args = sub.args();
286    }
287
288    if !current_args.has_subcommands() {
289        let command_display = if resolved_path.is_empty() {
290            program_name
291        } else {
292            let cli_chain = resolve_cli_chain(schema, &resolved_path);
293            if cli_chain.is_empty() {
294                program_name
295            } else {
296                format!("{} {}", program_name, cli_chain.join(" "))
297            }
298        };
299        return format!("No subcommands available for {command_display}.");
300    }
301
302    match mode {
303        HelpListMode::Short => {
304            let mut cli_chain = if resolved_path.is_empty() {
305                Vec::new()
306            } else {
307                resolve_cli_chain(schema, &resolved_path)
308            };
309            let mut commands = Vec::new();
310            collect_short_help_commands(
311                &mut commands,
312                program_name.as_str(),
313                &mut cli_chain,
314                current_args,
315            );
316            commands.join("\n")
317        }
318        HelpListMode::Full => {
319            let mut sections = Vec::new();
320            let mut leaf_paths = Vec::new();
321            let mut working_path = resolved_path.clone();
322            collect_leaf_subcommand_paths(&mut leaf_paths, &mut working_path, current_args);
323
324            for child_path in leaf_paths {
325                sections.push(generate_help_for_subcommand(schema, &child_path, config));
326            }
327            sections.join("\n\n")
328        }
329    }
330}
331
332fn collect_leaf_subcommand_paths(
333    leaf_paths: &mut Vec<Vec<String>>,
334    current_path: &mut Vec<String>,
335    args: &ArgLevelSchema,
336) {
337    if !args.has_subcommands() {
338        if !current_path.is_empty() {
339            leaf_paths.push(current_path.clone());
340        }
341        return;
342    }
343
344    for sub in args.subcommands().values() {
345        current_path.push(sub.effective_name().to_string());
346        collect_leaf_subcommand_paths(leaf_paths, current_path, sub.args());
347        current_path.pop();
348    }
349}
350
351fn collect_short_help_commands(
352    commands: &mut Vec<String>,
353    program_name: &str,
354    cli_chain: &mut Vec<String>,
355    args: &ArgLevelSchema,
356) {
357    if args.subcommand_optional() {
358        if cli_chain.is_empty() {
359            commands.push(program_name.to_string());
360        } else {
361            commands.push(format!("{} {}", program_name, cli_chain.join(" ")));
362        }
363    }
364
365    if !args.has_subcommands() {
366        if !cli_chain.is_empty() {
367            commands.push(format!("{} {}", program_name, cli_chain.join(" ")));
368        }
369        return;
370    }
371
372    for sub in args.subcommands().values() {
373        cli_chain.push(sub.cli_name().to_string());
374        collect_short_help_commands(commands, program_name, cli_chain, sub.args());
375        cli_chain.pop();
376    }
377}
378
379fn resolve_cli_chain(schema: &Schema, subcommand_path: &[String]) -> Vec<String> {
380    let mut current_args = schema.args();
381    let mut cli_path = Vec::new();
382
383    for name in subcommand_path {
384        let sub = current_args
385            .subcommands()
386            .values()
387            .find(|s| s.effective_name() == name);
388        let Some(sub) = sub else {
389            break;
390        };
391        cli_path.push(sub.cli_name().to_string());
392        current_args = sub.args();
393    }
394
395    cli_path
396}
397
398/// Generate help from a built Schema.
399fn generate_help_from_schema(schema: &Schema, program_name: &str, config: &HelpConfig) -> String {
400    let mut out = String::new();
401
402    // Program name and version
403    if let Some(version) = &config.version {
404        out.push_str(&format!("{program_name} {version}\n"));
405    } else {
406        out.push_str(&format!("{program_name}\n"));
407    }
408
409    // Type doc comment from schema
410    if let Some(summary) = schema.docs().summary() {
411        out.push('\n');
412        out.push_str(summary.trim());
413        out.push('\n');
414    }
415    if let Some(details) = schema.docs().details() {
416        for line in details.lines() {
417            out.push_str(line.trim());
418            out.push('\n');
419        }
420    }
421
422    // Additional description
423    if let Some(desc) = &config.description {
424        out.push('\n');
425        out.push_str(desc);
426        out.push('\n');
427    }
428
429    out.push('\n');
430
431    generate_arg_level_help(&mut out, schema.args(), program_name, &[]);
432
433    out
434}
435
436/// Generate help for a subcommand level.
437fn generate_help_for_subcommand_level(
438    args: &ArgLevelSchema,
439    subcommand: Option<&Subcommand>,
440    full_command: &str,
441    inherited_flags: &[ArgSchema],
442    config: &HelpConfig,
443) -> String {
444    let mut out = String::new();
445
446    // Header with full command
447    out.push_str(&format!("{full_command}\n"));
448
449    // Doc comment for the subcommand
450    if let Some(sub) = subcommand {
451        if let Some(summary) = sub.docs().summary() {
452            out.push('\n');
453            out.push_str(summary.trim());
454            out.push('\n');
455        }
456        if let Some(details) = sub.docs().details() {
457            for line in details.lines() {
458                out.push_str(line.trim());
459                out.push('\n');
460            }
461        }
462    }
463
464    // Additional description from config
465    if let Some(desc) = &config.description {
466        out.push('\n');
467        out.push_str(desc);
468        out.push('\n');
469    }
470
471    out.push('\n');
472
473    generate_arg_level_help(&mut out, args, full_command, inherited_flags);
474
475    out
476}
477
478/// Generate help output for an argument level (args + subcommands).
479fn generate_arg_level_help(
480    out: &mut String,
481    args: &ArgLevelSchema,
482    program_name: &str,
483    inherited_flags: &[ArgSchema],
484) {
485    // Separate positionals and named flags
486    let mut positionals: Vec<&ArgSchema> = Vec::new();
487    let mut flags = inherited_flags.to_vec();
488
489    for (_name, arg) in args.args().iter() {
490        if arg.kind().is_positional() {
491            positionals.push(arg);
492        } else {
493            flags.push(arg.clone());
494        }
495    }
496
497    // Usage line
498    out.push_str(&format!("{}:\n    ", "USAGE".yellow().bold()));
499    out.push_str(program_name);
500
501    if !flags.is_empty() {
502        out.push_str(" [OPTIONS]");
503    }
504
505    for pos in &positionals {
506        let name = pos.name().to_uppercase();
507        if pos.required() {
508            out.push_str(&format!(" <{name}>"));
509        } else {
510            out.push_str(&format!(" [{name}]"));
511        }
512    }
513
514    if args.has_subcommands() {
515        if args.subcommand_optional() {
516            out.push_str(" [COMMAND]");
517        } else {
518            out.push_str(" <COMMAND>");
519        }
520    }
521
522    out.push_str("\n\n");
523
524    // Positional arguments
525    if !positionals.is_empty() {
526        out.push_str(&format!("{}:\n", "ARGUMENTS".yellow().bold()));
527        for arg in &positionals {
528            write_arg_help(out, arg);
529        }
530        out.push('\n');
531    }
532
533    // Options
534    if !flags.is_empty() {
535        out.push_str(&format!("{}:\n", "OPTIONS".yellow().bold()));
536        for arg in &flags {
537            write_arg_help(out, arg);
538        }
539        out.push('\n');
540    }
541
542    // Subcommands
543    if args.has_subcommands() {
544        out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
545        for sub in args.subcommands().values() {
546            write_subcommand_help(out, sub);
547        }
548        out.push('\n');
549    }
550}
551
552fn merge_named_flags(flags: &mut Vec<ArgSchema>, args: &ArgLevelSchema) {
553    for (_name, arg) in args.args().iter() {
554        if arg.kind().is_positional() {
555            continue;
556        }
557
558        flags.retain(|existing| !arg_schemas_conflict(existing, arg));
559        flags.push(arg.clone());
560    }
561}
562
563fn remove_shadowed_named_flags(flags: &mut Vec<ArgSchema>, args: &ArgLevelSchema) {
564    let local_flags = args
565        .args()
566        .iter()
567        .map(|(_name, arg)| arg)
568        .filter(|arg| !arg.kind().is_positional())
569        .collect::<Vec<_>>();
570
571    flags.retain(|existing| {
572        !local_flags
573            .iter()
574            .any(|local| arg_schemas_conflict(existing, local))
575    });
576}
577
578fn arg_schemas_conflict(left: &ArgSchema, right: &ArgSchema) -> bool {
579    left.name() == right.name()
580        || matches!(
581            (left.kind().short(), right.kind().short()),
582            (Some(left_short), Some(right_short)) if left_short == right_short
583        )
584}
585
586/// Write help for a single argument.
587fn write_arg_help(out: &mut String, arg: &ArgSchema) {
588    out.push_str("    ");
589
590    let is_positional = arg.kind().is_positional();
591
592    // Short flag (or spacing for alignment)
593    if let Some(c) = arg.kind().short() {
594        out.push_str(&format!(
595            "{}, ",
596            format!("-{c}").if_supports_color(Stdout, |text| text.green())
597        ));
598    } else {
599        // Add spacing to align with flags that have short options
600        out.push_str("    ");
601    }
602
603    // Long flag or positional name
604    let name = arg.name();
605    let is_counted = arg.kind().is_counted();
606
607    if is_positional {
608        out.push_str(&format!(
609            "{}",
610            format!("<{}>", name.to_uppercase()).if_supports_color(Stdout, |text| text.green())
611        ));
612    } else {
613        out.push_str(&format!(
614            "{}",
615            format!("--{name}").if_supports_color(Stdout, |text| text.green())
616        ));
617
618        // Show value placeholder for non-bool, non-counted types
619        if !is_counted && !arg.value().is_bool() {
620            let placeholder = if let Some(desc) = arg.label() {
621                desc.to_uppercase()
622            } else if let Some(variants) = arg.value().inner_if_option().enum_variants() {
623                variants.join(",")
624            } else {
625                arg.value().type_identifier().to_uppercase()
626            };
627            out.push_str(&format!(" <{}>", placeholder));
628        }
629    }
630
631    // Doc comment
632    if let Some(summary) = arg.docs().summary() {
633        out.push_str("\n            ");
634        out.push_str(summary.trim());
635    }
636
637    if is_counted {
638        out.push_str("\n            ");
639        out.push_str("[can be repeated]");
640    }
641
642    out.push('\n');
643}
644
645/// Write help for a subcommand.
646fn write_subcommand_help(out: &mut String, sub: &Subcommand) {
647    out.push_str("    ");
648
649    out.push_str(&format!(
650        "{}",
651        sub.cli_name()
652            .if_supports_color(Stdout, |text| text.green())
653    ));
654
655    // Doc comment
656    if let Some(summary) = sub.docs().summary() {
657        out.push_str("\n            ");
658        out.push_str(summary.trim());
659    }
660
661    out.push('\n');
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use facet::Facet;
668    use figue_attrs as args;
669
670    /// Common arguments that can be flattened into other structs
671    #[derive(Facet)]
672    struct CommonArgs {
673        /// Enable verbose output
674        #[facet(args::named, crate::short = 'v')]
675        verbose: bool,
676
677        /// Enable quiet mode
678        #[facet(args::named, crate::short = 'q')]
679        quiet: bool,
680    }
681
682    /// Args struct with flattened common args
683    #[derive(Facet)]
684    struct ArgsWithFlatten {
685        /// Input file
686        #[facet(args::positional)]
687        input: String,
688
689        /// Common options
690        #[facet(flatten)]
691        common: CommonArgs,
692    }
693
694    #[test]
695    fn test_flatten_args_appear_in_help() {
696        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
697        let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
698
699        // Flattened fields should appear at top level
700        assert!(
701            help.contains("--verbose"),
702            "help should contain --verbose from flattened CommonArgs"
703        );
704        assert!(help.contains("-v"), "help should contain -v short flag");
705        assert!(
706            help.contains("--quiet"),
707            "help should contain --quiet from flattened CommonArgs"
708        );
709        assert!(help.contains("-q"), "help should contain -q short flag");
710
711        // The flattened field name 'common' should NOT appear as a flag
712        assert!(
713            !help.contains("--common"),
714            "help should not show --common as a flag"
715        );
716    }
717
718    #[test]
719    fn test_flatten_docs_preserved() {
720        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
721        let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
722
723        // Doc comments from flattened fields should be present
724        assert!(
725            help.contains("verbose output"),
726            "help should contain verbose field doc"
727        );
728        assert!(
729            help.contains("quiet mode"),
730            "help should contain quiet field doc"
731        );
732    }
733
734    /// Arguments for the serve subcommand
735    #[derive(Facet)]
736    struct ServeArgs {
737        /// Port to serve on
738        #[facet(args::named)]
739        port: u16,
740
741        /// Host to bind to
742        #[facet(args::named)]
743        host: String,
744    }
745
746    /// Top-level command with tuple variant subcommand
747    #[derive(Facet)]
748    struct TupleVariantArgs {
749        /// Subcommand to run
750        #[facet(args::subcommand)]
751        command: Option<TupleVariantCommand>,
752    }
753
754    /// Command enum with tuple variant
755    #[derive(Facet)]
756    #[repr(u8)]
757    #[allow(dead_code)]
758    enum TupleVariantCommand {
759        /// Start the server
760        Serve(ServeArgs),
761    }
762
763    #[test]
764    fn test_label_overrides_placeholder() {
765        #[derive(Facet)]
766        struct TDArgs {
767            /// Input path
768            #[facet(args::named, args::label = "PATH")]
769            input: std::path::PathBuf,
770        }
771        let schema = Schema::from_shape(TDArgs::SHAPE).unwrap();
772        let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
773        // Only assert on the placeholder to avoid issues with ANSI color codes around the flag name
774        assert!(
775            help.contains("<PATH>"),
776            "help should use custom label placeholder"
777        );
778    }
779
780    #[test]
781    fn test_tuple_variant_fields_not_shown_as_option() {
782        let schema = Schema::from_shape(TupleVariantArgs::SHAPE).unwrap();
783        // Path contains effective names (e.g., "Serve" not "serve")
784        let help =
785            generate_help_for_subcommand(&schema, &["Serve".to_string()], &HelpConfig::default());
786
787        // The inner struct's fields should appear
788        assert!(
789            help.contains("--port"),
790            "help should contain --port from ServeArgs"
791        );
792        assert!(
793            help.contains("--host"),
794            "help should contain --host from ServeArgs"
795        );
796
797        // The tuple field "0" should NOT appear as --0
798        assert!(
799            !help.contains("--0"),
800            "help should NOT show --0 for tuple variant wrapper field"
801        );
802        assert!(
803            !help.contains("SERVEARGS"),
804            "help should NOT show SERVEARGS as an option value"
805        );
806    }
807
808    #[derive(Facet)]
809    struct NestedRootArgs {
810        #[facet(args::subcommand)]
811        command: NestedRootCommand,
812    }
813
814    #[derive(Facet)]
815    #[repr(u8)]
816    #[allow(dead_code)]
817    enum NestedRootCommand {
818        Home(NestedHomeArgs),
819        Cache(NestedCacheArgs),
820    }
821
822    #[derive(Facet)]
823    struct NestedHomeArgs {
824        #[facet(args::subcommand)]
825        command: NestedHomeCommand,
826    }
827
828    #[derive(Facet)]
829    #[repr(u8)]
830    #[allow(dead_code)]
831    enum NestedHomeCommand {
832        Open,
833        Show,
834    }
835
836    #[derive(Facet)]
837    struct NestedCacheArgs {
838        #[facet(args::subcommand)]
839        command: NestedCacheCommand,
840    }
841
842    #[derive(Facet)]
843    #[repr(u8)]
844    #[allow(dead_code)]
845    enum NestedCacheCommand {
846        Open,
847        Show,
848    }
849
850    #[test]
851    fn test_help_list_short_is_recursive_with_full_command_paths() {
852        let schema = Schema::from_shape(NestedRootArgs::SHAPE).unwrap();
853        let output = generate_help_list_for_subcommand(
854            &schema,
855            &[],
856            &HelpConfig {
857                program_name: Some("myapp".to_string()),
858                ..HelpConfig::default()
859            },
860            HelpListMode::Short,
861        );
862
863        let lines: Vec<&str> = output.lines().collect();
864        assert_eq!(
865            lines,
866            vec![
867                "myapp home open",
868                "myapp home show",
869                "myapp cache open",
870                "myapp cache show"
871            ]
872        );
873    }
874
875    #[test]
876    fn test_help_list_full_is_recursive_for_leaf_subcommands() {
877        let schema = Schema::from_shape(NestedRootArgs::SHAPE).unwrap();
878        let output = generate_help_list_for_subcommand(
879            &schema,
880            &[],
881            &HelpConfig {
882                program_name: Some("myapp".to_string()),
883                ..HelpConfig::default()
884            },
885            HelpListMode::Full,
886        );
887
888        assert!(output.contains("myapp home open"));
889        assert!(output.contains("myapp home show"));
890        assert!(output.contains("myapp cache open"));
891        assert!(output.contains("myapp cache show"));
892        assert!(!output.contains("myapp home\n\n"));
893        assert!(!output.contains("myapp cache\n\n"));
894    }
895
896    #[derive(Facet)]
897    struct InheritedHelpRootArgs {
898        /// Enable debug output
899        #[facet(args::named, crate::short = 'd')]
900        debug: bool,
901
902        #[facet(args::subcommand)]
903        command: InheritedHelpRootCommand,
904    }
905
906    #[derive(Facet)]
907    #[repr(u8)]
908    #[allow(dead_code)]
909    enum InheritedHelpRootCommand {
910        Repo(InheritedHelpRepoArgs),
911    }
912
913    #[derive(Facet)]
914    struct InheritedHelpRepoArgs {
915        /// Enable quiet mode
916        #[facet(args::named, crate::short = 'q')]
917        quiet: bool,
918
919        #[facet(args::subcommand)]
920        command: InheritedHelpRepoCommand,
921    }
922
923    #[derive(Facet)]
924    #[repr(u8)]
925    #[allow(dead_code)]
926    enum InheritedHelpRepoCommand {
927        Clone {
928            /// Repository URL
929            #[facet(args::positional)]
930            url: String,
931        },
932    }
933
934    #[test]
935    fn test_nested_subcommand_help_includes_inherited_parent_flags() {
936        let schema = Schema::from_shape(InheritedHelpRootArgs::SHAPE).unwrap();
937        let help = generate_help_for_subcommand(
938            &schema,
939            &["Repo".to_string(), "Clone".to_string()],
940            &HelpConfig {
941                program_name: Some("myapp".to_string()),
942                ..HelpConfig::default()
943            },
944        );
945
946        assert!(help.contains("myapp repo clone [OPTIONS] <URL>"));
947        assert!(help.contains("--debug"));
948        assert!(help.contains("--quiet"));
949        assert!(help.contains("<URL>"));
950    }
951
952    #[derive(Facet)]
953    struct ShadowedHelpRootArgs {
954        /// Root verbose flag
955        #[facet(args::named, crate::short = 'v')]
956        verbose: bool,
957
958        #[facet(args::subcommand)]
959        command: ShadowedHelpCommand,
960    }
961
962    #[derive(Facet)]
963    #[repr(u8)]
964    #[allow(dead_code)]
965    enum ShadowedHelpCommand {
966        Run(ShadowedHelpRunArgs),
967    }
968
969    #[derive(Facet)]
970    struct ShadowedHelpRunArgs {
971        /// Local verbose flag
972        #[facet(args::named, crate::short = 'v')]
973        verbose: bool,
974    }
975
976    #[test]
977    fn test_subcommand_help_omits_shadowed_parent_flags() {
978        let schema = Schema::from_shape(ShadowedHelpRootArgs::SHAPE).unwrap();
979        let help =
980            generate_help_for_subcommand(&schema, &["Run".to_string()], &HelpConfig::default());
981
982        assert!(help.contains("Local verbose flag"));
983        assert!(!help.contains("Root verbose flag"));
984        assert_eq!(help.matches("--verbose").count(), 1);
985    }
986}