1#![cfg_attr(feature = "app", doc = "", doc = "[`App`]: crate::App")]
21#![cfg_attr(not(feature = "app"), doc = "", doc = "[`App`]: crate#cargo-features")]
22
23use std::io;
24
25use clap::{Command, CommandFactory};
26use ctl_term::Stream;
27
28use crate::document::{Document, Role, Section, Table, Text};
29use crate::render::RenderOptions;
30
31const NARROW_HELP_WIDTH: u16 = 64;
32
33pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
47 let raw = std::env::args_os().collect::<Vec<_>>();
48 let color = crate::parser::parsed_output::<C>(&raw).color;
49 try_emit_with_options::<C>(RenderOptions::new(color))
50}
51
52pub fn try_emit_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<bool> {
62 let args = std::env::args_os()
63 .map(|arg| arg.to_string_lossy().into_owned())
64 .collect::<Vec<_>>();
65 try_emit_from_with_options::<C>(&args, options)
66}
67
68pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
79 let raw = args
80 .iter()
81 .map(std::ffi::OsString::from)
82 .collect::<Vec<_>>();
83 let color = crate::parser::parsed_output::<C>(&raw).color;
84 try_emit_from_with_options::<C>(args, RenderOptions::new(color))
85}
86
87pub fn try_emit_from_with_options<C: CommandFactory>(
97 args: &[String],
98 options: RenderOptions,
99) -> io::Result<bool> {
100 let raw = args
101 .iter()
102 .map(std::ffi::OsString::from)
103 .collect::<Vec<_>>();
104 if !crate::parser::wants_help::<C>(&raw) {
105 return Ok(false);
106 }
107 emit_selected::<C>(args, options)?;
108 Ok(true)
109}
110
111pub fn emit_bare_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<()> {
122 options.print_for(Stream::Stderr, &document(C::command()))
123}
124
125pub(crate) fn emit_selected<C: CommandFactory>(
127 args: &[String],
128 options: RenderOptions,
129) -> io::Result<()> {
130 options.print_for(Stream::Stdout, &document(help_command::<C>(args)))
131}
132
133#[cfg(feature = "app")]
136pub(crate) fn emit_selected_to_stderr<C: CommandFactory>(
137 args: &[String],
138 options: RenderOptions,
139) -> io::Result<()> {
140 options.print_for(Stream::Stderr, &document(help_command::<C>(args)))
141}
142
143fn help_command<C: CommandFactory>(args: &[String]) -> Command {
144 let declared = C::command();
145 let mut root = crate::parser::with_help(declared.clone());
146 root.build();
147 select_command(root, declared, args.get(1..).unwrap_or(&[]))
148}
149
150fn select_command(mut command: Command, mut declared: Command, args: &[String]) -> Command {
151 for value in args {
152 if value == "-h" || value == "--help" {
153 break;
154 }
155 if value.starts_with('-') {
156 continue;
157 }
158 let declared_next = declared
159 .get_subcommands()
160 .find(|subcommand| subcommand.get_name() == value)
161 .cloned();
162 if value == "help" && declared_next.is_none() {
163 continue;
164 }
165 let Some(next) = command
166 .get_subcommands()
167 .find(|subcommand| subcommand.get_name() == value)
168 .cloned()
169 else {
170 continue;
171 };
172 command = next;
173 if let Some(next) = declared_next {
174 declared = next;
175 }
176 }
177 command
178}
179
180#[must_use]
194pub fn document(command: Command) -> Document {
195 let mut command = crate::parser::with_help(command);
196 command.build();
197 let usage = command.render_usage().to_string();
198 let mut output =
199 Document::new().paragraph(Text::new().span(Role::Heading, usage.trim().to_owned()));
200 if let Some(about) = command.get_about() {
201 output = output.paragraph(about.to_string());
202 }
203
204 let commands = command
205 .get_subcommands()
206 .filter(|subcommand| !subcommand.is_hide_set())
207 .fold(Table::plain().token_column(0), |table, subcommand| {
208 table.row([
209 Text::plain(subcommand.get_name()),
210 Text::plain(
211 subcommand
212 .get_about()
213 .map_or_else(String::new, ToString::to_string),
214 ),
215 ])
216 });
217 if !commands.is_empty() {
218 output = output.section(Section::new(
219 "Commands",
220 Document::new().table(commands.stacked_below(NARROW_HELP_WIDTH, 1)),
221 ));
222 }
223
224 let positionals = command
225 .get_positionals()
226 .filter(|arg| !arg.is_hide_set())
227 .fold(Table::plain(), |table, arg| {
228 table.row([
229 Text::new(),
230 Text::new().value(value_label(arg)),
231 Text::new(),
232 description(arg),
233 ])
234 });
235 if !positionals.is_empty() {
236 output = output.section(Section::new(
237 "Arguments",
238 Document::new().table(positionals.stacked_below(NARROW_HELP_WIDTH, 3)),
239 ));
240 }
241
242 let mut headings = Vec::<String>::new();
243 for arg in command
244 .get_arguments()
245 .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
246 {
247 let heading = arg
248 .get_help_heading()
249 .map_or_else(|| "Options".to_owned(), ToString::to_string);
250 if !headings.contains(&heading) {
251 headings.push(heading);
252 }
253 }
254 if command
255 .get_arguments()
256 .any(|arg| arg.get_id().as_str() == "help")
257 {
258 headings.push("Help".into());
259 }
260 for heading in headings {
261 let rows = command
262 .get_arguments()
263 .filter(|arg| {
264 if heading == "Help" {
265 return arg.get_id().as_str() == "help";
266 }
267 !arg.is_positional()
268 && !arg.is_hide_set()
269 && arg.get_id().as_str() != "help"
270 && arg.get_help_heading().map_or("Options", |value| value) == heading
271 })
272 .fold(Table::plain(), |table, arg| {
273 table.row([
274 arg.get_short()
275 .map_or_else(Text::new, |value| Text::new().token(format!("-{value}"))),
276 arg.get_long()
277 .map_or_else(Text::new, |value| Text::new().token(format!("--{value}"))),
278 Text::new().value(value_label(arg)),
279 description(arg),
280 ])
281 });
282 if !rows.is_empty() {
283 output = output.section(Section::new(
284 heading,
285 Document::new().table(rows.stacked_below(NARROW_HELP_WIDTH, 3)),
286 ));
287 }
288 }
289 output
290}
291
292fn value_label(arg: &clap::Arg) -> String {
293 if matches!(
294 arg.get_action(),
295 clap::ArgAction::SetTrue
296 | clap::ArgAction::SetFalse
297 | clap::ArgAction::Help
298 | clap::ArgAction::Version
299 ) {
300 return String::new();
301 }
302 let names = arg
303 .get_value_names()
304 .map(|names| {
305 names
306 .iter()
307 .map(ToString::to_string)
308 .collect::<Vec<_>>()
309 .join(" ")
310 })
311 .unwrap_or_default();
312 let choices = arg
313 .get_possible_values()
314 .into_iter()
315 .filter(|value| !value.is_hide_set())
316 .map(|value| value.get_name().to_owned())
317 .collect::<Vec<_>>();
318 if choices.is_empty() {
319 names
320 } else {
321 format!("[{}]", choices.join("|"))
322 }
323}
324
325fn description(arg: &clap::Arg) -> Text {
328 let description = arg.get_help().map_or_else(String::new, ToString::to_string);
329 let defaults = arg
330 .get_default_values()
331 .iter()
332 .map(|value| value.to_string_lossy())
333 .collect::<Vec<_>>();
334 let mut notes = Vec::new();
335 if !defaults.is_empty()
336 && !matches!(
337 arg.get_action(),
338 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
339 )
340 {
341 notes.push(format!("[default: {}]", defaults.join(", ")));
342 }
343 if let Some(name) = arg.get_env().filter(|_| !arg.is_hide_env_set()) {
344 notes.push(format!("[env: {}]", name.to_string_lossy()));
345 }
346 let mut separator = if description.is_empty() { "" } else { " " };
347 let mut text = Text::plain(description);
348 for note in notes {
349 text = text.then(separator).muted(note);
350 separator = " ";
351 }
352 text
353}
354
355#[cfg(test)]
356mod tests {
357 use clap::{CommandFactory, Parser};
358
359 use super::{document, help_command, try_emit_from};
360 use crate::color::ColorMode;
361 use crate::flags::{DryRunArgs, OutputArgs};
362 use crate::render::RenderOptions;
363
364 #[derive(Parser)]
365 #[command(version, about = "toy ctl", arg_required_else_help = true)]
366 struct Toy {
367 #[command(flatten)]
368 output: OutputArgs,
369 #[command(flatten)]
370 dry: DryRunArgs,
371 #[command(subcommand)]
372 command: ToyCmd,
373 }
374
375 #[derive(clap::Subcommand)]
376 enum ToyCmd {
377 Status(StatusArgs),
379 Group {
381 #[command(subcommand)]
382 command: GroupCmd,
383 },
384 }
385
386 #[derive(clap::Subcommand)]
387 enum GroupCmd {
388 Show,
390 }
391
392 #[derive(clap::Args)]
393 struct StatusArgs {
394 #[arg(short = 'm', long, allow_hyphen_values = true)]
396 message: Option<String>,
397 }
398
399 #[test]
400 fn document_lists_commands_and_flags() {
401 let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
402 assert!(text.contains("Commands"));
403 assert!(text.contains("status"));
404 assert!(text.contains("--dry-run"));
405 assert!(text.contains("--format"));
406 assert!(text.contains("--no-color"));
407 }
408
409 #[test]
410 fn colorless_help_has_no_ansi() {
411 let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
412 assert!(!text.contains('\u{1b}'));
413 }
414
415 #[test]
416 fn long_usage_wraps_to_the_render_width() {
417 let command = clap::Command::new("refresh")
418 .bin_name("forkctl patch refresh")
419 .arg(
420 clap::Arg::new("rewrite")
421 .long("rewrite-below")
422 .action(clap::ArgAction::SetTrue),
423 )
424 .arg(clap::Arg::new("name").value_name("NAME"));
425 let text = document(command).render(RenderOptions::new(ColorMode::Never).width(40));
426 assert!(text.lines().all(|line| line.chars().count() <= 40));
427 assert_eq!(
428 text.lines().take(2).collect::<Vec<_>>(),
429 ["Usage: forkctl patch refresh [OPTIONS]", "[NAME]"]
430 );
431 }
432
433 #[test]
434 fn help_names_a_variable_unless_it_is_hidden() {
435 let command = clap::Command::new("toy")
436 .arg(clap::Arg::new("config").long("config").env("TOY_CONFIG"))
437 .arg(
438 clap::Arg::new("token")
439 .long("token")
440 .env("TOY_TOKEN")
441 .hide_env(true),
442 );
443 let text = document(command).render(RenderOptions::new(ColorMode::Never).width(80));
444 assert!(text.contains("[env: TOY_CONFIG]"), "{text}");
445 assert!(!text.contains("TOY_TOKEN"), "{text}");
446 }
447
448 #[test]
449 fn help_subcommand_selects_the_requested_command() {
450 let args = ["toy", "help", "status"].map(String::from);
451 assert_eq!(help_command::<Toy>(&args).get_name(), "status");
452 }
453
454 #[test]
455 fn subcommand_help_keeps_parent_usage_and_globals() {
456 let command = help_command::<Toy>(&["toy", "status", "--help"].map(String::from));
457 assert_eq!(command.get_bin_name(), Some("ctl-core status"));
458 assert!(command.get_arguments().any(|arg| arg.get_id() == "format"));
459 }
460
461 #[test]
462 fn nested_help_subcommand_selects_the_requested_command() {
463 let args = ["toy", "group", "help", "show"].map(String::from);
464 assert_eq!(help_command::<Toy>(&args).get_name(), "show");
465 }
466
467 #[test]
468 fn try_emit_skips_without_help() {
469 let args = ["toy", "status"]
470 .into_iter()
471 .map(String::from)
472 .collect::<Vec<_>>();
473 assert!(!try_emit_from::<Toy>(&args).unwrap());
474 }
475
476 #[test]
477 fn try_emit_ignores_help_used_as_a_domain_value() {
478 let args = ["toy", "status", "-m", "--help"]
479 .into_iter()
480 .map(String::from)
481 .collect::<Vec<_>>();
482 assert!(!try_emit_from::<Toy>(&args).unwrap());
483 }
484
485 #[test]
486 fn try_emit_ignores_help_after_separator() {
487 let args = ["toy", "status", "--", "--help"]
488 .into_iter()
489 .map(String::from)
490 .collect::<Vec<_>>();
491 assert!(!try_emit_from::<Toy>(&args).unwrap());
492 }
493
494 #[test]
495 fn try_emit_does_not_claim_bare_invocation() {
496 let args = ["toy"].map(String::from);
497 assert!(!try_emit_from::<Toy>(&args).unwrap());
498 }
499}