use crate::color::sanitize_visible_text;
use crate::{Console, Tone, Verdict};
use std::io::Write;
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProgressMode {
#[default]
Auto,
Always,
Never,
}
impl ProgressMode {
pub const fn is_interactive(self, is_terminal: bool) -> bool {
match self {
Self::Auto => is_terminal,
Self::Always => true,
Self::Never => false,
}
}
}
pub trait ProgressSink: Send + Sync {
fn start(&self, total: u64, message: &str);
fn advance(&self, position: u64, message: &str);
fn notice(&self, tone: Tone, message: &str);
fn finish(&self, verdict: Verdict, message: &str);
}
#[derive(Debug, Default)]
pub struct SilentProgress;
impl ProgressSink for SilentProgress {
fn start(&self, _: u64, _: &str) {}
fn advance(&self, _: u64, _: &str) {}
fn notice(&self, _: Tone, _: &str) {}
fn finish(&self, _: Verdict, _: &str) {}
}
pub struct PlainProgress<W> {
console: Console,
writer: Mutex<W>,
}
impl<W> PlainProgress<W> {
pub fn new(console: Console, writer: W) -> Self {
Self {
console,
writer: Mutex::new(writer),
}
}
pub fn into_inner(self) -> W {
self.writer
.into_inner()
.expect("progress writer mutex must not be poisoned")
}
}
impl<W: Write + Send> ProgressSink for PlainProgress<W> {
fn start(&self, total: u64, message: &str) {
let Ok(mut writer) = self.writer.lock() else {
return;
};
let _ = writeln!(writer, "{} (0/{total})", sanitize_visible_text(message));
}
fn advance(&self, _: u64, _: &str) {
}
fn notice(&self, tone: Tone, message: &str) {
let Ok(mut writer) = self.writer.lock() else {
return;
};
let _ = self.console.write_paint(tone, message, &mut *writer);
let _ = writeln!(writer);
}
fn finish(&self, verdict: Verdict, message: &str) {
let Ok(mut writer) = self.writer.lock() else {
return;
};
let _ = verdict.write_to(self.console, message, &mut *writer);
let _ = writeln!(writer);
}
}
pub enum TerminalProgress {
Silent(SilentProgress),
Plain(PlainProgress<std::io::Stderr>),
#[cfg(feature = "progress")]
Indicatif(IndicatifProgress),
}
impl TerminalProgress {
pub fn stderr(mode: ProgressMode, console: Console, _is_terminal: bool) -> Self {
if mode == ProgressMode::Never {
return Self::Silent(SilentProgress);
}
#[cfg(feature = "progress")]
if mode.is_interactive(_is_terminal) && !indicatif::ProgressDrawTarget::stderr().is_hidden()
{
return Self::Indicatif(IndicatifProgress::new(console));
}
Self::Plain(PlainProgress::new(console, std::io::stderr()))
}
pub fn is_interactive(&self) -> bool {
match self {
Self::Silent(_) | Self::Plain(_) => false,
#[cfg(feature = "progress")]
Self::Indicatif(_) => true,
}
}
}
impl ProgressSink for TerminalProgress {
fn start(&self, total: u64, message: &str) {
match self {
Self::Silent(progress) => progress.start(total, message),
Self::Plain(progress) => progress.start(total, message),
#[cfg(feature = "progress")]
Self::Indicatif(progress) => progress.start(total, message),
}
}
fn advance(&self, position: u64, message: &str) {
match self {
Self::Silent(progress) => progress.advance(position, message),
Self::Plain(progress) => progress.advance(position, message),
#[cfg(feature = "progress")]
Self::Indicatif(progress) => progress.advance(position, message),
}
}
fn notice(&self, tone: Tone, message: &str) {
match self {
Self::Silent(progress) => progress.notice(tone, message),
Self::Plain(progress) => progress.notice(tone, message),
#[cfg(feature = "progress")]
Self::Indicatif(progress) => progress.notice(tone, message),
}
}
fn finish(&self, verdict: Verdict, message: &str) {
match self {
Self::Silent(progress) => progress.finish(verdict, message),
Self::Plain(progress) => progress.finish(verdict, message),
#[cfg(feature = "progress")]
Self::Indicatif(progress) => progress.finish(verdict, message),
}
}
}
#[cfg(feature = "progress")]
pub struct IndicatifProgress {
console: Console,
bar: indicatif::ProgressBar,
}
#[cfg(feature = "progress")]
impl IndicatifProgress {
pub fn new(console: Console) -> Self {
Self {
console,
bar: indicatif::ProgressBar::new(0),
}
}
}
#[cfg(feature = "progress")]
impl ProgressSink for IndicatifProgress {
fn start(&self, total: u64, message: &str) {
self.bar.set_style(
indicatif::ProgressStyle::with_template(
"{spinner:.cyan} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
)
.expect("the built-in progress template is valid")
.progress_chars("#>-"),
);
self.bar.set_length(total);
self.bar
.set_message(sanitize_visible_text(message).into_owned());
}
fn advance(&self, position: u64, message: &str) {
self.bar.set_position(position);
self.bar
.set_message(sanitize_visible_text(message).into_owned());
}
fn notice(&self, tone: Tone, message: &str) {
self.bar.println(self.console.paint(tone, message));
}
fn finish(&self, verdict: Verdict, message: &str) {
self.bar
.finish_with_message(verdict.render(self.console, message));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ColorMode;
#[test]
fn plain_progress_emits_bounded_lifecycle_lines() {
let progress = PlainProgress::new(Console::new(ColorMode::Never, false), Vec::new());
progress.start(4, "Auditing URLs");
progress.advance(1, "https://example.com");
progress.notice(Tone::Warning, "One URL failed");
progress.finish(Verdict::Warning, "Audit complete");
assert_eq!(
String::from_utf8(progress.into_inner()).unwrap(),
"Auditing URLs (0/4)\nOne URL failed\n[WARN] Audit complete\n"
);
}
#[test]
fn terminal_progress_is_interactive_only_where_a_bar_can_be_drawn() {
let console = Console::new(ColorMode::Never, false);
assert!(!TerminalProgress::stderr(ProgressMode::Auto, console, false).is_interactive());
assert!(!TerminalProgress::stderr(ProgressMode::Never, console, true).is_interactive());
#[cfg(feature = "progress")]
{
let drawable = !indicatif::ProgressDrawTarget::stderr().is_hidden();
let progress = TerminalProgress::stderr(ProgressMode::Always, console, true);
assert_eq!(progress.is_interactive(), drawable);
}
}
#[test]
fn auto_mode_is_interactive_only_for_terminals() {
assert!(ProgressMode::Auto.is_interactive(true));
assert!(!ProgressMode::Auto.is_interactive(false));
assert!(ProgressMode::Always.is_interactive(false));
}
#[test]
fn plain_progress_neutralizes_control_characters() {
let progress = PlainProgress::new(Console::new(ColorMode::Never, false), Vec::new());
progress.start(1, "Scan\x1b[31m\rspoof");
progress.finish(Verdict::Passed, "Done\x07");
assert_eq!(
String::from_utf8(progress.into_inner()).unwrap(),
"Scan^[[31m^Mspoof (0/1)\n[OK] Done^G\n"
);
}
}