script-wizard 0.3.0

script-wizard is a shell script (Bash) helper program, to delegate the responsibility of asking questions to the user, asking for confirmation, making selections, etc. The normalized response is printed to stdout for the script to consume.
Documentation
use std::process::Command;

use chrono::{NaiveDate, Weekday};
use clap::ValueEnum;
use inquire::{
    autocompletion::Replacement, error::CustomUserError, Confirm, DateSelect, Editor, InquireError,
    MultiSelect, Select, Text,
};

#[derive(Clone, ValueEnum)]
pub enum Confirmation {
    Yes,
    No,
}

fn read_json_array(json: &str) -> Result<Vec<String>, CustomUserError> {
    let a: Vec<String> = serde_json::from_str(json).expect("invalid json array");
    Ok(a)
}

#[derive(Clone, Default)]
pub struct AskAutoCompleter {
    input: String,
    suggestions_json: String,
    suggestions: Vec<String>,
    suggestion_index: usize,
}

impl AskAutoCompleter {
    fn update_input(&mut self, input: &str) -> Result<(), CustomUserError> {
        if input == self.input {
            // No change:
            return Ok(());
        }
        self.input = input.to_string();
        self.suggestion_index = 0;
        Ok(())
    }
}

impl inquire::Autocomplete for AskAutoCompleter {
    fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> {
        self.update_input(input)?;
        self.suggestions = read_json_array(&self.suggestions_json)
            .expect("Couldn't parse suggestions")
            .iter()
            .filter(|s| s.to_lowercase().contains(&input.to_lowercase()))
            .map(|s| String::from(s.clone()))
            .collect();
        Ok(self.suggestions.clone())
    }

    fn get_completion(
        &mut self,
        input: &str,
        highlighted_suggestion: Option<String>,
    ) -> Result<Replacement, CustomUserError> {
        self.update_input(input)?;
        match highlighted_suggestion {
            Some(suggestion) => Ok(Replacement::Some(suggestion)),
            None => {
                if self.suggestions.len() > 0 {
                    self.suggestion_index = (self.suggestion_index + 1) % self.suggestions.len();
                    Ok(Replacement::Some(
                        self.suggestions
                            .get(self.suggestion_index)
                            .unwrap()
                            .to_string(),
                    ))
                } else {
                    Ok(Replacement::None)
                }
            }
        }
    }
}

pub fn ask_prompt(
    question: &str,
    default: &str,
    allow_blank: bool,
    suggestions_json: &str,
    cancel_code: u8,
) -> String {
    if question == "" {
        panic!("Blank question")
    }
    let mut auto_completer = AskAutoCompleter::default();
    auto_completer.suggestions_json = suggestions_json.to_string();
    match allow_blank {
        true => {
            let r: Result<String, InquireError>;
            match default {
                "" => {
                    r = Text::new(question)
                        .with_autocomplete(auto_completer.clone())
                        .prompt();
                }
                _ => {
                    r = Text::new(question)
                        .with_autocomplete(auto_completer.clone())
                        .with_default(default)
                        .prompt();
                }
            }
            if r.is_err() {
                std::process::exit(cancel_code.into());
            }
            r.unwrap()
        }
        false => {
            let mut a = String::from("");
            while a == "" {
                let r: Result<String, InquireError>;
                match default {
                    "" => {
                        r = Text::new(question)
                            .with_autocomplete(auto_completer.clone())
                            .prompt();
                    }
                    _ => {
                        r = Text::new(question)
                            .with_default(default)
                            .with_autocomplete(auto_completer.clone())
                            .prompt();
                    }
                }
                if r.is_err() {
                    std::process::exit(cancel_code.into());
                }
                a = r.unwrap();
            }
            a
        }
    }
}

#[macro_export]
macro_rules! ask {
    ($question: expr, $default: expr, $allow_blank: expr, $suggestions_json: expr, $cancel_code: expr) => {
        ask::ask_prompt($question, $default, $allow_blank, $suggestions_json, $cancel_code)
    };
    ($question: expr, $default: expr, $allow_blank: expr, $suggestions_json: expr) => {
        ask::ask_prompt($question, $default, $allow_blank, $suggestions_json, 1)
    };
    ($question: expr, $default: expr, $allow_blank: expr) => {
        ask::ask_prompt($question, $default, $allow_blank, "", 1)
    };
    ($question: expr, $default: expr) => {
        ask::ask_prompt($question, $default, false, "", 1)
    };
    ($question: expr) => {
        ask::ask_prompt($question, "", false, "", 1)
    };
}
pub use ask;

pub fn confirm(question: &str, default_answer: Option<Confirmation>, cancel_code: u8) -> bool {
    let mut c = Confirm::new(question);
    match default_answer {
        Some(Confirmation::Yes) => c = c.with_default(true),
        Some(Confirmation::No) => c = c.with_default(false),
        _ => (),
    }
    match c.prompt() {
        Ok(true) => true,
        Ok(false) => false,
        Err(_) => std::process::exit(cancel_code.into()),
    }
}

pub fn choose(
    question: &str,
    default: &str,
    options: Vec<&str>,
    numeric: &bool,
    cancel_code: u8,
) -> String {
    let default_index: usize;
    match default.trim().parse::<usize>() {
        Ok(n) => {
            default_index = n;
        }
        Err(_) => {
            default_index = options.iter().position(|&r| r == default).unwrap_or(0);
        }
    }
    let ans: Result<&str, InquireError> = Select::new(question, options.clone())
        .with_starting_cursor(default_index)
        .with_help_message("up/down to move, enter to select, type to filter, ESC to cancel")
        .prompt();
    match ans {
        Ok(selection) => match numeric {
            true => {
                let index = options.iter().position(|&r| r == selection).unwrap();
                format!("{}", index)
            }
            false => String::from(selection),
        },
        Err(_) => std::process::exit(cancel_code.into()),
    }
}

pub fn select(question: &str, default: &str, options: Vec<&str>, cancel_code: u8) -> Vec<String> {
    let defaults: Vec<&str> = serde_json::from_str(default).unwrap_or(vec![]);
    let mut default_indices = vec![];
    for (index, item) in options.iter().enumerate() {
        match defaults.iter().find(|&r| r == item) {
            Some(_) => default_indices.append(&mut vec![index]),
            None => {}
        };
    }
    let ans = MultiSelect::new(question, options)
        .with_default(&default_indices)
        .with_help_message("spacebar: toggle one, right/left: select all/none, type to filter, ESC to cancel")
        .prompt();
    match ans {
        Ok(selection) => selection.iter().map(|&x| x.into()).collect(),
        Err(_) => std::process::exit(cancel_code.into()),
    }
}

pub fn date(
    question: &str,
    default: &str,
    min_date: &str,
    max_date: &str,
    starting_date: &str,
    week_start: Weekday,
    help_message: &str,
    date_format: &str,
    cancel_code: u8,
) -> String {
    let mut picker = DateSelect::new(question)
        .with_starting_date(
            NaiveDate::parse_from_str(default, date_format)
                .unwrap_or(chrono::Local::now().naive_local().into()),
        )
        .with_min_date(NaiveDate::parse_from_str(min_date, date_format).unwrap_or(NaiveDate::MIN))
        .with_max_date(NaiveDate::parse_from_str(max_date, date_format).unwrap_or(NaiveDate::MAX))
        .with_week_start(week_start)
        .with_help_message(help_message);
    if let Ok(d) = NaiveDate::parse_from_str(starting_date, date_format) {
        picker = picker.with_starting_date(d);
    }
    match picker.prompt() {
        Ok(date) => date.format(date_format).to_string(),
        Err(_) => std::process::exit(cancel_code.into()),
    }
}

pub fn editor(message: &str, default: &str, help_message: &str, file_extension: &str, cancel_code: u8) -> String {
    let ans = Editor::new(message)
        .with_predefined_text(default)
        .with_help_message(help_message)
        .with_file_extension(file_extension)
        .prompt();
    match ans {
        Ok(text) => text,
        Err(_) => std::process::exit(cancel_code.into()),
    }
}

pub fn menu(
    heading: &str,
    entries: &Vec<String>,
    default: &Option<String>,
    once: &bool,
    cancel_code: u8,
) -> Result<usize, u8> {
    if cfg!(target_os = "windows") {
        eprintln!("Error: the 'menu' subcommand is not supported on Windows.");
        eprintln!("Use 'choose' with --numeric to implement your own menu loop.");
        std::process::exit(1);
    }
    let mut new_default: String = default.clone().unwrap_or("".to_string());
    loop {
        eprintln!("");
        let titles: Vec<&str> = entries
            .iter()
            .map(|e| e.split(" = ").collect::<Vec<&str>>()[0])
            .collect();
        let commands: Vec<&str> = entries
            .iter()
            .map(|e| e.split(" = ").collect::<Vec<&str>>()[1])
            .collect();
        let command_index = choose(heading, new_default.as_str(), titles, &true, cancel_code)
            .parse::<usize>()
            .unwrap_or(1);

        new_default = command_index.to_string();

        // Run the command:
        let cmd = commands[command_index];
        let status = Command::new("/bin/bash")
            .args(["-c", cmd])
            .status()
            .unwrap();

        match status.code().unwrap_or(1) {
            0 => {
                //Keep looping unless --once is given:
                if *once {
                    return Ok(0);
                }
            }
            2 => {
                // Ok(2) signals to quit the loop:
                return Ok(2);
            }
            _ => {
                return Err(1);
            }
        }
    }
}