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}