use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::Error;
use crate::types::TargetRef;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PartitionScore {
#[serde(rename = "u")]
Undefined,
#[serde(rename = "p")]
Positive,
#[serde(rename = "n")]
Negative,
#[serde(rename = "e")]
Excluded,
}
impl PartitionScore {
#[must_use]
pub fn as_char(self) -> char {
match self {
Self::Undefined => 'u',
Self::Positive => 'p',
Self::Negative => 'n',
Self::Excluded => 'e',
}
}
}
impl fmt::Display for PartitionScore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_char())
}
}
impl TryFrom<char> for PartitionScore {
type Error = Error;
fn try_from(c: char) -> Result<Self, Error> {
match c {
'u' => Ok(Self::Undefined),
'p' => Ok(Self::Positive),
'n' => Ok(Self::Negative),
'e' => Ok(Self::Excluded),
other => Err(Error::InvalidValue(format!(
"`{other}` is not a partition score (expected u, p, n, or e)"
))),
}
}
}
impl FromStr for PartitionScore {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
let mut chars = s.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => c.try_into(),
_ => Err(Error::InvalidValue(format!(
"`{s}` is not a partition score (expected a single character u, p, n, or e)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PartitionPoint {
pub fluor: f64,
pub score: PartitionScore,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PartitionColumn {
pub target: TargetRef,
pub points: Vec<PartitionPoint>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PartitionTable {
columns: Vec<PartitionColumn>,
}
impl PartitionTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push_column(
&mut self,
target: TargetRef,
points: Vec<PartitionPoint>,
) -> Result<(), Error> {
if let Some(first) = self.columns.first()
&& first.points.len() != points.len()
{
return Err(Error::InvalidValue(format!(
"column for `{target}` has {} partitions but the table has {} \
(one line per partition, all columns equal length)",
points.len(),
first.points.len()
)));
}
self.columns.push(PartitionColumn { target, points });
Ok(())
}
#[must_use]
pub fn columns(&self) -> &[PartitionColumn] {
&self.columns
}
#[must_use]
pub fn column(&self, target: &str) -> Option<&PartitionColumn> {
self.columns.iter().find(|c| c.target.as_str() == target)
}
#[must_use]
pub fn partition_count(&self) -> usize {
self.columns.first().map_or(0, |c| c.points.len())
}
pub fn parse(bytes: &[u8], name: &str) -> Result<Self, Error> {
let text = std::str::from_utf8(bytes).map_err(|_| Error::PartitionTable {
name: name.to_string(),
line: 0,
message: "not valid UTF-8".into(),
})?;
let mut lines = text.lines();
let Some(header) = lines.next() else {
return Err(Error::PartitionTable {
name: name.to_string(),
line: 0,
message: "empty file (a header line of target ids is required)".into(),
});
};
let header: Vec<&str> = header.split('\t').map(str::trim).collect();
if !header.len().is_multiple_of(2) {
return Err(Error::PartitionTable {
name: name.to_string(),
line: 0,
message: format!(
"header has {} columns; expected two per target (value and score)",
header.len()
),
});
}
let mut table = PartitionTable::new();
for pair in header.chunks_exact(2) {
if pair[0] != pair[1] {
return Err(Error::PartitionTable {
name: name.to_string(),
line: 0,
message: format!(
"header pair `{}`/`{}` does not repeat the same target id over \
its value and score columns",
pair[0], pair[1]
),
});
}
let target = TargetRef::new(pair[0]).map_err(|_| Error::PartitionTable {
name: name.to_string(),
line: 0,
message: "empty target id in header".into(),
})?;
table.columns.push(PartitionColumn {
target,
points: Vec::new(),
});
}
for (i, line) in lines.enumerate() {
let line_no = i + 2; if line.is_empty() {
continue; }
let fields: Vec<&str> = line.split('\t').map(str::trim).collect();
if fields.len() != table.columns.len() * 2 {
return Err(Error::PartitionTable {
name: name.to_string(),
line: line_no,
message: format!(
"expected {} columns, found {}",
table.columns.len() * 2,
fields.len()
),
});
}
for (column, pair) in table.columns.iter_mut().zip(fields.chunks_exact(2)) {
let fluor: f64 = pair[0].parse().map_err(|_| Error::PartitionTable {
name: name.to_string(),
line: line_no,
message: format!("`{}` is not a valid fluorescence value", pair[0]),
})?;
let score: PartitionScore = pair[1].parse().map_err(|_| Error::PartitionTable {
name: name.to_string(),
line: line_no,
message: format!(
"`{}` is not a partition score (expected u, p, n, or e)",
pair[1]
),
})?;
column.points.push(PartitionPoint { fluor, score });
}
}
Ok(table)
}
#[must_use]
pub fn to_tsv(&self) -> String {
let mut out = String::new();
for (i, column) in self.columns.iter().enumerate() {
if i > 0 {
out.push('\t');
}
out.push_str(column.target.as_str());
out.push('\t');
out.push_str(column.target.as_str());
}
out.push('\n');
for row in 0..self.partition_count() {
for (i, column) in self.columns.iter().enumerate() {
if i > 0 {
out.push('\t');
}
let point = &column.points[row];
out.push_str(&crate::xml::format_float(point.fluor));
out.push('\t');
out.push(point.score.as_char());
}
out.push('\n');
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::float_cmp)]
fn parses_the_format_notes_example() {
let tsv = "GAPDH\tGAPDH\tHBV\tHBV\n3412.32\tp\t121.89\tn\n239.23\tn\t3459.27\tp\n";
let table = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap();
assert_eq!(table.columns().len(), 2);
assert_eq!(table.partition_count(), 2);
let gapdh = table.column("GAPDH").unwrap();
assert_eq!(gapdh.points[0].fluor, 3412.32);
assert_eq!(gapdh.points[0].score, PartitionScore::Positive);
assert_eq!(gapdh.points[1].score, PartitionScore::Negative);
assert_eq!(table.column("HBV").unwrap().points[1].fluor, 3459.27);
assert_eq!(table.to_tsv(), tsv);
}
#[test]
fn crlf_and_trailing_blank_tolerated() {
let tsv = "T\tT\r\n1.5\tu\r\n2.5\te\r\n\r\n";
let table = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap();
assert_eq!(table.partition_count(), 2);
assert_eq!(
table.column("T").unwrap().points[1].score,
PartitionScore::Excluded
);
}
#[test]
fn errors_carry_line_numbers() {
let tsv = "T\tT\n1.5\tu\noops\tp\n";
let err = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap_err();
match err {
Error::PartitionTable { line, .. } => assert_eq!(line, 3),
other => panic!("unexpected error {other}"),
}
let tsv = "T\tX\n";
assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
let tsv = "T\tT\n1.5\n";
assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
let tsv = "T\tT\n1.5\tq\n";
assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
}
#[test]
fn push_column_enforces_equal_length() {
let mut table = PartitionTable::new();
table
.push_column(
"A".parse().unwrap(),
vec![PartitionPoint {
fluor: 1.0,
score: PartitionScore::Positive,
}],
)
.unwrap();
let err = table.push_column("B".parse().unwrap(), vec![]).unwrap_err();
assert!(err.to_string().contains("equal length"));
}
}