facet_args/
help.rs

1//! Help text generation for command-line interfaces.
2//!
3//! This module provides utilities to generate help text from Facet type metadata,
4//! including doc comments, field names, and attribute information.
5
6use alloc::string::String;
7use alloc::vec::Vec;
8use facet_core::{Def, Facet, Field, Shape, Type, UserType, Variant};
9use heck::ToKebabCase;
10
11/// Configuration for help text generation.
12#[derive(Debug, Clone)]
13pub struct HelpConfig {
14    /// Program name (defaults to executable name)
15    pub program_name: Option<String>,
16    /// Program version
17    pub version: Option<String>,
18    /// Additional description to show after the auto-generated one
19    pub description: Option<String>,
20    /// Width for wrapping text (0 = no wrapping)
21    pub width: usize,
22}
23
24impl Default for HelpConfig {
25    fn default() -> Self {
26        Self {
27            program_name: None,
28            version: None,
29            description: None,
30            width: 80,
31        }
32    }
33}
34
35/// Generate help text for a Facet type.
36pub fn generate_help<T: facet_core::Facet<'static>>(config: &HelpConfig) -> String {
37    generate_help_for_shape(T::SHAPE, config)
38}
39
40/// Generate help text for a shape.
41pub fn generate_help_for_shape(shape: &'static Shape, config: &HelpConfig) -> String {
42    let mut out = String::new();
43
44    // Program name and version
45    let program_name = config
46        .program_name
47        .clone()
48        .or_else(|| std::env::args().next())
49        .unwrap_or_else(|| "program".to_string());
50
51    if let Some(version) = &config.version {
52        out.push_str(&format!("{program_name} {version}\n"));
53    } else {
54        out.push_str(&format!("{program_name}\n"));
55    }
56
57    // Type doc comment
58    if !shape.doc.is_empty() {
59        out.push('\n');
60        for line in shape.doc {
61            out.push_str(line.trim());
62            out.push('\n');
63        }
64    }
65
66    // Additional description
67    if let Some(desc) = &config.description {
68        out.push('\n');
69        out.push_str(desc);
70        out.push('\n');
71    }
72
73    out.push('\n');
74
75    // Generate based on type
76    match &shape.ty {
77        Type::User(UserType::Struct(struct_type)) => {
78            generate_struct_help(&mut out, &program_name, struct_type.fields);
79        }
80        Type::User(UserType::Enum(enum_type)) => {
81            generate_enum_help(&mut out, &program_name, enum_type.variants);
82        }
83        _ => {
84            out.push_str("(No help available for this type)\n");
85        }
86    }
87
88    out
89}
90
91fn generate_struct_help(out: &mut String, program_name: &str, fields: &'static [Field]) {
92    // Collect flags, positionals, and subcommand
93    let mut flags: Vec<&Field> = Vec::new();
94    let mut positionals: Vec<&Field> = Vec::new();
95    let mut subcommand: Option<&Field> = None;
96
97    for field in fields {
98        if field.has_attr(Some("args"), "subcommand") {
99            subcommand = Some(field);
100        } else if field.has_attr(Some("args"), "positional") {
101            positionals.push(field);
102        } else {
103            flags.push(field);
104        }
105    }
106
107    // Usage line
108    out.push_str("USAGE:\n    ");
109    out.push_str(program_name);
110
111    if !flags.is_empty() {
112        out.push_str(" [OPTIONS]");
113    }
114
115    for pos in &positionals {
116        let name = pos.name.to_kebab_case().to_uppercase();
117        let is_optional = matches!(pos.shape().def, Def::Option(_)) || pos.has_default();
118        if is_optional {
119            out.push_str(&format!(" [{name}]"));
120        } else {
121            out.push_str(&format!(" <{name}>"));
122        }
123    }
124
125    if let Some(sub) = subcommand {
126        let is_optional = matches!(sub.shape().def, Def::Option(_));
127        if is_optional {
128            out.push_str(" [COMMAND]");
129        } else {
130            out.push_str(" <COMMAND>");
131        }
132    }
133
134    out.push_str("\n\n");
135
136    // Positional arguments
137    if !positionals.is_empty() {
138        out.push_str("ARGUMENTS:\n");
139        for field in &positionals {
140            write_field_help(out, field, true);
141        }
142        out.push('\n');
143    }
144
145    // Options
146    if !flags.is_empty() {
147        out.push_str("OPTIONS:\n");
148        for field in &flags {
149            write_field_help(out, field, false);
150        }
151        out.push('\n');
152    }
153
154    // Subcommands
155    if let Some(sub_field) = subcommand {
156        let sub_shape = sub_field.shape();
157        // Handle Option<Enum> or direct Enum
158        let enum_shape = if let Def::Option(opt) = sub_shape.def {
159            opt.t
160        } else {
161            sub_shape
162        };
163
164        if let Type::User(UserType::Enum(enum_type)) = enum_shape.ty {
165            out.push_str("COMMANDS:\n");
166            for variant in enum_type.variants {
167                write_variant_help(out, variant);
168            }
169            out.push('\n');
170        }
171    }
172}
173
174fn generate_enum_help(out: &mut String, program_name: &str, variants: &'static [Variant]) {
175    // For top-level enum, show subcommands
176    out.push_str("USAGE:\n    ");
177    out.push_str(program_name);
178    out.push_str(" <COMMAND>\n\n");
179
180    out.push_str("COMMANDS:\n");
181    for variant in variants {
182        write_variant_help(out, variant);
183    }
184    out.push('\n');
185}
186
187fn write_field_help(out: &mut String, field: &Field, is_positional: bool) {
188    out.push_str("    ");
189
190    // Short flag
191    let short = get_short_flag(field);
192    if let Some(c) = short {
193        out.push_str(&format!("-{c}, "));
194    } else {
195        out.push_str("    ");
196    }
197
198    // Long flag or positional name
199    let kebab_name = field.name.to_kebab_case();
200    if is_positional {
201        out.push_str(&format!("<{}>", kebab_name.to_uppercase()));
202    } else {
203        out.push_str(&format!("--{kebab_name}"));
204
205        // Show value placeholder for non-bool types
206        let shape = field.shape();
207        if !shape.is_shape(bool::SHAPE) {
208            out.push_str(&format!(" <{}>", shape.type_identifier.to_uppercase()));
209        }
210    }
211
212    // Doc comment
213    if let Some(doc) = field.doc.first() {
214        out.push_str("\n            ");
215        out.push_str(doc.trim());
216    }
217
218    out.push('\n');
219}
220
221fn write_variant_help(out: &mut String, variant: &Variant) {
222    out.push_str("    ");
223
224    // Variant name (check for rename)
225    let name = variant
226        .get_builtin_attr("rename")
227        .and_then(|attr| attr.get_as::<&str>())
228        .map(|s| (*s).to_string())
229        .unwrap_or_else(|| variant.name.to_kebab_case());
230
231    out.push_str(&name);
232
233    // Doc comment
234    if let Some(doc) = variant.doc.first() {
235        out.push_str("\n            ");
236        out.push_str(doc.trim());
237    }
238
239    out.push('\n');
240}
241
242/// Get the short flag character for a field, if any
243fn get_short_flag(field: &Field) -> Option<char> {
244    field
245        .get_attr(Some("args"), "short")
246        .and_then(|attr| attr.get_as::<crate::Attr>())
247        .and_then(|attr| {
248            if let crate::Attr::Short(c) = attr {
249                // If explicit char provided, use it; otherwise use first char of field name
250                c.or_else(|| field.name.chars().next())
251            } else {
252                None
253            }
254        })
255}
256
257/// Generate help for a specific subcommand variant.
258pub fn generate_subcommand_help(
259    variant: &'static Variant,
260    parent_program: &str,
261    config: &HelpConfig,
262) -> String {
263    let mut out = String::new();
264
265    let variant_name = variant
266        .get_builtin_attr("rename")
267        .and_then(|attr| attr.get_as::<&str>())
268        .map(|s| (*s).to_string())
269        .unwrap_or_else(|| variant.name.to_kebab_case());
270
271    let full_name = format!("{parent_program} {variant_name}");
272
273    // Header
274    if let Some(version) = &config.version {
275        out.push_str(&format!("{full_name} {version}\n"));
276    } else {
277        out.push_str(&format!("{full_name}\n"));
278    }
279
280    // Variant doc comment
281    if !variant.doc.is_empty() {
282        out.push('\n');
283        for line in variant.doc {
284            out.push_str(line.trim());
285            out.push('\n');
286        }
287    }
288
289    out.push('\n');
290
291    // Generate help for variant fields
292    generate_struct_help(&mut out, &full_name, variant.data.fields);
293
294    out
295}