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