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