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,
git: bool,
}
impl Ui {
const HEADER_WIDTH: usize = 75;
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,
git: false,
}
}
pub fn with_git(mut self, git: bool) -> Self {
self.git = git;
self
}
pub fn without_git(&self) -> Self {
Self {
git: false,
..self.clone()
}
}
pub fn stage_requested(&self) -> bool {
self.git
}
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 command(&self, cmd: impl AsRef<str>) {
println!(" {}", self.paint(cmd.as_ref(), Style::new().bold().cyan()));
}
pub fn banner_success(&self, msg: impl AsRef<str>) {
self.banner("✔", msg.as_ref(), Style::new().black().on_green().bold());
}
pub fn banner_info(&self, msg: impl AsRef<str>) {
self.banner("ℹ", msg.as_ref(), Style::new().white().on_blue().bold());
}
pub fn banner_warn(&self, msg: impl AsRef<str>) {
self.banner("⚠", msg.as_ref(), Style::new().black().on_yellow().bold());
}
pub fn banner_alert(&self, msg: impl AsRef<str>) {
self.banner("✗", msg.as_ref(), Style::new().white().on_red().bold());
}
fn banner(&self, glyph: &str, msg: &str, style: Style) {
let width = term_width().max(20);
let lines = wrap_text(msg, width - 6);
println!();
if self.color {
let pad = " ".repeat(width);
println!("{}", self.paint(&pad, style));
for (i, line) in lines.iter().enumerate() {
let lead = if i == 0 {
format!(" {glyph} ")
} else {
" ".into()
};
let used = lead.chars().count() + line.chars().count();
let text = format!("{lead}{line}{}", " ".repeat(width.saturating_sub(used)));
println!("{}", self.paint(&text, style));
}
println!("{}", self.paint(&pad, style));
} else {
println!("┌{}┐", "─".repeat(width - 2));
for (i, line) in lines.iter().enumerate() {
let lead = if i == 0 {
format!("{glyph} ")
} else {
" ".into()
};
let used = 4 + lead.chars().count() + line.chars().count();
println!("│ {lead}{line}{} │", " ".repeat(width.saturating_sub(used)));
}
println!("└{}┘", "─".repeat(width - 2));
}
println!();
}
pub fn header(&self, title: impl AsRef<str>) {
let title = title.as_ref();
println!();
println!();
if self.color {
let pad = Self::HEADER_WIDTH.saturating_sub(title.chars().count() + 2);
let band = format!(" {title}{}", " ".repeat(pad));
println!(
"{}",
self.paint(
&self.paint(&band, Style::new().truecolor(255, 255, 255).bold()),
Style::new().on_yellow(),
)
);
} else {
println!("{title}");
}
println!();
}
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 pause(&self, duration: Duration) {
if self.interactive {
std::thread::sleep(duration);
}
}
pub fn press_enter(&self, prompt: &str) -> Result<()> {
if !self.interactive {
return Err(Error::NonInteractive {
prompt: prompt.to_string(),
flag: "an interactive terminal (this step cannot be scripted)".to_string(),
});
}
println!();
print!("{} ", self.paint(prompt, Style::new().bold().cyan()));
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(())
}
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)
}
pub fn text_with_default(&self, prompt: &str, flag: &str, default: &str) -> Result<String> {
self.ensure_interactive(prompt, flag)?;
Text::new(prompt)
.with_default(default)
.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 term_width() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80)
}
fn wrap_text(text: &str, max: usize) -> Vec<String> {
let max = max.max(1);
let mut lines = Vec::new();
for raw in text.lines() {
let mut current = String::new();
let mut count = 0usize;
for word in raw.split_whitespace() {
let chars: Vec<char> = word.chars().collect();
for piece in chars.chunks(max) {
let piece: String = piece.iter().collect();
let sep = usize::from(count > 0);
if count + sep + piece.chars().count() > max {
lines.push(std::mem::take(&mut current));
count = 0;
}
if count > 0 {
current.push(' ');
count += 1;
}
count += piece.chars().count();
current.push_str(&piece);
}
}
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
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,
git: false,
}
}
fn color_ui() -> Ui {
Ui {
color: true,
verbose: true,
interactive: true,
git: false,
}
}
#[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 git_flag_is_off_by_default_and_toggled_by_builder() {
assert!(!color_ui().stage_requested());
let staging = color_ui().with_git(true);
assert!(staging.stage_requested());
let nested = staging.without_git();
assert!(!nested.stage_requested());
assert!(nested.color_enabled());
assert!(nested.is_interactive());
}
#[test]
fn command_prints_without_a_status_glyph() {
noninteractive_ui().command("git commit -m \"x\"");
color_ui().command("git push -u origin HEAD");
}
#[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 { .. }
));
assert!(matches!(
ui.text_with_default("Name?", "--name", "default")
.expect_err("text_with_default should refuse to prompt"),
Error::NonInteractive { .. }
));
assert!(matches!(
ui.press_enter("Press ENTER:")
.expect_err("press_enter should refuse without a terminal"),
Error::NonInteractive { .. }
));
}
#[test]
fn pause_is_a_noop_when_not_interactive() {
noninteractive_ui().pause(Duration::from_secs(3600));
}
#[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 wrap_text_wraps_at_word_boundaries() {
assert_eq!(wrap_text("a bb ccc", 5), vec!["a bb", "ccc"]);
assert_eq!(wrap_text("hello", 80), vec!["hello"]);
assert_eq!(wrap_text("one\n\ntwo", 80), vec!["one", "", "two"]);
assert_eq!(wrap_text("", 80), vec![""]);
}
#[test]
fn wrap_text_hard_splits_overlong_words() {
assert_eq!(wrap_text("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
assert_eq!(wrap_text("x abcdefgh y", 4), vec!["x", "abcd", "efgh", "y"]);
}
#[test]
fn banners_render_in_both_color_modes() {
let plain = noninteractive_ui();
let color = color_ui();
for ui in [&plain, &color] {
ui.banner_success("all set");
ui.banner_info("for your information");
ui.banner_warn("action required — store the key offline");
ui.banner_alert(
"this message is intentionally long enough that it must wrap onto \
several lines inside the banner box regardless of terminal width",
);
}
}
#[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"));
}
}