promptt 1.1.0

Interactive CLI prompts library, lightweight and easy to use.
Documentation
//! Question registry and prompt runners. Maps `type_name` to element runners.

use crate::elements::*;
use std::io::{self, BufRead, Write};

/// Result value of a single prompt (string or bool).
#[derive(Debug, Clone, PartialEq)]
pub enum PromptValue {
    String(String),
    Bool(bool),
}

/// Question configuration for the sequential prompt flow. Fields map to specific prompt types.
pub struct Question {
    /// Answer key in the returned map.
    pub name: String,
    /// Prompt type: "text", "confirm", "select".
    pub type_name: String,
    /// Display message.
    pub message: String,
    /// Default for text.
    pub initial_text: Option<String>,
    /// Default for confirm.
    pub initial_bool: Option<bool>,
    /// Options for select.
    pub choices: Option<Vec<Choice>>,
    /// Hint text (e.g. select).
    pub hint: Option<String>,
}

impl Default for Question {
    fn default() -> Self {
        Self {
            name: String::new(),
            type_name: String::new(),
            message: String::new(),
            initial_text: None,
            initial_bool: None,
            choices: None,
            hint: None,
        }
    }
}

/// Runs a prompt for the given question. Returns `Some(value)` on success or `None` on skip.
#[inline]
pub fn run_prompt<R: BufRead, W: Write>(
    q: &Question,
    stdin: &mut R,
    stdout: &mut W,
) -> io::Result<Option<PromptValue>> {
    match q.type_name.as_str() {
        "text" => {
            let opts = TextPromptOptions {
                message: q.message.clone(),
                initial: q.initial_text.clone(),
            };
            run_text(&opts, stdin, stdout).map(|s| Some(PromptValue::String(s)))
        }
        "confirm" => {
            let opts = ConfirmPromptOptions {
                message: q.message.clone(),
                initial: q.initial_bool.unwrap_or(false),
                ..Default::default()
            };
            run_confirm(&opts, stdin, stdout).map(|b| Some(PromptValue::Bool(b)))
        }
        "select" => {
            let choices = q.choices.clone().unwrap_or_default();
            let opts = SelectPromptOptions {
                message: q.message.clone(),
                choices,
                initial: None,
                hint: q.hint.clone(),
            };
            run_select(&opts, stdin, stdout).map(|s| Some(PromptValue::String(s)))
        }
        _ => Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("prompt type '{}' is not defined", q.type_name),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn question_default_values() {
        let q = Question::default();
        assert!(q.name.is_empty());
        assert!(q.type_name.is_empty());
        assert!(q.message.is_empty());
        assert!(q.initial_text.is_none());
        assert!(q.initial_bool.is_none());
        assert!(q.choices.is_none());
        assert!(q.hint.is_none());
    }

    #[test]
    fn run_prompt_text() {
        let q = Question {
            name: "name".into(),
            type_name: "text".into(),
            message: "Your name?".into(),
            initial_text: Some("default".into()),
            ..Default::default()
        };
        let mut stdin = Cursor::new(b"Alice\n");
        let mut stdout = Vec::new();
        let out = run_prompt(&q, &mut stdin, &mut stdout);
        assert!(out.is_ok());
        let val = out.unwrap();
        assert!(matches!(val, Some(PromptValue::String(s)) if s == "Alice"));
    }

    #[test]
    fn run_prompt_text_empty_uses_initial() {
        let q = Question {
            name: "x".into(),
            type_name: "text".into(),
            message: "Msg".into(),
            initial_text: Some("init".into()),
            ..Default::default()
        };
        let mut stdin = Cursor::new(b"\n");
        let mut stdout = Vec::new();
        let out = run_prompt(&q, &mut stdin, &mut stdout);
        assert!(out.is_ok());
        let val = out.unwrap();
        assert!(matches!(val, Some(PromptValue::String(s)) if s == "init"));
    }

    #[test]
    fn run_prompt_confirm_yes() {
        let q = Question {
            name: "ok".into(),
            type_name: "confirm".into(),
            message: "Continue?".into(),
            initial_bool: Some(false),
            ..Default::default()
        };
        let mut stdin = Cursor::new(b"y\n");
        let mut stdout = Vec::new();
        let out = run_prompt(&q, &mut stdin, &mut stdout);
        assert!(out.is_ok());
        assert!(matches!(out.unwrap(), Some(PromptValue::Bool(true))));
    }

    #[test]
    fn run_prompt_confirm_no() {
        let q = Question {
            name: "ok".into(),
            type_name: "confirm".into(),
            message: "Continue?".into(),
            initial_bool: Some(true),
            ..Default::default()
        };
        let mut stdin = Cursor::new(b"n\n");
        let mut stdout = Vec::new();
        let out = run_prompt(&q, &mut stdin, &mut stdout);
        assert!(out.is_ok());
        assert!(matches!(out.unwrap(), Some(PromptValue::Bool(false))));
    }

    #[test]
    fn run_prompt_unknown_type_err() {
        let q = Question {
            name: "x".into(),
            type_name: "unknown_type".into(),
            message: "Msg".into(),
            ..Default::default()
        };
        let mut stdin = Cursor::new(b"");
        let mut stdout = Vec::new();
        let out = run_prompt(&q, &mut stdin, &mut stdout);
        assert!(out.is_err());
    }
}