Skip to content

Commit 21640f0

Browse files
committed
Stop corrupting INFO when a codon MNV merges differing original INFO
merge_original_info joined the distinct original-INFO strings of an MNV's constituent SNVs with '|'. With --keep-original-info, build_info_string then re-split that on ';'/'=' and emitted malformed fields (e.g. AF=0.5|DP=12) and duplicate keys. Two independent INFO field-sets cannot be merged into one valid INFO column, so use the first source record's INFO as the representative for the combined row (identical to the shared string in the common case). Adds unit tests for shared, divergent, and absent INFO.
1 parent 45fa915 commit 21640f0

1 file changed

Lines changed: 51 additions & 14 deletions

File tree

src/variants/codon/grouping.rs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,19 @@ pub(super) fn merge_snp_into_groups(groups: &mut Vec<Vec<Snp>>, snp: Snp) {
5757
}
5858
*groups = new_groups;
5959
}
60-
/// Merge `original_info` from all SNPs in a codon group.
61-
/// When all SNPs share the same info string, return that single string.
62-
/// When they differ, concatenate unique info strings with `|` as separator
63-
/// so no original INFO data is lost for MNV records.
60+
/// Representative `original_info` for the SNPs that form a codon group.
61+
///
62+
/// When the SNVs come from VCF records with *different* INFO, two independent
63+
/// INFO field-sets cannot be combined into one valid INFO column: joining them
64+
/// (the previous behaviour) produced malformed entries such as `AF=0.5|DP=12`
65+
/// and duplicate keys once re-split on `;`/`=`. Use the first record's INFO as
66+
/// the representative for the combined row; when every SNV shares the same INFO
67+
/// (the common case) this is exactly that shared string.
6468
pub(super) fn merge_original_info(snps: &[Snp]) -> Option<String> {
65-
let infos: Vec<&str> = snps
66-
.iter()
69+
snps.iter()
6770
.filter_map(|s| s.original_info.as_deref())
68-
.collect();
69-
if infos.is_empty() {
70-
return None;
71-
}
72-
// Deduplicate while preserving order
73-
let mut seen = std::collections::HashSet::new();
74-
let unique: Vec<&str> = infos.into_iter().filter(|s| seen.insert(*s)).collect();
75-
Some(unique.join("|"))
71+
.next()
72+
.map(str::to_string)
7673
}
7774

7875
pub(super) fn collect_all_usize(values: impl Iterator<Item = Option<usize>>) -> Option<Vec<usize>> {
@@ -103,3 +100,43 @@ pub(super) fn event_metadata(
103100
pub(super) fn variant_event_metadata(variant: &crate::io::VcfPosition) -> (String, Vec<String>) {
104101
event_metadata(variant.position, &variant.ref_allele, &variant.alt_allele)
105102
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
use super::merge_original_info;
107+
use crate::variants::Snp;
108+
109+
fn snp(original_info: Option<&str>) -> Snp {
110+
Snp {
111+
index: 1,
112+
position: 1,
113+
ref_base: "A".to_string(),
114+
base: "T".to_string(),
115+
original_dp: None,
116+
original_freq: None,
117+
original_info: original_info.map(str::to_string),
118+
}
119+
}
120+
121+
#[test]
122+
fn test_merge_original_info_shared_value() {
123+
let snps = vec![snp(Some("DP=10;AF=0.5")), snp(Some("DP=10;AF=0.5"))];
124+
assert_eq!(merge_original_info(&snps).as_deref(), Some("DP=10;AF=0.5"));
125+
}
126+
127+
#[test]
128+
fn test_merge_original_info_divergent_keeps_first_without_pipe() {
129+
// Regression: differing INFO from two source records must not be joined
130+
// with '|' (which corrupts the INFO column once re-split on ';'/'='); the
131+
// first record's INFO is used as the representative for the MNV row.
132+
let snps = vec![snp(Some("DP=10;AF=0.5")), snp(Some("DP=12;AF=0.6"))];
133+
let merged = merge_original_info(&snps).expect("some info");
134+
assert_eq!(merged, "DP=10;AF=0.5");
135+
assert!(!merged.contains('|'));
136+
}
137+
138+
#[test]
139+
fn test_merge_original_info_none_when_absent() {
140+
assert_eq!(merge_original_info(&[snp(None), snp(None)]), None);
141+
}
142+
}

0 commit comments

Comments
 (0)