Skip to main content

appcore_args/
help.rs

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