1use std::fmt::Write as _;
2use std::io::{self, Write};
3
4use clap::{Command, CommandFactory};
5use comfy_table::presets::UTF8_FULL_CONDENSED;
6use comfy_table::{ContentArrangement, Table};
7
8use crate::color::ColorMode;
9use crate::layout;
10use crate::style::{HEADING, MUTED, OPTION, VALUE, styled};
11
12const NARROW_HELP_WIDTH: u16 = 64;
13
14pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
16 let args = std::env::args_os()
17 .map(|arg| arg.to_string_lossy().into_owned())
18 .collect::<Vec<_>>();
19 try_emit_from::<C>(&args)
20}
21
22pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
24 let wants_help = args.len() == 1 || args.iter().any(|arg| arg == "-h" || arg == "--help");
25 if !wants_help {
26 return Ok(false);
27 }
28 let color = ColorMode::from_args(args.iter().map(String::as_str));
29 let mut root = C::command();
30 root.build();
31 let command = select_command(root, args.get(1..).unwrap_or(&[]));
32 let output = render(command);
33 let mut stream = anstream::AutoStream::new(io::stdout().lock(), color.choice());
34 stream.write_all(output.as_bytes())?;
35 stream.flush()?;
36 Ok(true)
37}
38
39fn select_command(mut command: Command, args: &[String]) -> Command {
40 for value in args {
41 if value == "-h" || value == "--help" {
42 break;
43 }
44 if value.starts_with('-') {
45 continue;
46 }
47 let Some(next) = command
48 .get_subcommands()
49 .find(|subcommand| subcommand.get_name() == value)
50 .cloned()
51 else {
52 continue;
53 };
54 command = next;
55 }
56 command
57}
58
59#[must_use]
60pub fn render(mut command: Command) -> String {
62 command.build();
63 if let Some(width) = layout::terminal_width() {
64 command = command.term_width(usize::from(width));
65 }
66 let mut output = String::new();
67 let usage = format!(
68 "{}{}{}",
69 HEADING.render(),
70 command.render_usage().to_string().trim(),
71 HEADING.render_reset()
72 );
73 layout::push_line(&mut output, &usage);
74 output.push('\n');
75 if let Some(about) = command.get_about() {
76 layout::push_line(&mut output, &about.to_string());
77 output.push('\n');
78 }
79 let subcommands = command
80 .get_subcommands()
81 .filter(|subcommand| !subcommand.is_hide_set())
82 .map(|subcommand| {
83 vec![
84 styled(HEADING, subcommand.get_name()),
85 subcommand
86 .get_about()
87 .map_or_else(String::new, ToString::to_string),
88 ]
89 })
90 .collect::<Vec<_>>();
91 if !subcommands.is_empty() {
92 section(&mut output, "Commands", subcommands);
93 }
94 let positionals = command
95 .get_positionals()
96 .filter(|arg| !arg.is_hide_set())
97 .map(|arg| {
98 vec![
99 String::new(),
100 styled(VALUE, &value_label(arg)),
101 String::new(),
102 description(arg),
103 ]
104 })
105 .collect::<Vec<_>>();
106 if !positionals.is_empty() {
107 section(&mut output, "Arguments", positionals);
108 }
109 let mut headings = Vec::<String>::new();
110 for arg in command
111 .get_arguments()
112 .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
113 {
114 let heading = arg
115 .get_help_heading()
116 .map_or_else(|| "Options".to_string(), ToString::to_string);
117 if !headings.contains(&heading) {
118 headings.push(heading);
119 }
120 }
121 if command
122 .get_arguments()
123 .any(|arg| arg.get_id().as_str() == "help")
124 {
125 headings.push("Help".into());
126 }
127 for heading in headings {
128 let rows = command
129 .get_arguments()
130 .filter(|arg| {
131 if heading == "Help" {
132 return arg.get_id().as_str() == "help";
133 }
134 !arg.is_positional()
135 && !arg.is_hide_set()
136 && arg.get_id().as_str() != "help"
137 && arg.get_help_heading().map_or("Options", |value| value) == heading
138 })
139 .map(|arg| {
140 vec![
141 arg.get_short()
142 .map_or_else(String::new, |value| styled(OPTION, &format!("-{value}"))),
143 arg.get_long()
144 .map_or_else(String::new, |value| styled(OPTION, &format!("--{value}"))),
145 styled(VALUE, &value_label(arg)),
146 description(arg),
147 ]
148 })
149 .collect::<Vec<_>>();
150 if !rows.is_empty() {
151 section(&mut output, &heading, rows);
152 }
153 }
154 output
155}
156
157fn section(output: &mut String, title: &str, rows: Vec<Vec<String>>) {
158 let _ = writeln!(
159 output,
160 "{}{}{}",
161 HEADING.render(),
162 title,
163 HEADING.render_reset()
164 );
165 if layout::terminal_width().is_some_and(|width| width < NARROW_HELP_WIDTH) {
166 narrow_rows(output, rows);
167 return;
168 }
169 let mut table = Table::new();
170 table
171 .load_style(UTF8_FULL_CONDENSED)
172 .set_content_arrangement(ContentArrangement::Dynamic);
173 layout::constrain(&mut table);
174 for row in rows {
175 table.add_row(row);
176 }
177 let _ = writeln!(output, "{table}");
178}
179
180fn narrow_rows(output: &mut String, rows: Vec<Vec<String>>) {
181 for row in rows {
182 let (label, description) = if row.len() == 2 {
183 (row[0].clone(), row[1].clone())
184 } else {
185 (
186 row.iter()
187 .take(3)
188 .filter(|value| !value.is_empty())
189 .cloned()
190 .collect::<Vec<_>>()
191 .join(" "),
192 row.get(3).cloned().unwrap_or_default(),
193 )
194 };
195 layout::push_indented(output, &label, 2);
196 if !description.is_empty() {
197 layout::push_indented(output, &description, 4);
198 }
199 }
200 output.push('\n');
201}
202
203fn value_label(arg: &clap::Arg) -> String {
204 if matches!(
205 arg.get_action(),
206 clap::ArgAction::SetTrue
207 | clap::ArgAction::SetFalse
208 | clap::ArgAction::Help
209 | clap::ArgAction::Version
210 ) {
211 return String::new();
212 }
213 let names = arg
214 .get_value_names()
215 .map(|names| {
216 names
217 .iter()
218 .map(ToString::to_string)
219 .collect::<Vec<_>>()
220 .join(" ")
221 })
222 .unwrap_or_default();
223 let choices = arg
224 .get_possible_values()
225 .into_iter()
226 .filter(|value| !value.is_hide_set())
227 .map(|value| value.get_name().to_string())
228 .collect::<Vec<_>>();
229 if choices.is_empty() {
230 names
231 } else {
232 format!("[{}]", choices.join("|"))
233 }
234}
235
236fn description(arg: &clap::Arg) -> String {
237 let mut description = arg.get_help().map_or_else(String::new, ToString::to_string);
238 let defaults = arg
239 .get_default_values()
240 .iter()
241 .map(|value| value.to_string_lossy())
242 .collect::<Vec<_>>();
243 if !defaults.is_empty()
244 && !matches!(
245 arg.get_action(),
246 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
247 )
248 {
249 if !description.is_empty() {
250 description.push(' ');
251 }
252 description.push_str(&styled(
253 MUTED,
254 &format!("[default: {}]", defaults.join(", ")),
255 ));
256 }
257 description
258}
259
260#[cfg(test)]
261mod tests {
262 use clap::{CommandFactory, Parser};
263
264 use super::{render, try_emit_from};
265 use crate::flags::{DryRunArgs, OutputArgs};
266
267 #[derive(Parser)]
268 #[command(version, about = "toy ctl", arg_required_else_help = true)]
269 struct Toy {
270 #[command(flatten)]
271 output: OutputArgs,
272 #[command(flatten)]
273 dry: DryRunArgs,
274 #[command(subcommand)]
275 command: ToyCmd,
276 }
277
278 #[derive(clap::Subcommand)]
279 enum ToyCmd {
280 Status,
282 }
283
284 #[test]
285 fn render_lists_commands_and_flags() {
286 let text = render(Toy::command());
287 assert!(text.contains("Commands"));
288 assert!(text.contains("status"));
289 assert!(text.contains("--dry-run"));
290 assert!(text.contains("--format"));
291 assert!(text.contains("--no-color"));
292 }
293
294 #[test]
295 fn try_emit_skips_without_help() {
296 let args = ["toy", "status"]
297 .into_iter()
298 .map(String::from)
299 .collect::<Vec<_>>();
300 assert!(!try_emit_from::<Toy>(&args).unwrap());
301 }
302}