ctl-core 0.1.0

Shared clap chassis for the *ctl CLIs
Documentation
use std::fmt::Write as _;
use std::io::{self, Write};

use clap::{Command, CommandFactory};
use comfy_table::presets::UTF8_FULL_CONDENSED;
use comfy_table::{ContentArrangement, Table};

use crate::color::ColorMode;
use crate::layout;
use crate::style::{HEADING, MUTED, OPTION, VALUE, styled};

const NARROW_HELP_WIDTH: u16 = 64;

/// Styled `-h/--help` used by forkctl and state-sync. Returns true if help ran.
pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
    let args = std::env::args_os()
        .map(|arg| arg.to_string_lossy().into_owned())
        .collect::<Vec<_>>();
    try_emit_from::<C>(&args)
}

/// Same as [`try_emit`] with an explicit argv (tests).
pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
    let wants_help = args.len() == 1 || args.iter().any(|arg| arg == "-h" || arg == "--help");
    if !wants_help {
        return Ok(false);
    }
    let color = ColorMode::from_args(args.iter().map(String::as_str));
    let mut root = C::command();
    root.build();
    let command = select_command(root, args.get(1..).unwrap_or(&[]));
    let output = render(command);
    let mut stream = anstream::AutoStream::new(io::stdout().lock(), color.choice());
    stream.write_all(output.as_bytes())?;
    stream.flush()?;
    Ok(true)
}

fn select_command(mut command: Command, args: &[String]) -> Command {
    for value in args {
        if value == "-h" || value == "--help" {
            break;
        }
        if value.starts_with('-') {
            continue;
        }
        let Some(next) = command
            .get_subcommands()
            .find(|subcommand| subcommand.get_name() == value)
            .cloned()
        else {
            continue;
        };
        command = next;
    }
    command
}

#[must_use]
/// Render styled help for `command` (no I/O).
pub fn render(mut command: Command) -> String {
    command.build();
    if let Some(width) = layout::terminal_width() {
        command = command.term_width(usize::from(width));
    }
    let mut output = String::new();
    let usage = format!(
        "{}{}{}",
        HEADING.render(),
        command.render_usage().to_string().trim(),
        HEADING.render_reset()
    );
    layout::push_line(&mut output, &usage);
    output.push('\n');
    if let Some(about) = command.get_about() {
        layout::push_line(&mut output, &about.to_string());
        output.push('\n');
    }
    let subcommands = command
        .get_subcommands()
        .filter(|subcommand| !subcommand.is_hide_set())
        .map(|subcommand| {
            vec![
                styled(HEADING, subcommand.get_name()),
                subcommand
                    .get_about()
                    .map_or_else(String::new, ToString::to_string),
            ]
        })
        .collect::<Vec<_>>();
    if !subcommands.is_empty() {
        section(&mut output, "Commands", subcommands);
    }
    let positionals = command
        .get_positionals()
        .filter(|arg| !arg.is_hide_set())
        .map(|arg| {
            vec![
                String::new(),
                styled(VALUE, &value_label(arg)),
                String::new(),
                description(arg),
            ]
        })
        .collect::<Vec<_>>();
    if !positionals.is_empty() {
        section(&mut output, "Arguments", positionals);
    }
    let mut headings = Vec::<String>::new();
    for arg in command
        .get_arguments()
        .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
    {
        let heading = arg
            .get_help_heading()
            .map_or_else(|| "Options".to_string(), ToString::to_string);
        if !headings.contains(&heading) {
            headings.push(heading);
        }
    }
    if command
        .get_arguments()
        .any(|arg| arg.get_id().as_str() == "help")
    {
        headings.push("Help".into());
    }
    for heading in headings {
        let rows = command
            .get_arguments()
            .filter(|arg| {
                if heading == "Help" {
                    return arg.get_id().as_str() == "help";
                }
                !arg.is_positional()
                    && !arg.is_hide_set()
                    && arg.get_id().as_str() != "help"
                    && arg.get_help_heading().map_or("Options", |value| value) == heading
            })
            .map(|arg| {
                vec![
                    arg.get_short()
                        .map_or_else(String::new, |value| styled(OPTION, &format!("-{value}"))),
                    arg.get_long()
                        .map_or_else(String::new, |value| styled(OPTION, &format!("--{value}"))),
                    styled(VALUE, &value_label(arg)),
                    description(arg),
                ]
            })
            .collect::<Vec<_>>();
        if !rows.is_empty() {
            section(&mut output, &heading, rows);
        }
    }
    output
}

fn section(output: &mut String, title: &str, rows: Vec<Vec<String>>) {
    let _ = writeln!(
        output,
        "{}{}{}",
        HEADING.render(),
        title,
        HEADING.render_reset()
    );
    if layout::terminal_width().is_some_and(|width| width < NARROW_HELP_WIDTH) {
        narrow_rows(output, rows);
        return;
    }
    let mut table = Table::new();
    table
        .load_style(UTF8_FULL_CONDENSED)
        .set_content_arrangement(ContentArrangement::Dynamic);
    layout::constrain(&mut table);
    for row in rows {
        table.add_row(row);
    }
    let _ = writeln!(output, "{table}");
}

fn narrow_rows(output: &mut String, rows: Vec<Vec<String>>) {
    for row in rows {
        let (label, description) = if row.len() == 2 {
            (row[0].clone(), row[1].clone())
        } else {
            (
                row.iter()
                    .take(3)
                    .filter(|value| !value.is_empty())
                    .cloned()
                    .collect::<Vec<_>>()
                    .join(" "),
                row.get(3).cloned().unwrap_or_default(),
            )
        };
        layout::push_indented(output, &label, 2);
        if !description.is_empty() {
            layout::push_indented(output, &description, 4);
        }
    }
    output.push('\n');
}

fn value_label(arg: &clap::Arg) -> String {
    if matches!(
        arg.get_action(),
        clap::ArgAction::SetTrue
            | clap::ArgAction::SetFalse
            | clap::ArgAction::Help
            | clap::ArgAction::Version
    ) {
        return String::new();
    }
    let names = arg
        .get_value_names()
        .map(|names| {
            names
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(" ")
        })
        .unwrap_or_default();
    let choices = arg
        .get_possible_values()
        .into_iter()
        .filter(|value| !value.is_hide_set())
        .map(|value| value.get_name().to_string())
        .collect::<Vec<_>>();
    if choices.is_empty() {
        names
    } else {
        format!("[{}]", choices.join("|"))
    }
}

fn description(arg: &clap::Arg) -> String {
    let mut description = arg.get_help().map_or_else(String::new, ToString::to_string);
    let defaults = arg
        .get_default_values()
        .iter()
        .map(|value| value.to_string_lossy())
        .collect::<Vec<_>>();
    if !defaults.is_empty()
        && !matches!(
            arg.get_action(),
            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
        )
    {
        if !description.is_empty() {
            description.push(' ');
        }
        description.push_str(&styled(
            MUTED,
            &format!("[default: {}]", defaults.join(", ")),
        ));
    }
    description
}

#[cfg(test)]
mod tests {
    use clap::{CommandFactory, Parser};

    use super::{render, try_emit_from};
    use crate::flags::{DryRunArgs, OutputArgs};

    #[derive(Parser)]
    #[command(version, about = "toy ctl", arg_required_else_help = true)]
    struct Toy {
        #[command(flatten)]
        output: OutputArgs,
        #[command(flatten)]
        dry: DryRunArgs,
        #[command(subcommand)]
        command: ToyCmd,
    }

    #[derive(clap::Subcommand)]
    enum ToyCmd {
        /// Show status.
        Status,
    }

    #[test]
    fn render_lists_commands_and_flags() {
        let text = render(Toy::command());
        assert!(text.contains("Commands"));
        assert!(text.contains("status"));
        assert!(text.contains("--dry-run"));
        assert!(text.contains("--format"));
        assert!(text.contains("--no-color"));
    }

    #[test]
    fn try_emit_skips_without_help() {
        let args = ["toy", "status"]
            .into_iter()
            .map(String::from)
            .collect::<Vec<_>>();
        assert!(!try_emit_from::<Toy>(&args).unwrap());
    }
}