Skip to main content

ctl_core/
help.rs

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
14/// Styled `-h/--help` used by forkctl and state-sync. Returns true if help ran.
15pub 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
22/// Same as [`try_emit`] with an explicit argv (tests).
23pub 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]
60/// Render styled help for `command` (no I/O).
61pub 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        /// Show status.
281        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}