diff --git a/Cargo.toml b/Cargo.toml index 817295c..0e856d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" readme = "README.md" # Own version (decoupled from workspace.version): data-beans evolves its CLI / # QC API independently of the shared utility crates. -version = "0.8.4" +version = "0.8.5" rust-version = "1.91" # Ship only the sources, README and license: nothing else in the working tree # (data stores, saved name lists, scratch files) can end up on crates.io. diff --git a/src/aux/gene_sets.rs b/src/aux/gene_sets.rs index e268471..314868e 100644 --- a/src/aux/gene_sets.rs +++ b/src/aux/gene_sets.rs @@ -94,20 +94,24 @@ pub fn read_gmt(path: &str) -> Result { /// Parse a two-column membership file — `gene label` (a marker panel, /// a TF→target list, any gene→category table) — into `(gene, label)` pairs -/// via the shared, gz-aware line reader (tab or comma delimited). Takes the -/// first two tokens per line; skips blank lines, `#` comments, a -/// `gene`/`symbol` header row and rows missing a label. Labels are kept -/// verbatim; genes are not case-folded. +/// via the shared, gz-aware line reader. A line with a tab (trailing +/// whitespace aside) splits on its tabs only, so a label keeps its commas +/// (`CD4-positive, alpha-beta T cell`); a line without one splits on its +/// commas. Cells keep their columns: an empty gene or label skips the row. Takes the first two +/// fields per line; skips blank lines, `#` comments, a `gene`/`symbol` +/// header row and rows missing a label. Labels are kept verbatim; genes are +/// not case-folded. pub fn read_membership_pairs(path: &str) -> Result, Box)>> { - let lines = - legume_numeric::matrix::common_io::read_lines_of_words_delim(path, &['\t', ','][..], -1) - .with_context(|| format!("reading membership pairs from {path}"))? - .lines; + let lines = legume_numeric::matrix::common_io::read_lines(path) + .with_context(|| format!("reading membership pairs from {path}"))?; Ok(lines - .into_iter() - .filter_map(|words| { - let gene = words.first()?.trim(); - let label = words.get(1)?.trim(); + .iter() + .filter_map(|line| { + // Trailing whitespace (a stray tab, a CR) does not choose the split. + let line = line.trim_end(); + let mut words = line.split(if line.contains('\t') { '\t' } else { ',' }); + let gene = words.next()?.trim(); + let label = words.next()?.trim(); let gl = gene.to_lowercase(); if gene.is_empty() || gene.starts_with('#') @@ -433,6 +437,31 @@ mod tests { ); } + #[test] + fn a_tab_separated_label_keeps_its_commas() { + let f = tmp( + "CD4\tCD4-positive, alpha-beta T cell\nCD8A\tCD8-positive, alpha-beta T cell\nMS4A1,B cell\t\nCD14\t\t1.8\n\tCD4-positive, x\n", + ".tsv", + ); + let pairs = read_membership_pairs(f.path().to_str().unwrap()).unwrap(); + assert_eq!( + pairs, + vec![ + ( + Box::from("CD4"), + Box::from("CD4-positive, alpha-beta T cell") + ), + ( + Box::from("CD8A"), + Box::from("CD8-positive, alpha-beta T cell") + ), + (Box::from("MS4A1"), Box::from("B cell")), + ], + "a line with a tab splits on the tab only; one without (trailing space aside), on its \ + comma; cells keep their columns, so an empty gene or label skips the row" + ); + } + fn tmp(contents: &str, suffix: &str) -> tempfile::NamedTempFile { let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap(); f.write_all(contents.as_bytes()).unwrap();