#[cfg(target_arch = "wasm32")]
use crate::crossterm_stub as crossterm;
use std::fmt;
use std::io::{BufRead, IsTerminal, Write};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reason {
StdinNotTerminal,
StdoutNotTerminal,
StderrNotTerminal,
NoTerminal,
Ci,
DumbTerminal,
Requested,
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Reason::StdinNotTerminal => "standard input is not a terminal",
Reason::StdoutNotTerminal => "standard output is not a terminal",
Reason::StderrNotTerminal => "standard error is not a terminal",
Reason::NoTerminal => "there is no terminal to read keys from",
Reason::Ci => "running under CI",
Reason::DumbTerminal => "TERM is dumb",
Reason::Requested => "interactive mode is turned off",
})
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Fallback {
#[default]
Prompt,
Default,
Error,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Policy {
pub interactive: Option<bool>,
pub fallback: Fallback,
pub tty_keys: bool,
}
impl Policy {
pub fn detect(&self) -> Result<(), Reason> {
self.decide(
std::io::stdin().is_terminal(),
std::io::stdout().is_terminal(),
|name| std::env::var(name).ok(),
)
}
pub fn detect_for(&self, output: crate::session::Output) -> Result<(), Reason> {
use std::io::IsTerminal;
let stdin = std::io::stdin().is_terminal();
if self.interactive == Some(false) {
return Err(Reason::Requested);
}
if !stdin && !(self.tty_keys && crate::session::keys_available()) {
return Err(if self.tty_keys {
Reason::NoTerminal
} else {
Reason::StdinNotTerminal
});
}
if !output.is_terminal() {
return Err(match output {
crate::session::Output::Stdout => Reason::StdoutNotTerminal,
crate::session::Output::Stderr => Reason::StderrNotTerminal,
});
}
self.decide(true, true, |name| std::env::var(name).ok())
}
pub fn decide(
&self,
stdin_terminal: bool,
stdout_terminal: bool,
env: impl Fn(&str) -> Option<String>,
) -> Result<(), Reason> {
if self.interactive == Some(false) {
return Err(Reason::Requested);
}
if !stdin_terminal {
return Err(Reason::StdinNotTerminal);
}
if !stdout_terminal {
return Err(Reason::StdoutNotTerminal);
}
if self.interactive == Some(true) {
return Ok(());
}
if env("TERM").is_some_and(|term| term == "dumb") {
return Err(Reason::DumbTerminal);
}
if env("CI")
.is_some_and(|ci| !matches!(ci.to_ascii_lowercase().as_str(), "" | "0" | "false"))
{
return Err(Reason::Ci);
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NotInteractive {
Terminal(Reason),
NoDefault(Reason),
NoPrompt,
Invalid(String),
Ended,
Interrupted,
}
impl fmt::Display for NotInteractive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NotInteractive::Terminal(reason) => write!(f, "not interactive: {reason}"),
NotInteractive::NoDefault(reason) => {
write!(f, "not interactive ({reason}) and no default value")
}
NotInteractive::NoPrompt => f.write_str("no line-based prompt for this component"),
NotInteractive::Invalid(message) => f.write_str(message),
NotInteractive::Ended => f.write_str("input ended without an answer"),
NotInteractive::Interrupted => f.write_str("interrupted"),
}
}
}
impl std::error::Error for NotInteractive {}
pub trait LineIo {
fn write(&mut self, text: &str);
fn read_line(&mut self) -> Option<String>;
fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
self.read_line().map(Some).ok_or(NotInteractive::Ended)
}
fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
self.write(prompt);
self.read_secret()
}
}
pub(crate) struct Shown<'a>(pub(crate) &'a mut dyn LineIo);
impl LineIo for Shown<'_> {
fn write(&mut self, text: &str) {
let shown: String = text
.chars()
.map(|c| match c {
'\n' => c,
_ => crate::paint::visible(c).unwrap_or(c),
})
.collect();
self.0.write(&shown);
}
fn read_line(&mut self) -> Option<String> {
self.0.read_line()
}
fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
self.0.read_secret()
}
fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
let shown: String = prompt
.chars()
.map(|c| match c {
'\n' => c,
_ => crate::paint::visible(c).unwrap_or(c),
})
.collect();
self.0.prompt_secret(&shown)
}
}
pub struct StdLineIo;
impl LineIo for StdLineIo {
fn write(&mut self, text: &str) {
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(text.as_bytes());
let _ = stderr.flush();
}
fn read_line(&mut self) -> Option<String> {
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()),
}
}
fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
self.prompt_secret("")
}
fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
if !std::io::stdin().is_terminal() {
self.write(prompt);
return self.read_line().map(Some).ok_or(NotInteractive::Ended);
}
let answer = read_hidden(|| self.write(prompt));
self.write("\n");
answer
}
}
fn read_hidden(shown: impl FnOnce()) -> Result<Option<String>, NotInteractive> {
#[cfg(target_arch = "wasm32")]
use crate::crossterm_stub::from_crossterm;
#[cfg(not(target_arch = "wasm32"))]
use crate::event::from_crossterm;
use crate::event::{Event, KeyCode};
struct Raw;
impl Drop for Raw {
fn drop(&mut self) {
crate::session::raw_line(false);
let _ = crossterm::terminal::disable_raw_mode();
}
}
crate::session::raw_line(true);
if crossterm::terminal::enable_raw_mode().is_err() {
crate::session::raw_line(false);
return Err(NotInteractive::Ended);
}
let _raw = Raw;
shown();
let mut line = String::new();
loop {
let event = crossterm::event::read().map_err(|_| NotInteractive::Ended)?;
match from_crossterm(event) {
Some(Event::Key(key)) => {
let ctrl = key.modifiers.ctrl;
match key.code {
KeyCode::Enter => return Ok(Some(line)),
KeyCode::Escape => return Ok(None),
KeyCode::Char('c') if ctrl => return Err(NotInteractive::Interrupted),
KeyCode::Char('d') if ctrl && line.is_empty() => {
return Err(NotInteractive::Ended);
}
KeyCode::Char('u') if ctrl => line.clear(),
KeyCode::Backspace => {
line.pop();
}
KeyCode::Char(c) if !ctrl => line.push(c),
_ => {}
}
}
Some(Event::Paste(text)) => line.push_str(&crate::components::pasted(&text, "")),
_ => {}
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ScriptedLineIo {
pub answers: std::collections::VecDeque<String>,
pub written: String,
pub secrets: usize,
}
impl ScriptedLineIo {
pub fn new<I: IntoIterator<Item = S>, S: Into<String>>(answers: I) -> ScriptedLineIo {
ScriptedLineIo {
answers: answers.into_iter().map(Into::into).collect(),
written: String::new(),
secrets: 0,
}
}
}
impl LineIo for ScriptedLineIo {
fn write(&mut self, text: &str) {
self.written.push_str(text);
}
fn read_line(&mut self) -> Option<String> {
self.answers.pop_front()
}
fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
self.secrets += 1;
self.read_line().map(Some).ok_or(NotInteractive::Ended)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.to_string())
}
}
#[test]
fn decides_from_terminals_and_environment() {
let auto = Policy::default();
assert_eq!(auto.decide(true, true, env(&[])), Ok(()));
assert_eq!(
auto.decide(false, true, env(&[])),
Err(Reason::StdinNotTerminal)
);
assert_eq!(
auto.decide(true, false, env(&[])),
Err(Reason::StdoutNotTerminal)
);
assert_eq!(
auto.decide(true, true, env(&[("TERM", "dumb")])),
Err(Reason::DumbTerminal)
);
assert_eq!(
auto.decide(true, true, env(&[("CI", "true")])),
Err(Reason::Ci)
);
assert_eq!(auto.decide(true, true, env(&[("CI", "false")])), Ok(()));
assert_eq!(auto.decide(true, true, env(&[("CI", "")])), Ok(()));
let forced = Policy {
interactive: Some(true),
..Policy::default()
};
assert_eq!(forced.decide(true, true, env(&[("CI", "1")])), Ok(()));
assert_eq!(
forced.decide(false, true, env(&[])),
Err(Reason::StdinNotTerminal)
);
let off = Policy {
interactive: Some(false),
..Policy::default()
};
assert_eq!(off.decide(true, true, env(&[])), Err(Reason::Requested));
}
#[test]
fn detect_for_honours_a_request_first_whatever_the_output() {
let off = Policy {
interactive: Some(false),
tty_keys: true,
..Policy::default()
};
for output in [crate::Output::Stdout, crate::Output::Stderr] {
assert_eq!(off.detect_for(output), Err(Reason::Requested));
}
}
}