use std::io::{IsTerminal, Write};
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use inquire::{Confirm, MultiSelect, Select, Text};
use owo_colors::{OwoColorize, Style};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct Ui {
color: bool,
verbose: bool,
interactive: bool,
}
impl Ui {
pub fn new(color: bool, verbose: bool, interactive: bool) -> Self {
let stdout_tty = std::io::stdout().is_terminal();
let no_color_env = std::env::var_os("NO_COLOR").is_some();
Self::resolve(color, verbose, interactive, stdout_tty, no_color_env)
}
fn resolve(
color: bool,
verbose: bool,
interactive: bool,
stdout_tty: bool,
no_color_env: bool,
) -> Self {
Self {
color: color && stdout_tty && !no_color_env,
verbose,
interactive: interactive && stdout_tty,
}
}
pub fn color_enabled(&self) -> bool {
self.color
}
pub fn is_interactive(&self) -> bool {
self.interactive
}
pub fn is_verbose(&self) -> bool {
self.verbose
}
fn paint(&self, text: &str, style: Style) -> String {
if self.color {
text.style(style).to_string()
} else {
text.to_string()
}
}
pub fn success(&self, msg: impl AsRef<str>) {
println!(
"{} {}",
self.paint("✔", Style::new().green().bold()),
msg.as_ref()
);
}
pub fn failure(&self, msg: impl AsRef<str>) {
println!(
"{} {}",
self.paint("✗", Style::new().red().bold()),
msg.as_ref()
);
}
pub fn warn(&self, msg: impl AsRef<str>) {
println!(
"{} {}",
self.paint("⚠", Style::new().yellow().bold()),
msg.as_ref()
);
}
pub fn info(&self, msg: impl AsRef<str>) {
println!(
"{} {}",
self.paint("ℹ", Style::new().blue().bold()),
msg.as_ref()
);
}
pub fn debug(&self, msg: impl AsRef<str>) {
if self.verbose {
println!("{}", self.paint(msg.as_ref(), Style::new().dimmed()));
}
}
pub fn header(&self, title: impl AsRef<str>) {
println!();
println!(
"{}",
self.paint(title.as_ref(), Style::new().bold().underline().cyan())
);
}
pub fn animated_line(&self, text: impl AsRef<str>) {
let text = text.as_ref();
if !self.color {
println!("{text}");
return;
}
let palette = [
Style::new().red(),
Style::new().yellow(),
Style::new().green(),
Style::new().cyan(),
Style::new().blue(),
Style::new().magenta(),
];
let mut out = String::new();
for (i, ch) in text.chars().enumerate() {
let style = palette[i % palette.len()];
out.push_str(&ch.to_string().style(style).to_string());
}
println!("{out}");
}
pub fn spinner(&self, message: impl Into<String>) -> ProgressBar {
if !self.color {
return ProgressBar::hidden();
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner())
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]),
);
pb.set_message(message.into());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn flush(&self) {
let _ = std::io::stdout().flush();
}
pub fn select(&self, prompt: &str, flag: &str, options: Vec<String>) -> Result<String> {
self.ensure_interactive(prompt, flag)?;
Select::new(prompt, options).prompt().map_err(into_other)
}
pub fn multi_select(
&self,
prompt: &str,
flag: &str,
options: Vec<String>,
) -> Result<Vec<String>> {
self.ensure_interactive(prompt, flag)?;
MultiSelect::new(prompt, options)
.prompt()
.map_err(into_other)
}
pub fn confirm(&self, prompt: &str, flag: &str, default: bool) -> Result<bool> {
self.ensure_interactive(prompt, flag)?;
Confirm::new(prompt)
.with_default(default)
.prompt()
.map_err(into_other)
}
pub fn text(&self, prompt: &str, flag: &str) -> Result<String> {
self.ensure_interactive(prompt, flag)?;
Text::new(prompt).prompt().map_err(into_other)
}
fn ensure_interactive(&self, prompt: &str, flag: &str) -> Result<()> {
if self.interactive {
Ok(())
} else {
Err(Error::NonInteractive {
prompt: prompt.to_string(),
flag: flag.to_string(),
})
}
}
}
fn into_other<E>(error: E) -> Error
where
E: std::error::Error + Send + Sync + 'static,
{
Error::Other(error.into())
}
#[cfg(test)]
mod tests {
use super::*;
fn noninteractive_ui() -> Ui {
Ui {
color: false,
verbose: false,
interactive: false,
}
}
fn color_ui() -> Ui {
Ui {
color: true,
verbose: true,
interactive: true,
}
}
#[test]
fn resolve_applies_tty_and_no_color_downgrades() {
let ui = Ui::resolve(true, true, true, false, false);
assert!(!ui.color_enabled());
assert!(!ui.is_interactive());
assert!(ui.is_verbose());
let ui = Ui::resolve(true, false, true, true, false);
assert!(ui.color_enabled());
assert!(ui.is_interactive());
let ui = Ui::resolve(true, false, true, true, true);
assert!(!ui.color_enabled());
assert!(ui.is_interactive());
let ui = Ui::resolve(false, false, false, true, false);
assert!(!ui.color_enabled());
assert!(!ui.is_interactive());
}
#[test]
fn new_constructs_from_ambient_environment() {
assert!(Ui::new(true, true, true).is_verbose());
assert!(!Ui::new(true, false, true).is_verbose());
}
#[test]
fn accessors_reflect_constructed_flags() {
let ui = color_ui();
assert!(ui.color_enabled());
assert!(ui.is_verbose());
assert!(ui.is_interactive());
}
#[test]
fn all_prompts_error_in_non_interactive_mode() {
let ui = noninteractive_ui();
assert!(matches!(
ui.select("Pick one", "--choice", vec!["a".into(), "b".into()])
.expect_err("select should refuse to prompt"),
Error::NonInteractive { .. }
));
assert!(matches!(
ui.multi_select("Pick some", "--choices", vec!["a".into()])
.expect_err("multi_select should refuse to prompt"),
Error::NonInteractive { .. }
));
assert!(matches!(
ui.confirm("Proceed?", "--yes", true)
.expect_err("confirm should refuse to prompt"),
Error::NonInteractive { .. }
));
assert!(matches!(
ui.text("Name?", "--name")
.expect_err("text should refuse to prompt"),
Error::NonInteractive { .. }
));
}
#[test]
fn ensure_interactive_is_ok_when_interactive() {
let ui = color_ui();
assert!(ui.ensure_interactive("Proceed?", "--yes").is_ok());
}
#[test]
fn formatters_render_plainly_without_color() {
let ui = noninteractive_ui();
ui.success("done");
ui.failure("nope");
ui.warn("careful");
ui.info("fyi");
ui.header("Section");
ui.debug("hidden");
assert_eq!(ui.paint("plain", Style::new().red()), "plain");
}
#[test]
fn formatters_render_with_color() {
let ui = color_ui();
ui.success("done");
ui.failure("nope");
ui.warn("careful");
ui.info("fyi");
ui.header("Section");
ui.debug("verbose detail");
let painted = ui.paint("plain", Style::new().red());
assert!(painted.contains("plain"));
assert_ne!(painted, "plain");
}
#[test]
fn animated_line_is_plain_without_color() {
let ui = noninteractive_ui();
ui.animated_line("");
ui.animated_line("hello sopsy");
}
#[test]
fn animated_line_colorizes_each_character() {
let ui = color_ui();
ui.animated_line("");
ui.animated_line("the quick brown fox jumps");
}
#[test]
fn hidden_spinner_when_no_color() {
let ui = noninteractive_ui();
let pb = ui.spinner("working");
pb.finish_and_clear();
}
#[test]
fn real_spinner_when_color_enabled() {
let ui = color_ui();
let pb = ui.spinner("working");
pb.finish_and_clear();
}
#[test]
fn flush_does_not_panic() {
noninteractive_ui().flush();
}
#[test]
fn into_other_wraps_standard_errors() {
let err = into_other(std::io::Error::other("boom"));
assert!(matches!(err, Error::Other(_)));
assert!(err.to_string().contains("boom"));
}
}