Skip to main content

appcore_args/
help.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: help.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use crate::{ArgumentSpec, CliSpec, CommandSpec, OptionSpec, SpecError, ValueMode};
12
13pub struct HelpRenderer<'a> {
14    spec: &'a CliSpec,
15    width: usize,
16}
17
18impl<'a> HelpRenderer<'a> {
19    pub fn new(spec: &'a CliSpec) -> Self {
20        Self { spec, width: 100 }
21    }
22
23    pub fn width(mut self, width: usize) -> Self {
24        self.width = width.max(40);
25        self
26    }
27
28    pub fn render(&self, command_path: &[&str]) -> Result<String, SpecError> {
29        self.spec.validate()?;
30        let resolved = find_commands(self.spec, command_path)?;
31        let command = resolved.last().copied();
32        let name = full_name(self.spec.name(), command_path);
33        let mut output = heading(self.spec, command, &name);
34        output.push_str("Usage:\n  ");
35        output.push_str(&usage(&name, self.spec, command, &resolved));
36        output.push('\n');
37        let commands = command
38            .map(CommandSpec::commands)
39            .unwrap_or_else(|| self.spec.commands());
40        render_commands(&mut output, commands, self.width);
41        let arguments = command
42            .map(CommandSpec::arguments)
43            .unwrap_or_else(|| self.spec.arguments());
44        render_arguments(&mut output, arguments, self.width);
45        render_options(
46            &mut output,
47            visible_options(self.spec, &resolved),
48            self.width,
49        );
50        Ok(output)
51    }
52}
53
54fn heading(spec: &CliSpec, command: Option<&CommandSpec>, name: &str) -> String {
55    let mut output = name.to_string();
56    if let Some(version) = spec.version_text() {
57        output.push(' ');
58        output.push_str(version);
59    }
60    output.push('\n');
61    let about = command
62        .map(CommandSpec::about_text)
63        .unwrap_or_else(|| spec.about_text());
64    if !about.is_empty() {
65        output.push_str(about);
66        output.push_str("\n\n");
67    }
68    output
69}
70
71fn find_commands<'a>(spec: &'a CliSpec, path: &[&str]) -> Result<Vec<&'a CommandSpec>, SpecError> {
72    let mut resolved = Vec::new();
73    for name in path {
74        let commands = resolved
75            .last()
76            .copied()
77            .map(CommandSpec::commands)
78            .unwrap_or_else(|| spec.commands());
79        let command = commands
80            .iter()
81            .find(|command| command.matches(name))
82            .ok_or_else(|| {
83                SpecError::new_internal(format!("unknown help command `{}`", path.join(" ")))
84            })?;
85        resolved.push(command);
86    }
87    Ok(resolved)
88}
89
90fn full_name(binary: &str, path: &[&str]) -> String {
91    if path.is_empty() {
92        binary.to_string()
93    } else {
94        format!("{binary} {}", path.join(" "))
95    }
96}
97
98fn usage(
99    name: &str,
100    spec: &CliSpec,
101    command: Option<&CommandSpec>,
102    resolved: &[&CommandSpec],
103) -> String {
104    let mut usage = name.to_string();
105    if !visible_options(spec, resolved).is_empty() {
106        usage.push_str(" [OPTIONS]");
107    }
108    let commands = command
109        .map(CommandSpec::commands)
110        .unwrap_or_else(|| spec.commands());
111    let required = command
112        .map(CommandSpec::is_command_required)
113        .unwrap_or_else(|| spec.is_command_required());
114    if !commands.is_empty() {
115        usage.push_str(if required { " <COMMAND>" } else { " [COMMAND]" });
116    }
117    let arguments = command
118        .map(CommandSpec::arguments)
119        .unwrap_or_else(|| spec.arguments());
120    for argument in arguments {
121        usage.push(' ');
122        usage.push_str(&argument_usage(argument));
123    }
124    usage
125}
126
127fn argument_usage(argument: &ArgumentSpec) -> String {
128    let suffix = if argument.is_multiple() { "..." } else { "" };
129    if argument.is_required() {
130        format!("<{}{suffix}>", argument.name())
131    } else {
132        format!("[{}{suffix}]", argument.name())
133    }
134}
135
136fn visible_options<'a>(spec: &'a CliSpec, commands: &[&'a CommandSpec]) -> Vec<&'a OptionSpec> {
137    let mut options = spec.options().iter().collect::<Vec<_>>();
138    for command in commands {
139        options.extend(command.options());
140    }
141    options
142}
143
144fn render_commands(output: &mut String, commands: &[CommandSpec], width: usize) {
145    let rows = commands
146        .iter()
147        .filter(|command| !command.is_hidden())
148        .map(|command| (command.name().to_string(), command.about_text()))
149        .collect::<Vec<_>>();
150    render_rows(output, "Commands", rows, width);
151}
152
153fn render_arguments(output: &mut String, arguments: &[ArgumentSpec], width: usize) {
154    let rows = arguments
155        .iter()
156        .map(|argument| (argument_usage(argument), argument.about_text()))
157        .collect::<Vec<_>>();
158    render_rows(output, "Arguments", rows, width);
159}
160
161fn render_options(output: &mut String, options: Vec<&OptionSpec>, width: usize) {
162    let rows = options
163        .into_iter()
164        .filter(|option| !option.is_hidden())
165        .map(|option| (option_usage(option), option.about_text()))
166        .collect::<Vec<_>>();
167    render_rows(output, "Options", rows, width);
168}
169
170fn option_usage(option: &OptionSpec) -> String {
171    let mut usage = option
172        .short_name()
173        .map(|short| format!("-{short}, "))
174        .unwrap_or_default();
175    usage.push_str("--");
176    usage.push_str(option.long());
177    match option.value_mode() {
178        ValueMode::Forbidden => {}
179        ValueMode::Required => usage.push_str(&format!(" <{}>", option.value_name_text())),
180        ValueMode::Optional => usage.push_str(&format!("[=<{}>]", option.value_name_text())),
181    }
182    usage
183}
184
185fn render_rows(output: &mut String, title: &str, rows: Vec<(String, &str)>, width: usize) {
186    if rows.is_empty() {
187        return;
188    }
189    output.push('\n');
190    output.push_str(title);
191    output.push_str(":\n");
192    let label_width = rows
193        .iter()
194        .map(|(label, _)| label.len())
195        .max()
196        .unwrap_or(0)
197        .min(width / 2);
198    for (label, about) in rows {
199        output.push_str("  ");
200        output.push_str(&label);
201        output.push_str(&" ".repeat(label_width.saturating_sub(label.len()) + 2));
202        output.push_str(about);
203        output.push('\n');
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::HelpRenderer;
210    use crate::{ArgumentSpec, CliSpec, CommandSpec, OptionSpec};
211
212    #[test]
213    fn renders_root_and_command_help() {
214        let spec = CliSpec::new("demo")
215            .version("1.0.0")
216            .about("Demo tool.")
217            .option(OptionSpec::flag("help").short('h').about("Show help."))
218            .command(
219                CommandSpec::new("run")
220                    .about("Run it.")
221                    .argument(ArgumentSpec::new("file").required(true)),
222            );
223        let root = HelpRenderer::new(&spec).render(&[]).unwrap();
224        let command = HelpRenderer::new(&spec).render(&["run"]).unwrap();
225        assert!(root.contains("demo 1.0.0"));
226        assert!(root.contains("Commands:"));
227        assert!(command.contains("demo run [OPTIONS] <file>"));
228    }
229
230    #[test]
231    fn nested_help_includes_inherited_options() {
232        let spec = CliSpec::new("demo").command(
233            CommandSpec::new("publish")
234                .option(OptionSpec::flag("dry-run"))
235                .command(CommandSpec::new("status")),
236        );
237
238        let help = HelpRenderer::new(&spec)
239            .render(&["publish", "status"])
240            .unwrap();
241
242        assert!(help.contains("demo publish status [OPTIONS]"));
243        assert!(help.contains("--dry-run"));
244    }
245}