clap-tui 0.1.0

Auto-generate a TUI from clap commands
Documentation
use std::collections::VecDeque;
use std::ffi::OsString;
use std::time::Duration;

use clap::{Arg, ArgAction, Command};
use clap_tui::{
    AppEvent, AppKeyCode, AppKeyEvent, AppKeyModifiers, Runtime, TuiApp, TuiConfig, TuiError,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;

#[derive(Debug)]
struct ScriptedRuntime {
    events: VecDeque<AppEvent>,
}

impl ScriptedRuntime {
    fn new(events: impl IntoIterator<Item = AppEvent>) -> Self {
        Self {
            events: events.into_iter().collect(),
        }
    }
}

impl Runtime for ScriptedRuntime {
    type Backend = TestBackend;

    fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
        Terminal::new(TestBackend::new(80, 24)).map_err(TuiError::from)
    }

    fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}

    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
        Ok(!self.events.is_empty())
    }

    fn read_event(&mut self) -> Result<AppEvent, TuiError> {
        Ok(self.events.pop_front().expect("queued event"))
    }

    fn copy_to_clipboard(&mut self, _text: &str) -> Result<(), String> {
        Ok(())
    }
}

fn key(code: AppKeyCode) -> AppEvent {
    AppEvent::Key(AppKeyEvent::new(code, AppKeyModifiers::default()))
}

fn ctrl(code: AppKeyCode) -> AppEvent {
    AppEvent::Key(AppKeyEvent::new(
        code,
        AppKeyModifiers {
            control: true,
            ..AppKeyModifiers::default()
        },
    ))
}

fn argv_to_strings(argv: Vec<OsString>) -> Vec<String> {
    argv.into_iter()
        .map(|token| token.to_string_lossy().to_string())
        .collect()
}

fn text(input: &str) -> Vec<AppEvent> {
    input.chars().map(|c| key(AppKeyCode::Char(c))).collect()
}

#[test]
fn repeated_occurrence_input_round_trips_through_the_real_app_loop() {
    let command = Command::new("tool").arg(
        Arg::new("include")
            .long("include")
            .action(ArgAction::Append)
            .num_args(1),
    );
    let mut events = vec![key(AppKeyCode::Tab), key(AppKeyCode::Tab)];
    events.extend(text("alpha"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("beta"));
    events.push(ctrl(AppKeyCode::Char('r')));

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--include".to_string(),
            "alpha".to_string(),
            "--include".to_string(),
            "beta".to_string(),
        ]
    );
}

#[test]
fn grouped_multi_value_input_round_trips_through_the_real_app_loop() {
    let command = Command::new("tool").arg(Arg::new("pair").long("pair").num_args(1..));
    let mut events = vec![key(AppKeyCode::Tab), key(AppKeyCode::Tab)];
    events.extend(text("alpha"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("beta"));
    events.push(ctrl(AppKeyCode::Char('r')));

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--pair".to_string(),
            "alpha".to_string(),
            "beta".to_string(),
        ]
    );
}

#[test]
fn delimited_append_input_round_trips_through_the_real_app_loop() {
    let command = Command::new("tool").arg(
        Arg::new("tag")
            .long("tag")
            .action(ArgAction::Append)
            .num_args(1..)
            .value_delimiter(','),
    );
    let mut events = vec![key(AppKeyCode::Tab), key(AppKeyCode::Tab)];
    events.extend(text("alpha,beta"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("gamma"));
    events.push(ctrl(AppKeyCode::Char('r')));

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--tag=alpha,beta".to_string(),
            "--tag=gamma".to_string(),
        ]
    );
}

#[test]
fn fixed_arity_append_input_preserves_grouped_occurrences() {
    let command = Command::new("tool").arg(
        Arg::new("define")
            .long("define")
            .action(ArgAction::Append)
            .num_args(2),
    );
    let mut events = vec![key(AppKeyCode::Tab), key(AppKeyCode::Tab)];
    events.extend(text("KEY1"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("VALUE1"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("KEY2"));
    events.push(key(AppKeyCode::Enter));
    events.extend(text("VALUE2"));
    events.push(ctrl(AppKeyCode::Char('r')));

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--define".to_string(),
            "KEY1".to_string(),
            "VALUE1".to_string(),
            "--define".to_string(),
            "KEY2".to_string(),
            "VALUE2".to_string(),
        ]
    );
}

#[test]
fn positional_append_input_keeps_space_separated_values_in_one_occurrence() {
    let command = Command::new("tool").arg(
        Arg::new("path")
            .required(true)
            .index(1)
            .action(ArgAction::Append)
            .num_args(1..),
    );
    let mut events = vec![key(AppKeyCode::Tab), key(AppKeyCode::Tab)];
    events.extend(text("src tests"));
    events.push(ctrl(AppKeyCode::Char('r')));

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec!["tool".to_string(), "src".to_string(), "tests".to_string(),]
    );
}

#[test]
fn paste_event_round_trips_through_the_real_app_loop() {
    let command = Command::new("tool").arg(Arg::new("path").long("path"));
    let events = vec![
        key(AppKeyCode::Tab),
        key(AppKeyCode::Tab),
        AppEvent::Paste("/tmp/foo".to_string()),
        ctrl(AppKeyCode::Char('r')),
    ];

    let argv = TuiApp::from_command(command)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--path".to_string(),
            "/tmp/foo".to_string(),
        ]
    );
}

#[test]
fn inherited_global_choice_fields_work_from_descendant_forms() {
    let command = Command::new("tool")
        .arg(
            Arg::new("color")
                .long("color")
                .global(true)
                .value_parser(["red", "green", "blue"]),
        )
        .subcommand(Command::new("admin"));
    let mut config = TuiConfig::default();
    config.start_command = Some("admin".to_string());
    let events = vec![
        key(AppKeyCode::Tab),
        key(AppKeyCode::Tab),
        key(AppKeyCode::Enter),
        key(AppKeyCode::Down),
        key(AppKeyCode::Enter),
        ctrl(AppKeyCode::Char('r')),
    ];

    let argv = TuiApp::from_command(command)
        .with_config(config)
        .with_runtime(ScriptedRuntime::new(events))
        .run()
        .expect("app run should succeed")
        .expect("run should return argv");
    let argv = argv_to_strings(argv);

    assert_eq!(
        argv,
        vec![
            "tool".to_string(),
            "--color".to_string(),
            "green".to_string(),
            "admin".to_string(),
        ]
    );
}