use std::fmt;
use std::str::FromStr;
pub use read_structure::SegmentType;
const ANY_LENGTH_CHAR: char = '+';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadSegment {
pub kind: SegmentType,
length: Option<usize>,
}
impl ReadSegment {
#[must_use]
pub fn length(&self) -> Option<usize> {
self.length
}
#[must_use]
pub fn has_length(&self) -> bool {
self.length.is_some()
}
}
impl fmt::Display for ReadSegment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.length {
Some(l) => write!(f, "{l}")?,
None => write!(f, "{ANY_LENGTH_CHAR}")?,
}
write!(f, "{}", self.kind.value())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LengthCheck {
Ok,
TooFew {
need: usize,
},
OverLong {
need: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadStructureError {
Empty,
MultiplePlus(String),
ZeroLength(String),
MissingLength(String),
MissingOperator(String),
UnknownType(String),
LengthOverflow(String),
}
impl fmt::Display for ReadStructureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "Read structure contained no segments"),
Self::MultiplePlus(rs) => {
write!(f, "Read structure contains more than one any-length (+) segment: {rs}")
}
Self::ZeroLength(rs) => {
write!(f, "Read structure contains a zero-length segment: {rs}")
}
Self::MissingLength(rs) => {
write!(f, "Read structure is missing a length before an operator: {rs}")
}
Self::MissingOperator(rs) => {
write!(f, "Read structure is missing a segment operator: {rs}")
}
Self::UnknownType(rs) => {
write!(f, "Read structure contains an unknown segment type: {rs}")
}
Self::LengthOverflow(rs) => {
write!(f, "Read structure segment length overflowed: {rs}")
}
}
}
}
impl std::error::Error for ReadStructureError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SegOffset {
FromStart(usize),
FromEnd(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadStructure {
segments: Vec<ReadSegment>,
plus_index: Option<usize>,
offsets: Vec<SegOffset>,
post_plus_len: usize,
fixed_length_sum: usize,
}
impl ReadStructure {
fn new(segments: Vec<ReadSegment>, rendered: &str) -> Result<Self, ReadStructureError> {
if segments.is_empty() {
return Err(ReadStructureError::Empty);
}
let plus_indices: Vec<usize> =
segments.iter().enumerate().filter(|(_, s)| !s.has_length()).map(|(i, _)| i).collect();
if plus_indices.len() > 1 {
return Err(ReadStructureError::MultiplePlus(rendered.to_string()));
}
let plus_index = plus_indices.first().copied();
let overflow = || ReadStructureError::LengthOverflow(rendered.to_string());
let fixed_length_sum: usize = segments
.iter()
.filter_map(ReadSegment::length)
.try_fold(0usize, usize::checked_add)
.ok_or_else(overflow)?;
let post_plus_len: usize = match plus_index {
Some(p) => segments[p + 1..]
.iter()
.filter_map(ReadSegment::length)
.try_fold(0usize, usize::checked_add)
.ok_or_else(overflow)?,
None => 0,
};
let n = segments.len();
let mut offsets = vec![SegOffset::FromStart(0); n];
let forward_end = plus_index.map_or(n, |p| p + 1);
let mut off: usize = 0;
for (i, seg) in segments.iter().enumerate().take(forward_end) {
offsets[i] = SegOffset::FromStart(off);
off += seg.length.unwrap_or(0);
}
if let Some(p) = plus_index {
let mut dist_from_end: usize = 0;
for i in (p + 1..n).rev() {
dist_from_end += segments[i].length.unwrap_or(0);
offsets[i] = SegOffset::FromEnd(dist_from_end);
}
}
Ok(Self { segments, plus_index, offsets, post_plus_len, fixed_length_sum })
}
#[must_use]
pub fn segments(&self) -> &[ReadSegment] {
&self.segments
}
pub fn iter(&self) -> impl Iterator<Item = &ReadSegment> {
self.segments.iter()
}
#[must_use]
pub fn number_of_segments(&self) -> usize {
self.segments.len()
}
pub fn segments_by_type(&self, kind: SegmentType) -> impl Iterator<Item = &ReadSegment> {
self.segments.iter().filter(move |s| s.kind == kind)
}
#[must_use]
pub fn has_fixed_length(&self) -> bool {
self.plus_index.is_none()
}
#[must_use]
pub fn span_of(&self, index: usize, read_len: usize) -> (usize, usize) {
let start = match self.offsets[index] {
SegOffset::FromStart(s) => s,
SegOffset::FromEnd(dist) => read_len - dist,
};
if self.plus_index == Some(index) {
(start, read_len - self.post_plus_len)
} else {
(start, start + self.segments[index].length.expect("fixed segment has a length"))
}
}
#[must_use]
pub fn check_read_length(&self, read_len: usize) -> LengthCheck {
if read_len < self.fixed_length_sum {
LengthCheck::TooFew { need: self.fixed_length_sum }
} else if self.has_fixed_length() && read_len > self.fixed_length_sum {
LengthCheck::OverLong { need: self.fixed_length_sum }
} else {
LengthCheck::Ok
}
}
}
impl fmt::Display for ReadStructure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for seg in &self.segments {
write!(f, "{seg}")?;
}
Ok(())
}
}
impl FromStr for ReadStructure {
type Err = ReadStructureError;
fn from_str(rs: &str) -> Result<Self, Self::Err> {
let chars: Vec<char> = rs.to_uppercase().chars().filter(|c| !c.is_whitespace()).collect();
let rendered: String = chars.iter().collect();
let mut segments: Vec<ReadSegment> = Vec::new();
let mut i = 0;
while i < chars.len() {
let length = if chars[i] == ANY_LENGTH_CHAR {
i += 1;
None
} else if chars[i].is_ascii_digit() {
let mut len: usize = 0;
while i < chars.len() && chars[i].is_ascii_digit() {
len = len
.checked_mul(10)
.and_then(|v| v.checked_add(chars[i] as usize - '0' as usize))
.ok_or_else(|| ReadStructureError::LengthOverflow(rendered.clone()))?;
i += 1;
}
Some(len)
} else {
return Err(ReadStructureError::MissingLength(rendered));
};
if i >= chars.len() {
return Err(ReadStructureError::MissingOperator(rendered));
}
let kind = SegmentType::try_from(chars[i])
.map_err(|_| ReadStructureError::UnknownType(rendered.clone()))?;
if length == Some(0) {
return Err(ReadStructureError::ZeroLength(rendered));
}
i += 1;
segments.push(ReadSegment { kind, length });
}
Self::new(segments, &rendered)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
fn rs(s: &str) -> ReadStructure {
ReadStructure::from_str(s).expect("valid read structure")
}
#[test]
fn parses_terminal_plus_and_fully_fixed() {
assert_eq!(rs("5M+T").to_string(), "5M+T");
assert_eq!(rs("+T").to_string(), "+T");
assert_eq!(rs("10T").to_string(), "10T");
assert_eq!(rs("8B8B75T").to_string(), "8B8B75T");
}
#[test]
fn parses_non_terminal_plus() {
assert_eq!(rs("8B+M10T").to_string(), "8B+M10T");
assert_eq!(rs("+M70T").to_string(), "+M70T");
assert_eq!(rs("2M1S2M+T").to_string(), "2M1S2M+T");
}
#[rstest]
#[case::plus_then_plus_operator("++M")]
#[case::plus_operator_after_segment("5M++T")]
#[case::two_any_length_segments("+M+T")]
#[case::explicit_zero_length("0T")]
#[case::unknown_segment_type("9R")]
#[case::operator_without_length("T")]
#[case::operator_without_following_length("23TT")]
#[case::trailing_length_without_operator("23T2")]
fn rejects_malformed_structures(#[case] bad: &str) {
assert!(ReadStructure::from_str(bad).is_err(), "expected error for {bad}");
}
#[test]
fn bare_plus_without_operator_is_missing_operator() {
assert!(matches!(
ReadStructure::from_str("8B+"),
Err(ReadStructureError::MissingOperator(_))
));
}
#[test]
fn rejects_length_token_that_overflows_usize() {
let overflowing = "9".repeat(20) + "T";
assert!(matches!(
ReadStructure::from_str(&overflowing),
Err(ReadStructureError::LengthOverflow(_))
));
}
#[test]
fn multiple_plus_is_a_specific_error() {
assert!(matches!(
ReadStructure::from_str("+M+T"),
Err(ReadStructureError::MultiplePlus(_))
));
}
#[test]
fn extracts_non_terminal_plus_by_walking_back_from_end() {
let structure = rs("8B+M10T");
let read_len = 30;
assert_eq!(structure.span_of(0, read_len), (0, 8)); assert_eq!(structure.span_of(1, read_len), (8, 20)); assert_eq!(structure.span_of(2, read_len), (20, 30)); }
#[test]
fn terminal_plus_absorbs_the_remainder() {
let structure = rs("4M+T");
assert_eq!(structure.span_of(0, 10), (0, 4));
assert_eq!(structure.span_of(1, 10), (4, 10));
assert_eq!(structure.span_of(1, 4), (4, 4));
}
#[test]
fn length_check_fully_fixed_requires_exact_length() {
let structure = rs("8M2T"); assert_eq!(structure.check_read_length(10), LengthCheck::Ok);
assert_eq!(structure.check_read_length(12), LengthCheck::OverLong { need: 10 });
assert_eq!(structure.check_read_length(8), LengthCheck::TooFew { need: 10 });
}
#[test]
fn length_check_variable_is_zero_or_more() {
let structure = rs("4M+T"); assert_eq!(structure.check_read_length(4), LengthCheck::Ok); assert_eq!(structure.check_read_length(100), LengthCheck::Ok);
assert_eq!(structure.check_read_length(3), LengthCheck::TooFew { need: 4 });
}
#[test]
fn segments_by_type_and_counts() {
let structure = rs("8B+M10T");
assert_eq!(structure.number_of_segments(), 3);
assert_eq!(structure.segments_by_type(SegmentType::Template).count(), 1);
assert_eq!(structure.segments_by_type(SegmentType::MolecularBarcode).count(), 1);
assert_eq!(structure.segments_by_type(SegmentType::SampleBarcode).count(), 1);
}
proptest::proptest! {
#[test]
fn parse_round_trips_and_spans_tile_the_read(
segs in proptest::collection::vec((0usize..5, 1usize..=20), 1..=6),
plus_pick in proptest::option::of(0usize..6),
extra in 0usize..=50,
) {
const KINDS: [char; 5] = ['T', 'B', 'M', 'C', 'S'];
let plus_index = plus_pick.filter(|&i| i < segs.len());
let mut rendered = String::new();
for (i, (kind, len)) in segs.iter().enumerate() {
if Some(i) == plus_index {
rendered.push(ANY_LENGTH_CHAR);
} else {
rendered.push_str(&len.to_string());
}
rendered.push(KINDS[*kind]);
}
let structure =
ReadStructure::from_str(&rendered).expect("generated read structure must parse");
proptest::prop_assert_eq!(structure.to_string(), rendered.clone());
let fixed_sum: usize = segs
.iter()
.enumerate()
.filter(|(i, _)| Some(*i) != plus_index)
.map(|(_, (_, len))| *len)
.sum();
let read_len = if plus_index.is_some() { fixed_sum + extra } else { fixed_sum };
proptest::prop_assert_eq!(structure.check_read_length(read_len), LengthCheck::Ok);
let mut cursor = 0usize;
for i in 0..structure.number_of_segments() {
let (start, end) = structure.span_of(i, read_len);
proptest::prop_assert_eq!(start, cursor, "segment {} start is not contiguous", i);
proptest::prop_assert!(start <= end, "segment {} has start > end", i);
proptest::prop_assert!(end <= read_len, "segment {} end exceeds read_len", i);
cursor = end;
}
proptest::prop_assert_eq!(cursor, read_len, "segments must cover the whole read");
}
}
}