bashers 0.8.8

Installable cli helpers
Documentation
use anyhow::{Context, Result};
use spinoff::{spinners, Color, Spinner, Streams};
use std::io::Write;
use std::process::{Command, ExitStatus};
use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor};

pub fn create_spinner(msg: &str) -> Option<Spinner> {
    if !should_show_spinner() {
        return None;
    }
    let msg = colorize_spinner_message(msg, Color::Cyan);
    Some(Spinner::new_with_stream(
        spinners::Arrow2,
        msg,
        Color::Cyan,
        Streams::Stderr,
    ))
}

pub fn finish_with_message(sp: Option<&mut Spinner>, message: &str) {
    if let Some(sp) = sp {
        sp.clear();
        print_success_message_replace_line(message);
    }
}

pub fn stop_spinner(sp: Option<&mut Spinner>) {
    if let Some(sp) = sp {
        sp.stop();
    }
}

pub fn print_success_message(message: &str) {
    let mut stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) {
        ColorChoice::Auto
    } else {
        ColorChoice::Never
    });
    let _ = stderr.set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Green)));
    let _ = writeln!(stderr, "{}", message);
    let _ = stderr.reset();
    let _ = stderr.flush();
}

pub fn print_success_message_replace_line(message: &str) {
    let mut stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) {
        ColorChoice::Auto
    } else {
        ColorChoice::Never
    });
    let _ = write!(stderr, "\r\x1b[K");
    let _ = stderr.set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Green)));
    let _ = writeln!(stderr, "{}", message);
    let _ = stderr.reset();
    let _ = stderr.flush();
}

pub fn print_failure_message(message: &str) {
    let mut stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) {
        ColorChoice::Auto
    } else {
        ColorChoice::Never
    });
    let _ = stderr.set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Red)));
    let _ = writeln!(stderr, "{}", message);
    let _ = stderr.reset();
    let _ = stderr.flush();
}

pub fn should_show_spinner() -> bool {
    if std::env::var("NO_SPINNER").is_ok() {
        return false;
    }
    atty::is(atty::Stream::Stdout)
}

fn colorize_spinner_message(msg: &str, color: Color) -> String {
    if !atty::is(atty::Stream::Stderr) {
        return msg.to_string();
    }
    let code = match color {
        Color::Red => "\x1b[31m",
        Color::Green => "\x1b[32m",
        Color::Yellow => "\x1b[33m",
        Color::Blue => "\x1b[34m",
        Color::Cyan => "\x1b[36m",
        Color::White | Color::Magenta | Color::Black | _ => "\x1b[0m",
    };
    format!("{}{}\x1b[0m", code, msg)
}

const DEFAULT_SUCCESS_MESSAGE: &str = "Updated";

pub fn run_with_completion<T, E>(
    dry_run: bool,
    spinner_msg: &str,
    success_msg: &str,
    color: Option<Color>,
    f: impl FnOnce() -> std::result::Result<T, E>,
    is_success: impl FnOnce(&T) -> bool,
) -> std::result::Result<T, E> {
    let show = !dry_run && should_show_spinner();
    let mut sp = if show {
        let color = color.unwrap_or(Color::Green);
        let msg = colorize_spinner_message(spinner_msg, color);
        Some(Spinner::new_with_stream(
            spinners::Arrow2,
            msg,
            color,
            Streams::Stderr,
        ))
    } else {
        None
    };
    let start = std::time::Instant::now();
    let result = f();
    if let Some(ref mut sp) = sp {
        const MIN_DISPLAY: std::time::Duration = std::time::Duration::from_millis(200);
        if let Some(remaining) = MIN_DISPLAY.checked_sub(start.elapsed()) {
            std::thread::sleep(remaining);
        }
        if let Ok(ref t) = result {
            if is_success(t) {
                sp.clear();
                print_success_message_replace_line(success_msg);
            } else {
                sp.clear();
            }
        } else {
            sp.clear();
        }
    }
    result
}

pub fn run_with_spinner(message: &str, command: &mut Command) -> Result<ExitStatus> {
    run_with_spinner_and_message(message, command, None)
}

pub fn run_with_spinner_and_message(
    message: &str,
    command: &mut Command,
    success_message: Option<&str>,
) -> Result<ExitStatus> {
    let mut sp = if should_show_spinner() {
        let msg = colorize_spinner_message(message, Color::Cyan);
        Some(Spinner::new_with_stream(
            spinners::Arrow2,
            msg,
            Color::Cyan,
            Streams::Stderr,
        ))
    } else {
        None
    };

    let output = command.output().context("Failed to run command")?;
    let status = output.status;

    if let Some(ref mut sp) = sp {
        if status.success() {
            let msg = success_message.unwrap_or(DEFAULT_SUCCESS_MESSAGE);
            sp.clear();
            print_success_message_replace_line(msg);
        } else {
            sp.clear();
        }
    }

    let _ = std::io::stdout().write_all(&output.stdout);
    let _ = std::io::stderr().write_all(&output.stderr);
    let _ = std::io::stdout().flush();
    let _ = std::io::stderr().flush();

    Ok(status)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;

    #[test]
    fn test_should_show_spinner() {
        let _: bool = should_show_spinner();
    }

    #[test]
    fn test_spinner_with_no_spinner_env() {
        std::env::set_var("NO_SPINNER", "1");
        assert!(!should_show_spinner());
        std::env::remove_var("NO_SPINNER");
    }

    #[test]
    fn test_spinner_without_no_spinner_env() {
        std::env::remove_var("NO_SPINNER");
        let _: bool = should_show_spinner();
    }

    #[test]
    fn test_run_with_completion_dry_run_success() {
        let out: Result<i32, ()> =
            run_with_completion(true, "msg", "done", None, || Ok(42), |&x| x > 0);
        assert!(out.is_ok());
        assert_eq!(out.unwrap(), 42);
    }

    #[test]
    fn test_run_with_completion_dry_run_failure() {
        let out: Result<i32, ()> =
            run_with_completion(true, "msg", "done", None, || Ok(0), |&x| x > 0);
        assert!(out.is_ok());
        assert_eq!(out.unwrap(), 0);
    }

    #[test]
    fn test_run_with_completion_dry_run_err() {
        let out: Result<i32, &str> =
            run_with_completion(true, "msg", "done", None, || Err("error"), |&x: &i32| x > 0);
        assert!(out.is_err());
        assert_eq!(out.unwrap_err(), "error");
    }

    #[test]
    fn test_run_with_completion_no_spinner_success() {
        std::env::set_var("NO_SPINNER", "1");
        let out: Result<i32, ()> =
            run_with_completion(false, "msg", "done", None, || Ok(1), |&x| x > 0);
        std::env::remove_var("NO_SPINNER");
        assert!(out.is_ok());
        assert_eq!(out.unwrap(), 1);
    }

    #[test]
    fn test_run_with_completion_no_spinner_err() {
        std::env::set_var("NO_SPINNER", "1");
        let out: Result<i32, &str> =
            run_with_completion(false, "msg", "done", None, || Err("fail"), |&x: &i32| x > 0);
        std::env::remove_var("NO_SPINNER");
        assert!(out.is_err());
    }

    #[test]
    fn test_print_success_message_no_panic() {
        print_success_message("test");
    }

    #[test]
    fn test_print_success_message_replace_line_no_panic() {
        print_success_message_replace_line("test");
    }

    #[test]
    fn test_print_failure_message_no_panic() {
        print_failure_message("test");
    }

    #[test]
    fn test_finish_with_message_none_no_panic() {
        finish_with_message(None, "msg");
    }

    #[test]
    fn test_stop_spinner_none_no_panic() {
        stop_spinner(None);
    }

    #[test]
    fn test_create_spinner_returns_none_when_no_spinner() {
        std::env::set_var("NO_SPINNER", "1");
        let sp = create_spinner("loading");
        std::env::remove_var("NO_SPINNER");
        assert!(sp.is_none());
    }

    #[test]
    fn test_run_with_spinner_and_message_success() {
        std::env::set_var("NO_SPINNER", "1");
        let mut cmd = if cfg!(windows) {
            let mut c = Command::new("cmd");
            c.args(["/c", "exit 0"]);
            c
        } else {
            Command::new("true")
        };
        let result = run_with_spinner_and_message("running", &mut cmd, Some("Done"));
        std::env::remove_var("NO_SPINNER");
        assert!(result.is_ok());
        assert!(result.unwrap().success());
    }

    #[test]
    fn test_run_with_spinner_and_message_failure() {
        std::env::set_var("NO_SPINNER", "1");
        let mut cmd = if cfg!(windows) {
            let mut c = Command::new("cmd");
            c.args(["/c", "exit 1"]);
            c
        } else {
            Command::new("false")
        };
        let result = run_with_spinner_and_message("running", &mut cmd, None);
        std::env::remove_var("NO_SPINNER");
        assert!(result.is_ok());
        assert!(!result.unwrap().success());
    }
}