use runemark::{
Console, ProgressMode, ProgressSink, SilentProgress, TerminalProgress, Tone, Verdict,
};
use auditmysite::cli::ProgressPolicy;
use auditmysite::util::truncate_url;
pub(crate) struct BatchLifecyclePresenter {
sink: Box<dyn ProgressSink>,
}
impl BatchLifecyclePresenter {
pub(crate) fn new(
progress: ProgressPolicy,
quiet: bool,
console: Console,
is_terminal: bool,
) -> Self {
Self::with_visible_sink(progress, quiet, |mode| {
Box::new(TerminalProgress::stderr(mode, console, is_terminal))
})
}
fn with_visible_sink(
progress: ProgressPolicy,
quiet: bool,
visible_sink: impl FnOnce(ProgressMode) -> Box<dyn ProgressSink>,
) -> Self {
let sink: Box<dyn ProgressSink> = if quiet {
Box::new(SilentProgress)
} else {
match progress {
ProgressPolicy::Never => Box::new(SilentProgress),
ProgressPolicy::Auto => visible_sink(ProgressMode::Auto),
ProgressPolicy::Always => visible_sink(ProgressMode::Always),
}
};
Self { sink }
}
#[cfg(test)]
fn from_sink(sink: Box<dyn ProgressSink>) -> Self {
Self { sink }
}
pub(crate) fn start(&self, total: usize, stage: &str) {
self.sink.start(total as u64, stage);
}
pub(crate) fn advance(&self, completed: usize, url: &str) {
self.sink.advance(completed as u64, &truncate_url(url, 50));
}
pub(crate) fn notice_error(&self, url: &str, err: &str) {
self.sink.notice(Tone::Error, &format!("{url}: {err}"));
}
pub(crate) fn notice(&self, message: &str) {
self.sink.notice(Tone::Info, message);
}
pub(crate) fn finish(&self, verdict: Verdict, message: &str) {
self.sink.finish(verdict, message);
}
pub(crate) fn finish_verdict(&self, verdict: auditmysite::Verdict, message: &str) {
let mapped = match verdict {
auditmysite::Verdict::Pass => Verdict::Passed,
auditmysite::Verdict::Warn => Verdict::Warning,
auditmysite::Verdict::Fail => Verdict::Failed,
};
self.finish(mapped, message);
}
pub(crate) fn finish_then_render<T>(
&self,
verdict: auditmysite::Verdict,
message: &str,
render: impl FnOnce() -> T,
) -> T {
self.finish_verdict(verdict, message);
render()
}
}
#[cfg(test)]
mod tests {
use super::*;
use runemark::{ColorMode, PlainProgress};
use std::sync::{Arc, Mutex};
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn plain_presenter() -> (BatchLifecyclePresenter, Arc<Mutex<Vec<u8>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
let console = Console::stdout(ColorMode::Never);
let sink = PlainProgress::new(console, SharedBuf(buf.clone()));
(BatchLifecyclePresenter::from_sink(Box::new(sink)), buf)
}
fn buf_to_string(buf: &Arc<Mutex<Vec<u8>>>) -> String {
String::from_utf8(buf.lock().unwrap().clone()).unwrap()
}
#[test]
fn start_notice_finish_appear_in_order_and_bounded() {
let (presenter, buf) = plain_presenter();
presenter.start(3, "Auditing URLs");
presenter.advance(1, "https://example.com/a");
presenter.notice_error("https://example.com/b", "timeout");
presenter.finish(Verdict::Warning, "2/3 passed");
let output = buf_to_string(&buf);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 3, "unexpected output: {output:?}");
assert!(lines[0].contains("Auditing URLs") && lines[0].contains("(0/3)"));
assert!(lines[1].contains("timeout"));
assert!(lines[2].contains("2/3 passed"));
}
#[test]
fn advance_does_not_produce_one_line_per_url() {
let (presenter, buf) = plain_presenter();
presenter.start(5, "Auditing URLs");
for i in 1..=5 {
presenter.advance(i, &format!("https://example.com/{i}"));
}
presenter.finish(Verdict::Passed, "done");
let output = buf_to_string(&buf);
assert_eq!(
output.lines().count(),
2,
"advance() must stay silent: {output:?}"
);
}
#[test]
fn lifecycle_is_finished_before_report_renders() {
let (presenter, buf) = plain_presenter();
presenter.start(2, "Auditing URLs");
let seen_at_render =
presenter.finish_then_render(auditmysite::Verdict::Warn, "1/2 passed", || {
buf_to_string(&buf)
});
assert!(
seen_at_render
.lines()
.last()
.is_some_and(|l| l.contains("1/2 passed")),
"verdict must be written before the report renders: {seen_at_render:?}"
);
assert_eq!(buf_to_string(&buf), seen_at_render);
}
fn policy_presenter(
progress: ProgressPolicy,
quiet: bool,
) -> (BatchLifecyclePresenter, Arc<Mutex<Vec<u8>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
let sink_buf = buf.clone();
let presenter = BatchLifecyclePresenter::with_visible_sink(progress, quiet, |_| {
Box::new(PlainProgress::new(
Console::stdout(ColorMode::Never),
SharedBuf(sink_buf),
))
});
(presenter, buf)
}
fn drive_full_lifecycle(presenter: &BatchLifecyclePresenter) {
presenter.start(1, "x");
presenter.advance(1, "https://example.com");
presenter.notice_error("https://example.com", "boom");
presenter.notice("info");
presenter.finish(Verdict::Failed, "done");
}
#[test]
fn visible_policy_writes_to_the_sink() {
let (presenter, buf) = policy_presenter(ProgressPolicy::Always, false);
drive_full_lifecycle(&presenter);
assert!(!buf_to_string(&buf).is_empty());
}
#[test]
fn quiet_forces_silence_regardless_of_progress_policy() {
for progress in [
ProgressPolicy::Always,
ProgressPolicy::Auto,
ProgressPolicy::Never,
] {
let (presenter, buf) = policy_presenter(progress, true);
drive_full_lifecycle(&presenter);
let output = buf_to_string(&buf);
assert!(
output.is_empty(),
"--quiet must stay silent for {progress:?}: {output:?}"
);
}
}
#[test]
fn progress_never_forces_silence() {
let (presenter, buf) = policy_presenter(ProgressPolicy::Never, false);
drive_full_lifecycle(&presenter);
let output = buf_to_string(&buf);
assert!(
output.is_empty(),
"--progress never must stay silent: {output:?}"
);
}
}