nils-git-cli 0.7.3

CLI crate for nils-git-cli in the nils-cli workspace.
Documentation
use std::io::{self, BufRead, Write};

#[derive(Debug, thiserror::Error)]
pub enum PromptError {
    #[error("aborted")]
    Aborted,
    #[error(transparent)]
    Io(#[from] io::Error),
}

pub fn confirm(prompt: &str) -> Result<bool, PromptError> {
    let stdin = io::stdin();
    let mut input = stdin.lock();
    let mut output = io::stdout();
    confirm_with_io(prompt, &mut input, &mut output)
}

pub fn confirm_with_io(
    prompt: &str,
    input: &mut impl BufRead,
    output: &mut impl Write,
) -> Result<bool, PromptError> {
    write!(output, "{prompt}")?;
    output.flush()?;

    let mut line = String::new();
    input.read_line(&mut line)?;
    let trimmed = line.trim_end_matches(['\n', '\r']);
    Ok(matches!(trimmed, "y" | "Y"))
}

pub fn confirm_or_abort(prompt: &str) -> Result<(), PromptError> {
    let stdin = io::stdin();
    let mut input = stdin.lock();
    let mut output = io::stdout();
    confirm_or_abort_with_io(prompt, &mut input, &mut output)
}

pub fn confirm_or_abort_with_io(
    prompt: &str,
    input: &mut impl BufRead,
    output: &mut impl Write,
) -> Result<(), PromptError> {
    if confirm_with_io(prompt, input, output)? {
        return Ok(());
    }

    writeln!(output, "🚫 Aborted")?;
    Err(PromptError::Aborted)
}

pub fn select_menu_with_io(
    prompt: &str,
    valid_choices: &[&str],
    default_choice: &str,
    input: &mut impl BufRead,
    output: &mut impl Write,
) -> Result<String, PromptError> {
    write!(output, "{prompt}")?;
    output.flush()?;

    let mut line = String::new();
    input.read_line(&mut line)?;
    let trimmed = line.trim_end_matches(['\n', '\r']).trim();
    let choice = if trimmed.is_empty() {
        default_choice
    } else {
        trimmed
    };

    if valid_choices.contains(&choice) {
        return Ok(choice.to_string());
    }

    writeln!(output, "🚫 Aborted")?;
    Err(PromptError::Aborted)
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::{assert_eq, assert_ne};
    use std::io::Cursor;

    #[test]
    fn confirm_accepts_only_y_or_uppercase_y() {
        let mut out: Vec<u8> = Vec::new();

        let mut input = Cursor::new("y\n");
        assert_eq!(confirm_with_io("?", &mut input, &mut out).unwrap(), true);

        let mut input = Cursor::new("Y\n");
        assert_eq!(confirm_with_io("?", &mut input, &mut out).unwrap(), true);

        let mut input = Cursor::new("yes\n");
        assert_eq!(confirm_with_io("?", &mut input, &mut out).unwrap(), false);

        let mut input = Cursor::new("\n");
        assert_eq!(confirm_with_io("?", &mut input, &mut out).unwrap(), false);
    }

    #[test]
    fn confirm_or_abort_prints_aborted_and_errors_on_decline() {
        let mut out: Vec<u8> = Vec::new();
        let mut input = Cursor::new("n\n");

        let err = confirm_or_abort_with_io("prompt ", &mut input, &mut out).unwrap_err();
        assert_ne!(out.len(), 0);
        assert_eq!(String::from_utf8_lossy(&out), "prompt 🚫 Aborted\n");
        assert!(matches!(err, PromptError::Aborted));
    }

    #[test]
    fn select_menu_defaults_and_aborts_on_invalid() {
        let mut out: Vec<u8> = Vec::new();

        let mut input = Cursor::new("\n");
        let v = select_menu_with_io("choose ", &["1", "2"], "2", &mut input, &mut out).unwrap();
        assert_eq!(v, "2");

        let mut out: Vec<u8> = Vec::new();
        let mut input = Cursor::new("nope\n");
        let err =
            select_menu_with_io("choose ", &["1", "2"], "2", &mut input, &mut out).unwrap_err();
        assert!(matches!(err, PromptError::Aborted));
        assert_eq!(String::from_utf8_lossy(&out), "choose 🚫 Aborted\n");
    }
}