1use 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}