nils-term 1.9.5

Library crate for nils-term in the nils-cli workspace.
Documentation
use std::fmt;
use std::io::{self, BufRead, IsTerminal, Write};

#[derive(Debug, Clone, Copy)]
pub struct PromptOptions {
    assume_yes: bool,
    require_tty: bool,
}

impl Default for PromptOptions {
    fn default() -> Self {
        Self::new()
    }
}

impl PromptOptions {
    pub fn new() -> Self {
        Self {
            assume_yes: false,
            require_tty: true,
        }
    }

    pub fn with_assume_yes(mut self, assume_yes: bool) -> Self {
        self.assume_yes = assume_yes;
        self
    }

    pub fn with_require_tty(mut self, require_tty: bool) -> Self {
        self.require_tty = require_tty;
        self
    }
}

#[derive(Debug)]
pub enum PromptError {
    NonInteractive,
    Io(io::Error),
}

impl fmt::Display for PromptError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NonInteractive => write!(f, "confirmation requires an interactive terminal"),
            Self::Io(err) => write!(f, "{err}"),
        }
    }
}

impl std::error::Error for PromptError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::NonInteractive => None,
            Self::Io(err) => Some(err),
        }
    }
}

impl From<io::Error> for PromptError {
    fn from(value: io::Error) -> Self {
        Self::Io(value)
    }
}

pub fn confirm(
    question: &str,
    default_no: bool,
    options: PromptOptions,
) -> Result<bool, PromptError> {
    if options.assume_yes {
        return Ok(true);
    }
    if options.require_tty && (!io::stdin().is_terminal() || !io::stderr().is_terminal()) {
        return Err(PromptError::NonInteractive);
    }

    let stdin = io::stdin();
    let mut input = stdin.lock();
    let stderr = io::stderr();
    let mut output = stderr.lock();
    confirm_with_io(question, default_no, &mut input, &mut output)
}

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

    let mut line = String::new();
    input.read_line(&mut line)?;
    let normalized = line.trim().to_ascii_lowercase();
    if matches!(normalized.as_str(), "y" | "yes") {
        return Ok(true);
    }
    if normalized.is_empty() {
        return Ok(!default_no);
    }
    Ok(false)
}

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

    #[test]
    fn confirm_with_io_accepts_y_and_yes() {
        for input in ["y\n", "Y\n", " yes \n"] {
            let mut input = Cursor::new(input);
            let mut output = Vec::new();
            let confirmed =
                confirm_with_io("Proceed? [y/N] ", true, &mut input, &mut output).expect("prompt");
            assert!(confirmed);
            assert_eq!(String::from_utf8_lossy(&output), "Proceed? [y/N] ");
        }
    }

    #[test]
    fn confirm_with_io_rejects_default_no_empty_input() {
        let mut input = Cursor::new("\n");
        let mut output = Vec::new();
        let confirmed =
            confirm_with_io("Proceed? [y/N] ", true, &mut input, &mut output).expect("prompt");
        assert!(!confirmed);
    }

    #[test]
    fn confirm_with_io_accepts_empty_input_when_default_is_yes() {
        let mut input = Cursor::new("\n");
        let mut output = Vec::new();
        let confirmed =
            confirm_with_io("Proceed? [Y/n] ", false, &mut input, &mut output).expect("prompt");
        assert!(confirmed);
    }

    #[test]
    fn prompt_options_default_requires_tty() {
        let options = PromptOptions::new();
        assert!(options.require_tty);
        assert!(!options.assume_yes);
    }
}