use crate::error::Result;
use crate::hts::{BamRecord, Header, HtsFile, BGZF_CACHE_SIZE};
use crate::index::{
estimated_qbi1_size, estimated_qbi2_size, generate_index_filename, qname_hash64, BamMetadata,
BucketIndexBuilder, Index, IndexFormat,
};
use std::collections::HashSet;
use std::io::{BufWriter, IsTerminal, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GetOrder {
Query,
Bam,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OutputFormat {
Sam,
Bam,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ColorMode {
Auto,
#[cfg(feature = "biosyntax")]
Always,
#[cfg(feature = "biosyntax")]
Never,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CheckMode {
Quick,
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StatsFormat {
Text,
Json,
}
impl OutputFormat {
fn hts_mode(self) -> &'static str {
match self {
Self::Sam => "w",
Self::Bam => "wb",
}
}
fn name(self) -> &'static str {
match self {
Self::Sam => "SAM",
Self::Bam => "BAM",
}
}
}
struct Hit {
query_index: usize,
file_offset: i64,
}
type MissingWriter = BufWriter<std::fs::File>;
struct GetContext<'a> {
bam: &'a HtsFile,
header: &'a Header,
out: &'a mut RecordWriter,
rec: &'a BamRecord,
index: &'a Index,
missing_out: &'a mut Option<MissingWriter>,
}
pub(crate) struct GetOptions<'a> {
pub(crate) input_index: Option<&'a str>,
pub(crate) threads: usize,
pub(crate) order: GetOrder,
pub(crate) unique: bool,
pub(crate) with_header: bool,
pub(crate) output_format: OutputFormat,
pub(crate) output_path: Option<&'a str>,
pub(crate) missing_path: Option<&'a str>,
pub(crate) readnames_path: Option<&'a str>,
pub(crate) color_mode: ColorMode,
}
pub(crate) struct BuildIndexOptions<'a> {
pub(crate) output_index: Option<&'a str>,
pub(crate) verbose: bool,
pub(crate) threads: usize,
pub(crate) memory_limit: usize,
pub(crate) bucket_bits: u8,
pub(crate) sort_threads: usize,
pub(crate) temp_dir: Option<&'a str>,
pub(crate) index_format: IndexFormat,
pub(crate) qbi2_radix_bits: Option<u8>,
}
pub(crate) fn build_index(input_bam: &str, options: BuildIndexOptions<'_>) -> Result<()> {
let out_fn = generate_index_filename(Some(input_bam), options.output_index)?;
if paths_refer_to_same_file(Path::new(&out_fn), Path::new(input_bam)) {
return Err(format!(
"[qbix] output index must not overwrite the input BAM: {out_fn}"
));
}
let bam =
HtsFile::open(input_bam, "r").map_err(|_| format!("[qbix] could not open {input_bam}"))?;
bam.set_threads(options.threads)?;
let header = bam
.read_header()
.map_err(|_| format!("[qbix] could not read BAM header from {input_bam}"))?;
let bam_metadata = BamMetadata::from_bam(input_bam, header.text_hash()?)?;
let rec = BamRecord::new()?;
let mut builder = BucketIndexBuilder::new_with_format_and_radix(
&out_fn,
options.memory_limit,
options.bucket_bits,
options.sort_threads,
options.temp_dir,
options.index_format,
options.qbi2_radix_bits,
)?;
let mut file_offset = bam
.tell()
.map_err(|_| format!("[qbix] {input_bam} is not a BGZF-compressed BAM file"))?;
loop {
let ret = bam.read_next(&header, &rec);
if ret < -1 {
return Err(format!(
"[qbix] error while reading BAM records from {input_bam}"
));
}
if ret < 0 {
break;
}
let readname = rec.qname()?;
let record = builder.add(readname, file_offset)?;
if options.verbose
&& (builder.total_records() == 1 || builder.total_records().is_multiple_of(100_000))
{
eprintln!(
"[qbix] build: record {} [{} {}] {}",
builder.total_records(),
record.qhash,
record.file_offset,
readname
);
}
file_offset = bam.tell()?;
}
if options.verbose {
eprintln!("[qbix] build: writing to disk...");
}
let total_records = builder.total_records();
builder.finish(bam_metadata)?;
if options.verbose {
eprintln!("[qbix] build: wrote index for {total_records} records.");
}
Ok(())
}
pub(crate) fn get_records<I>(input_bam: &str, readnames: I, options: GetOptions<'_>) -> Result<()>
where
I: IntoIterator<Item = Result<String>>,
{
let input_index = generate_index_filename(Some(input_bam), options.input_index)?;
let output_path = options.output_path.unwrap_or("-");
validate_get_paths(
input_bam,
&input_index,
output_path,
options.missing_path,
options.readnames_path,
)?;
let bam = HtsFile::open(input_bam, "r")
.map_err(|_| format!("[qbix] could not open BAM file: {input_bam}"))?;
bam.set_threads(options.threads)?;
bam.set_bgzf_cache_size(BGZF_CACHE_SIZE)?;
let header = bam
.read_header()
.map_err(|_| format!("[qbix] could not read BAM header: {input_bam}"))?;
let bam_metadata = BamMetadata::from_bam(input_bam, header.text_hash()?)?;
let index = Index::load(Some(input_bam), Some(&input_index), Some(bam_metadata))?;
let mut missing_out = options
.missing_path
.map(|path| {
std::fs::File::create(path)
.map(BufWriter::new)
.map_err(|e| format!("[qbix] could not open missing QNAME output {path}: {e}"))
})
.transpose()?;
let rec = BamRecord::new()?;
let mut out = RecordWriter::open(
output_path,
options.output_format,
options.color_mode,
options.with_header,
options.threads,
&header,
)?;
{
let mut context = GetContext {
bam: &bam,
header: &header,
out: &mut out,
rec: &rec,
index: &index,
missing_out: &mut missing_out,
};
if options.order == GetOrder::Query {
write_hits_in_query_order(&mut context, readnames, options.unique)?;
} else {
let readnames = collect_readnames(readnames, options.unique)?;
write_hits_in_bam_order(&mut context, &readnames)?;
}
}
flush_missing_qnames(&mut missing_out)
}
fn validate_get_paths(
input_bam: &str,
input_index: &str,
output_path: &str,
missing_path: Option<&str>,
readnames_path: Option<&str>,
) -> Result<()> {
if missing_path == Some("-") {
return Err("[qbix] --missing requires a file path; '-' is not supported".to_string());
}
for (option, path) in [("--output", Some(output_path)), ("--missing", missing_path)] {
let Some(path) = path.filter(|path| *path != "-") else {
continue;
};
for (input, input_path) in [
("input BAM", Some(input_bam)),
("input index", Some(input_index)),
("read-name input", readnames_path),
] {
let Some(input_path) = input_path else {
continue;
};
if paths_refer_to_same_file(Path::new(path), Path::new(input_path)) {
return Err(format!(
"[qbix] {option} path must not overwrite the {input}: {path}"
));
}
}
}
if let Some(missing_path) = missing_path {
if paths_refer_to_same_file(Path::new(output_path), Path::new(missing_path)) {
return Err("[qbix] --missing and --output must use different paths".to_string());
}
}
Ok(())
}
pub(crate) fn paths_refer_to_same_file(first: &Path, second: &Path) -> bool {
same_existing_file(first, second) || comparable_path(first) == comparable_path(second)
}
#[cfg(unix)]
fn same_existing_file(first: &Path, second: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(first), std::fs::metadata(second)) {
(Ok(first), Ok(second)) => first.dev() == second.dev() && first.ino() == second.ino(),
_ => false,
}
}
#[cfg(not(unix))]
fn same_existing_file(first: &Path, second: &Path) -> bool {
match (std::fs::canonicalize(first), std::fs::canonicalize(second)) {
(Ok(first), Ok(second)) => first == second,
_ => false,
}
}
fn comparable_path(path: &Path) -> PathBuf {
if let Ok(path) = std::fs::canonicalize(path) {
return path;
}
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
match (absolute.parent(), absolute.file_name()) {
(Some(parent), Some(file_name)) => std::fs::canonicalize(parent)
.map(|parent| parent.join(file_name))
.unwrap_or(absolute),
_ => absolute,
}
}
fn write_hits_in_query_order<I>(
context: &mut GetContext<'_>,
readnames: I,
unique: bool,
) -> Result<()>
where
I: IntoIterator<Item = Result<String>>,
{
let mut seen = HashSet::new();
let mut query_count = 0usize;
for readname in readnames {
let readname = readname?;
query_count += 1;
if unique && !seen.insert(readname.clone()) {
continue;
}
let mut found = false;
for file_offset in context.index.candidate_offsets(&readname)? {
let file_offset = file_offset?;
context
.bam
.read_record_at(context.header, context.rec, file_offset)?;
if context.rec.qname()? == readname.as_str() {
found = true;
context.out.write_record(context.header, context.rec)?;
}
}
if !found {
write_missing_qname(context.missing_out, &readname)?;
}
}
if query_count == 0 {
return Err("[qbix] missing required argument: readnames".to_string());
}
Ok(())
}
fn collect_readnames<I>(readnames: I, unique: bool) -> Result<Vec<String>>
where
I: IntoIterator<Item = Result<String>>,
{
let mut collected = Vec::new();
let mut seen = HashSet::new();
for readname in readnames {
let readname = readname?;
if !unique || seen.insert(readname.clone()) {
collected.push(readname);
}
}
if collected.is_empty() {
return Err("[qbix] missing required argument: readnames".to_string());
}
Ok(collected)
}
fn write_hits_in_bam_order(context: &mut GetContext<'_>, readnames: &[String]) -> Result<()> {
let mut hits = Vec::new();
for (query_index, readname) in readnames.iter().enumerate() {
for file_offset in context.index.candidate_offsets(readname)? {
hits.push(Hit {
query_index,
file_offset: file_offset?,
});
}
}
hits.sort_by_key(|hit| hit.file_offset);
let mut found = context
.missing_out
.is_some()
.then(|| vec![false; readnames.len()]);
for hit in hits {
context
.bam
.read_record_at(context.header, context.rec, hit.file_offset)?;
if context.rec.qname()? == readnames[hit.query_index].as_str() {
if let Some(found) = &mut found {
found[hit.query_index] = true;
}
context.out.write_record(context.header, context.rec)?;
}
}
if let Some(found) = found {
for (readname, found) in readnames.iter().zip(found) {
if !found {
write_missing_qname(context.missing_out, readname)?;
}
}
}
Ok(())
}
fn write_missing_qname(missing_out: &mut Option<MissingWriter>, readname: &str) -> Result<()> {
let Some(out) = missing_out else {
return Ok(());
};
writeln!(out, "{readname}")
.map_err(|e| format!("[qbix] could not write missing QNAME output: {e}"))
}
fn flush_missing_qnames(missing_out: &mut Option<MissingWriter>) -> Result<()> {
let Some(out) = missing_out else {
return Ok(());
};
out.flush()
.map_err(|e| format!("[qbix] could not write missing QNAME output: {e}"))
}
enum RecordWriter {
Hts(HtsFile),
#[cfg(feature = "biosyntax")]
Colored(ColorSamWriter),
}
impl RecordWriter {
fn open(
output_path: &str,
output_format: OutputFormat,
color_mode: ColorMode,
with_header: bool,
threads: usize,
header: &Header,
) -> Result<Self> {
if should_color(output_format, color_mode, output_path) {
#[cfg(feature = "biosyntax")]
{
return Ok(Self::Colored(ColorSamWriter::open(
output_path,
with_header.then_some(header),
)?));
}
#[cfg(not(feature = "biosyntax"))]
{
return Err(
"[qbix] colored SAM output requires building with --features biosyntax"
.to_string(),
);
}
}
let out = HtsFile::open(output_path, output_format.hts_mode()).map_err(|_| {
format!(
"[qbix] could not open {} output: {output_path}",
output_format.name()
)
})?;
if output_format == OutputFormat::Bam {
out.set_threads(threads)?;
}
if output_format == OutputFormat::Bam || with_header {
out.write_header(header)?;
}
Ok(Self::Hts(out))
}
fn write_record(&mut self, header: &Header, rec: &BamRecord) -> Result<()> {
match self {
Self::Hts(out) => out.write_record(header, rec),
#[cfg(feature = "biosyntax")]
Self::Colored(out) => out.write_record(header, rec),
}
}
}
#[cfg(feature = "biosyntax")]
struct ColorSamWriter {
writer: std::io::BufWriter<Box<dyn std::io::Write>>,
line_no: u64,
}
#[cfg(feature = "biosyntax")]
impl ColorSamWriter {
fn open(output_path: &str, header: Option<&Header>) -> Result<Self> {
let writer: Box<dyn std::io::Write> = if output_path == "-" {
Box::new(std::io::stdout())
} else {
Box::new(
std::fs::File::create(output_path)
.map_err(|e| format!("[qbix] could not open SAM output: {output_path}: {e}"))?,
)
};
let mut out = Self {
writer: std::io::BufWriter::new(writer),
line_no: 0,
};
if let Some(header) = header {
out.write_header(header)?;
}
Ok(out)
}
fn write_header(&mut self, header: &Header) -> Result<()> {
for line in header.text()?.split(|byte| *byte == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if !line.is_empty() {
self.write_line(line)?;
}
}
Ok(())
}
fn write_record(&mut self, header: &Header, rec: &BamRecord) -> Result<()> {
let line = rec.format_sam(header)?;
self.write_line(&line)
}
fn write_line(&mut self, line: &[u8]) -> Result<()> {
let rendered = crate::biosyntax::render_sam_ansi(line, self.line_no)?;
self.writer
.write_all(&rendered)
.map_err(|e| format!("[qbix] could not write colored SAM output: {e}"))?;
self.writer
.write_all(b"\n")
.map_err(|e| format!("[qbix] could not write colored SAM output: {e}"))?;
self.line_no += 1;
Ok(())
}
}
fn should_color(output_format: OutputFormat, color_mode: ColorMode, output_path: &str) -> bool {
should_color_with_terminal(
output_format,
color_mode,
output_path,
std::io::stdout().is_terminal(),
)
}
fn should_color_with_terminal(
output_format: OutputFormat,
color_mode: ColorMode,
output_path: &str,
stdout_is_terminal: bool,
) -> bool {
if output_format != OutputFormat::Sam {
return false;
}
match color_mode {
#[cfg(feature = "biosyntax")]
ColorMode::Never => false,
#[cfg(feature = "biosyntax")]
ColorMode::Always => true,
ColorMode::Auto => cfg!(feature = "biosyntax") && output_path == "-" && stdout_is_terminal,
}
}
pub(crate) fn show_index(input_index: &str) -> Result<()> {
let index = Index::load(None, Some(input_index), None)?;
let stdout = std::io::stdout();
let mut out = BufWriter::new(stdout.lock());
for record in index.iter_records() {
let record = record?;
if let Err(error) = writeln!(out, "{}\t{}", record.qhash, record.file_offset) {
if error.kind() == std::io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(format!("[qbix] show: could not write output: {error}"));
}
}
if let Err(error) = out.flush() {
if error.kind() == std::io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(format!("[qbix] show: could not write output: {error}"));
}
Ok(())
}
pub(crate) fn check_index(
input_bam: &str,
input_index: Option<&str>,
threads: usize,
verbose: bool,
mode: CheckMode,
) -> Result<()> {
let bam = HtsFile::open(input_bam, "r")
.map_err(|_| "[qbix] check: could not open BAM file".to_string())?;
bam.set_threads(threads)
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
let header = bam
.read_header()
.map_err(|_| "[qbix] check: could not read BAM header".to_string())?;
let bam_metadata = BamMetadata::from_bam(input_bam, header.text_hash()?)
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
let index = Index::load(Some(input_bam), input_index, Some(bam_metadata))
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
if mode == CheckMode::Quick {
eprintln!("[qbix] check: ok (quick, {} records)", index.record_count());
return Ok(());
}
index
.validate_full_structure()
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
bam.set_bgzf_cache_size(BGZF_CACHE_SIZE)
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
let rec =
BamRecord::new().map_err(|_| "[qbix] check: could not allocate BAM record".to_string())?;
let mut checked = 0usize;
for record in index.iter_records() {
let record = record?;
bam.read_record_at(&header, &rec, record.file_offset)
.map_err(|e| e.replace("[qbix]", "[qbix] check:"))?;
let got = rec.qname()?;
let got_hash = qname_hash64(got.as_bytes());
if verbose {
eprintln!("[qbix] check: {} {}", record.qhash, got_hash);
}
if got_hash != record.qhash {
return Err(
"[qbix] check: lookup returned a record with the wrong read name hash".to_string(),
);
}
checked += 1;
if !verbose && checked.is_multiple_of(1_000_000) {
eprintln!("[qbix] check: checked {checked} records...");
}
}
eprintln!("[qbix] check: ok (full, {checked} records)");
Ok(())
}
pub(crate) fn stats_index(
input_bam: &str,
input_index: Option<&str>,
format: StatsFormat,
) -> Result<()> {
let index_path = generate_index_filename(Some(input_bam), input_index)?;
let index_size = std::fs::metadata(&index_path)
.map_err(|e| format!("[qbix] could not stat index file '{index_path}': {e}"))?
.len();
let index = Index::load(Some(input_bam), input_index, None)?;
let stats = compute_qname_hash_stats(&index)?;
match format {
StatsFormat::Text => print_stats_text(input_bam, &index_path, index_size, &index, &stats),
StatsFormat::Json => print_stats_json(input_bam, &index_path, index_size, &index, &stats),
}
}
struct QnameHashStats {
records: usize,
distinct_hashes: usize,
singleton_hashes: usize,
pair_hashes: usize,
multi_hashes: usize,
max_records_per_hash: usize,
mean_records_per_hash: f64,
qbi1_estimated_size: u64,
qbi2_p8_estimated_size: u64,
qbi2_p16_estimated_size: u64,
}
fn compute_qname_hash_stats(index: &Index) -> Result<QnameHashStats> {
let records = index.record_count();
let mut distinct_hashes = 0usize;
let mut singleton_hashes = 0usize;
let mut pair_hashes = 0usize;
let mut multi_hashes = 0usize;
let mut max_records_per_hash = 0usize;
for group in index.iter_hash_groups() {
let (_, run_len) = group?;
distinct_hashes += 1;
max_records_per_hash = max_records_per_hash.max(run_len);
match run_len {
1 => singleton_hashes += 1,
2 => pair_hashes += 1,
_ => multi_hashes += 1,
}
}
let mean_records_per_hash = if distinct_hashes == 0 {
0.0
} else {
records as f64 / distinct_hashes as f64
};
Ok(QnameHashStats {
records,
distinct_hashes,
singleton_hashes,
pair_hashes,
multi_hashes,
max_records_per_hash,
mean_records_per_hash,
qbi1_estimated_size: estimated_qbi1_size(records)?,
qbi2_p8_estimated_size: estimated_qbi2_size(records, distinct_hashes, 8)?,
qbi2_p16_estimated_size: estimated_qbi2_size(records, distinct_hashes, 16)?,
})
}
fn print_stats_text(
input_bam: &str,
index_path: &str,
index_size: u64,
index: &Index,
stats: &QnameHashStats,
) -> Result<()> {
let metadata = index
.bam_metadata()
.ok_or_else(|| "[qbix] index metadata is unavailable".to_string())?;
println!("Records:\t{}", stats.records);
println!("Distinct read-name hashes:\t{}", stats.distinct_hashes);
println!("Records per hash:");
println!(
" 1 (singletons):\t{} ({:.1}%)",
stats.singleton_hashes,
percent(stats.singleton_hashes, stats.distinct_hashes)
);
println!(
" 2 (pairs):\t{} ({:.1}%)",
stats.pair_hashes,
percent(stats.pair_hashes, stats.distinct_hashes)
);
println!(
" 3+ (multi/suppl.):\t{} ({:.1}%)",
stats.multi_hashes,
percent(stats.multi_hashes, stats.distinct_hashes)
);
println!(" max:\t{}", stats.max_records_per_hash);
println!(" mean:\t{:.2}", stats.mean_records_per_hash);
println!("Estimated index sizes:");
println!(" QBI1:\t{}", stats.qbi1_estimated_size);
println!(
" QBI2 (P=8):\t{} ({:.1}% vs QBI1)",
stats.qbi2_p8_estimated_size,
estimated_saving(stats.qbi1_estimated_size, stats.qbi2_p8_estimated_size)
);
println!(
" QBI2 (P=16):\t{} ({:.1}% vs QBI1)",
stats.qbi2_p16_estimated_size,
estimated_saving(stats.qbi1_estimated_size, stats.qbi2_p16_estimated_size)
);
println!(
" Smallest QBI2 layout:\tP={}",
smallest_qbi2_radix_bits(stats)
);
println!("Index metadata:");
println!(" BAM:\t{input_bam}");
println!(" Index:\t{index_path}");
println!(" Format:\t{}", index.format().name());
if let Some(radix_bits) = index.qbi2_radix_bits() {
println!(" QBI2 radix bits:\t{radix_bits}");
}
println!(" Index size:\t{index_size}");
println!(" BAM size:\t{}", metadata.size());
println!(" BAM mtime ns:\t{}", metadata.mtime());
println!(" Header hash:\t0x{:016x}", metadata.header_hash());
Ok(())
}
fn print_stats_json(
input_bam: &str,
index_path: &str,
index_size: u64,
index: &Index,
stats: &QnameHashStats,
) -> Result<()> {
let metadata = index
.bam_metadata()
.ok_or_else(|| "[qbix] index metadata is unavailable".to_string())?;
println!("{{");
println!(" \"bam\": \"{}\",", json_escape(input_bam));
println!(" \"index\": \"{}\",", json_escape(index_path));
println!(" \"format\": \"{}\",", index.format().name());
match index.qbi2_radix_bits() {
Some(radix_bits) => println!(" \"qbi2_radix_bits\": {radix_bits},"),
None => println!(" \"qbi2_radix_bits\": null,"),
}
println!(" \"records\": {},", stats.records);
println!(" \"distinct_qname_hashes\": {},", stats.distinct_hashes);
println!(" \"records_per_hash\": {{");
println!(" \"singletons\": {},", stats.singleton_hashes);
println!(" \"pairs\": {},", stats.pair_hashes);
println!(" \"multi_or_supplementary\": {},", stats.multi_hashes);
println!(" \"max\": {},", stats.max_records_per_hash);
println!(" \"mean\": {:.6}", stats.mean_records_per_hash);
println!(" }},");
println!(" \"estimated_sizes\": {{");
println!(" \"qbi1\": {},", stats.qbi1_estimated_size);
println!(" \"qbi2_p8\": {},", stats.qbi2_p8_estimated_size);
println!(" \"qbi2_p16\": {}", stats.qbi2_p16_estimated_size);
println!(" }},");
println!(
" \"smallest_qbi2_radix_bits\": {},",
smallest_qbi2_radix_bits(stats)
);
println!(" \"estimated_saving_percent\": {{");
println!(
" \"qbi2_p8\": {:.6},",
estimated_saving(stats.qbi1_estimated_size, stats.qbi2_p8_estimated_size)
);
println!(
" \"qbi2_p16\": {:.6}",
estimated_saving(stats.qbi1_estimated_size, stats.qbi2_p16_estimated_size)
);
println!(" }},");
println!(" \"index_size\": {index_size},");
println!(" \"bam_size\": {},", metadata.size());
println!(" \"bam_mtime_ns\": {},", metadata.mtime());
println!(" \"header_hash\": \"0x{:016x}\"", metadata.header_hash());
println!("}}");
Ok(())
}
fn percent(count: usize, total: usize) -> f64 {
if total == 0 {
0.0
} else {
count as f64 * 100.0 / total as f64
}
}
fn estimated_saving(qbi1: u64, qbi2: u64) -> f64 {
if qbi1 == 0 {
0.0
} else {
(qbi1 as f64 - qbi2 as f64) * 100.0 / qbi1 as f64
}
}
fn smallest_qbi2_radix_bits(stats: &QnameHashStats) -> u8 {
if stats.qbi2_p8_estimated_size <= stats.qbi2_p16_estimated_size {
8
} else {
16
}
}
fn json_escape(value: &str) -> String {
let mut escaped = String::new();
for ch in value.chars() {
match ch {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
ch if ch.is_control() => escaped.push_str(&format!("\\u{:04x}", ch as u32)),
ch => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::{should_color_with_terminal, ColorMode, OutputFormat};
#[test]
#[cfg(not(feature = "biosyntax"))]
fn auto_color_falls_back_to_plain_without_biosyntax_even_on_terminal() {
assert!(!should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Auto,
"-",
true,
));
}
#[test]
#[cfg(feature = "biosyntax")]
fn auto_color_uses_biosyntax_for_terminal_sam_stdout() {
assert!(should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Auto,
"-",
true,
));
}
#[test]
fn auto_color_leaves_pipes_files_and_bam_plain() {
assert!(!should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Auto,
"-",
false,
));
assert!(!should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Auto,
"hits.sam",
true,
));
assert!(!should_color_with_terminal(
OutputFormat::Bam,
ColorMode::Auto,
"-",
true,
));
}
#[test]
#[cfg(feature = "biosyntax")]
fn explicit_color_modes_do_not_depend_on_terminal_detection() {
assert!(should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Always,
"-",
false,
));
assert!(!should_color_with_terminal(
OutputFormat::Sam,
ColorMode::Never,
"-",
true,
));
}
}