use std::str::FromStr;
use anyhow::{Context, Result, anyhow, bail};
use regex::bytes::Regex;
const REQUIRED_COLONS: usize = 6;
const REGEX_PREFIX: &str = "regex:";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ImagingLocation<'a> {
pub unit: &'a [u8],
pub tile: &'a [u8],
}
#[derive(Clone, Debug)]
pub enum ReadNameFormat {
ColonDelimited,
Custom(CustomReadNameRegex),
}
impl Default for ReadNameFormat {
fn default() -> Self {
Self::ColonDelimited
}
}
impl ReadNameFormat {
#[inline]
pub(crate) fn extract<'a>(&self, name: &'a [u8]) -> Option<ImagingLocation<'a>> {
match self {
Self::ColonDelimited => colon_delimited_location(name),
Self::Custom(custom) => custom.extract(name),
}
}
pub(crate) fn parse_error(&self, name: &[u8]) -> anyhow::Error {
anyhow!(
"read name {:?} does not match the {} read-name format",
String::from_utf8_lossy(name),
self.describe()
)
}
fn describe(&self) -> String {
match self {
Self::ColonDelimited => {
"illumina/element (instrument:run:flowcell:lane:tile:x:y)".to_string()
}
Self::Custom(custom) => format!("{REGEX_PREFIX}{}", custom.regex.as_str()),
}
}
}
impl FromStr for ReadNameFormat {
type Err = anyhow::Error;
fn from_str(spec: &str) -> Result<Self> {
match spec {
"illumina" | "element" => Ok(Self::ColonDelimited),
_ => match spec.strip_prefix(REGEX_PREFIX) {
Some(pattern) => CustomReadNameRegex::new(pattern).map(Self::Custom),
None => bail!(
"unknown read-name format {spec:?}; expected `illumina`, `element`, or \
`{REGEX_PREFIX}<pattern>`"
),
},
}
}
}
#[derive(Clone, Debug)]
pub struct CustomReadNameRegex {
regex: Regex,
unit_group: usize,
tile_group: usize,
}
impl CustomReadNameRegex {
fn new(pattern: &str) -> Result<Self> {
let regex =
Regex::new(pattern).with_context(|| format!("invalid read-name regex {pattern:?}"))?;
let unit_group = named_group(®ex, "su")?;
let tile_group = named_group(®ex, "tile")?;
Ok(Self { regex, unit_group, tile_group })
}
#[inline]
fn extract<'a>(&self, name: &'a [u8]) -> Option<ImagingLocation<'a>> {
let captures = self.regex.captures(name)?;
let unit = captures.get(self.unit_group)?.as_bytes();
let tile = captures.get(self.tile_group)?.as_bytes();
if unit.is_empty() || tile.is_empty() {
return None;
}
Some(ImagingLocation { unit, tile })
}
}
fn colon_delimited_location(name: &[u8]) -> Option<ImagingLocation<'_>> {
let mut colon = [0usize; REQUIRED_COLONS];
let mut found = 0;
let mut i = 0;
while i + 8 <= name.len() && found < REQUIRED_COLONS {
let word = u64::from_le_bytes(name[i..i + 8].try_into().expect("8 bytes"));
let xored = word ^ u64::from_le_bytes([b':'; 8]);
const LOW7: u64 = 0x7F7F_7F7F_7F7F_7F7F;
let mut hits = !(((xored & LOW7) + LOW7) | xored | LOW7);
while hits != 0 && found < REQUIRED_COLONS {
colon[found] = i + (hits.trailing_zeros() / 8) as usize;
found += 1;
hits &= hits - 1;
}
i += 8;
}
while i < name.len() && found < REQUIRED_COLONS {
if name[i] == b':' {
colon[found] = i;
found += 1;
}
i += 1;
}
if found < REQUIRED_COLONS {
return None;
}
let flowcell = name.get(colon[1] + 1..colon[2])?;
let lane = name.get(colon[2] + 1..colon[3])?;
let tile = name.get(colon[3] + 1..colon[4])?;
if flowcell.is_empty() || lane.is_empty() || tile.is_empty() {
return None;
}
Some(ImagingLocation { unit: name.get(colon[1] + 1..colon[3])?, tile })
}
fn named_group(regex: &Regex, name: &str) -> Result<usize> {
regex.capture_names().position(|group| group == Some(name)).ok_or_else(|| {
anyhow!("read-name regex {:?} has no (?<{name}>...) capture group", regex.as_str())
})
}
#[cfg(test)]
mod tests {
use super::*;
const ILLUMINA: &[u8] = b"A00354:1305:H72CFDSXF:2:1101:1027:1986";
const ELEMENT: &[u8] = b"P2-05:Dual-Index-Sim:FC-63067cc:1:10201:0000:0015";
fn extract(name: &[u8]) -> Option<ImagingLocation<'_>> {
ReadNameFormat::ColonDelimited.extract(name)
}
fn format(spec: &str) -> ReadNameFormat {
spec.parse().expect("spec parses")
}
#[test]
fn illumina_name_yields_flowcell_lane_unit_and_tile() {
let loc = extract(ILLUMINA).expect("parses");
assert_eq!(loc.unit, b"H72CFDSXF:2");
assert_eq!(loc.tile, b"1101");
}
#[test]
fn element_aviti_name_parses_with_the_same_layout() {
let loc = extract(ELEMENT).expect("parses");
assert_eq!(loc.unit, b"FC-63067cc:1");
assert_eq!(loc.tile, b"10201");
}
#[test]
fn trailing_fields_beyond_the_seventh_are_ignored() {
let loc = extract(b"A00354:1305:H72CFDSXF:2:1101:1027:1986:ACGTACGT").expect("parses");
assert_eq!(loc.unit, b"H72CFDSXF:2");
assert_eq!(loc.tile, b"1101");
}
#[test]
fn mate_suffix_does_not_disturb_the_tile() {
let loc = extract(b"A00354:1305:H72CFDSXF:2:1101:1027:1986/1").expect("parses");
assert_eq!(loc.tile, b"1101");
}
#[test]
fn legacy_five_field_illumina_name_is_rejected() {
assert!(extract(b"HWUSI-EAS100R:6:73:941:1973#0/1").is_none());
}
#[test]
fn name_with_no_colons_is_rejected() {
assert!(extract(b"SRR1234567.1").is_none());
}
#[test]
fn name_with_empty_tile_field_is_rejected() {
assert!(extract(b"A00354:1305:H72CFDSXF:2::1027:1986").is_none());
}
#[test]
fn name_with_empty_flowcell_field_is_rejected() {
assert!(extract(b"A00354:1305::2:1101:1027:1986").is_none());
}
#[test]
fn name_with_empty_lane_field_is_rejected() {
assert!(extract(b"A00354:1305:H72CFDSXF::1101:1027:1986").is_none());
}
#[test]
fn a_semicolon_directly_after_a_colon_is_not_read_as_a_colon() {
let loc = extract(b"A:;B:FC:2:1101:3:4").expect("parses");
assert_eq!(loc.unit, b"FC:2");
assert_eq!(loc.tile, b"1101");
}
#[test]
fn a_run_of_semicolons_after_a_colon_is_not_read_as_colons() {
let loc = extract(b"A:;;;:FC:2:1101:3:4").expect("parses");
assert_eq!(loc.unit, b"FC:2");
assert_eq!(loc.tile, b"1101");
}
#[test]
fn tile_tokens_are_compared_verbatim_without_numeric_normalization() {
let padded = extract(b"A:1:FC:1:0001:0:0").expect("parses");
let bare = extract(b"A:1:FC:1:1:0:0").expect("parses");
assert_ne!(padded.tile, bare.tile);
}
#[test]
fn same_tile_number_on_two_flowcells_yields_different_units() {
let a = extract(b"A00354:1305:H72CFDSXF:2:1101:1027:1986").expect("parses");
let b = extract(b"A00354:1305:22T3L2LT4:2:1101:1027:1986").expect("parses");
assert_eq!(a.tile, b.tile);
assert_ne!(a.unit, b.unit);
}
#[test]
fn different_lanes_of_one_flowcell_are_different_units() {
let a = extract(b"A00354:1305:H72CFDSXF:1:1101:1027:1986").expect("parses");
let b = extract(b"A00354:1305:H72CFDSXF:2:1101:1027:1986").expect("parses");
assert_ne!(a.unit, b.unit);
}
#[test]
fn illumina_and_element_specs_select_the_same_extractor() {
for spec in ["illumina", "element"] {
let loc = format(spec).extract(ILLUMINA).expect("parses");
assert_eq!(loc.unit, b"H72CFDSXF:2");
}
}
#[test]
fn unknown_format_spec_is_rejected_and_lists_the_alternatives() {
let err = "novaseq".parse::<ReadNameFormat>().expect_err("must be rejected").to_string();
assert!(err.contains("illumina"), "{err}");
assert!(err.contains("regex:"), "{err}");
}
#[test]
fn custom_regex_extracts_tokens_from_an_undelimited_name() {
let mgi = format(r"regex:^(?<su>F\w+L\d)(?<tile>C\d{3}R\d{3})");
let loc = mgi.extract(b"F350009384L1C001R0010008170").expect("parses");
assert_eq!(loc.unit, b"F350009384L1");
assert_eq!(loc.tile, b"C001R001");
}
#[test]
fn custom_regex_need_not_be_anchored_to_the_whole_name() {
let loc = format(r"regex:lane(?<su>\d+)_tile(?<tile>\d+)")
.extract(b"run7_lane3_tile21_x0_y0")
.expect("parses");
assert_eq!(loc.unit, b"3");
assert_eq!(loc.tile, b"21");
}
#[test]
fn custom_regex_without_an_su_group_is_rejected() {
let err = r"regex:^(?<tile>\d+)".parse::<ReadNameFormat>().expect_err("must be rejected");
assert!(err.to_string().contains("(?<su>"), "{err}");
}
#[test]
fn custom_regex_without_a_tile_group_is_rejected() {
let err = r"regex:^(?<su>\d+)".parse::<ReadNameFormat>().expect_err("must be rejected");
assert!(err.to_string().contains("(?<tile>"), "{err}");
}
#[test]
fn syntactically_invalid_custom_regex_is_rejected() {
assert!(r"regex:^(?<su>[".parse::<ReadNameFormat>().is_err());
}
#[test]
fn custom_regex_that_does_not_match_yields_no_location() {
let mgi = format(r"regex:^(?<su>F\w+L\d)(?<tile>C\d+R\d+)");
assert!(mgi.extract(ILLUMINA).is_none());
}
#[test]
fn parse_error_names_the_read_name_and_the_chosen_format() {
let err = format("illumina").parse_error(b"SRR1234567.1").to_string();
assert!(err.contains("SRR1234567.1"), "{err}");
assert!(err.contains("instrument:run:flowcell:lane:tile:x:y"), "{err}");
}
#[test]
fn parse_error_for_a_custom_format_quotes_the_pattern() {
let err = format(r"regex:^(?<su>F\w+L\d)(?<tile>C\d+R\d+)")
.parse_error(b"SRR1234567.1")
.to_string();
assert!(err.contains("(?<su>"), "{err}");
}
}