use std::time::Instant;
use log::Level;
use noodles::sam::Header;
use crate::sam::riker_record::RikerRecord;
const COUNT_WIDTH: usize = 13;
const POS_WIDTH: usize = 18;
fn format_count(n: u64) -> String {
let s = n.to_string();
s.as_bytes()
.rchunks(3)
.rev()
.map(|c| std::str::from_utf8(c).unwrap())
.collect::<Vec<_>>()
.join(",")
}
fn format_position(chrom: &str, pos: u64) -> String {
format!("{chrom}:{}", format_count(pos))
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "elapsed seconds are non-negative and fit in u64 for any practical duration"
)]
fn format_elapsed(secs: f64) -> String {
let total_secs = secs as u64;
let m = total_secs / 60;
let s = total_secs % 60;
format!("{m:02}m {s:02}s")
}
pub struct ProgressLogger {
name: &'static str,
unit: &'static str,
every: u64,
count: u64,
next_milestone: u64,
last_milestone: Instant,
start: Instant,
}
impl ProgressLogger {
#[must_use]
pub fn new(name: &'static str, unit: &'static str, every: u64) -> Self {
let now = Instant::now();
Self { name, unit, every, count: 0, next_milestone: every, last_milestone: now, start: now }
}
pub fn record(&mut self) {
self.count += 1;
if self.count >= self.next_milestone {
self.next_milestone += self.every;
self.emit(None);
}
}
pub fn record_with_position(&mut self, chrom: &str, pos: u64) {
self.count += 1;
if self.count >= self.next_milestone {
self.next_milestone += self.every;
self.emit(Some(&format_position(chrom, pos)));
}
}
pub fn record_with(&mut self, record: &RikerRecord, header: &Header) {
self.count += 1;
if self.count >= self.next_milestone {
self.next_milestone += self.every;
let (chrom, pos) = extract_chrom_pos(record, header);
self.emit(Some(&format_position(&chrom, pos)));
}
}
pub fn record_n_with_position(&mut self, n: u64, chrom: &str, pos: u64) {
if n == 0 {
return;
}
self.count += n;
if self.count >= self.next_milestone {
while self.next_milestone <= self.count {
self.next_milestone += self.every;
}
self.emit(Some(&format_position(chrom, pos)));
}
}
pub fn finish(&self) {
let total = format_elapsed(self.start.elapsed().as_secs_f64());
log::log!(
target: self.name, Level::Info,
"Processed {:>COUNT_WIDTH$} {} total in {total}.",
format_count(self.count), self.unit,
);
}
fn emit(&mut self, pos: Option<&str>) {
let milestone_secs = self.last_milestone.elapsed().as_secs_f64();
let total_elapsed = format_elapsed(self.start.elapsed().as_secs_f64());
let last_took = format!("last {} took {:.1}s", format_count(self.every), milestone_secs);
match pos {
None => log::log!(
target: self.name, Level::Info,
"Processed {:>COUNT_WIDTH$} {} - elapsed time {total_elapsed} - {last_took}.",
format_count(self.count), self.unit,
),
Some(p) => log::log!(
target: self.name, Level::Info,
"Processed {:>COUNT_WIDTH$} {} @ {:<POS_WIDTH$} - elapsed time {total_elapsed} - {last_took}.",
format_count(self.count), self.unit, p,
),
}
self.last_milestone = Instant::now();
}
}
fn extract_chrom_pos(record: &RikerRecord, header: &Header) -> (String, u64) {
let chrom = record
.reference_sequence_id()
.and_then(|id| header.reference_sequences().get_index(id))
.map_or_else(|| "<unmapped>".to_string(), |(name, _)| name.to_string());
let pos = record.alignment_start().map_or(0, usize::from) as u64;
(chrom, pos)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_count_small() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(1), "1");
assert_eq!(format_count(999), "999");
}
#[test]
fn test_format_count_thousands() {
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(10_000), "10,000");
assert_eq!(format_count(100_000), "100,000");
}
#[test]
fn test_format_count_millions() {
assert_eq!(format_count(1_000_000), "1,000,000");
assert_eq!(format_count(10_000_000), "10,000,000");
assert_eq!(format_count(1_234_567_890), "1,234,567,890");
}
#[test]
fn test_format_position_small() {
assert_eq!(format_position("chr1", 100), "chr1:100");
}
#[test]
fn test_format_position_large() {
assert_eq!(format_position("chr1", 123_456_789), "chr1:123,456,789");
}
#[test]
fn test_format_position_zero() {
assert_eq!(format_position("chrX", 0), "chrX:0");
}
#[test]
fn test_format_elapsed_seconds_only() {
assert_eq!(format_elapsed(5.3), "00m 05s");
}
#[test]
fn test_format_elapsed_minutes_and_seconds() {
assert_eq!(format_elapsed(125.7), "02m 05s");
}
#[test]
fn test_format_elapsed_exact_minute() {
assert_eq!(format_elapsed(60.0), "01m 00s");
}
#[test]
fn test_record_no_panic() {
let mut pl = ProgressLogger::new("test", "reads", 10);
for _ in 0..25 {
pl.record();
}
pl.finish();
}
#[test]
fn test_record_with_position_no_panic() {
let mut pl = ProgressLogger::new("test", "reads", 10);
for i in 0..25 {
pl.record_with_position("chr1", i);
}
pl.finish();
}
#[test]
fn test_record_n_zero_is_noop() {
let mut pl = ProgressLogger::new("test", "reads", 10);
pl.record_n_with_position(0, "chr1", 1);
pl.record_n_with_position(0, "chr1", 2);
pl.record_n_with_position(0, "chr1", 3);
pl.finish();
}
#[test]
fn test_record_n_crosses_milestones() {
let mut pl = ProgressLogger::new("test", "reads", 10);
pl.record_n_with_position(35, "chr1", 100);
pl.finish();
}
#[test]
fn test_extract_chrom_pos_mapped() {
use noodles::core::Position;
use noodles::sam::alignment::RecordBuf;
use noodles::sam::alignment::record::Flags;
use noodles::sam::header::record::value::{Map, map::ReferenceSequence};
use std::num::NonZeroUsize;
let header = Header::builder()
.add_reference_sequence(
b"chr1",
Map::<ReferenceSequence>::new(NonZeroUsize::new(1000).unwrap()),
)
.build();
let rec = RecordBuf::builder()
.set_flags(Flags::empty())
.set_reference_sequence_id(0)
.set_alignment_start(Position::new(100).unwrap())
.build();
let riker = RikerRecord::from_alignment_record(&header, &rec).unwrap();
let (chrom, pos) = extract_chrom_pos(&riker, &header);
assert_eq!(chrom, "chr1");
assert_eq!(pos, 100);
}
#[test]
fn test_extract_chrom_pos_unmapped() {
use noodles::sam::alignment::RecordBuf;
let header = Header::default();
let rec = RecordBuf::default();
let riker = RikerRecord::from_alignment_record(&header, &rec).unwrap();
let (chrom, pos) = extract_chrom_pos(&riker, &header);
assert_eq!(chrom, "<unmapped>");
assert_eq!(pos, 0);
}
}