clap-tui 0.1.3

Auto-generate a TUI from clap commands
Documentation
use std::collections::HashSet;

use crate::spec::{CommandPath, CommandSpec};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TreeItem {
    pub(crate) name: String,
    pub(crate) display_label: String,
    pub(crate) version: Option<String>,
    pub(crate) path: CommandPath,
    pub(crate) has_children: bool,
    pub(crate) indent: usize,
    pub(crate) expanded: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TreeRow {
    Heading { title: String, indent: usize },
    Item(TreeItem),
}

impl TreeItem {
    pub(crate) fn prefix(&self) -> String {
        let indent = "| ".repeat(self.indent / 2);
        if self.has_children {
            let caret = if self.expanded { "-" } else { "+" };
            format!("{indent}{caret} ")
        } else {
            indent
        }
    }

    #[cfg(test)]
    pub(crate) fn label(&self) -> String {
        let mut label = format!("{}{}", self.prefix(), self.display_label);
        if let Some(version) = &self.version {
            label.push(' ');
            label.push_str(version);
        }
        label
    }
}

pub(crate) fn tree_items(
    root: &CommandSpec,
    expanded: &HashSet<String>,
    search: &str,
) -> Vec<TreeItem> {
    tree_rows(root, expanded, search)
        .into_iter()
        .filter_map(|row| match row {
            TreeRow::Item(item) => Some(item),
            TreeRow::Heading { .. } => None,
        })
        .collect()
}

pub(crate) fn tree_rows(
    root: &CommandSpec,
    expanded: &HashSet<String>,
    search: &str,
) -> Vec<TreeRow> {
    let mut rows = Vec::new();
    let filter = search.trim().to_lowercase();
    let root_path = vec![root.name.clone()];
    let mut child_rows = Vec::new();
    for subcommand in &root.subcommands {
        let mut child_path = root_path.clone();
        build_tree_rows_inner(
            subcommand,
            expanded,
            &filter,
            &mut child_path,
            0,
            &mut child_rows,
        );
    }

    if !child_rows.is_empty() {
        if let Some(heading) = root
            .subcommand_help_heading()
            .filter(|heading| !heading.is_empty())
        {
            rows.push(TreeRow::Heading {
                title: heading.to_string(),
                indent: 0,
            });
        }
        rows.extend(child_rows);
    }

    rows
}

fn build_tree_rows_inner(
    cmd: &CommandSpec,
    expanded: &HashSet<String>,
    filter: &str,
    path: &mut Vec<String>,
    depth: usize,
    rows: &mut Vec<TreeRow>,
) -> bool {
    let matches = command_matches_filter(cmd, filter);

    let mut any_child_matches = false;
    path.push(cmd.name.clone());
    let key = path.join("::");

    let is_expanded = expanded.contains(&key);
    let mut child_rows = Vec::new();
    for sub in &cmd.subcommands {
        let mut child_path = path.clone();
        let child_matches = build_tree_rows_inner(
            sub,
            expanded,
            filter,
            &mut child_path,
            depth + 1,
            &mut child_rows,
        );
        if child_matches {
            any_child_matches = true;
        }
    }

    let include = matches || any_child_matches;
    if include {
        let display_path = CommandPath::from(path[1..].to_vec());
        rows.push(TreeRow::Item(TreeItem {
            name: cmd.name.clone(),
            display_label: cmd.display_label().to_string(),
            version: None,
            path: display_path,
            has_children: !cmd.subcommands.is_empty(),
            indent: depth * 2,
            expanded: is_expanded,
        }));
        let show_children = if filter.is_empty() {
            is_expanded
        } else {
            any_child_matches
        };
        if show_children && !child_rows.is_empty() {
            if let Some(heading) = cmd
                .subcommand_help_heading()
                .filter(|heading| !heading.is_empty())
            {
                rows.push(TreeRow::Heading {
                    title: heading.to_string(),
                    indent: depth * 2 + 2,
                });
            }
            rows.extend(child_rows);
        }
    }

    path.pop();
    include
}

fn command_matches_filter(cmd: &CommandSpec, filter: &str) -> bool {
    filter.is_empty()
        || cmd.name.to_lowercase().contains(filter)
        || cmd.display_label().to_lowercase().contains(filter)
        || cmd
            .visible_aliases()
            .iter()
            .any(|alias| alias.to_lowercase().contains(filter))
        || cmd
            .about
            .as_deref()
            .unwrap_or("")
            .to_lowercase()
            .contains(filter)
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use super::{TreeItem, TreeRow, tree_items, tree_rows};
    use crate::spec::{ArgSpec, CommandSpec};

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

    #[test]
    fn root_only_tree_contains_root_item() {
        let root = command("tool", Some("root"), Vec::new());

        let items = tree_items(&root, &HashSet::new(), "");

        assert!(items.is_empty());
    }

    #[test]
    fn expanded_nested_subcommands_are_included() {
        let leaf = command("serve", Some("serve"), Vec::new());
        let child = command("api", Some("api"), vec![leaf]);
        let root = command("tool", Some("root"), vec![child]);
        let expanded = HashSet::from(["tool".to_string(), "tool::api".to_string()]);

        let items = tree_items(&root, &expanded, "");

        let labels = items
            .into_iter()
            .map(|item| item.label())
            .collect::<Vec<_>>();
        assert_eq!(labels, vec!["- api", "| serve"]);
    }

    #[test]
    fn filtered_search_keeps_matching_parent_chain() {
        let leaf = command("deploy", Some("ship release"), Vec::new());
        let child = command("release", Some("release ops"), vec![leaf]);
        let root = command("tool", Some("root"), vec![child]);

        let items = tree_items(&root, &HashSet::new(), "ship");

        let paths = items.into_iter().map(|item| item.path).collect::<Vec<_>>();
        assert_eq!(
            paths,
            vec![
                vec!["release".to_string()].into(),
                vec!["release".to_string(), "deploy".to_string()].into(),
            ]
        );
    }

    #[test]
    fn collapsed_children_are_hidden_without_search() {
        let child = command("build", Some("build"), Vec::new());
        let root = command("tool", Some("root"), vec![child]);
        let expanded = HashSet::from(["tool".to_string()]);

        let items = tree_items(&root, &expanded, "");

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].path, vec!["build".to_string()].into());
    }

    #[test]
    fn filtered_search_matches_visible_aliases() {
        let mut child = command("deploy", Some("ship release"), Vec::new());
        child.display.visible_aliases = vec!["ship".to_string()];
        child.display.display_label = "deploy (ship)".to_string();
        let root = command("tool", Some("root"), vec![child]);

        let items = tree_items(&root, &HashSet::new(), "ship");

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].name, "deploy");
    }

    #[test]
    fn tree_rows_include_subcommand_heading_before_children() {
        let mut root = command(
            "tool",
            Some("root"),
            vec![command("build", Some("build"), Vec::new())],
        );
        root.display.subcommand_help_heading = Some("Applets".to_string());

        let rows = tree_rows(&root, &HashSet::new(), "");

        assert_eq!(
            rows,
            vec![
                TreeRow::Heading {
                    title: "Applets".to_string(),
                    indent: 0,
                },
                TreeRow::Item(TreeItem {
                    name: "build".to_string(),
                    display_label: "build".to_string(),
                    version: None,
                    path: vec!["build".to_string()].into(),
                    has_children: false,
                    indent: 0,
                    expanded: false,
                }),
            ]
        );
    }
}