use crate::batch_run::progress::Progress;
use crate::batch_run::summary::Summary;
use crate::cli::Cmd;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use tokio::sync::Semaphore;
use tokio::task::{JoinSet, LocalSet};
pub type DispatchFn = Arc<dyn Fn(Cmd) -> Pin<Box<dyn Future<Output = i32>>> + Send + Sync>;
pub struct PreparedLine {
pub line_no: usize,
pub raw: String,
pub kind: PreparedLineKind,
}
pub enum PreparedLineKind {
Cmd(Box<Cmd>),
Invalid(String),
}
pub const EXIT_CODE_INVALID_LINE: i32 = 2;
fn log_start(line_no: usize, raw: &str) {
tracing::info!("line {line_no}: start: {}", raw.trim_end());
}
async fn execute_line(line: PreparedLine, dispatch: &DispatchFn) -> (usize, i32) {
let PreparedLine { line_no, raw, kind } = line;
let code = match kind {
PreparedLineKind::Cmd(cmd) => {
log_start(line_no, &raw);
let code = dispatch(*cmd).await;
log_end(line_no, &raw, code);
code
}
PreparedLineKind::Invalid(message) => {
tracing::error!("{message}");
EXIT_CODE_INVALID_LINE
}
};
(line_no, code)
}
fn log_end(line_no: usize, raw: &str, code: i32) {
let raw = raw.trim_end();
match code {
0 => tracing::info!("line {line_no}: success: {raw}"),
3 | 4 => tracing::warn!("line {line_no}: warning (exit {code}): {raw}"),
130 => tracing::warn!("line {line_no}: skipped (exit 130): {raw}"),
_ => tracing::error!("line {line_no}: failure (exit {code}): {raw}"),
}
}
#[derive(Debug, Clone, Copy)]
pub struct ExecutorOptions {
pub workers: usize, pub error_threshold: Option<u64>,
pub continue_on_warning: bool,
pub streaming: bool,
pub no_progress: bool,
}
fn is_stop_worthy(code: i32, continue_on_warning: bool) -> bool {
match code {
0 | 130 => false,
3 | 4 => !continue_on_warning,
_ => true,
}
}
fn severity_rank(code: i32) -> u32 {
match code {
0 => 0,
1 => 5,
2 => 4,
3 => 3,
4 => 2,
_ => 1,
}
}
fn worse_of(a: i32, b: i32) -> i32 {
if severity_rank(b) > severity_rank(a) {
b
} else {
a
}
}
pub type Interrupt = Arc<AtomicBool>;
pub async fn run_sequential(
lines: Vec<PreparedLine>,
opts: ExecutorOptions,
dispatch: DispatchFn,
interrupt: Interrupt,
) -> (i32, Summary) {
let total = lines.len() as u64;
let mut progress = Progress::new(
total,
Progress::should_show(opts.streaming, opts.no_progress),
);
let mut summary = Summary::default();
let start = Instant::now();
let mut worst = 0i32;
let mut fail_count: u64 = 0;
for (idx, line) in lines.into_iter().enumerate() {
if interrupt.load(Ordering::SeqCst) {
let processed = idx as u64;
summary.skipped += total.saturating_sub(processed);
break;
}
let (_, code) = execute_line(line, &dispatch).await;
progress.tick(code);
summary.record(code);
worst = worse_of(worst, code);
if is_stop_worthy(code, opts.continue_on_warning) {
fail_count += 1;
if opts.error_threshold.is_some_and(|t| fail_count >= t) {
let processed = (idx + 1) as u64;
summary.skipped += total.saturating_sub(processed);
break;
}
}
}
finish_or_abandon(&progress, &summary);
summary.elapsed = start.elapsed();
(worst, summary)
}
pub async fn run_parallel(
lines: Vec<PreparedLine>,
opts: ExecutorOptions,
dispatch: DispatchFn,
interrupt: Interrupt,
) -> (i32, Summary) {
let total = lines.len() as u64;
let progress = Arc::new(tokio::sync::Mutex::new(Progress::new(
total,
Progress::should_show(opts.streaming, opts.no_progress),
)));
let mut summary = Summary::default();
let start = Instant::now();
let sem = Arc::new(Semaphore::new(opts.workers));
let fail_cancel = Arc::new(AtomicBool::new(false));
let fail_count = Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut spawned = 0u64;
let local = LocalSet::new();
let worst = local
.run_until(async {
let mut joinset: JoinSet<(usize, i32)> = JoinSet::new();
for line in lines {
if interrupt.load(Ordering::SeqCst) {
break;
}
if fail_cancel.load(Ordering::SeqCst) {
break;
}
let permit = sem.clone().acquire_owned().await.expect("sem closed");
let dispatch = Arc::clone(&dispatch);
let progress = Arc::clone(&progress);
let fail_cancel = Arc::clone(&fail_cancel);
let fail_count = Arc::clone(&fail_count);
let error_threshold = opts.error_threshold;
let continue_on_warning = opts.continue_on_warning;
spawned += 1;
joinset.spawn_local(async move {
let _permit = permit;
let (line_no, code) = execute_line(line, &dispatch).await;
if is_stop_worthy(code, continue_on_warning) {
let new_count = fail_count.fetch_add(1, Ordering::SeqCst) + 1;
if let Some(threshold) = error_threshold
&& new_count >= threshold
{
fail_cancel.store(true, Ordering::SeqCst);
}
}
progress.lock().await.tick(code);
(line_no, code)
});
}
let mut worst = 0i32;
while let Some(joined) = joinset.join_next().await {
match joined {
Ok((_, code)) => {
summary.record(code);
worst = worse_of(worst, code);
}
Err(e) => {
tracing::error!(error = format!("{e:#}"), "task panicked");
summary.record(1);
worst = worse_of(worst, 1);
}
}
}
worst
})
.await;
summary.skipped += total.saturating_sub(spawned);
{
let p = progress.lock().await;
if summary.skipped > 0 {
p.abandon();
} else {
p.finish();
}
}
summary.elapsed = start.elapsed();
(worst, summary)
}
pub async fn run_sequential_streaming(
mut rx: tokio::sync::mpsc::UnboundedReceiver<PreparedLine>,
opts: ExecutorOptions,
dispatch: DispatchFn,
interrupt: Interrupt,
) -> (i32, Summary) {
let mut progress = Progress::new(0, false);
let mut summary = Summary::default();
let start = Instant::now();
let mut worst = 0i32;
let mut fail_count: u64 = 0;
while let Some(line) = rx.recv().await {
if interrupt.load(Ordering::SeqCst) {
summary.skipped += 1;
while rx.recv().await.is_some() {
summary.skipped += 1;
}
break;
}
let (_, code) = execute_line(line, &dispatch).await;
progress.tick(code);
summary.record(code);
worst = worse_of(worst, code);
if is_stop_worthy(code, opts.continue_on_warning) {
fail_count += 1;
if opts.error_threshold.is_some_and(|t| fail_count >= t) {
while rx.recv().await.is_some() {
summary.skipped += 1;
}
break;
}
}
}
finish_or_abandon(&progress, &summary);
summary.elapsed = start.elapsed();
(worst, summary)
}
pub async fn run_parallel_streaming(
mut rx: tokio::sync::mpsc::UnboundedReceiver<PreparedLine>,
opts: ExecutorOptions,
dispatch: DispatchFn,
interrupt: Interrupt,
) -> (i32, Summary) {
let progress = Arc::new(tokio::sync::Mutex::new(Progress::new(0, false)));
let mut summary = Summary::default();
let start = Instant::now();
let sem = Arc::new(Semaphore::new(opts.workers));
let fail_cancel = Arc::new(AtomicBool::new(false));
let fail_count = Arc::new(std::sync::atomic::AtomicU64::new(0));
let local = LocalSet::new();
let worst = local
.run_until(async {
let mut joinset: JoinSet<(usize, i32)> = JoinSet::new();
loop {
if interrupt.load(Ordering::SeqCst) {
break;
}
if fail_cancel.load(Ordering::SeqCst) {
break;
}
let line = match rx.recv().await {
Some(line) => line,
None => break, };
let permit = sem.clone().acquire_owned().await.expect("sem closed");
let dispatch = Arc::clone(&dispatch);
let progress = Arc::clone(&progress);
let fail_cancel = Arc::clone(&fail_cancel);
let fail_count = Arc::clone(&fail_count);
let error_threshold = opts.error_threshold;
let continue_on_warning = opts.continue_on_warning;
joinset.spawn_local(async move {
let _permit = permit;
let (line_no, code) = execute_line(line, &dispatch).await;
if is_stop_worthy(code, continue_on_warning) {
let new_count = fail_count.fetch_add(1, Ordering::SeqCst) + 1;
if let Some(threshold) = error_threshold
&& new_count >= threshold
{
fail_cancel.store(true, Ordering::SeqCst);
}
}
progress.lock().await.tick(code);
(line_no, code)
});
}
while rx.recv().await.is_some() {
summary.skipped += 1;
}
let mut worst = 0i32;
while let Some(joined) = joinset.join_next().await {
match joined {
Ok((_, code)) => {
summary.record(code);
worst = worse_of(worst, code);
}
Err(e) => {
tracing::error!(error = format!("{e:#}"), "task panicked");
summary.record(1);
worst = worse_of(worst, 1);
}
}
}
worst
})
.await;
{
let p = progress.lock().await;
if summary.skipped > 0 {
p.abandon();
} else {
p.finish();
}
}
summary.elapsed = start.elapsed();
(worst, summary)
}
fn finish_or_abandon(progress: &Progress, summary: &Summary) {
if summary.skipped > 0 {
progress.abandon();
} else {
progress.finish();
}
}
pub fn resolve_workers(parallel: usize) -> usize {
if parallel == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
} else {
parallel
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn fake_dispatch(codes: Vec<i32>) -> DispatchFn {
let codes = Arc::new(tokio::sync::Mutex::new(codes.into_iter()));
Arc::new(move |_cmd: Cmd| {
let codes = Arc::clone(&codes);
Box::pin(async move {
let mut g = codes.lock().await;
g.next().expect("not enough fake codes")
})
})
}
fn panicking_dispatch() -> DispatchFn {
Arc::new(|_cmd: Cmd| Box::pin(async move { panic!("synthetic panic for test") }))
}
fn make_lines(n: usize) -> Vec<PreparedLine> {
(0..n)
.map(|i| PreparedLine {
line_no: i + 1,
raw: format!("create-bucket s3://b{i}"),
kind: PreparedLineKind::Cmd(Box::new(
crate::cli::Cli::try_parse_from(["s7cmd", "create-bucket", "s3://b"])
.unwrap()
.command
.unwrap(),
)),
})
.collect()
}
fn make_mixed_lines(kinds: &[bool]) -> Vec<PreparedLine> {
kinds
.iter()
.enumerate()
.map(|(i, is_cmd)| {
let line_no = i + 1;
let raw = format!("line-{line_no}");
let kind = if *is_cmd {
PreparedLineKind::Cmd(Box::new(
crate::cli::Cli::try_parse_from(["s7cmd", "create-bucket", "s3://b"])
.unwrap()
.command
.unwrap(),
))
} else {
PreparedLineKind::Invalid(format!(
"line {line_no}: parse error: synthetic test failure"
))
};
PreparedLine { line_no, raw, kind }
})
.collect()
}
fn opts(workers: usize, error_threshold: Option<u64>) -> ExecutorOptions {
ExecutorOptions {
workers,
error_threshold,
continue_on_warning: false,
streaming: false,
no_progress: false,
}
}
fn opts_warn(workers: usize, error_threshold: Option<u64>) -> ExecutorOptions {
ExecutorOptions {
workers,
error_threshold,
continue_on_warning: true,
streaming: false,
no_progress: false,
}
}
fn no_interrupt() -> Interrupt {
Arc::new(AtomicBool::new(false))
}
fn already_interrupted() -> Interrupt {
Arc::new(AtomicBool::new(true))
}
#[tokio::test]
async fn sequential_all_ok() {
let lines = make_lines(3);
let (code, summary) = run_sequential(
lines,
opts(1, Some(1)),
fake_dispatch(vec![0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 3);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_fail_fast() {
let lines = make_lines(5);
let (code, summary) = run_sequential(
lines,
opts(1, Some(1)),
fake_dispatch(vec![0, 1]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.ok, 1);
assert_eq!(summary.failed, 1);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn sequential_continue_on_error() {
let lines = make_lines(4);
let (code, summary) = run_sequential(
lines,
opts(1, None),
fake_dispatch(vec![0, 1, 0, 4]),
no_interrupt(),
)
.await;
assert_eq!(code, 1); assert_eq!(summary.ok, 2);
assert_eq!(summary.failed, 1); assert_eq!(summary.warning, 1); assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_interrupt_stops_before_first_command() {
let lines = make_lines(3);
let (code, summary) = run_sequential(
lines,
opts(1, None), fake_dispatch(vec![]),
already_interrupted(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn parallel_all_ok() {
let lines = make_lines(4);
let (code, summary) = run_parallel(
lines,
opts(2, Some(1)),
fake_dispatch(vec![0, 0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 4);
}
#[tokio::test]
async fn parallel_fail_fast_skips_nothing_already_in_flight() {
let lines = make_lines(5);
let (code, _) = run_parallel(
lines,
opts(2, Some(1)),
fake_dispatch(vec![1, 0, 0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
}
#[test]
fn resolve_workers_zero_picks_num_cpus() {
let n = resolve_workers(0);
assert!(n >= 1);
}
#[test]
fn resolve_workers_passthrough() {
assert_eq!(resolve_workers(1), 1);
assert_eq!(resolve_workers(8), 8);
}
#[test]
fn worse_of_orders_codes_by_severity_not_numeric_value() {
assert_eq!(worse_of(0, 1), 1);
assert_eq!(worse_of(1, 130), 1);
assert_eq!(worse_of(130, 1), 1);
assert_eq!(worse_of(1, 4), 1);
assert_eq!(worse_of(4, 1), 1);
assert_eq!(worse_of(2, 4), 2);
assert_eq!(worse_of(3, 4), 3);
assert_eq!(worse_of(0, 130), 130);
assert_eq!(worse_of(4, 130), 4);
assert_eq!(worse_of(101, 4), 4);
assert_eq!(worse_of(0, 0), 0);
assert_eq!(worse_of(1, 1), 1);
}
#[tokio::test]
async fn sequential_streaming_all_ok() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(3) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts(1, Some(1)),
fake_dispatch(vec![0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 3);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_streaming_fail_fast_drains_channel() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(5) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts(1, Some(1)),
fake_dispatch(vec![0, 1]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.ok, 1);
assert_eq!(summary.failed, 1);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn sequential_streaming_continue_on_error() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(4) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts(1, None),
fake_dispatch(vec![0, 1, 0, 4]),
no_interrupt(),
)
.await;
assert_eq!(code, 1); assert_eq!(summary.ok, 2);
assert_eq!(summary.failed, 1); assert_eq!(summary.warning, 1); assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_streaming_interrupt_skips_all() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(3) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts(1, None),
fake_dispatch(vec![]),
already_interrupted(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn parallel_streaming_all_ok() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(4) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_parallel_streaming(
rx,
opts(2, Some(1)),
fake_dispatch(vec![0, 0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 4);
}
#[tokio::test]
async fn parallel_streaming_fail_fast_stops_spawning() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(5) {
tx.send(line).unwrap();
}
drop(tx);
let (code, _) = run_parallel_streaming(
rx,
opts(2, Some(1)),
fake_dispatch(vec![1, 0, 0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
}
#[tokio::test]
async fn sequential_max_errors_two_stops_after_second_failure() {
let lines = make_lines(5);
let (code, summary) = run_sequential(
lines,
opts(1, Some(2)),
fake_dispatch(vec![1, 0, 1, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.ok, 1);
assert_eq!(summary.failed, 2);
assert_eq!(summary.skipped, 2); }
#[tokio::test]
async fn sequential_max_errors_three_runs_to_completion_with_two_failures() {
let lines = make_lines(4);
let (code, summary) = run_sequential(
lines,
opts(1, Some(3)),
fake_dispatch(vec![0, 1, 0, 1]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.ok, 2);
assert_eq!(summary.failed, 2);
assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_streaming_max_errors_two_stops_after_second_failure() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(5) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts(1, Some(2)),
fake_dispatch(vec![1, 0, 1, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.ok, 1);
assert_eq!(summary.failed, 2);
assert_eq!(summary.skipped, 2);
}
#[tokio::test]
async fn parallel_max_errors_two_stops_spawning() {
let lines = make_lines(8);
let (code, summary) = run_parallel(
lines,
opts(2, Some(2)),
fake_dispatch(vec![1, 1, 0, 0, 0, 0, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert!(summary.failed >= 2);
assert!(
summary.skipped > 0,
"threshold should have stopped the spawn loop; summary={summary:?}"
);
}
#[test]
fn is_stop_worthy_zero_never_counts() {
assert!(!is_stop_worthy(0, false));
assert!(!is_stop_worthy(0, true));
}
#[test]
fn is_stop_worthy_warnings_only_count_without_continue_on_warning() {
assert!(is_stop_worthy(3, false));
assert!(is_stop_worthy(4, false));
assert!(!is_stop_worthy(3, true));
assert!(!is_stop_worthy(4, true));
}
#[test]
fn is_stop_worthy_other_nonzero_always_counts() {
for code in [1, 2, 5, 255] {
assert!(is_stop_worthy(code, false), "code {code}");
assert!(is_stop_worthy(code, true), "code {code}");
}
}
#[test]
fn is_stop_worthy_130_never_counts() {
assert!(!is_stop_worthy(130, false));
assert!(!is_stop_worthy(130, true));
}
#[tokio::test]
async fn sequential_continue_on_warning_skips_warnings() {
let lines = make_lines(4);
let (code, summary) = run_sequential(
lines,
opts_warn(1, Some(1)),
fake_dispatch(vec![0, 3, 4, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 3); assert_eq!(summary.ok, 2);
assert_eq!(summary.warning, 2); assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 0); }
#[tokio::test]
async fn sequential_continue_on_warning_stops_on_failure() {
let lines = make_lines(5);
let (code, summary) = run_sequential(
lines,
opts_warn(1, Some(1)),
fake_dispatch(vec![3, 4, 1, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1); assert_eq!(summary.ok, 0);
assert_eq!(summary.warning, 2); assert_eq!(summary.failed, 1); assert_eq!(summary.skipped, 2); }
#[tokio::test]
async fn sequential_continue_on_warning_with_max_errors_two() {
let lines = make_lines(6);
let (code, summary) = run_sequential(
lines,
opts_warn(1, Some(2)),
fake_dispatch(vec![3, 1, 4, 1, 0, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 1); assert_eq!(summary.failed, 2); assert_eq!(summary.warning, 2); assert_eq!(summary.skipped, 2); }
#[tokio::test]
async fn sequential_streaming_continue_on_warning_skips_warnings() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(4) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_sequential_streaming(
rx,
opts_warn(1, Some(1)),
fake_dispatch(vec![0, 3, 4, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 3);
assert_eq!(summary.ok, 2);
assert_eq!(summary.warning, 2);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn parallel_continue_on_warning_skips_warnings() {
let lines = make_lines(4);
let (code, summary) = run_parallel(
lines,
opts_warn(2, Some(1)),
fake_dispatch(vec![3, 4, 3, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 3);
assert_eq!(summary.skipped, 0);
assert_eq!(summary.warning, 3); assert_eq!(summary.failed, 0);
}
#[tokio::test]
async fn parallel_streaming_continue_on_warning_skips_warnings() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(4) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_parallel_streaming(
rx,
opts_warn(2, Some(1)),
fake_dispatch(vec![3, 4, 3, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 3);
assert_eq!(summary.skipped, 0);
assert_eq!(summary.warning, 3);
assert_eq!(summary.failed, 0);
}
#[tokio::test]
async fn sequential_invalid_line_counts_as_failure_with_continue_on_error() {
let lines = make_mixed_lines(&[false, true, false, false]);
let (code, summary) = run_sequential(
lines,
opts(1, None), fake_dispatch(vec![0]),
no_interrupt(),
)
.await;
assert_eq!(code, 2);
assert_eq!(summary.ok, 1);
assert_eq!(summary.failed, 3);
assert_eq!(summary.skipped, 0);
}
#[tokio::test]
async fn sequential_invalid_line_trips_max_errors_threshold() {
let lines = make_mixed_lines(&[false; 6]);
let (code, summary) = run_sequential(
lines,
opts(1, Some(3)),
fake_dispatch(vec![]), no_interrupt(),
)
.await;
assert_eq!(code, 2);
assert_eq!(summary.failed, 3);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn sequential_invalid_line_default_threshold_stops_after_first() {
let lines = make_mixed_lines(&[false, true, true, false, false]);
let (code, summary) = run_sequential(
lines,
opts(1, Some(1)),
fake_dispatch(vec![]), no_interrupt(),
)
.await;
assert_eq!(code, 2);
assert_eq!(summary.failed, 1);
assert_eq!(summary.skipped, 4);
}
#[tokio::test]
async fn parallel_invalid_line_counts_toward_max_errors() {
let lines = make_mixed_lines(&[false, false, false, false]);
let (code, summary) = run_parallel(
lines,
opts(2, Some(2)),
fake_dispatch(vec![]),
no_interrupt(),
)
.await;
assert_eq!(code, 2);
assert!(summary.failed >= 2);
assert!(
summary.failed + summary.skipped == 4,
"all lines accounted for; summary={summary:?}"
);
}
#[tokio::test]
async fn sequential_exit_130_buckets_as_skipped_and_does_not_trip_threshold() {
let lines = make_lines(4);
let (code, summary) = run_sequential(
lines,
opts(1, Some(1)),
fake_dispatch(vec![130, 0, 130, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 130);
assert_eq!(summary.ok, 2);
assert_eq!(summary.failed, 0);
assert_eq!(summary.warning, 0);
assert_eq!(summary.skipped, 2); }
#[tokio::test]
async fn parallel_exit_130_buckets_as_skipped_and_does_not_trip_threshold() {
let lines = make_lines(4);
let (code, summary) = run_parallel(
lines,
opts(2, Some(1)),
fake_dispatch(vec![130, 0, 130, 0]),
no_interrupt(),
)
.await;
assert_eq!(code, 130);
assert_eq!(summary.ok, 2);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 2);
}
#[tokio::test]
async fn sequential_exit_130_then_failure_accumulates_skipped() {
let lines = make_lines(5);
let (code, summary) = run_sequential(
lines,
opts(1, Some(1)),
fake_dispatch(vec![130, 1]), no_interrupt(),
)
.await;
assert_eq!(code, 1); assert_eq!(summary.ok, 0);
assert_eq!(summary.failed, 1); assert_eq!(summary.skipped, 1 + 3); }
#[tokio::test]
async fn parallel_join_arm_handles_task_panic() {
let lines = make_lines(1);
let (code, summary) = run_parallel(
lines,
opts(1, None), panicking_dispatch(),
no_interrupt(),
)
.await;
assert_eq!(code, 1);
assert_eq!(summary.failed, 1);
assert_eq!(summary.ok, 0);
}
#[tokio::test]
async fn parallel_streaming_join_arm_handles_task_panic() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(1) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) =
run_parallel_streaming(rx, opts(1, None), panicking_dispatch(), no_interrupt()).await;
assert_eq!(code, 1);
assert_eq!(summary.failed, 1);
assert_eq!(summary.ok, 0);
}
#[tokio::test]
async fn parallel_interrupt_breaks_spawn_loop_immediately() {
let lines = make_lines(3);
let (code, summary) = run_parallel(
lines,
opts(2, Some(1)),
fake_dispatch(vec![]),
already_interrupted(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 3);
}
#[tokio::test]
async fn parallel_streaming_interrupt_breaks_spawn_loop_immediately() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for line in make_lines(3) {
tx.send(line).unwrap();
}
drop(tx);
let (code, summary) = run_parallel_streaming(
rx,
opts(2, Some(1)),
fake_dispatch(vec![]),
already_interrupted(),
)
.await;
assert_eq!(code, 0);
assert_eq!(summary.ok, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.skipped, 3);
}
}