//! Interactive prompting layer.
//!
//! Eris can run in two modes (per its design): non-interactive/CI, where every
//! input comes from flags, args, and env vars and prompting is never used; and
//! interactive, where missing values are collected from the user via stdin.
//!
//! Whether prompting happens is decided by [`InputMode`]:
//! - `Never` โ never prompt (CI-safe); missing required values are errors.
//! - `Always` โ always prompt (forced via `-i`/`--interactive`).
//! - `Auto` โ prompt only when stdin is a terminal.
//!
//! The [`Prompter`] is generic over its reader and writer so it can be unit
//! tested with in-memory buffers.
use std::io::{self, BufRead, IsTerminal, Write};
/// How Eris should decide whether to prompt for missing input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputMode {
/// Prompt only when stdin is an interactive terminal.
Auto,
/// Always prompt, regardless of whether stdin is a terminal.
Always,
/// Never prompt; missing required values become errors.
Never,
}
impl InputMode {
/// Resolve whether prompting should occur, given whether stdin is a TTY.
#[must_use]
pub fn is_interactive(self, stdin_is_tty: bool) -> bool {
match self {
InputMode::Always => true,
InputMode::Never => false,
InputMode::Auto => stdin_is_tty,
}
}
}
/// Collects input from a reader while writing prompts to a writer.
///
/// When `interactive` is `false`, [`Prompter::require`] never reads input and
/// instead uses the supplied default (or errors if there is none).
pub struct Prompter<R: BufRead, W: Write> {
reader: R,
writer: W,
interactive: bool,
}
impl<R: BufRead, W: Write> Prompter<R, W> {
/// Create a new prompter over the given reader/writer.
pub fn new(reader: R, writer: W, interactive: bool) -> Self {
Self {
reader,
writer,
interactive,
}
}
/// Whether this prompter will actually prompt for input.
#[must_use]
pub fn interactive(&self) -> bool {
self.interactive
}
/// Resolve a required value.
///
/// - Non-interactive: return `default` if present, else an error.
/// - Interactive: prompt (showing `default` if any). An empty response uses
/// the default when one exists, otherwise the user is re-prompted. On EOF,
/// the default is used if present, else an error is returned.
pub fn require(&mut self, label: &str, default: Option<&str>) -> io::Result<String> {
if !self.interactive {
return default.map(str::to_string).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("missing required value: {label}"),
)
});
}
loop {
match default {
Some(d) => write!(self.writer, "{label} [{d}]: ")?,
None => write!(self.writer, "{label}: ")?,
}
self.writer.flush()?;
let mut line = String::new();
let read = self.reader.read_line(&mut line)?;
if read == 0 {
// EOF: fall back to default or fail.
return default.map(str::to_string).ok_or_else(|| {
io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("no input provided for: {label}"),
)
});
}
let trimmed = line.trim();
if trimmed.is_empty() {
if let Some(d) = default {
return Ok(d.to_string());
}
writeln!(self.writer, " (a value is required)")?;
continue;
}
return Ok(trimmed.to_string());
}
}
}
/// Build a [`Prompter`] wired to real stdin (for reading) and stderr (for
/// prompts, so stdout stays clean for machine-consumable output).
///
/// The `mode` and stdin's TTY status together decide whether prompting occurs.
#[must_use]
pub fn stdio(mode: InputMode) -> Prompter<io::BufReader<io::Stdin>, io::Stderr> {
let interactive = mode.is_interactive(io::stdin().is_terminal());
Prompter::new(io::BufReader::new(io::stdin()), io::stderr(), interactive)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn prompter(input: &str, interactive: bool) -> Prompter<Cursor<Vec<u8>>, Vec<u8>> {
Prompter::new(
Cursor::new(input.as_bytes().to_vec()),
Vec::new(),
interactive,
)
}
#[test]
fn input_mode_resolution() {
assert!(InputMode::Always.is_interactive(false));
assert!(!InputMode::Never.is_interactive(true));
assert!(InputMode::Auto.is_interactive(true));
assert!(!InputMode::Auto.is_interactive(false));
}
#[test]
fn non_interactive_uses_default() {
let mut p = prompter("", false);
assert_eq!(p.require("from", Some("HEAD")).unwrap(), "HEAD");
}
#[test]
fn non_interactive_missing_default_errors() {
let mut p = prompter("", false);
let err = p.require("from", None).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn interactive_reads_value() {
let mut p = prompter("v1.2.0\n", true);
assert_eq!(p.require("from", None).unwrap(), "v1.2.0");
}
#[test]
fn interactive_trims_whitespace() {
let mut p = prompter(" main \n", true);
assert_eq!(p.require("to", None).unwrap(), "main");
}
#[test]
fn interactive_empty_uses_default() {
let mut p = prompter("\n", true);
assert_eq!(p.require("to", Some("HEAD")).unwrap(), "HEAD");
}
#[test]
fn interactive_empty_then_value_when_required() {
// First line empty (re-prompt), second line provides the value.
let mut p = prompter("\nrelease\n", true);
assert_eq!(p.require("from", None).unwrap(), "release");
}
#[test]
fn interactive_eof_without_default_errors() {
let mut p = prompter("", true);
let err = p.require("from", None).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn interactive_eof_with_default_uses_default() {
let mut p = prompter("", true);
assert_eq!(p.require("to", Some("HEAD")).unwrap(), "HEAD");
}
#[test]
fn prompt_text_includes_default_hint() {
let mut p = prompter("\n", true);
let _ = p.require("to", Some("HEAD")).unwrap();
let written = String::from_utf8(p.writer).unwrap();
assert!(written.contains("to [HEAD]:"));
}
}