#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
use num_format::{Locale, ToFormattedString};
use std::fmt;
use std::sync::{Arc, Mutex};
use crate::traits::{ParserTrait, Search};
pub fn count<T: ParserTrait>(parser: &T, filters: &[String]) -> (usize, usize) {
let filters = parser.filters(filters);
let (mut good, mut total) = (0, 0);
parser.root().act_on_node(&mut |node, ancestors| {
total += 1;
good += usize::from(filters.any(node, ancestors));
});
(good, total)
}
#[derive(Debug, Clone)]
pub struct CountCollector(Arc<Mutex<Count>>);
impl CountCollector {
#[must_use]
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Count::default())))
}
#[must_use]
pub fn with_count(count: Count) -> Self {
Self(Arc::new(Mutex::new(count)))
}
pub fn add(&self, good: usize, total: usize) {
let mut results = self.0.lock().unwrap_or_else(|poisoned| {
self.0.clear_poison();
poisoned.into_inner()
});
results.good += good;
results.total += total;
}
#[must_use]
pub fn into_count(self) -> Count {
match Arc::try_unwrap(self.0) {
Ok(mutex) => mutex
.into_inner()
.unwrap_or_else(std::sync::PoisonError::into_inner),
Err(shared) => {
debug_assert!(
false,
"CountCollector::into_count called while the collector \
is still shared (a worker failed to join); the \
returned Count is a non-final snapshot"
);
let guard = shared
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Count {
good: guard.good,
total: guard.total,
}
}
}
}
}
impl Default for CountCollector {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default)]
pub struct Count {
pub good: usize,
pub total: usize,
}
impl fmt::Display for Count {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(
f,
"Total nodes: {}",
self.total.to_formatted_string(&Locale::en)
)?;
writeln!(
f,
"Found nodes: {}",
self.good.to_formatted_string(&Locale::en)
)?;
let percentage = if self.total == 0 {
0.0
} else {
(self.good as f64) / (self.total as f64) * 100.
};
write!(f, "Percentage: {percentage:.2}%")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn add_degrades_on_poisoned_stats_mutex() {
let stats = Arc::new(Mutex::new(Count::default()));
let poisoner = stats.clone();
let handle = thread::spawn(move || {
let _guard = poisoner.lock().expect("fresh mutex is unpoisoned");
panic!("intentional panic to poison the stats mutex");
});
assert!(
handle.join().is_err(),
"poisoner thread should have panicked"
);
assert!(stats.is_poisoned(), "test setup failed to poison the mutex");
let collector = CountCollector(stats.clone());
collector.add(2, 5);
assert!(
!stats.is_poisoned(),
"recovery should clear the poison flag"
);
let recovered = stats.lock().expect("poison cleared, lock must succeed");
assert_eq!(
(recovered.good, recovered.total),
(2, 5),
"the surviving worker's counts must still be applied"
);
}
#[test]
fn into_count_returns_accumulated_tally() {
let collector = CountCollector::new();
let mut handles = Vec::new();
for _ in 0..4 {
let worker = collector.clone();
handles.push(thread::spawn(move || {
let mut guard = worker.0.lock().expect("fresh mutex is unpoisoned");
guard.good += 1;
guard.total += 10;
}));
}
for handle in handles {
handle.join().expect("worker thread must not panic");
}
let count = collector.into_count();
assert_eq!(count.good, 4, "every worker's good count must be summed");
assert_eq!(count.total, 40, "every worker's total count must be summed");
}
#[test]
fn into_count_degrades_on_poisoned_mutex() {
let collector = CountCollector::with_count(Count { good: 3, total: 7 });
let poisoner = collector.clone();
let handle = thread::spawn(move || {
let _guard = poisoner.0.lock().expect("fresh mutex is unpoisoned");
panic!("intentional panic to poison the collector mutex");
});
assert!(
handle.join().is_err(),
"poisoner thread should have panicked"
);
let count = collector.into_count();
assert_eq!(count.good, 3, "poison recovery must preserve the tally");
assert_eq!(count.total, 7, "poison recovery must preserve the tally");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "still shared")]
fn into_count_panics_in_debug_when_still_shared() {
let collector = CountCollector::with_count(Count { good: 1, total: 2 });
let _surviving_peer = collector.clone();
let _ = collector.into_count();
}
#[test]
fn display_reports_zero_percent_for_empty_count() {
let rendered = Count::default().to_string();
assert!(
rendered.contains("Percentage: 0.00%"),
"empty Count must render 0.00%, got: {rendered}"
);
assert!(
!rendered.contains("NaN"),
"empty Count must not render NaN, got: {rendered}"
);
}
#[test]
fn display_reports_true_percentage_for_nonempty_count() {
let rendered = Count { good: 3, total: 7 }.to_string();
assert!(
rendered.contains("Percentage: 42.86%"),
"3/7 must render 42.86%, got: {rendered}"
);
}
}