mod diagnostics;
mod flex;
mod format;
mod job;
mod output;
mod render;
mod spinners;
mod state;
mod tera_setup;
#[cfg(feature = "log")]
mod log;
pub use job::{ProgressJob, ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
pub use output::{ProgressOutput, output, set_output};
pub use state::{
active_jobs, flush, interval, is_disabled, is_paused, job_count, pause, resume, set_interval,
stop, stop_clear, with_terminal_lock,
};
#[cfg(feature = "log")]
pub use log::{
ProgressLogger, init_log_integration, init_log_integration_with_level,
try_init_log_integration, try_init_log_integration_with_level,
};
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tera::Context;
fn test_render_context(progress: Option<(usize, usize)>) -> render::RenderContext {
use std::time::Instant;
let now = Instant::now();
render::RenderContext {
start: now,
now,
width: 80,
tera_ctx: Context::new(),
indent: 0,
include_children: false,
progress,
}
}
fn render_template(job: &ProgressJob, ctx: &render::RenderContext) -> String {
let mut tera = tera::Tera::default();
tera_setup::add_tera_functions(&mut tera, ctx, job);
tera.add_raw_template("body", &job.body.lock().unwrap())
.unwrap();
tera.render("body", &ctx.tera_ctx).unwrap()
}
#[test]
fn test_template_elapsed_renders() {
let job = ProgressJobBuilder::new().body("{{ elapsed() }}").build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "0s");
}
#[test]
fn test_template_eta_no_progress() {
let job = ProgressJobBuilder::new().body("{{ eta() }}").build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "-");
}
#[test]
fn test_template_eta_with_progress() {
let job = ProgressJobBuilder::new()
.body("{{ eta() }}")
.progress_current(50)
.progress_total(100)
.build();
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert!(
result.ends_with('s') || result.ends_with('m') || result == "-" || result == "0s",
"Expected duration format, got: {}",
result
);
}
#[test]
fn test_template_eta_hide_complete() {
let job = ProgressJobBuilder::new()
.body("{{ eta(hide_complete=true) }}")
.progress_current(100)
.progress_total(100)
.build();
let ctx = test_render_context(Some((100, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "");
}
#[test]
fn test_template_rate_no_progress() {
let job = ProgressJobBuilder::new().body("{{ rate() }}").build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "-/s");
}
#[test]
fn test_template_rate_with_smoothed_rate() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(100)
.progress_total(200)
.build();
*job.smoothed_rate.lock().unwrap() = Some(10.0);
let ctx = test_render_context(Some((100, 200)));
let result = render_template(&job, &ctx);
assert_eq!(result, "10.0/s");
}
#[test]
fn test_template_rate_slow() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(1)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(0.5);
let ctx = test_render_context(Some((1, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "30.0/m");
}
#[test]
fn test_template_rate_very_slow() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(1)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(0.01);
let ctx = test_render_context(Some((1, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "0.01/s");
}
#[test]
fn test_template_bytes_no_progress() {
let job = ProgressJobBuilder::new().body("{{ bytes() }}").build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "");
}
#[test]
fn test_template_bytes_with_progress() {
let job = ProgressJobBuilder::new()
.body("{{ bytes() }}")
.progress_current(1024 * 512)
.progress_total(1024 * 1024)
.build();
let ctx = test_render_context(Some((1024 * 512, 1024 * 1024)));
let result = render_template(&job, &ctx);
assert_eq!(result, "512.0 KB / 1.0 MB");
}
#[test]
fn test_template_bytes_hide_complete() {
let job = ProgressJobBuilder::new()
.body("{{ bytes(hide_complete=true) }}")
.progress_current(1024)
.progress_total(1024)
.build();
let ctx = test_render_context(Some((1024, 1024)));
let result = render_template(&job, &ctx);
assert_eq!(result, "");
}
#[test]
fn test_template_bytes_total_false() {
let job = ProgressJobBuilder::new()
.body("{{ bytes(total=false) }}")
.progress_current(1024 * 512)
.progress_total(1024 * 1024)
.build();
let ctx = test_render_context(Some((1024 * 512, 1024 * 1024)));
let result = render_template(&job, &ctx);
assert_eq!(result, "512.0 KB");
}
#[test]
fn test_template_spinner_running() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::Running)
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert!(
result.contains("\x1b[") || !result.is_empty(),
"Expected spinner output, got: {:?}",
result
);
}
#[test]
fn test_template_spinner_done() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::Done)
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert!(
result.contains('✔'),
"Expected checkmark, got: {:?}",
result
);
}
#[test]
fn test_template_spinner_failed() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::Failed)
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert!(result.contains('✗'), "Expected X mark, got: {:?}", result);
}
#[test]
fn test_template_spinner_pending() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::Pending)
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert!(
result.contains('⏸'),
"Expected pause symbol, got: {:?}",
result
);
}
#[test]
fn test_template_spinner_warn() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::Warn)
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert!(
result.contains('⚠'),
"Expected warning symbol, got: {:?}",
result
);
}
#[test]
fn test_template_spinner_custom() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::RunningCustom("🔥".to_string()))
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "🔥");
}
#[test]
fn test_template_spinner_done_custom() {
let job = ProgressJobBuilder::new()
.body("{{ spinner() }}")
.status(ProgressStatus::DoneCustom("🎉".to_string()))
.build();
let ctx = test_render_context(None);
let result = render_template(&job, &ctx);
assert_eq!(result, "🎉");
}
#[test]
fn test_template_progress_bar_basic() {
let job = ProgressJobBuilder::new()
.body("{{ progress_bar(width=20) }}")
.progress_current(5)
.progress_total(10)
.build();
let ctx = test_render_context(Some((5, 10)));
let result = render_template(&job, &ctx);
assert!(result.contains('[') && result.contains(']'));
assert!(result.contains('━') || result.contains('=') || result.contains('#'));
}
#[test]
fn test_template_progress_bar_hide_complete() {
let job = ProgressJobBuilder::new()
.body("{{ progress_bar(width=20, hide_complete=true) }}")
.progress_current(10)
.progress_total(10)
.build();
let ctx = test_render_context(Some((10, 10)));
let result = render_template(&job, &ctx);
assert_eq!(result, "");
}
#[test]
fn test_smoothed_rate_initial_value() {
let job = ProgressJobBuilder::new().progress_total(100).build();
assert!(job.smoothed_rate.lock().unwrap().is_none());
job.progress_current(10);
std::thread::sleep(Duration::from_millis(150));
job.progress_current(20);
let rate = job.smoothed_rate.lock().unwrap();
assert!(rate.is_some(), "Expected smoothed rate after second update");
let rate_value = rate.unwrap();
assert!(
rate_value > 0.0,
"Expected positive rate, got {}",
rate_value
);
}
#[test]
fn test_smoothed_rate_exponential_moving_average() {
let job = ProgressJobBuilder::new().progress_total(1000).build();
job.progress_current(0);
std::thread::sleep(Duration::from_millis(150));
job.progress_current(100);
std::thread::sleep(Duration::from_millis(150));
let rate1 = job.smoothed_rate.lock().unwrap().unwrap();
job.progress_current(200);
std::thread::sleep(Duration::from_millis(150));
let rate2 = job.smoothed_rate.lock().unwrap().unwrap();
job.progress_current(300);
let rate3 = job.smoothed_rate.lock().unwrap().unwrap();
assert!(rate1 > 0.0);
assert!(rate2 > 0.0);
assert!(rate3 > 0.0);
}
#[test]
fn test_smoothed_rate_no_update_on_backwards_progress() {
let job = ProgressJobBuilder::new().progress_total(100).build();
job.progress_current(0);
std::thread::sleep(Duration::from_millis(150));
job.progress_current(50);
let rate_after_forward = *job.smoothed_rate.lock().unwrap();
assert!(
rate_after_forward.is_some(),
"Expected rate to be set after forward progress"
);
std::thread::sleep(Duration::from_millis(150));
job.progress_current(30);
let rate_after_attempt = *job.smoothed_rate.lock().unwrap();
assert_eq!(rate_after_forward, rate_after_attempt);
}
#[test]
fn test_smoothed_rate_no_update_on_tiny_elapsed_time() {
let job = ProgressJobBuilder::new().progress_total(100).build();
job.progress_current(0);
job.progress_current(10);
job.progress_current(20);
let _rate = job.smoothed_rate.lock().unwrap();
}
#[test]
fn test_increment_updates_smoothed_rate() {
let job = ProgressJobBuilder::new().progress_total(100).build();
assert!(job.smoothed_rate.lock().unwrap().is_none());
job.increment(10);
std::thread::sleep(Duration::from_millis(150));
job.increment(10);
let rate = job.smoothed_rate.lock().unwrap();
assert!(rate.is_some(), "Expected smoothed rate after increments");
}
#[test]
fn test_smoothed_rate_affects_eta_calculation() {
let job = ProgressJobBuilder::new()
.body("{{ eta() }}")
.progress_current(50)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(10.0);
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "5s");
}
#[test]
fn test_smoothed_rate_affects_rate_display() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(50)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(42.5);
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "42.5/s");
}
#[test]
fn test_eta_fallback_to_linear_extrapolation() {
let job = ProgressJobBuilder::new()
.body("{{ eta() }}")
.progress_current(50)
.progress_total(100)
.build();
assert!(job.smoothed_rate.lock().unwrap().is_none());
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert!(
result.ends_with('s') || result.ends_with('m') || result == "-" || result == "0s",
"Expected valid ETA format, got: {}",
result
);
}
#[test]
fn test_rate_fallback_to_average() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(100)
.progress_total(200)
.build();
assert!(job.smoothed_rate.lock().unwrap().is_none());
let ctx = test_render_context(Some((100, 200)));
let result = render_template(&job, &ctx);
assert!(
result.contains("/s") || result.contains("/m"),
"Expected rate format, got: {}",
result
);
}
#[test]
fn test_eta_with_zero_smoothed_rate() {
let job = ProgressJobBuilder::new()
.body("{{ eta() }}")
.progress_current(50)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(0.0);
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert!(
result.ends_with('s') || result.ends_with('m') || result == "-" || result == "0s",
"Expected valid ETA format, got: {}",
result
);
}
#[test]
fn test_rate_with_zero_smoothed_rate() {
let job = ProgressJobBuilder::new()
.body("{{ rate() }}")
.progress_current(50)
.progress_total(100)
.build();
*job.smoothed_rate.lock().unwrap() = Some(0.0);
let ctx = test_render_context(Some((50, 100)));
let result = render_template(&job, &ctx);
assert_eq!(result, "-/s");
}
#[test]
fn test_job_count_and_active_jobs() {
let initial_count = job_count();
let initial_active = active_jobs();
let job = ProgressJobBuilder::new().prop("message", "test").start();
assert_eq!(job_count(), initial_count + 1);
assert_eq!(active_jobs(), initial_active + 1);
job.set_status(ProgressStatus::Done);
assert_eq!(job_count(), initial_count + 1);
assert_eq!(active_jobs(), initial_active);
job.remove();
assert_eq!(job_count(), initial_count);
}
}