use std::fmt::Display;
use std::io::{BufRead, IsTerminal, Write};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Copy, Clone, Debug)]
pub struct Ui {
color: bool,
unicode: bool,
interactive: bool,
}
impl Ui {
pub fn new(choice: ColorChoice) -> Self {
let tty = std::io::stdout().is_terminal();
let color = match choice {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => {
tty && std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").as_deref() != Ok("dumb")
}
};
Ui {
color,
unicode: tty,
interactive: std::io::stdin().is_terminal() && std::io::stderr().is_terminal(),
}
}
#[cfg(test)]
pub(crate) fn piped() -> Ui {
Ui {
color: false,
unicode: false,
interactive: false,
}
}
pub fn out(&self, line: impl Display) {
let mut stdout = std::io::stdout().lock();
if let Err(e) = writeln!(stdout, "{line}") {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(BROKEN_PIPE);
}
eprintln!("writing to stdout: {e}");
std::process::exit(1);
}
}
pub fn note(&self, line: impl Display) {
eprintln!("{line}");
}
pub fn warn(&self, line: impl Display) {
eprintln!("{}{line}", self.style("warning: ", YELLOW));
}
pub fn dash(&self) -> &'static str {
if self.unicode {
"—"
} else {
"-"
}
}
pub fn unicode(&self) -> bool {
self.unicode
}
pub fn heading(&self, s: impl Display) -> String {
self.style(s, BOLD_RED)
}
pub fn bold(&self, s: impl Display) -> String {
self.style(s, BOLD)
}
pub fn dim(&self, s: impl Display) -> String {
self.style(s, DIM)
}
pub fn danger(&self, s: impl Display) -> String {
self.style(s, RED)
}
fn style(&self, s: impl Display, code: &str) -> String {
if self.color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn confirm(&self, already: bool) -> Result<(), String> {
if already {
return Ok(());
}
if !self.interactive {
return Err(format!(
"refusing to proceed without {}",
self.bold("--yes")
));
}
eprint!("{} [y/N] ", self.danger("proceed?"));
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.lock()
.read_line(&mut answer)
.map_err(|e| format!("reading the answer: {e}"))?;
match answer.trim() {
"y" | "Y" | "yes" => Ok(()),
_ => Err("canceled".into()),
}
}
pub fn ask(&self, question: &str, initial: &str) -> Result<Option<String>, String> {
if !self.interactive {
return Err(format!("{question:?} needs a terminal to ask on"));
}
let answer = dialoguer::Input::<String>::new()
.with_prompt(question)
.with_initial_text(initial)
.allow_empty(true)
.interact_text()
.map_err(|e| format!("reading the answer: {e}"))?;
let answer = answer.trim();
Ok((!answer.is_empty()).then(|| answer.to_string()))
}
}
const BROKEN_PIPE: i32 = 141;
const BOLD: &str = "1";
const BOLD_RED: &str = "1;31";
const DIM: &str = "2";
const RED: &str = "31";
const YELLOW: &str = "33";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_a_tty_nothing_is_decorated() {
let ui = Ui {
color: false,
unicode: false,
interactive: false,
};
assert_eq!(ui.bold("x"), "x");
assert_eq!(ui.danger("x"), "x");
assert_eq!(ui.heading("x"), "x");
assert_eq!(ui.dim("x"), "x");
assert_eq!(ui.dash(), "-");
assert!(!ui.unicode());
}
#[test]
fn with_color_the_reset_always_closes_the_sequence() {
let ui = Ui {
color: true,
unicode: true,
interactive: false,
};
assert_eq!(ui.bold("x"), "\x1b[1mx\x1b[0m");
assert_eq!(ui.heading("x"), "\x1b[1;31mx\x1b[0m");
assert_eq!(ui.dash(), "—");
assert!(ui.unicode());
}
#[test]
fn a_pipe_without_yes_is_refused_rather_than_asked() {
let ui = Ui {
color: false,
unicode: false,
interactive: false,
};
assert!(ui.confirm(true).is_ok());
let err = ui.confirm(false).unwrap_err();
assert!(err.contains("--yes"), "{err}");
}
#[test]
fn a_pipe_is_never_asked_an_open_question() {
let ui = Ui {
color: false,
unicode: false,
interactive: false,
};
let err = ui.ask("what changed", "").unwrap_err();
assert!(err.contains("terminal"), "{err}");
}
}