use crate::shared::{format_finalize_plain, BLUE, CLEAR_LINE, FRAMES, GREEN, RED, RESET, YELLOW};
use std::io::{self, IsTerminal};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum LineStatus {
Active,
Succeeded,
SucceededWith(String),
Failed,
FailedWith(String),
Warned,
WarnedWith(String),
Informed,
InformedWith(String),
Cleared,
}
#[derive(Clone)]
pub(crate) struct SpinnerLine {
pub(crate) message: String,
pub(crate) status: LineStatus,
}
pub struct MultiSpinner<W: io::Write + Send + 'static = io::Stdout> {
writer: W,
is_tty: bool,
}
impl Default for MultiSpinner<io::Stdout> {
fn default() -> Self {
Self::new()
}
}
impl MultiSpinner {
#[must_use]
pub fn new() -> MultiSpinner<io::Stdout> {
MultiSpinner {
writer: io::stdout(),
is_tty: io::stdout().is_terminal(),
}
}
}
impl<W: io::Write + Send + 'static> MultiSpinner<W> {
pub fn with_writer(writer: W) -> Self {
MultiSpinner {
writer,
is_tty: false,
}
}
pub fn with_writer_tty(writer: W, is_tty: bool) -> Self {
MultiSpinner { writer, is_tty }
}
#[must_use]
pub fn start(self) -> MultiSpinnerHandle {
let writer: Arc<Mutex<Box<dyn io::Write + Send>>> =
Arc::new(Mutex::new(Box::new(self.writer)));
let lines: Arc<Mutex<Vec<SpinnerLine>>> = Arc::new(Mutex::new(Vec::new()));
let stop_flag = Arc::new(AtomicBool::new(false));
let last_visible_count = Arc::new(AtomicUsize::new(0));
let is_tty = self.is_tty;
let thread = if is_tty {
let t_stop = Arc::clone(&stop_flag);
let t_lines = Arc::clone(&lines);
let t_writer = Arc::clone(&writer);
let t_visible = Arc::clone(&last_visible_count);
Some(thread::spawn(move || {
multi_spin_loop(
FRAMES,
Duration::from_millis(80),
&t_stop,
&t_lines,
&t_writer,
&t_visible,
);
}))
} else {
stop_flag.store(true, Ordering::Release);
None
};
MultiSpinnerHandle {
lines,
writer,
stop_flag,
thread: Mutex::new(thread),
is_tty,
last_visible_count,
}
}
}
pub struct MultiSpinnerHandle {
lines: Arc<Mutex<Vec<SpinnerLine>>>,
writer: Arc<Mutex<Box<dyn io::Write + Send>>>,
stop_flag: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
is_tty: bool,
last_visible_count: Arc<AtomicUsize>,
}
impl MultiSpinnerHandle {
pub fn add(&self, message: impl Into<String>) -> SpinnerLineHandle {
let mut lines = self.lines.lock().unwrap();
lines.push(SpinnerLine {
message: message.into(),
status: LineStatus::Active,
});
let index = lines.len() - 1;
SpinnerLineHandle {
index,
lines: Arc::clone(&self.lines),
writer: Arc::clone(&self.writer),
is_tty: self.is_tty,
}
}
pub fn stop(self) {
self.shutdown();
}
fn shutdown(&self) {
self.stop_flag.store(true, Ordering::Release);
let thread = self.thread.lock().unwrap().take();
if let Some(thread) = thread {
let _ = thread.join();
self.render_final();
}
}
fn render_final(&self) {
if !self.is_tty {
return;
}
let Ok(snapshot) = self.lines.lock().map(|g| g.clone()) else {
return;
};
let visible = self.last_visible_count.load(Ordering::Relaxed);
if visible == 0 {
return;
}
let Ok(mut w) = self.writer.lock() else {
return;
};
let _ = write!(w, "\x1b[{visible}A");
let mut final_visible: usize = 0;
for line in &snapshot {
match &line.status {
LineStatus::Active => {
let _ = write!(w, "\r{CLEAR_LINE}\n");
final_visible += 1;
}
LineStatus::Succeeded => {
let _ = write!(w, "\r{}{}✔{} {}\n", CLEAR_LINE, GREEN, RESET, line.message);
final_visible += 1;
}
LineStatus::SucceededWith(msg) => {
let _ = write!(w, "\r{CLEAR_LINE}{GREEN}✔{RESET} {msg}\n");
final_visible += 1;
}
LineStatus::Failed => {
let _ = write!(w, "\r{}{}✖{} {}\n", CLEAR_LINE, RED, RESET, line.message);
final_visible += 1;
}
LineStatus::FailedWith(msg) => {
let _ = write!(w, "\r{CLEAR_LINE}{RED}✖{RESET} {msg}\n");
final_visible += 1;
}
LineStatus::Warned => {
let _ = write!(w, "\r{}{}⚠{} {}\n", CLEAR_LINE, YELLOW, RESET, line.message);
final_visible += 1;
}
LineStatus::WarnedWith(msg) => {
let _ = write!(w, "\r{CLEAR_LINE}{YELLOW}⚠{RESET} {msg}\n");
final_visible += 1;
}
LineStatus::Informed => {
let _ = write!(w, "\r{}{}ℹ{} {}\n", CLEAR_LINE, BLUE, RESET, line.message);
final_visible += 1;
}
LineStatus::InformedWith(msg) => {
let _ = write!(w, "\r{CLEAR_LINE}{BLUE}ℹ{RESET} {msg}\n");
final_visible += 1;
}
LineStatus::Cleared => { }
}
}
for _ in 0..visible.saturating_sub(final_visible) {
let _ = write!(w, "\r{CLEAR_LINE}\n");
}
let _ = w.flush();
}
}
impl Drop for MultiSpinnerHandle {
fn drop(&mut self) {
self.shutdown();
}
}
pub struct SpinnerLineHandle {
index: usize,
lines: Arc<Mutex<Vec<SpinnerLine>>>,
writer: Arc<Mutex<Box<dyn io::Write + Send>>>,
is_tty: bool,
}
impl SpinnerLineHandle {
pub fn update(&self, message: impl Into<String>) {
let mut lines = self.lines.lock().unwrap();
lines[self.index].message = message.into();
}
pub fn success(self) {
let mut lines = self.lines.lock().unwrap();
let message = lines[self.index].message.clone();
lines[self.index].status = LineStatus::Succeeded;
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("✔", &message)).unwrap();
w.flush().unwrap();
}
}
pub fn success_with(self, message: impl Into<String>) {
let msg = message.into();
let mut lines = self.lines.lock().unwrap();
lines[self.index].status = LineStatus::SucceededWith(msg.clone());
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("✔", &msg)).unwrap();
w.flush().unwrap();
}
}
pub fn fail(self) {
let mut lines = self.lines.lock().unwrap();
let message = lines[self.index].message.clone();
lines[self.index].status = LineStatus::Failed;
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("✖", &message)).unwrap();
w.flush().unwrap();
}
}
pub fn fail_with(self, message: impl Into<String>) {
let msg = message.into();
let mut lines = self.lines.lock().unwrap();
lines[self.index].status = LineStatus::FailedWith(msg.clone());
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("✖", &msg)).unwrap();
w.flush().unwrap();
}
}
pub fn warn(self) {
let mut lines = self.lines.lock().unwrap();
let message = lines[self.index].message.clone();
lines[self.index].status = LineStatus::Warned;
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("⚠", &message)).unwrap();
w.flush().unwrap();
}
}
pub fn warn_with(self, message: impl Into<String>) {
let msg = message.into();
let mut lines = self.lines.lock().unwrap();
lines[self.index].status = LineStatus::WarnedWith(msg.clone());
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("⚠", &msg)).unwrap();
w.flush().unwrap();
}
}
pub fn info(self) {
let mut lines = self.lines.lock().unwrap();
let message = lines[self.index].message.clone();
lines[self.index].status = LineStatus::Informed;
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("ℹ", &message)).unwrap();
w.flush().unwrap();
}
}
pub fn info_with(self, message: impl Into<String>) {
let msg = message.into();
let mut lines = self.lines.lock().unwrap();
lines[self.index].status = LineStatus::InformedWith(msg.clone());
drop(lines);
if !self.is_tty {
let mut w = self.writer.lock().unwrap();
write!(w, "{}", format_finalize_plain("ℹ", &msg)).unwrap();
w.flush().unwrap();
}
}
pub fn clear(self) {
let mut lines = self.lines.lock().unwrap();
lines[self.index].status = LineStatus::Cleared;
}
}
fn multi_spin_loop(
frames: &[char],
interval: Duration,
stop_flag: &Arc<AtomicBool>,
lines: &Arc<Mutex<Vec<SpinnerLine>>>,
writer: &Arc<Mutex<Box<dyn io::Write + Send>>>,
last_visible_count: &Arc<AtomicUsize>,
) {
let mut frame_idx: usize = 0;
let mut prev_line_count: usize = 0;
while !stop_flag.load(Ordering::Acquire) {
let snapshot = lines.lock().unwrap().clone();
if !snapshot.is_empty() {
let mut w = writer.lock().unwrap();
if prev_line_count > 0 {
write!(w, "\x1b[{prev_line_count}A").unwrap();
}
let frame_char = frames[frame_idx % frames.len()];
let mut visible_count: usize = 0;
for line in &snapshot {
match &line.status {
LineStatus::Active => {
write!(w, "\r{}{} {}\n", CLEAR_LINE, frame_char, line.message).unwrap();
visible_count += 1;
}
LineStatus::Succeeded => {
write!(w, "\r{}{}✔{} {}\n", CLEAR_LINE, GREEN, RESET, line.message)
.unwrap();
visible_count += 1;
}
LineStatus::SucceededWith(msg) => {
write!(w, "\r{CLEAR_LINE}{GREEN}✔{RESET} {msg}\n").unwrap();
visible_count += 1;
}
LineStatus::Failed => {
write!(w, "\r{}{}✖{} {}\n", CLEAR_LINE, RED, RESET, line.message).unwrap();
visible_count += 1;
}
LineStatus::FailedWith(msg) => {
write!(w, "\r{CLEAR_LINE}{RED}✖{RESET} {msg}\n").unwrap();
visible_count += 1;
}
LineStatus::Warned => {
write!(w, "\r{}{}⚠{} {}\n", CLEAR_LINE, YELLOW, RESET, line.message)
.unwrap();
visible_count += 1;
}
LineStatus::WarnedWith(msg) => {
write!(w, "\r{CLEAR_LINE}{YELLOW}⚠{RESET} {msg}\n").unwrap();
visible_count += 1;
}
LineStatus::Informed => {
write!(w, "\r{}{}ℹ{} {}\n", CLEAR_LINE, BLUE, RESET, line.message).unwrap();
visible_count += 1;
}
LineStatus::InformedWith(msg) => {
write!(w, "\r{CLEAR_LINE}{BLUE}ℹ{RESET} {msg}\n").unwrap();
visible_count += 1;
}
LineStatus::Cleared => { }
}
}
let vacated = prev_line_count.saturating_sub(visible_count);
for _ in 0..vacated {
write!(w, "\r{CLEAR_LINE}\n").unwrap();
}
if vacated > 0 {
write!(w, "\x1b[{vacated}A").unwrap();
}
w.flush().unwrap();
prev_line_count = visible_count;
last_visible_count.store(visible_count, Ordering::Relaxed);
}
frame_idx = frame_idx.wrapping_add(1);
thread::sleep(interval);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::tests::TestWriter;
use proptest::prelude::*;
fn _assert_send() {
fn assert_send<T: Send>() {}
assert_send::<SpinnerLineHandle>();
}
#[test]
fn test_multi_spinner_tty_single_spinner_renders() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let line = handle.add("Compiling crate");
thread::sleep(Duration::from_millis(200));
line.success();
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
let braille_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
assert!(
braille_frames.iter().any(|&c| output.contains(c)),
"TTY output must contain braille animation frames"
);
assert!(
output.contains(GREEN),
"TTY output must contain GREEN ANSI code"
);
assert!(output.contains("✔"), "TTY output must contain ✔");
assert!(
output.contains("Compiling crate"),
"TTY output must contain the spinner message"
);
assert!(
output.contains("\x1b["),
"TTY output must contain ANSI escape codes"
);
}
#[test]
fn test_multi_spinner_tty_add_after_finalize() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let line_a = handle.add("Task A");
thread::sleep(Duration::from_millis(200));
line_a.success();
let line_b = handle.add("Task B");
thread::sleep(Duration::from_millis(200));
line_b.fail();
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
assert!(
output.contains("Task A"),
"output must contain Task A message"
);
assert!(
output.contains("Task B"),
"output must contain Task B message"
);
assert!(output.contains("✔"), "output must contain ✔ for Task A");
assert!(output.contains("✖"), "output must contain ✖ for Task B");
}
#[test]
fn test_multi_spinner_drop_renders_same_as_stop() {
let (writer, buf_stop) = TestWriter::new();
let reader_stop = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let a = handle.add("Alpha");
let b = handle.add("Beta");
thread::sleep(Duration::from_millis(150));
a.success_with("Alpha done.");
b.fail_with("Beta failed.");
thread::sleep(Duration::from_millis(100));
handle.stop();
let len_stop = buf_stop.lock().unwrap().len();
let (writer, buf_drop) = TestWriter::new();
let reader_drop = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let a = handle.add("Alpha");
let b = handle.add("Beta");
thread::sleep(Duration::from_millis(150));
a.success_with("Alpha done.");
b.fail_with("Beta failed.");
thread::sleep(Duration::from_millis(100));
drop(handle);
let len_drop = buf_drop.lock().unwrap().len();
let out_stop = reader_stop.output();
let out_drop = reader_drop.output();
assert!(out_stop.contains("✔"), "stop output must contain ✔");
assert!(out_stop.contains("✖"), "stop output must contain ✖");
assert!(out_drop.contains("✔"), "drop output must contain ✔");
assert!(out_drop.contains("✖"), "drop output must contain ✖");
assert!(len_stop > 0, "stop must produce output");
assert!(len_drop > 0, "drop must produce output");
}
#[test]
fn test_spinner_line_handle_send_to_thread() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add("Task from another thread");
let t = thread::spawn(move || {
line_handle.success();
});
t.join().expect("thread must not panic");
let output = reader.output();
assert_eq!(output, "✔ Task from another thread\n");
}
#[test]
fn test_multiple_handles_finalized_from_different_threads() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let h1 = handle.add("alpha");
let h2 = handle.add("beta");
let h3 = handle.add("gamma");
let threads: Vec<thread::JoinHandle<()>> = vec![
thread::spawn(move || {
h1.success();
}),
thread::spawn(move || {
h2.fail();
}),
thread::spawn(move || {
h3.success_with("gamma done");
}),
];
for t in threads {
t.join()
.expect("thread must not panic during concurrent finalization");
}
let output = reader.output();
let output_lines: Vec<&str> = output.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(output_lines.len(), 3, "must have exactly 3 output lines");
assert!(
output_lines.contains(&"✔ alpha"),
"output must contain '✔ alpha'"
);
assert!(
output_lines.contains(&"✖ beta"),
"output must contain '✖ beta'"
);
assert!(
output_lines.contains(&"✔ gamma done"),
"output must contain '✔ gamma done'"
);
}
#[test]
fn test_stop_finalization_clear_one_among_others() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let line1 = handle.add("first-line");
let line2 = handle.add("second-line");
let line3 = handle.add("third-line");
thread::sleep(Duration::from_millis(200));
line1.success();
line2.clear();
line3.fail();
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
let last_cursor_up_pos = {
let bytes = output.as_bytes();
let mut last_pos = None;
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
last_pos = Some(i);
}
}
}
last_pos
};
let final_frame = last_cursor_up_pos
.map(|pos| &output[pos..])
.expect("TTY output must contain at least one cursor-up sequence");
assert!(
!final_frame.contains("second-line"),
"cleared line 'second-line' must NOT appear in the final frame"
);
assert!(
final_frame.contains("first-line"),
"succeeded line 'first-line' must appear in the final frame"
);
assert!(
final_frame.contains("third-line"),
"failed line 'third-line' must appear in the final frame"
);
assert!(final_frame.contains("✔"), "final frame must contain ✔");
assert!(final_frame.contains("✖"), "final frame must contain ✖");
}
#[test]
fn test_stop_finalization_all_cleared() {
let (writer, buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let line1 = handle.add("alpha");
let line2 = handle.add("beta");
let line3 = handle.add("gamma");
thread::sleep(Duration::from_millis(200));
line1.clear();
line2.clear();
line3.clear();
thread::sleep(Duration::from_millis(100));
let len_before_stop = buf.lock().unwrap().len();
handle.stop();
let output = reader.output();
let output_after_stop = &output[len_before_stop..];
let has_cursor_up_after_stop = {
let bytes = output_after_stop.as_bytes();
let mut found = false;
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
found = true;
break;
}
}
}
found
};
assert!(
!has_cursor_up_after_stop,
"stop() must NOT write a cursor-up escape when all lines are cleared"
);
assert!(
!output_after_stop.contains("alpha"),
"cleared message 'alpha' must not appear in stop output"
);
assert!(
!output_after_stop.contains("beta"),
"cleared message 'beta' must not appear in stop output"
);
assert!(
!output_after_stop.contains("gamma"),
"cleared message 'gamma' must not appear in stop output"
);
}
proptest! {
#[test]
fn property_add_grows_line_list(msg in ".*") {
let (writer, _buf) = TestWriter::new();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg.clone());
let lines = handle.lines.lock().unwrap();
prop_assert_eq!(lines.len(), 1, "line count must be 1 after a single add()");
prop_assert_eq!(lines[0].message.clone(), msg, "stored message must match the input");
prop_assert_eq!(lines[0].status.clone(), LineStatus::Active, "new line must be Active");
drop(line_handle);
}
#[test]
fn property_plain_mode_defers_output(msg in ".*") {
let (writer, buf) = TestWriter::new();
let handle = MultiSpinner::with_writer(writer).start();
let _line_handle = handle.add(msg);
let output = buf.lock().unwrap();
prop_assert_eq!(output.len(), 0, "add() in plain mode must produce zero bytes of output");
}
#[test]
fn property_update_changes_message(initial in ".*", updated in ".*") {
let (writer, _buf) = TestWriter::new();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(initial);
line_handle.update(updated.clone());
let lines = handle.lines.lock().unwrap();
prop_assert_eq!(lines[0].message.clone(), updated, "message must match the updated value after update()");
}
#[test]
fn property_plain_mode_success_output(msg in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg.clone());
line_handle.success();
let output = reader.output();
let expected = format!("✔ {}\n", msg);
prop_assert_eq!(output.clone(), expected, "success() output must be '✔ {{message}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_success_with_output(original in "\\PC*", replacement in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(original);
line_handle.success_with(replacement.clone());
let output = reader.output();
let expected = format!("✔ {}\n", replacement);
prop_assert_eq!(output.clone(), expected, "success_with() output must be '✔ {{replacement}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_fail_output(msg in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg.clone());
line_handle.fail();
let output = reader.output();
let expected = format!("✖ {}\n", msg);
prop_assert_eq!(output.clone(), expected, "fail() output must be '✖ {{message}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_fail_with_output(original in "\\PC*", replacement in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(original);
line_handle.fail_with(replacement.clone());
let output = reader.output();
let expected = format!("✖ {}\n", replacement);
prop_assert_eq!(output.clone(), expected, "fail_with() output must be '✖ {{replacement}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_finalization_order(messages in prop::collection::vec("\\PC+", 2..8)) {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
let reversed_messages: Vec<String> = messages.iter().rev().cloned().collect();
for line_handle in handles.into_iter().rev() {
line_handle.success();
}
let output = reader.output();
let output_lines: Vec<&str> = output.split('\n').filter(|l| !l.is_empty()).collect();
let expected: Vec<String> = reversed_messages
.iter()
.map(|msg| format!("✔ {}", msg))
.collect();
prop_assert_eq!(
output_lines.len(),
expected.len(),
"number of output lines must match number of finalized spinners"
);
for (i, (actual, exp)) in output_lines.iter().zip(expected.iter()).enumerate() {
prop_assert_eq!(
*actual,
exp.as_str(),
"output line {} must match finalization order (reversed add order)",
i
);
}
}
#[test]
fn property_concurrent_finalization_safety(messages in prop::collection::vec("\\PC+", 2..8)) {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
let threads: Vec<thread::JoinHandle<()>> = line_handles
.into_iter()
.map(|lh| {
thread::spawn(move || {
lh.success();
})
})
.collect();
for t in threads {
t.join().expect("thread must not panic during concurrent finalization");
}
let output = reader.output();
let output_lines: Vec<&str> = output.split('\n').filter(|l| !l.is_empty()).collect();
prop_assert_eq!(
output_lines.len(),
messages.len(),
"number of output lines must equal number of finalized spinners"
);
for msg in &messages {
let expected = format!("✔ {}", msg);
let output_count = output_lines.iter().filter(|&&l| l == expected.as_str()).count();
let input_count = messages.iter().filter(|m| *m == msg).count();
prop_assert_eq!(
output_count,
input_count,
"message '{}' appears {} times in input but {} times in output",
msg,
input_count,
output_count
);
}
}
#[test]
fn property_clear_transitions_status_to_cleared(msg in ".*") {
let (writer, _buf) = TestWriter::new();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg);
line_handle.clear();
let lines = handle.lines.lock().unwrap();
prop_assert_eq!(
lines[0].status.clone(),
LineStatus::Cleared,
"clear() must set status to Cleared"
);
}
#[test]
fn property_clear_produces_no_output_plain_mode(
messages in prop::collection::vec("\\PC+", 1..=10),
clear_flags in prop::collection::vec(any::<bool>(), 1..=10),
) {
let count = messages.len().min(clear_flags.len());
let messages = &messages[..count];
let clear_flags = &clear_flags[..count];
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
for (lh, &should_clear) in handles.into_iter().zip(clear_flags.iter()) {
if should_clear {
lh.clear();
} else {
lh.success();
}
}
let output = reader.output();
let output_lines: Vec<&str> = output.split('\n').filter(|l| !l.is_empty()).collect();
let expected_count = clear_flags.iter().filter(|&&f| !f).count();
prop_assert_eq!(
output_lines.len(),
expected_count,
"output line count must equal number of non-cleared lines"
);
for line in &output_lines {
prop_assert!(
line.starts_with("✔ "),
"every output line must be a success line, got: '{}'",
line
);
}
}
#[test]
fn property_plain_mode_warn_output(msg in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg.clone());
line_handle.warn();
let output = reader.output();
let expected = format!("⚠ {}\n", msg);
prop_assert_eq!(output.clone(), expected, "warn() output must be '⚠ {{message}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_warn_with_output(original in "\\PC*", replacement in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(original);
line_handle.warn_with(replacement.clone());
let output = reader.output();
let expected = format!("⚠ {}\n", replacement);
prop_assert_eq!(output.clone(), expected, "warn_with() output must be '⚠ {{replacement}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_info_output(msg in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(msg.clone());
line_handle.info();
let output = reader.output();
let expected = format!("ℹ {}\n", msg);
prop_assert_eq!(output.clone(), expected, "info() output must be 'ℹ {{message}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
#[test]
fn property_plain_mode_info_with_output(original in "\\PC*", replacement in "\\PC*") {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer(writer).start();
let line_handle = handle.add(original);
line_handle.info_with(replacement.clone());
let output = reader.output();
let expected = format!("ℹ {}\n", replacement);
prop_assert_eq!(output.clone(), expected, "info_with() output must be 'ℹ {{replacement}}\\n'");
prop_assert!(!output.contains("\x1b["), "output must contain no ANSI escape codes");
prop_assert!(!output.contains('\r'), "output must contain no carriage returns");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn property_tty_render_loop_output(
success_msg in "[a-zA-Z0-9 ]{1,30}",
fail_msg in "[a-zA-Z0-9 ]{1,30}"
) {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let line1 = handle.add(success_msg.clone());
let line2 = handle.add(fail_msg.clone());
thread::sleep(Duration::from_millis(200));
line1.success();
line2.fail();
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
let has_cursor_up = output.contains("\x1b[") && {
let bytes = output.as_bytes();
let mut found = false;
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
found = true;
break;
}
}
}
found
};
prop_assert!(has_cursor_up, "TTY multi-spinner output must contain ANSI cursor-up sequences (\\x1b[{{n}}A)");
let braille_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let has_braille = braille_frames.iter().any(|&c| output.contains(c));
prop_assert!(has_braille, "TTY multi-spinner output must contain braille animation frame characters");
prop_assert!(output.contains(GREEN), "TTY multi-spinner output must contain GREEN ANSI code for success");
prop_assert!(output.contains("✔"), "TTY multi-spinner output must contain ✔ for success");
prop_assert!(output.contains(RED), "TTY multi-spinner output must contain RED ANSI code for failure");
prop_assert!(output.contains("✖"), "TTY multi-spinner output must contain ✖ for failure");
prop_assert!(output.contains(&success_msg), "TTY multi-spinner output must contain the success message");
prop_assert!(output.contains(&fail_msg), "TTY multi-spinner output must contain the fail message");
}
#[test]
fn property_cleared_lines_produce_no_rendered_output(
messages in prop::collection::vec("[a-zA-Z0-9]{3,15}", 2..=5),
clear_flags in prop::collection::vec(any::<bool>(), 2..=5),
) {
let count = messages.len().min(clear_flags.len());
let messages = &messages[..count];
let clear_flags = &clear_flags[..count];
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
thread::sleep(Duration::from_millis(200));
for (lh, &should_clear) in handles.into_iter().zip(clear_flags.iter()) {
if should_clear {
lh.clear();
} else {
lh.success();
}
}
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
let last_cursor_up_pos = {
let bytes = output.as_bytes();
let mut last_pos = None;
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
last_pos = Some(i);
}
}
}
last_pos
};
if let Some(pos) = last_cursor_up_pos {
let final_frame = &output[pos..];
for (i, msg) in messages.iter().enumerate() {
if clear_flags[i] {
prop_assert!(
!final_frame.contains(msg.as_str()),
"cleared message '{}' must NOT appear in the final rendered frame",
msg
);
}
}
for (i, msg) in messages.iter().enumerate() {
if !clear_flags[i] {
prop_assert!(
final_frame.contains(msg.as_str()),
"non-cleared message '{}' must appear in the final rendered frame",
msg
);
}
}
}
}
#[test]
fn property_visible_line_count_equals_total_minus_cleared(
messages in prop::collection::vec("[a-zA-Z0-9]{3,15}", 2..=5),
clear_flags in prop::collection::vec(any::<bool>(), 2..=5),
) {
let count = messages.len().min(clear_flags.len());
let messages = &messages[..count];
let clear_flags = &clear_flags[..count];
let (writer, _buf) = TestWriter::new();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
thread::sleep(Duration::from_millis(200));
let cleared_count = clear_flags.iter().filter(|&&f| f).count();
let expected_visible = count - cleared_count;
for (lh, &should_clear) in handles.into_iter().zip(clear_flags.iter()) {
if should_clear {
lh.clear();
} else {
lh.success();
}
}
thread::sleep(Duration::from_millis(200));
let visible = handle.last_visible_count.load(Ordering::Relaxed);
handle.stop();
prop_assert_eq!(
visible,
expected_visible,
"last_visible_count ({}) must equal total ({}) minus cleared ({})",
visible,
count,
cleared_count
);
}
}
fn count_occurrences(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
fn find_last_cursor_up(output: &str) -> Option<(usize, usize)> {
let bytes = output.as_bytes();
let mut last: Option<(usize, usize)> = None;
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
let n: usize = std::str::from_utf8(&bytes[i + 2..j])
.unwrap()
.parse()
.unwrap();
last = Some((i, n));
}
}
}
last
}
fn find_all_frames(output: &str) -> Vec<&str> {
let bytes = output.as_bytes();
let mut positions: Vec<usize> = Vec::new();
for i in 0..bytes.len().saturating_sub(3) {
if bytes[i] == b'\x1b' && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < bytes.len() && bytes[j] == b'A' {
positions.push(i);
}
}
}
let mut frames = Vec::new();
for (idx, &pos) in positions.iter().enumerate() {
let end = if idx + 1 < positions.len() {
positions[idx + 1]
} else {
output.len()
};
frames.push(&output[pos..end]);
}
frames
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn property_ghost_lines_render_loop(
total_lines in 2usize..=8,
clear_seed in prop::collection::vec(any::<bool>(), 2..=8),
) {
let count = total_lines.min(clear_seed.len());
let clear_flags: Vec<bool> = clear_seed[..count].to_vec();
let cleared_count = clear_flags.iter().filter(|&&f| f).count();
let visible_count = count - cleared_count;
prop_assume!(cleared_count > 0 && visible_count > 0);
let (writer, buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let handles: Vec<SpinnerLineHandle> = (0..count)
.map(|i| handle.add(format!("line-{}", i)))
.collect();
thread::sleep(Duration::from_millis(250));
let pos_before_clear = buf.lock().unwrap().len();
for (lh, &should_clear) in handles.into_iter().zip(clear_flags.iter()) {
if should_clear {
lh.clear();
} else {
lh.success();
}
}
thread::sleep(Duration::from_millis(250));
handle.stop();
let full_output = reader.output();
let post_clear_output = &full_output[pos_before_clear..];
let frames = find_all_frames(post_clear_output);
let has_frame_with_vacated_erasure = frames.iter().any(|frame| {
let cl_count = count_occurrences(frame, CLEAR_LINE);
cl_count >= count
});
let best_frame_cl = frames.iter()
.map(|frame| count_occurrences(frame, CLEAR_LINE))
.max()
.unwrap_or(0);
prop_assert!(
has_frame_with_vacated_erasure,
"After clearing {} of {} lines, at least one render frame must contain \
>= {} CLEAR_LINE sequences (visible={} + vacated={}), but best frame had {}. \
This confirms ghost lines are NOT erased.",
cleared_count, count, count, visible_count, cleared_count,
best_frame_cl
);
}
#[test]
fn property_ghost_lines_stop_path(
total_lines in 2usize..=8,
clear_seed in prop::collection::vec(any::<bool>(), 2..=8),
) {
let count = total_lines.min(clear_seed.len());
let clear_flags: Vec<bool> = clear_seed[..count].to_vec();
let cleared_count = clear_flags.iter().filter(|&&f| f).count();
let visible_count = count - cleared_count;
prop_assume!(cleared_count > 0 && visible_count > 0);
let (writer, buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let handles: Vec<SpinnerLineHandle> = (0..count)
.map(|i| handle.add(format!("stopline-{}", i)))
.collect();
thread::sleep(Duration::from_millis(250));
for (lh, &should_clear) in handles.into_iter().zip(clear_flags.iter()) {
if should_clear {
lh.clear();
} else {
lh.success();
}
}
let pos_before_stop = buf.lock().unwrap().len();
handle.stop();
let full_output = reader.output();
let stop_output = &full_output[pos_before_stop..];
if let Some((_last_up_pos, cursor_up_val)) = find_last_cursor_up(stop_output) {
let stop_frame = &stop_output[_last_up_pos..];
let clear_line_count = count_occurrences(stop_frame, CLEAR_LINE);
prop_assert!(
clear_line_count >= cursor_up_val,
"stop() frame moved cursor up by {} but only emitted {} CLEAR_LINE sequences. \
Expected at least {} to erase all rows (visible={}, vacated={}). \
Ghost lines remain in the stop output.",
cursor_up_val, clear_line_count, cursor_up_val,
visible_count, cleared_count
);
}
}
}
fn extract_cursor_up_value(frame: &str) -> Option<usize> {
let bytes = frame.as_bytes();
if bytes.len() >= 4 && bytes[0] == b'\x1b' && bytes[1] == b'[' {
let mut j = 2;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > 2 && j < bytes.len() && bytes[j] == b'A' {
return std::str::from_utf8(&bytes[2..j])
.ok()
.and_then(|s| s.parse().ok());
}
}
None
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn property_preservation_render_no_clears(
num_spinners in 1usize..=8,
) {
let (writer, buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let _handles: Vec<SpinnerLineHandle> = (0..num_spinners)
.map(|i| handle.add(format!("preserve-{}", i)))
.collect();
thread::sleep(Duration::from_millis(350));
let render_output_len = buf.lock().unwrap().len();
drop(_handles);
handle.stop();
let full_output = reader.output();
let render_output = &full_output[..render_output_len];
let frames = find_all_frames(render_output);
prop_assert!(
frames.len() >= 2,
"Expected at least 2 render frames for {} spinners, got {}",
num_spinners, frames.len()
);
for (idx, frame) in frames.iter().enumerate().skip(1) {
if let Some(up_val) = extract_cursor_up_value(frame) {
prop_assert_eq!(
up_val, num_spinners,
"Frame {} cursor-up should be {} (num_spinners), got {}",
idx, num_spinners, up_val
);
}
let cl_count = count_occurrences(frame, CLEAR_LINE);
prop_assert_eq!(
cl_count, num_spinners,
"Frame {} should have exactly {} CLEAR_LINE sequences (one per line), got {}",
idx, num_spinners, cl_count
);
for i in 0..num_spinners {
let msg = format!("preserve-{}", i);
prop_assert!(
frame.contains(&msg),
"Frame {} must contain message '{}' (no lines cleared)",
idx, msg
);
}
}
}
#[test]
fn property_preservation_stop_no_clears(
num_spinners in 1usize..=8,
finalize_pattern in prop::collection::vec(0u8..4, 1..=8),
) {
let count = num_spinners.min(finalize_pattern.len());
let (writer, buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let handles: Vec<SpinnerLineHandle> = (0..count)
.map(|i| handle.add(format!("stopkeep-{}", i)))
.collect();
thread::sleep(Duration::from_millis(250));
for (lh, &pattern) in handles.into_iter().zip(finalize_pattern.iter()) {
match pattern % 4 {
0 => lh.success(),
1 => lh.fail(),
2 => lh.success_with("custom-success"),
3 => lh.fail_with("custom-fail"),
_ => unreachable!(),
}
}
thread::sleep(Duration::from_millis(100));
let pos_before_stop = buf.lock().unwrap().len();
handle.stop();
let full_output = reader.output();
let stop_output = &full_output[pos_before_stop..];
if let Some((_, cursor_up_val)) = find_last_cursor_up(stop_output) {
prop_assert_eq!(
cursor_up_val, count,
"stop() cursor-up should be {} (all lines visible, none cleared), got {}",
count, cursor_up_val
);
}
if let Some((last_up_pos, _)) = find_last_cursor_up(stop_output) {
let stop_frame = &stop_output[last_up_pos..];
let cl_count = count_occurrences(stop_frame, CLEAR_LINE);
prop_assert_eq!(
cl_count, count,
"stop() frame should have exactly {} CLEAR_LINE sequences, got {}",
count, cl_count
);
}
}
#[test]
fn property_preservation_finalized_lines_visible(
num_spinners in 2usize..=8,
finalize_pattern in prop::collection::vec(0u8..4, 2..=8),
) {
let count = num_spinners.min(finalize_pattern.len());
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let messages: Vec<String> = (0..count)
.map(|i| format!("finmsg-{}", i))
.collect();
let handles: Vec<SpinnerLineHandle> = messages
.iter()
.map(|msg| handle.add(msg.clone()))
.collect();
thread::sleep(Duration::from_millis(250));
let patterns: Vec<u8> = finalize_pattern[..count].to_vec();
for (lh, &pattern) in handles.into_iter().zip(patterns.iter()) {
match pattern % 4 {
0 => lh.success(),
1 => lh.fail(),
2 => lh.success_with(format!("custom-{}", "ok")),
3 => lh.fail_with(format!("custom-{}", "err")),
_ => unreachable!(),
}
}
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
if let Some((last_up_pos, _)) = find_last_cursor_up(&output) {
let final_frame = &output[last_up_pos..];
let mut success_count = 0usize;
let mut fail_count = 0usize;
for &pattern in &patterns {
match pattern % 4 {
0 | 2 => success_count += 1,
1 | 3 => fail_count += 1,
_ => unreachable!(),
}
}
let checkmark_count = count_occurrences(final_frame, "✔");
let cross_count = count_occurrences(final_frame, "✖");
prop_assert_eq!(
checkmark_count, success_count,
"Final frame should have {} ✔ symbols, got {}",
success_count, checkmark_count
);
prop_assert_eq!(
cross_count, fail_count,
"Final frame should have {} ✖ symbols, got {}",
fail_count, cross_count
);
let total_symbols = checkmark_count + cross_count;
prop_assert_eq!(
total_symbols, count,
"Final frame should have {} total finalized lines, got {}",
count, total_symbols
);
for (i, &pattern) in patterns.iter().enumerate() {
match pattern % 4 {
0 | 1 => {
prop_assert!(
final_frame.contains(&messages[i]),
"Final frame must contain original message '{}' for line {}",
messages[i], i
);
}
2 => {
prop_assert!(
final_frame.contains("custom-ok"),
"Final frame must contain replacement message 'custom-ok' for success_with line {}",
i
);
}
3 => {
prop_assert!(
final_frame.contains("custom-err"),
"Final frame must contain replacement message 'custom-err' for fail_with line {}",
i
);
}
_ => unreachable!(),
}
}
}
}
}
#[test]
fn test_multi_spinner_tty_warn_info_all() {
let (writer, _buf) = TestWriter::new();
let reader = writer.clone();
let handle = MultiSpinner::with_writer_tty(writer, true).start();
let warn_line = handle.add("Task W");
let warn_with_line = handle.add("checking W");
let info_line = handle.add("Task I");
let info_with_line = handle.add("checking I");
thread::sleep(Duration::from_millis(200));
warn_line.warn();
warn_with_line.warn_with("warned result");
info_line.info();
info_with_line.info_with("informed result");
thread::sleep(Duration::from_millis(100));
handle.stop();
let output = reader.output();
assert!(
output.contains(YELLOW),
"TTY output must contain YELLOW ANSI code"
);
assert!(output.contains("⚠"), "TTY output must contain ⚠");
assert!(
output.contains(BLUE),
"TTY output must contain BLUE ANSI code"
);
assert!(output.contains("ℹ"), "TTY output must contain ℹ");
assert!(
output.contains("Task W"),
"TTY output must contain warn original message"
);
assert!(
output.contains("warned result"),
"TTY output must contain warn_with replacement message"
);
assert!(
output.contains("Task I"),
"TTY output must contain info original message"
);
assert!(
output.contains("informed result"),
"TTY output must contain info_with replacement message"
);
}
}