clap-tui 0.1.3

Auto-generate a TUI from clap commands
Documentation
use crate::input::ActiveTab;
use crate::spec::{ArgActionKind, ArgSpec, CommandPath, CommandSpec, format_command_path};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldWidget {
    Toggle,
    SingleText,
    RepeatedText,
    SingleChoice,
    MultiChoice,
    Counter,
    OptionalValue,
}

impl FieldWidget {
    pub(crate) fn accepts_text_input(self) -> bool {
        matches!(
            self,
            Self::SingleText | Self::RepeatedText | Self::OptionalValue
        )
    }

    pub(crate) fn uses_choice_popup(self) -> bool {
        matches!(self, Self::SingleChoice | Self::MultiChoice)
    }
}

#[derive(Debug, Clone)]
pub(crate) struct OrderedArg<'a> {
    pub(crate) order_index: usize,
    pub(crate) arg: &'a ArgSpec,
    pub(crate) widget: FieldWidget,
    pub(crate) section_heading: Option<String>,
}

pub(crate) fn widget_for(arg: &ArgSpec) -> FieldWidget {
    if arg.uses_count_semantics() {
        FieldWidget::Counter
    } else if arg.uses_optional_value_semantics() {
        FieldWidget::OptionalValue
    } else if arg.uses_toggle_semantics() {
        FieldWidget::Toggle
    } else if arg.has_value_choices() && arg.is_multi_value_input() {
        FieldWidget::MultiChoice
    } else if arg.has_value_choices() {
        FieldWidget::SingleChoice
    } else if arg.is_multi_value_input() {
        FieldWidget::RepeatedText
    } else {
        FieldWidget::SingleText
    }
}

#[cfg(test)]
pub(crate) fn ordered_args(command: &CommandSpec) -> Vec<OrderedArg<'_>> {
    ordered_args_with_heading(command, None)
}

fn ordered_args_with_heading<'a>(
    command: &'a CommandSpec,
    section_heading_prefix: Option<&str>,
) -> Vec<OrderedArg<'a>> {
    let mut positionals = command
        .all_args()
        .into_iter()
        .filter(|arg| arg.is_positional())
        .filter(|arg| is_form_visible_arg(arg))
        .collect::<Vec<_>>();
    positionals.sort_by_key(|arg| (arg.position.unwrap_or(usize::MAX), arg.display_order()));

    let mut others = command
        .all_args()
        .into_iter()
        .filter(|arg| !arg.is_positional())
        .filter(|arg| is_form_visible_arg(arg))
        .collect::<Vec<_>>();
    others.sort_by_key(|arg| (arg.display_order(), arg.display_label().to_string()));

    positionals.extend(others);
    positionals
        .into_iter()
        .enumerate()
        .map(|(order_index, arg)| OrderedArg {
            order_index,
            arg,
            widget: widget_for(arg),
            section_heading: section_heading(arg, section_heading_prefix),
        })
        .collect()
}

#[cfg(test)]
pub(crate) fn visible_args(command: &CommandSpec, active_tab: ActiveTab) -> Vec<OrderedArg<'_>> {
    match active_tab {
        ActiveTab::Inputs => ordered_args(command),
    }
}

pub(crate) fn visible_args_for_path<'a>(
    root: &'a CommandSpec,
    selected_path: &CommandPath,
    active_tab: ActiveTab,
) -> Vec<OrderedArg<'a>> {
    match active_tab {
        ActiveTab::Inputs => visible_input_args_for_path(root, selected_path),
    }
}

fn visible_input_args_for_path<'a>(
    root: &'a CommandSpec,
    selected_path: &CommandPath,
) -> Vec<OrderedArg<'a>> {
    let Some(lineage) = root.command_lineage(selected_path) else {
        return Vec::new();
    };
    let Some(current) = lineage.last().copied() else {
        return Vec::new();
    };

    let mut ordered = ordered_args_with_heading(current, None)
        .into_iter()
        .filter(|item| item.arg.owner_path() == selected_path)
        .collect::<Vec<_>>();

    for owner in lineage.iter().rev().skip(1) {
        let owner_heading = format!(
            "Inherited from {}",
            format_command_path(&root.name, &owner.path)
        );
        ordered.extend(
            ordered_args_with_heading(owner, Some(owner_heading.as_str()))
                .into_iter()
                .filter(|item| item.arg.owner_path() == &owner.path)
                .filter(|item| !item.arg.is_external_subcommand_field())
                .collect::<Vec<_>>(),
        );
    }

    ordered
        .into_iter()
        .enumerate()
        .map(|(order_index, item)| OrderedArg {
            order_index,
            ..item
        })
        .collect()
}

pub(crate) fn visible_arg_pairs<'a>(args: &[OrderedArg<'a>]) -> Vec<(usize, &'a ArgSpec)> {
    args.iter()
        .map(|item| (item.order_index, item.arg))
        .collect()
}

pub(crate) fn field_has_description(arg: &ArgSpec, field_error: Option<&str>) -> bool {
    field_error.is_some() || arg.help.is_some() || arg.value_hint.is_some()
}

pub(crate) fn field_heading<'a>(
    previous_heading: Option<&'a str>,
    item: &'a OrderedArg<'_>,
) -> Option<&'a str> {
    let heading = item
        .section_heading
        .as_deref()
        .filter(|heading| !heading.is_empty());
    if heading.is_some() && heading != previous_heading {
        heading
    } else {
        None
    }
}

pub(crate) fn field_is_in_section(item: &OrderedArg<'_>) -> bool {
    item.section_heading
        .as_deref()
        .is_some_and(|heading| !heading.is_empty())
}

fn is_help_arg(arg: &ArgSpec) -> bool {
    arg.id == "help" || arg.display_name == "--help" || arg.display_name == "-h"
}

fn is_form_visible_arg(arg: &ArgSpec) -> bool {
    !is_help_arg(arg) && arg.action_kind() != ArgActionKind::Version
}

fn section_heading(arg: &ArgSpec, prefix: Option<&str>) -> Option<String> {
    match (
        prefix,
        arg.help_heading().filter(|heading| !heading.is_empty()),
    ) {
        (Some(prefix), Some(heading)) => Some(format!("{prefix} · {heading}")),
        (Some(prefix), None) => Some(prefix.to_string()),
        (None, heading) => heading.map(str::to_string),
    }
}

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

    use super::{
        FieldWidget, field_heading, ordered_args, visible_args, visible_args_for_path, widget_for,
    };
    use crate::input::ActiveTab;
    use crate::spec::{ArgKind, ArgSpec, CommandPath, CommandSpec};

    fn arg(id: &str, name: &str, kind: ArgKind) -> ArgSpec {
        ArgSpec {
            id: id.to_string(),
            display_name: name.to_string(),
            help: None,
            required: false,
            kind,
            default_values: Vec::new(),
            choices: Vec::new(),
            position: None,
            value_cardinality: crate::spec::ValueCardinality::One,
            value_hint: None,
            ..ArgSpec::default()
        }
    }

    fn command(args: Vec<ArgSpec>) -> CommandSpec {
        CommandSpec {
            name: "tool".to_string(),
            version: None,
            about: None,
            help: String::new(),
            args,
            subcommands: Vec::new(),
            ..CommandSpec::default()
        }
    }

    #[test]
    fn positional_args_are_ordered_before_options() {
        let mut source = vec![
            arg("verbose", "--verbose", ArgKind::Flag),
            arg("path", "path", ArgKind::Positional),
            arg("alpha", "--alpha", ArgKind::Option),
        ];
        source[1].position = Some(1);
        let command = command(source);

        let ordered = ordered_args(&command);
        let names = ordered
            .into_iter()
            .map(|item| item.arg.display_name.clone())
            .collect::<Vec<_>>();

        assert_eq!(names, vec!["path", "--alpha", "--verbose"]);
    }

    #[test]
    fn help_args_are_excluded() {
        let command = command(vec![
            arg("help", "--help", ArgKind::Flag),
            arg("target", "--target", ArgKind::Option),
        ]);

        let ordered = ordered_args(&command);

        assert_eq!(ordered.len(), 1);
        assert_eq!(ordered[0].arg.id, "target");
    }

    #[test]
    fn visible_args_follow_active_tab() {
        let mut positional = arg("path", "path", ArgKind::Positional);
        positional.position = Some(1);
        let option = arg("target", "--target", ArgKind::Option);
        let command = command(vec![positional, option]);

        assert_eq!(visible_args(&command, ActiveTab::Inputs).len(), 2);
    }

    #[test]
    fn visible_args_for_path_keeps_local_fields_primary_and_groups_inherited_owners() {
        let root = CommandSpec::from_command(
            &Command::new("tool")
                .arg(Arg::new("config").long("config"))
                .subcommand(
                    Command::new("build")
                        .arg(Arg::new("target").long("target"))
                        .subcommand(
                            Command::new("release").arg(Arg::new("profile").long("profile")),
                        ),
                ),
        );
        let selected_path = CommandPath::from(vec!["build".to_string(), "release".to_string()]);

        let visible = visible_args_for_path(&root, &selected_path, ActiveTab::Inputs);
        let ids = visible
            .iter()
            .map(|item| item.arg.id.clone())
            .collect::<Vec<_>>();

        assert_eq!(ids, vec!["profile", "target", "config"]);
        assert_eq!(visible[0].section_heading, None);
        assert_eq!(
            visible[1].section_heading.as_deref(),
            Some("Inherited from tool > build")
        );
        assert_eq!(
            visible[2].section_heading.as_deref(),
            Some("Inherited from tool")
        );
    }

    #[test]
    fn autogenerated_version_flags_use_toggle_widget_geometry() {
        let command = CommandSpec::from_command(&Command::new("tool").version("1.2.3"));
        let version = command
            .args
            .iter()
            .find(|arg| arg.display_name == "--version")
            .expect("version arg should be present");

        assert!(matches!(widget_for(version), FieldWidget::Toggle));
    }

    #[test]
    fn autogenerated_version_flags_are_excluded_from_visible_form_args() {
        let command = CommandSpec::from_command(&Command::new("tool").version("1.2.3"));

        assert!(visible_args(&command, ActiveTab::Inputs).is_empty());
    }

    #[test]
    fn heading_rows_contribute_to_form_height_and_offsets() {
        let mut first = arg("first", "--first", ArgKind::Option);
        first.metadata.display.help_heading = Some("Inputs".to_string());
        let mut second = arg("second", "--second", ArgKind::Option);
        second.metadata.display.help_heading = Some("Inputs".to_string());
        let mut third = arg("third", "--third", ArgKind::Option);
        third.metadata.display.help_heading = Some("Outputs".to_string());
        let command = command(vec![first, second, third]);
        let visible = visible_args(&command, ActiveTab::Inputs);

        assert_eq!(field_heading(None, &visible[0]), Some("Inputs"));
        assert_eq!(field_heading(Some("Inputs"), &visible[1]), None);
        assert_eq!(field_heading(Some("Inputs"), &visible[2]), Some("Outputs"));
    }
}