From c0b258b27d2da1201d20bbc6d2818e919fb32291 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Tue, 7 Apr 2026 14:14:51 -0700 Subject: [PATCH] refactor: introduce SamTag newtype for two-character BAM tag fields Add a `SamTag` newtype in `fgumi-sam` that wraps `[u8; 2]` and validates printable-ASCII at construction. Provides named constants for SAM-spec tags (RX, QX, MI, CB, CY, BC, QT, OX, BZ, RG, NM, MQ, MC, MS, AS, XS, PG, XT) and the fgumi-internal `pa` tag, plus a `const fn to_noodles_tag()` helper for use in `const` contexts. `SamTag` implements `FromStr` for clap parsing, `Deref` for use with byte-slice APIs, and `From for Tag` for noodles interop. Sweep production code and tests to replace ad-hoc `Tag::new(b'X', b'Y')`, `Tag::from([b'X', b'Y'])`, and `b"XX"` literal tag patterns with the named constants. `extract`'s `single_tag` and `clipping_attribute` CLI fields are typed as `Option` so clap performs the validation. Closes #193. --- crates/fgumi-consensus/src/duplex_caller.rs | 2 +- crates/fgumi-sam/src/builder.rs | 6 +- crates/fgumi-sam/src/lib.rs | 2 + crates/fgumi-sam/src/tag.rs | 271 +++++++++++++++++++ src/lib/commands/codec.rs | 5 +- src/lib/commands/compare/bams.rs | 6 +- src/lib/commands/compare/raw_compare.rs | 47 ++-- src/lib/commands/correct.rs | 39 +-- src/lib/commands/dedup.rs | 39 +-- src/lib/commands/downsample.rs | 3 +- src/lib/commands/duplex.rs | 9 +- src/lib/commands/extract.rs | 138 +++------- src/lib/commands/group.rs | 7 +- src/lib/commands/review.rs | 9 +- src/lib/commands/shared_metrics.rs | 5 +- src/lib/commands/simplex.rs | 5 +- src/lib/commands/simulate/correct_reads.rs | 3 +- src/lib/commands/sort.rs | 5 +- src/lib/commands/zipper.rs | 84 +++--- src/lib/grouper.rs | 3 +- src/lib/mi_group.rs | 9 +- src/lib/read_info.rs | 30 +- src/lib/sam/mod.rs | 3 + src/lib/sort/keys.rs | 3 +- src/lib/sort/raw.rs | 12 +- src/lib/template.rs | 62 ++--- src/lib/unified_pipeline/bam.rs | 17 +- src/lib/unified_pipeline/base.rs | 3 +- tests/integration/test_bgzf_eof.rs | 4 +- tests/integration/test_downsample_command.rs | 2 +- tests/integration/test_duplex_command.rs | 2 +- tests/integration/test_simplex_command.rs | 4 +- tests/integration/test_streaming_input.rs | 2 +- 33 files changed, 539 insertions(+), 302 deletions(-) create mode 100644 crates/fgumi-sam/src/tag.rs diff --git a/crates/fgumi-consensus/src/duplex_caller.rs b/crates/fgumi-consensus/src/duplex_caller.rs index 9c6272964..92bc0543e 100644 --- a/crates/fgumi-consensus/src/duplex_caller.rs +++ b/crates/fgumi-consensus/src/duplex_caller.rs @@ -4736,7 +4736,7 @@ mod tests { ba_consensus: None, }; - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(fgumi_sam::SamTag::CB); let cell_barcode = "ACGTACGT-1"; let mut builder = UnmappedBamRecordBuilder::new(); diff --git a/crates/fgumi-sam/src/builder.rs b/crates/fgumi-sam/src/builder.rs index 0fa67ee69..52a8862a8 100644 --- a/crates/fgumi-sam/src/builder.rs +++ b/crates/fgumi-sam/src/builder.rs @@ -598,7 +598,7 @@ impl<'a> PairBuilder<'a> { } // Add read group tag - let rg_tag = Tag::new(b'R', b'G'); + let rg_tag = Tag::from(crate::SamTag::RG); first_read.data_mut().insert(rg_tag, BufValue::from(self.parent.read_group_id.clone())); // Add custom attributes @@ -833,7 +833,7 @@ impl<'a> FragBuilder<'a> { } // Add read group tag - let rg_tag = Tag::new(b'R', b'G'); + let rg_tag = Tag::from(crate::SamTag::RG); rec.data_mut().insert(rg_tag, BufValue::from(self.parent.read_group_id.clone())); // Add custom attributes @@ -2024,7 +2024,7 @@ mod tests { let (read_one, read_two) = builder.add_pair().attr("MI", "test_umi").attr("RX", "ACGT").build(); - let mi_tag = Tag::new(b'M', b'I'); + let mi_tag = Tag::from(crate::SamTag::MI); assert!(read_one.data().get(&mi_tag).is_some()); assert!(read_two.data().get(&mi_tag).is_some()); } diff --git a/crates/fgumi-sam/src/lib.rs b/crates/fgumi-sam/src/lib.rs index 814410d91..d76076d55 100644 --- a/crates/fgumi-sam/src/lib.rs +++ b/crates/fgumi-sam/src/lib.rs @@ -36,6 +36,7 @@ pub mod alignment_tags; pub mod builder; pub mod clipper; pub mod record_utils; +pub mod tag; /// Abstraction over reference genome access for alignment tag calculation. /// @@ -91,6 +92,7 @@ pub use record_utils::{ reference_length, trailing_clipping, trailing_soft_clipping, unclipped_end, unclipped_five_prime_position, unclipped_start, }; +pub use tag::SamTag; use bstr::ByteSlice; use log::warn; diff --git a/crates/fgumi-sam/src/tag.rs b/crates/fgumi-sam/src/tag.rs new file mode 100644 index 000000000..f5984084b --- /dev/null +++ b/crates/fgumi-sam/src/tag.rs @@ -0,0 +1,271 @@ +//! [`SamTag`] newtype for two-byte SAM/BAM tag identifiers. +//! +//! SAM/BAM aux tags match `[A-Za-z][A-Za-z0-9]` (e.g. `RX`, `MI`, `CB`). +//! [`SamTag`] wraps `[u8; 2]` and enforces that syntax at construction time. +//! Use [`SamTag::new`] (const, panics on invalid bytes) for compile-time +//! constants, or [`std::str::FromStr`] (fallible, used by clap for CLI argument +//! parsing) for runtime input. +//! +//! # Named Constants +//! +//! Standard SAM-spec and fgumi-internal tags are available as associated constants: +//! +//! ``` +//! use fgumi_sam::SamTag; +//! +//! assert_eq!(SamTag::RX.to_string(), "RX"); +//! assert_eq!(SamTag::MI.to_string(), "MI"); +//! assert_eq!(SamTag::CB.to_string(), "CB"); +//! ``` +//! +//! # CLI Integration +//! +//! [`SamTag`] implements [`std::str::FromStr`], so clap will parse and validate +//! tag arguments automatically: +//! +//! ``` +//! use fgumi_sam::SamTag; +//! +//! let tag: SamTag = "XT".parse().expect("valid tag"); +//! assert_eq!(*tag, [b'X', b'T']); +//! +//! let err = "ABC".parse::(); +//! assert!(err.is_err()); +//! ``` + +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; + +use noodles::sam::alignment::record::data::field::Tag; + +/// A validated two-byte SAM/BAM tag identifier (e.g. `RX`, `MI`, `CB`). +/// +/// Wraps `[u8; 2]` and guarantees the SAM aux-tag pattern `[A-Za-z][A-Za-z0-9]`. Use +/// [`SamTag::new`] for compile-time constants, or parse from a `&str` via +/// [`FromStr`] / [`TryFrom<&str>`] for runtime input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SamTag([u8; 2]); + +impl SamTag { + /// Returns true if `[a, b]` matches the SAM aux-tag pattern `[A-Za-z][A-Za-z0-9]`. + #[must_use] + pub const fn is_valid_tag_bytes(a: u8, b: u8) -> bool { + a.is_ascii_alphabetic() && (b.is_ascii_alphabetic() || b.is_ascii_digit()) + } + + /// Construct a `SamTag` from two raw bytes. + /// + /// This is a `const fn` intended for use in named constant definitions. + /// Both bytes must match the SAM aux-tag syntax `[A-Za-z][A-Za-z0-9]`; + /// the assertion is checked at compile time in `const` contexts and panics + /// at runtime otherwise. + /// + /// # Panics + /// + /// Panics if the bytes do not match `[A-Za-z][A-Za-z0-9]`. + #[must_use] + pub const fn new(a: u8, b: u8) -> Self { + assert!(Self::is_valid_tag_bytes(a, b), "SAM tag must match [A-Za-z][A-Za-z0-9]"); + Self([a, b]) + } + + /// Convert to a noodles [`Tag`] in `const` context. + #[must_use] + pub const fn to_noodles_tag(self) -> Tag { + Tag::new(self.0[0], self.0[1]) + } + + // ── SAM spec standard tags ────────────────────────────────────────────── + + /// Raw UMI sequence (SAM spec). + pub const RX: SamTag = SamTag::new(b'R', b'X'); + /// UMI base quality scores (SAM spec). + pub const QX: SamTag = SamTag::new(b'Q', b'X'); + /// Molecule identifier assigned by grouping (SAM spec). + pub const MI: SamTag = SamTag::new(b'M', b'I'); + /// Cell barcode sequence (SAM spec). + pub const CB: SamTag = SamTag::new(b'C', b'B'); + /// Cell barcode quality scores (SAM spec). + pub const CY: SamTag = SamTag::new(b'C', b'Y'); + /// Sample/library barcode bases (SAM spec). + pub const BC: SamTag = SamTag::new(b'B', b'C'); + /// Sample/library barcode quality scores (SAM spec). + pub const QT: SamTag = SamTag::new(b'Q', b'T'); + /// Original UMI bases, before error correction (SAM spec). + pub const OX: SamTag = SamTag::new(b'O', b'X'); + /// Original UMI base quality scores, before error correction (SAM spec). + pub const BZ: SamTag = SamTag::new(b'B', b'Z'); + /// Read group (SAM spec). + pub const RG: SamTag = SamTag::new(b'R', b'G'); + /// Number of mismatches (edit distance) to the reference (SAM spec). + pub const NM: SamTag = SamTag::new(b'N', b'M'); + /// Mate's mapping quality (SAM spec). + pub const MQ: SamTag = SamTag::new(b'M', b'Q'); + /// Mate's CIGAR string (SAM spec). + pub const MC: SamTag = SamTag::new(b'M', b'C'); + /// Mate's alignment score, lowercase per SAM spec convention (SAM spec). + pub const MS: SamTag = SamTag::new(b'm', b's'); + /// Alignment score (SAM spec / aligner-defined). + pub const AS: SamTag = SamTag::new(b'A', b'S'); + /// Suboptimal alignment score (aligner-defined, widely used). + pub const XS: SamTag = SamTag::new(b'X', b'S'); + /// Program record identifier (SAM spec). + pub const PG: SamTag = SamTag::new(b'P', b'G'); + /// Adapter clipping position (e.g. from Picard `MarkIlluminaAdapters`). + pub const XT: SamTag = SamTag::new(b'X', b'T'); + + // ── fgumi-internal tags ───────────────────────────────────────────────── + + /// Primary alignment sort key for secondary/supplementary reads. + /// + /// Lowercase per the SAM specification convention for non-standard program tags. + /// Written by `fgumi zipper`; consumed by `fgumi sort --order template-coordinate`. + pub const PA: SamTag = SamTag::new(b'p', b'a'); +} + +impl Deref for SamTag { + type Target = [u8; 2]; + + fn deref(&self) -> &[u8; 2] { + &self.0 + } +} + +impl AsRef<[u8; 2]> for SamTag { + fn as_ref(&self) -> &[u8; 2] { + &self.0 + } +} + +impl From for [u8; 2] { + fn from(tag: SamTag) -> [u8; 2] { + tag.0 + } +} + +impl From for Tag { + fn from(tag: SamTag) -> Tag { + Tag::from(tag.0) + } +} + +impl PartialEq<[u8; 2]> for SamTag { + fn eq(&self, other: &[u8; 2]) -> bool { + self.0 == *other + } +} + +impl PartialEq for [u8; 2] { + fn eq(&self, other: &SamTag) -> bool { + *self == other.0 + } +} + +impl fmt::Display for SamTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", self.0[0] as char, self.0[1] as char) + } +} + +impl FromStr for SamTag { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + let bytes = s.as_bytes(); + anyhow::ensure!( + bytes.len() == 2 && Self::is_valid_tag_bytes(bytes[0], bytes[1]), + "SAM tag must match [A-Za-z][A-Za-z0-9], got: {s:?}" + ); + Ok(SamTag([bytes[0], bytes[1]])) + } +} + +impl TryFrom<&str> for SamTag { + type Error = anyhow::Error; + + fn try_from(s: &str) -> anyhow::Result { + s.parse() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants_are_correct() { + assert_eq!(*SamTag::RX, [b'R', b'X']); + assert_eq!(*SamTag::MI, [b'M', b'I']); + assert_eq!(*SamTag::CB, [b'C', b'B']); + assert_eq!(*SamTag::QX, [b'Q', b'X']); + assert_eq!(*SamTag::CY, [b'C', b'Y']); + assert_eq!(*SamTag::PA, [b'p', b'a']); + assert_eq!(*SamTag::MS, [b'm', b's']); + assert_eq!(*SamTag::NM, [b'N', b'M']); + } + + #[test] + fn test_display() { + assert_eq!(SamTag::RX.to_string(), "RX"); + assert_eq!(SamTag::MI.to_string(), "MI"); + assert_eq!(SamTag::CB.to_string(), "CB"); + assert_eq!(SamTag::PA.to_string(), "pa"); + } + + #[test] + fn test_parse_valid() { + assert_eq!("RX".parse::().unwrap(), SamTag::RX); + assert_eq!("XT".parse::().unwrap(), SamTag::XT); + } + + #[test] + fn test_parse_invalid_length() { + assert!("R".parse::().is_err()); + assert!("RXX".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_parse_invalid_non_ascii() { + assert!("R\x01".parse::().is_err()); + assert!(" X".parse::().is_err()); + } + + #[test] + fn test_parse_rejects_non_aux_tag_syntax() { + // Punctuation in second byte + assert!("A!".parse::().is_err()); + // Digit in first byte + assert!("1A".parse::().is_err()); + // Punctuation in first byte + assert!("!A".parse::().is_err()); + // Digit in second byte is allowed + assert!("A0".parse::().is_ok()); + } + + #[test] + fn test_into_noodles_tag() { + let t: Tag = SamTag::CB.into(); + assert_eq!(t, Tag::from([b'C', b'B'])); + } + + #[test] + fn test_to_noodles_tag_const() { + const T: Tag = SamTag::PA.to_noodles_tag(); + assert_eq!(T, Tag::from([b'p', b'a'])); + } + + #[test] + fn test_partial_eq_with_bytes() { + assert_eq!(SamTag::RX, *b"RX"); + assert_eq!(*b"MI", SamTag::MI); + } + + #[test] + fn test_deref_to_byte_array() { + let tag = SamTag::CB; + let bytes: &[u8; 2] = &tag; + assert_eq!(bytes, b"CB"); + } +} diff --git a/src/lib/commands/codec.rs b/src/lib/commands/codec.rs index 4bbfa8116..0fa5b7976 100644 --- a/src/lib/commands/codec.rs +++ b/src/lib/commands/codec.rs @@ -35,6 +35,7 @@ use crate::unified_pipeline::{ }; use fgumi_raw_bam::RawRecord; // RejectionTracker now used via ConsensusStatsOps trait in consensus_runner +use crate::sam::SamTag; use crate::validation::validate_file_exists; use log::info; use noodles::sam::Header; @@ -276,7 +277,7 @@ impl Command for Codec { let read_name_prefix = self.read_group.prefix_or_from_header(&header); // Parse cell tag - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); // Enable rejects tracking if rejects file is specified let track_rejects = self.rejects_opts.is_enabled(); @@ -502,7 +503,7 @@ impl Codec { let collected_metrics_for_serialize = Arc::clone(&collected_metrics); // Parse cell tag - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); // Create options for CODEC consensus caller let options = CodecConsensusOptions { diff --git a/src/lib/commands/compare/bams.rs b/src/lib/commands/compare/bams.rs index f616711a1..6dc25a22d 100644 --- a/src/lib/commands/compare/bams.rs +++ b/src/lib/commands/compare/bams.rs @@ -16,6 +16,7 @@ use crate::bam_io::{RawBamReaderAuto, create_raw_bam_reader}; use crate::logging::OperationTimer; use crate::progress::ProgressTracker; +use crate::sam::SamTag; use crate::validation::validate_file_exists; use ahash::{AHashMap, AHashSet}; use anyhow::{Result, anyhow, bail}; @@ -438,10 +439,11 @@ struct MiExtractResult { /// Tries integer-type MI first, then falls back to string-type MI parsed as i64. fn get_mi_tag_raw_i64(raw: &RawRecord) -> Option { let aux = raw_fields::aux_data_slice(raw.as_ref()); - if let Some(v) = find_int_tag(aux, b"MI") { + if let Some(v) = find_int_tag(aux, &SamTag::MI) { return Some(v); } - find_string_tag(aux, b"MI").and_then(|bytes| std::str::from_utf8(bytes).ok()?.parse().ok()) + find_string_tag(aux, &SamTag::MI) + .and_then(|bytes| std::str::from_utf8(bytes).ok()?.parse().ok()) } /// Build an MI map from a BAM file using parallel batch processing. diff --git a/src/lib/commands/compare/raw_compare.rs b/src/lib/commands/compare/raw_compare.rs index db6b2d3d3..4234b9a68 100644 --- a/src/lib/commands/compare/raw_compare.rs +++ b/src/lib/commands/compare/raw_compare.rs @@ -179,6 +179,7 @@ pub fn raw_compare_structured(r1: &[u8], r2: &[u8]) -> RawCompareResult { #[cfg(test)] mod tests { use super::*; + use crate::sam::SamTag; use fgumi_raw_bam::testutil::make_bam_bytes; /// Helper to build aux bytes for a Z-type string tag. @@ -269,8 +270,8 @@ mod tests { #[test] fn test_core_equal_same_core_different_tags() { - let aux1 = make_z_tag(*b"RG", b"sample1"); - let aux2 = make_z_tag(*b"RG", b"sample2"); + let aux1 = make_z_tag(*SamTag::RG, b"sample1"); + let aux2 = make_z_tag(*SamTag::RG, b"sample2"); let r1 = base_record(&aux1); let r2 = base_record(&aux2); assert!(raw_core_fields_equal(&r1, &r2)); @@ -322,7 +323,7 @@ mod tests { #[test] fn test_tags_byte_equal_same_tags() { - let aux = make_z_tag(*b"RG", b"sample1"); + let aux = make_z_tag(*SamTag::RG, b"sample1"); let r1 = base_record(&aux); let r2 = base_record(&aux); assert!(raw_tags_byte_equal(&r1, &r2)); @@ -330,17 +331,17 @@ mod tests { #[test] fn test_tags_byte_not_equal_different_values() { - let r1 = base_record(&make_z_tag(*b"RG", b"sample1")); - let r2 = base_record(&make_z_tag(*b"RG", b"sample2")); + let r1 = base_record(&make_z_tag(*SamTag::RG, b"sample1")); + let r2 = base_record(&make_z_tag(*SamTag::RG, b"sample2")); assert!(!raw_tags_byte_equal(&r1, &r2)); } #[test] fn test_tags_byte_not_equal_different_order() { - let mut aux1 = make_z_tag(*b"RG", b"s1"); + let mut aux1 = make_z_tag(*SamTag::RG, b"s1"); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); let mut aux2 = make_i_tag(*b"NM", 5); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"s1")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"s1")); let r1 = base_record(&aux1); let r2 = base_record(&aux2); assert!(!raw_tags_byte_equal(&r1, &r2)); @@ -352,7 +353,7 @@ mod tests { #[test] fn test_order_independent_same_order() { - let mut aux = make_z_tag(*b"RG", b"s1"); + let mut aux = make_z_tag(*SamTag::RG, b"s1"); aux.extend_from_slice(&make_i_tag(*b"NM", 5)); let r1 = base_record(&aux); let r2 = base_record(&aux); @@ -361,10 +362,10 @@ mod tests { #[test] fn test_order_independent_different_order() { - let mut aux1 = make_z_tag(*b"RG", b"s1"); + let mut aux1 = make_z_tag(*SamTag::RG, b"s1"); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); let mut aux2 = make_i_tag(*b"NM", 5); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"s1")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"s1")); let r1 = base_record(&aux1); let r2 = base_record(&aux2); assert!(raw_tags_equal_order_independent(&r1, &r2)); @@ -379,9 +380,9 @@ mod tests { #[test] fn test_order_independent_missing_tag() { - let mut aux1 = make_z_tag(*b"RG", b"s1"); + let mut aux1 = make_z_tag(*SamTag::RG, b"s1"); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); - let aux2 = make_z_tag(*b"RG", b"s1"); + let aux2 = make_z_tag(*SamTag::RG, b"s1"); let r1 = base_record(&aux1); let r2 = base_record(&aux2); assert!(!raw_tags_equal_order_independent(&r1, &r2)); @@ -391,7 +392,7 @@ mod tests { fn test_order_independent_extra_tag() { let aux1 = make_i_tag(*b"NM", 5); let mut aux2 = make_i_tag(*b"NM", 5); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"s1")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"s1")); let r1 = base_record(&aux1); let r2 = base_record(&aux2); assert!(!raw_tags_equal_order_independent(&r1, &r2)); @@ -407,14 +408,14 @@ mod tests { #[test] fn test_order_independent_multiple_types() { // Mix of Z, i, C, and f tags in different orders - let mut aux1 = make_z_tag(*b"RG", b"grp"); + let mut aux1 = make_z_tag(*SamTag::RG, b"grp"); aux1.extend_from_slice(&make_i_tag(*b"NM", 3)); aux1.extend_from_slice(&make_c_tag(*b"MQ", 42)); aux1.extend_from_slice(&make_f_tag(*b"GC", 0.75)); let mut aux2 = make_f_tag(*b"GC", 0.75); aux2.extend_from_slice(&make_c_tag(*b"MQ", 42)); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"grp")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"grp")); aux2.extend_from_slice(&make_i_tag(*b"NM", 3)); let r1 = base_record(&aux1); @@ -457,10 +458,10 @@ mod tests { #[test] fn test_structured_same_core_different_tag_order() { - let mut aux1 = make_z_tag(*b"RG", b"s1"); + let mut aux1 = make_z_tag(*SamTag::RG, b"s1"); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); let mut aux2 = make_i_tag(*b"NM", 5); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"s1")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"s1")); let r1 = base_record(&aux1); let r2 = base_record(&aux2); let result = raw_compare_structured(&r1, &r2); @@ -492,10 +493,10 @@ mod tests { // Records with different bin values and different tag order should report: // core_match=true (bin is not semantically meaningful), // tags_match=false (byte order differs), tag_order_match=true (values match) - let mut aux1 = make_z_tag(*b"RG", b"s1"); + let mut aux1 = make_z_tag(*SamTag::RG, b"s1"); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); let mut aux2 = make_i_tag(*b"NM", 5); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"s1")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"s1")); let mut r1 = base_record(&aux1); let mut r2 = base_record(&aux2); // Set different bin values @@ -529,12 +530,12 @@ mod tests { #[test] fn test_collect_entries_multiple() { - let mut aux = make_z_tag(*b"RG", b"grp"); + let mut aux = make_z_tag(*SamTag::RG, b"grp"); aux.extend_from_slice(&make_i_tag(*b"NM", 5)); let entries = collect_tag_entries(&aux) .expect("collect_tag_entries should succeed for multiple tags"); assert_eq!(entries.len(), 2); - assert_eq!(entries[0].0, *b"RG"); + assert_eq!(entries[0].0, *SamTag::RG); assert_eq!(entries[1].0, *b"NM"); } @@ -632,13 +633,13 @@ mod tests { #[test] fn test_order_independent_semantic_int_with_reordered_tags() { // Multiple tags, different order, AND different integer encodings - let mut aux1 = make_z_tag(*b"RG", b"grp"); + let mut aux1 = make_z_tag(*SamTag::RG, b"grp"); aux1.extend_from_slice(&make_c_tag(*b"cD", 200)); aux1.extend_from_slice(&make_i_tag(*b"NM", 5)); let mut aux2 = make_i_tag(*b"NM", 5); aux2.extend_from_slice(&make_upper_s_tag(*b"cD", 200)); - aux2.extend_from_slice(&make_z_tag(*b"RG", b"grp")); + aux2.extend_from_slice(&make_z_tag(*SamTag::RG, b"grp")); let r1 = base_record(&aux1); let r2 = base_record(&aux2); diff --git a/src/lib/commands/correct.rs b/src/lib/commands/correct.rs index 90af44901..71d02e2fb 100644 --- a/src/lib/commands/correct.rs +++ b/src/lib/commands/correct.rs @@ -49,6 +49,7 @@ use crate::grouper::TemplateGrouper; use crate::logging::OperationTimer; use crate::metrics::correct::UmiCorrectionMetrics; use crate::progress::ProgressTracker; +use crate::sam::SamTag; use crate::sort::bam_fields; use crate::template::TemplateBatch; use crate::unified_pipeline::{Grouper, MemoryEstimate, run_bam_pipeline_from_reader}; @@ -706,7 +707,11 @@ impl CorrectUmis { // Store original UMI if there were actual mismatches // Use update_string_tag to avoid duplicate OX tags if !dont_store_original_umis && correction.has_mismatches { - bam_fields::update_string_tag(record, b"OX", correction.original_umi.as_bytes()); + bam_fields::update_string_tag( + record, + &SamTag::OX, + correction.original_umi.as_bytes(), + ); } } } @@ -788,7 +793,7 @@ impl CorrectUmis { const BATCH_SIZE: usize = 1000; // Templates per batch let max_mismatches = self.max_mismatches; let min_distance_diff = self.min_distance_diff; - let umi_tag = Tag::new(b'R', b'X'); + let umi_tag = Tag::from(SamTag::RX); let revcomp = self.revcomp; let cache_size = self.cache_size; let dont_store_original_umis = self.dont_store_original_umis; @@ -3057,7 +3062,7 @@ mod tests { b"AAAAAA", ); let result = - CorrectUmis::extract_and_validate_template_umi_raw(&[raw], [b'R', b'X']).unwrap(); + CorrectUmis::extract_and_validate_template_umi_raw(&[raw], *SamTag::RX).unwrap(); assert_eq!(result, Some("AAAAAA".to_string())); } @@ -3074,7 +3079,7 @@ mod tests { b"ACGTAC", ); let result = - CorrectUmis::extract_and_validate_template_umi_raw(&[r1, r2], [b'R', b'X']).unwrap(); + CorrectUmis::extract_and_validate_template_umi_raw(&[r1, r2], *SamTag::RX).unwrap(); assert_eq!(result, Some("ACGTAC".to_string())); } @@ -3090,14 +3095,14 @@ mod tests { crate::sort::bam_fields::flags::PAIRED | crate::sort::bam_fields::flags::LAST_SEGMENT, b"CCCCCC", ); - let err = CorrectUmis::extract_and_validate_template_umi_raw(&[r1, r2], [b'R', b'X']) - .unwrap_err(); + let err = + CorrectUmis::extract_and_validate_template_umi_raw(&[r1, r2], *SamTag::RX).unwrap_err(); assert!(err.to_string().contains("mismatched UMIs")); } #[test] fn test_extract_and_validate_template_umi_raw_empty() { - let result = CorrectUmis::extract_and_validate_template_umi_raw(&[], [b'R', b'X']).unwrap(); + let result = CorrectUmis::extract_and_validate_template_umi_raw(&[], *SamTag::RX).unwrap(); assert!(result.is_none()); } @@ -3114,7 +3119,7 @@ mod tests { raw[16..20].copy_from_slice(&4u32.to_le_bytes()); // l_seq = 4 raw[32..36].copy_from_slice(b"rea\0"); let result = - CorrectUmis::extract_and_validate_template_umi_raw(&[raw], [b'R', b'X']).unwrap(); + CorrectUmis::extract_and_validate_template_umi_raw(&[raw], *SamTag::RX).unwrap(); assert!(result.is_none()); } @@ -3142,14 +3147,14 @@ mod tests { rejection_reason: RejectionReason::None, }; - CorrectUmis::apply_correction_to_raw(&mut raw, &correction, [b'R', b'X'], false); + CorrectUmis::apply_correction_to_raw(&mut raw, &correction, *SamTag::RX, false); // Verify corrected UMI - let umi = bam_fields::find_string_tag_in_record(&raw, b"RX"); + let umi = bam_fields::find_string_tag_in_record(&raw, &SamTag::RX); assert_eq!(umi, Some(b"AAAAAA".as_ref())); // Verify OX tag with original UMI - let ox = bam_fields::find_string_tag_in_record(&raw, b"OX"); + let ox = bam_fields::find_string_tag_in_record(&raw, &SamTag::OX); assert_eq!(ox, Some(b"AAAAAG".as_ref())); } @@ -3174,11 +3179,11 @@ mod tests { rejection_reason: RejectionReason::None, }; - CorrectUmis::apply_correction_to_raw(&mut raw, &correction, [b'R', b'X'], false); + CorrectUmis::apply_correction_to_raw(&mut raw, &correction, *SamTag::RX, false); // Record should be unchanged assert_eq!(raw.len(), orig_len); - let umi = bam_fields::find_string_tag_in_record(&raw, b"RX"); + let umi = bam_fields::find_string_tag_in_record(&raw, &SamTag::RX); assert_eq!(umi, Some(b"AAAAAA".as_ref())); } @@ -3256,7 +3261,7 @@ mod tests { let mut reader = noodles::bam::io::reader::Builder.build_from_path(&paths.output)?; let header = reader.read_header()?; - let rx_tag = Tag::new(b'R', b'X'); + let rx_tag = Tag::from(SamTag::RX); let ox_tag = Tag::ORIGINAL_UMI_BARCODE_SEQUENCE; for result in reader.records() { @@ -3339,14 +3344,14 @@ mod tests { rejection_reason: RejectionReason::None, }; - CorrectUmis::apply_correction_to_raw(&mut raw, &correction, [b'R', b'X'], true); + CorrectUmis::apply_correction_to_raw(&mut raw, &correction, *SamTag::RX, true); // Corrected UMI should be present - let umi = bam_fields::find_string_tag_in_record(&raw, b"RX"); + let umi = bam_fields::find_string_tag_in_record(&raw, &SamTag::RX); assert_eq!(umi, Some(b"AAAAAA".as_ref())); // OX tag should NOT be present (dont_store_original_umis = true) - let ox = bam_fields::find_string_tag_in_record(&raw, b"OX"); + let ox = bam_fields::find_string_tag_in_record(&raw, &SamTag::OX); assert!(ox.is_none()); } } diff --git a/src/lib/commands/dedup.rs b/src/lib/commands/dedup.rs index e0f01d16b..956cad983 100644 --- a/src/lib/commands/dedup.rs +++ b/src/lib/commands/dedup.rs @@ -29,6 +29,7 @@ use crate::grouper::{ use crate::logging::OperationTimer; use crate::metrics::group::FamilySizeMetrics; use crate::read_info::LibraryIndex; +use crate::sam::SamTag; use crate::sam::{is_template_coordinate_sorted, unclipped_five_prime_position}; use crate::template::{MoleculeId, Template}; use crate::umi::{UmiValidation, validate_umi}; @@ -1194,9 +1195,9 @@ impl Command for MarkDuplicates { let header = crate::commands::common::add_pg_record(header, command_line)?; // Tag constants per SAM specification - let raw_tag: [u8; 2] = *b"RX"; - let cell_tag = Tag::new(b'C', b'B'); - let assign_tag_bytes: [u8; 2] = *b"MI"; + let raw_tag: [u8; 2] = *SamTag::RX; + let cell_tag = Tag::from(SamTag::CB); + let assign_tag_bytes: [u8; 2] = *SamTag::MI; let filter_config = DedupFilterConfig { umi_tag: raw_tag, @@ -1786,7 +1787,7 @@ mod tests { fn default_filter_config() -> DedupFilterConfig { DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -2226,7 +2227,7 @@ mod tests { let mut record = RecordBuilder::new().name("q1").sequence("ACGT").qualities(&[30, 30, 30, 30]).build(); let mi = MoleculeId::Single(42); - let assign_tag = Tag::from([b'M', b'I']); + let assign_tag = Tag::from(SamTag::MI); set_mi_tag_on_record(&mut record, mi, assign_tag, 0); let Some(DataValue::String(val)) = record.data().get(&assign_tag) else { @@ -2240,7 +2241,7 @@ mod tests { let mut record = RecordBuilder::new().name("q1").sequence("ACGT").qualities(&[30, 30, 30, 30]).build(); let mi = MoleculeId::None; - let assign_tag = Tag::from([b'M', b'I']); + let assign_tag = Tag::from(SamTag::MI); set_mi_tag_on_record(&mut record, mi, assign_tag, 0); let mi_value = record.data().get(&assign_tag); @@ -2252,7 +2253,7 @@ mod tests { let mut record = RecordBuilder::new().name("q1").sequence("ACGT").qualities(&[30, 30, 30, 30]).build(); let mi = MoleculeId::Single(42); - let assign_tag = Tag::from([b'M', b'I']); + let assign_tag = Tag::from(SamTag::MI); set_mi_tag_on_record(&mut record, mi, assign_tag, 100); let Some(DataValue::String(val)) = record.data().get(&assign_tag) else { @@ -2488,7 +2489,7 @@ mod tests { .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -2532,7 +2533,7 @@ mod tests { .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -2708,7 +2709,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -2734,7 +2735,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -2761,7 +2762,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: None, @@ -2787,7 +2788,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: None, @@ -2813,7 +2814,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: Some(6), @@ -2838,7 +2839,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: None, @@ -3012,7 +3013,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 20, include_non_pf: false, min_umi_length: None, @@ -3040,7 +3041,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: None, @@ -3068,7 +3069,7 @@ mod tests { let template = Template::from_raw_records(vec![raw]) .expect("test template construction should not fail"); let config = DedupFilterConfig { - umi_tag: [b'R', b'X'], + umi_tag: *SamTag::RX, min_mapq: 0, include_non_pf: false, min_umi_length: Some(6), @@ -3212,7 +3213,7 @@ mod tests { let mut mi_count = 0usize; let mut non_dup_names = HashSet::new(); - let mi_tag = Tag::from([b'M', b'I']); + let mi_tag = Tag::from(SamTag::MI); for result in reader.record_bufs(&out_header) { let record: RecordBuf = result.expect("read record"); diff --git a/src/lib/commands/downsample.rs b/src/lib/commands/downsample.rs index 23d4601f3..2733dcf59 100644 --- a/src/lib/commands/downsample.rs +++ b/src/lib/commands/downsample.rs @@ -8,6 +8,7 @@ use crate::bam_io::{create_bam_reader, create_bam_writer, create_optional_bam_writer}; use crate::logging::OperationTimer; use crate::progress::ProgressTracker; +use crate::sam::SamTag; use crate::sam::is_template_coordinate_sorted; use crate::validation::validate_file_exists; use anyhow::{Result, bail}; @@ -27,7 +28,7 @@ use crate::commands::command::Command; use crate::commands::common::{BamIoOptions, CompressionOptions, parse_bool}; /// MI tag for molecular identifier -const MI_TAG: Tag = Tag::new(b'M', b'I'); +const MI_TAG: Tag = SamTag::MI.to_noodles_tag(); /// Downsample a BAM file by UMI family using streaming. /// diff --git a/src/lib/commands/duplex.rs b/src/lib/commands/duplex.rs index 40d219849..62e13b835 100644 --- a/src/lib/commands/duplex.rs +++ b/src/lib/commands/duplex.rs @@ -31,6 +31,7 @@ use crate::overlapping_consensus::{ }; use crate::progress::ProgressTracker; use crate::read_info::LibraryIndex; +use crate::sam::SamTag; use crate::sort::bam_fields; use crate::umi::extract_mi_base; use crate::unified_pipeline::{ @@ -294,7 +295,7 @@ impl Command for Duplex { let read_name_prefix = self.read_group.prefix_or_from_header(&header); // Cell barcode tag is fixed to the SAM standard CB tag - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); // Enable rejects tracking if rejects file is specified let track_rejects = self.rejects_opts.is_enabled(); @@ -411,7 +412,7 @@ impl Command for Duplex { let mi_str = String::from_utf8_lossy(mi_bytes); extract_mi_base(&mi_str).to_string() }) - .with_cell_tag(Some(*b"CB")); + .with_cell_tag(Some(*SamTag::CB)); // Single-threaded processing // Create overlapping consensus caller for single-threaded mode let mut overlapping_caller = if overlapping_enabled { @@ -544,7 +545,7 @@ impl Duplex { let error_rate_post_umi = self.consensus.error_rate_post_umi; let overlapping_enabled = self.overlapping.consensus_call_overlapping_bases; let read_group_id = self.read_group.read_group_id.clone(); - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); let batch_size = 100; // MI groups per batch // Record filter for duplex on raw bytes: skip secondary/supplementary, keep mapped or mate-mapped @@ -592,7 +593,7 @@ impl Duplex { record_filter, mi_transform, ) - .with_cell_tag(Some(*b"CB")), + .with_cell_tag(Some(*SamTag::CB)), ) as Box + Send> }, // ========== process_fn: Duplex consensus calling ========== diff --git a/src/lib/commands/extract.rs b/src/lib/commands/extract.rs index c2bb65964..1ea3c906e 100644 --- a/src/lib/commands/extract.rs +++ b/src/lib/commands/extract.rs @@ -25,6 +25,7 @@ use crate::fastq::ReadSetIterator; use crate::grouper::FastqTemplate; use crate::logging::OperationTimer; use crate::progress::ProgressTracker; +use crate::sam::SamTag; use crate::unified_pipeline::{FastqPipelineConfig, MemoryEstimate, run_fastq_pipeline}; use crate::validation::validate_file_exists; use anyhow::{Result, bail, ensure}; @@ -87,16 +88,6 @@ enum CompressionFormat { Plain, } -/// Returns true if `tag` is a valid SAM optional-field tag name. -/// -/// Per the SAM v1.6 specification, tag names must match `/[A-Za-z][A-Za-z0-9]/`: -/// the first character must be ASCII alphabetic and the second ASCII alphanumeric. -/// Multi-byte (UTF-8) characters are rejected because `as_bytes()` will have length > 2. -fn is_valid_sam_tag(tag: &str) -> bool { - let b = tag.as_bytes(); - b.len() == 2 && b[0].is_ascii_alphabetic() && b[1].is_ascii_alphanumeric() -} - /// Detect the compression format of a file by reading its header. /// /// BGZF files are identified by: @@ -421,11 +412,11 @@ pub struct Extract { /// Single tag to store all concatenated UMIs (in addition to per-segment tags) #[arg(long, short = 's')] - single_tag: Option, + single_tag: Option, /// Tag containing adapter clipping position to adjust (e.g. 'XT' from `MarkIlluminaAdapters`) #[arg(long)] - clipping_attribute: Option, + clipping_attribute: Option, /// Read group ID to use in the file header #[arg(long, default_value = "A")] @@ -537,26 +528,17 @@ impl Extract { ensure!(!rs.segments().is_empty(), "Read structure {} is empty", i + 1); } - // Validate single_tag conforms to SAM spec (/[A-Za-z][A-Za-z0-9]/) and is not reserved - const RESERVED_TAGS: &[&str] = &["RX", "MI", "CB", "QX", "CY", "BC", "QT", "RG"]; - if let Some(ref tag) = self.single_tag { - ensure!( - is_valid_sam_tag(tag), - "Single tag must be a valid two-character SAM tag ([A-Za-z][A-Za-z0-9]): {tag}" - ); - let tag_upper = tag.to_ascii_uppercase(); - ensure!( - !RESERVED_TAGS.contains(&tag_upper.as_str()), - "Single tag cannot use a reserved BAM tag: {tag}" - ); - } - - // Validate clipping_attribute conforms to SAM spec (/[A-Za-z][A-Za-z0-9]/) - if let Some(ref tag) = self.clipping_attribute { + // SamTag::from_str enforces the SAM aux-tag pattern via clap, so format + // is already validated. Reject `--single-tag` matching any tag the + // extractor itself emits, since those would either be silently + // overwritten or produce conflicting aux fields. + const RESERVED_OUTPUT_TAGS: &[SamTag] = + &[SamTag::RX, SamTag::QX, SamTag::CB, SamTag::CY, SamTag::BC, SamTag::QT, SamTag::RG]; + if let Some(tag) = self.single_tag { ensure!( - is_valid_sam_tag(tag), - "Clipping attribute must be a valid two-character SAM tag ([A-Za-z][A-Za-z0-9]): \ - {tag}" + !RESERVED_OUTPUT_TAGS.contains(&tag), + "Single tag cannot collide with tags emitted by extract \ + (RX, QX, CB, CY, BC, QT, RG): {tag}" ); } @@ -788,8 +770,8 @@ impl Extract { }; let num_templates = templates.len(); - let umi_tag: &[u8; 2] = b"RX"; - let cell_tag: &[u8; 2] = b"CB"; + let umi_tag: &[u8; 2] = &SamTag::RX; + let cell_tag: &[u8; 2] = &SamTag::CB; for (index, template) in templates.iter().enumerate() { // Compute flags for unmapped reads @@ -827,7 +809,7 @@ impl Extract { // Append tags // Read group - builder.append_string_tag(b"RG", self.read_group_id.as_bytes()); + builder.append_string_tag(&SamTag::RG, self.read_group_id.as_bytes()); // Cell barcode if !cell_barcode_bs.is_empty() { @@ -835,16 +817,16 @@ impl Extract { } if !cell_quals_bs.is_empty() && self.store_cell_quals { - builder.append_string_tag(b"CY", cell_quals_bs.as_bytes()); + builder.append_string_tag(&SamTag::CY, cell_quals_bs.as_bytes()); } // Sample barcode if !sample_barcode_bs.is_empty() { - builder.append_string_tag(b"BC", sample_barcode_bs.as_bytes()); + builder.append_string_tag(&SamTag::BC, sample_barcode_bs.as_bytes()); } if self.store_sample_barcode_qualities && !sample_quals_bs.is_empty() { - builder.append_string_tag(b"QT", sample_quals_bs.as_bytes()); + builder.append_string_tag(&SamTag::QT, sample_quals_bs.as_bytes()); } // UMI @@ -852,15 +834,13 @@ impl Extract { builder.append_string_tag(umi_tag, final_umi_bs.as_bytes()); // Single tag for all concatenated UMIs (if specified) - if let Some(ref single_tag) = self.single_tag { - let st: &[u8; 2] = - single_tag.as_bytes().try_into().expect("single_tag must be 2 bytes"); - builder.append_string_tag(st, final_umi_bs.as_bytes()); + if let Some(single_tag) = self.single_tag { + builder.append_string_tag(&single_tag, final_umi_bs.as_bytes()); } // Only add UMI qualities if not extracted from read names if umi_from_name.is_none() && !umi_qual_bs.is_empty() && self.store_umi_quals { - builder.append_string_tag(b"QX", umi_qual_bs.as_bytes()); + builder.append_string_tag(&SamTag::QX, umi_qual_bs.as_bytes()); } } @@ -970,10 +950,7 @@ impl Extract { read_group_id: self.read_group_id.clone(), store_umi_quals: self.store_umi_quals, store_cell_quals: self.store_cell_quals, - single_tag: self - .single_tag - .as_ref() - .map(|t| t.as_bytes().try_into().expect("validated in validate()")), + single_tag: self.single_tag, annotate_read_names: self.annotate_read_names, extract_umis_from_read_names: self.extract_umis_from_read_names, store_sample_barcode_qualities: self.store_sample_barcode_qualities, @@ -1068,7 +1045,7 @@ struct ExtractConfig { read_group_id: String, store_umi_quals: bool, store_cell_quals: bool, - single_tag: Option<[u8; 2]>, + single_tag: Option, annotate_read_names: bool, extract_umis_from_read_names: bool, store_sample_barcode_qualities: bool, @@ -1169,38 +1146,38 @@ fn make_raw_records_static( // Append tags // Read group - builder.append_string_tag(b"RG", cfg.read_group_id.as_bytes()); + builder.append_string_tag(&SamTag::RG, cfg.read_group_id.as_bytes()); // Cell barcode if !cell_barcode_bs.is_empty() { - builder.append_string_tag(b"CB", cell_barcode_bs.as_bytes()); + builder.append_string_tag(&SamTag::CB, cell_barcode_bs.as_bytes()); } if !cell_quals_bs.is_empty() && cfg.store_cell_quals { - builder.append_string_tag(b"CY", cell_quals_bs.as_bytes()); + builder.append_string_tag(&SamTag::CY, cell_quals_bs.as_bytes()); } // Sample barcode if !sample_barcode_bs.is_empty() { - builder.append_string_tag(b"BC", sample_barcode_bs.as_bytes()); + builder.append_string_tag(&SamTag::BC, sample_barcode_bs.as_bytes()); } if cfg.store_sample_barcode_qualities && !sample_quals_bs.is_empty() { - builder.append_string_tag(b"QT", sample_quals_bs.as_bytes()); + builder.append_string_tag(&SamTag::QT, sample_quals_bs.as_bytes()); } // UMI if !final_umi_bs.is_empty() { - builder.append_string_tag(b"RX", final_umi_bs.as_bytes()); + builder.append_string_tag(&SamTag::RX, final_umi_bs.as_bytes()); // Single tag for all concatenated UMIs (if specified) - if let Some(ref st) = cfg.single_tag { - builder.append_string_tag(st, final_umi_bs.as_bytes()); + if let Some(st) = cfg.single_tag { + builder.append_string_tag(&st, final_umi_bs.as_bytes()); } // Only add UMI qualities if not extracted from read names if umi_from_name.is_none() && !umi_qual_bs.is_empty() && cfg.store_umi_quals { - builder.append_string_tag(b"QX", umi_qual_bs.as_bytes()); + builder.append_string_tag(&SamTag::QX, umi_qual_bs.as_bytes()); } } @@ -2552,7 +2529,7 @@ mod tests { store_sample_barcode_qualities: false, extract_umis_from_read_names: false, annotate_read_names: false, - single_tag: Some("ZU".to_string()), // Use single tag + single_tag: Some("ZU".parse().expect("valid tag")), // Use single tag clipping_attribute: None, read_group_id: "A".to_string(), sample: "s".to_string(), @@ -2588,46 +2565,7 @@ mod tests { } #[test] - #[should_panic(expected = "Single tag must be a valid two-character SAM tag")] - fn test_single_tag_invalid_length() { - let tmp = TempDir::new().expect("failed to create temp dir"); - let r1 = create_fastq(&tmp, "r1.fq", &[("q1", "ACGTAAAAAA", "IIII======")]); - let output = tmp.path().join("output.bam"); - - let extract = Extract { - inputs: vec![r1], - output, - read_structures: vec![ReadStructure::from_str("4M+T").expect("valid read structure")], - store_umi_quals: false, - store_cell_quals: false, - store_sample_barcode_qualities: false, - extract_umis_from_read_names: false, - annotate_read_names: false, - single_tag: Some("INVALID".to_string()), // Too long - clipping_attribute: None, - read_group_id: "A".to_string(), - sample: "s".to_string(), - library: "l".to_string(), - barcode: None, - platform: "illumina".to_string(), - platform_unit: None, - platform_model: None, - sequencing_center: None, - predicted_insert_size: None, - description: None, - comment: vec![], - run_date: None, - threading: ThreadingOptions::none(), - compression: CompressionOptions { compression_level: 1 }, - scheduler_opts: SchedulerOptions::default(), - queue_memory: QueueMemoryOptions::default(), - }; - - extract.execute("test").expect("execute should succeed"); - } - - #[test] - #[should_panic(expected = "Single tag cannot use a reserved BAM tag: RX")] + #[should_panic(expected = "Single tag cannot collide with tags emitted by extract")] fn test_single_tag_same_as_umi_tag() { let tmp = TempDir::new().expect("failed to create temp dir"); let r1 = create_fastq(&tmp, "r1.fq", &[("q1", "ACGTAAAAAA", "IIII======")]); @@ -2642,7 +2580,7 @@ mod tests { store_sample_barcode_qualities: false, extract_umis_from_read_names: false, annotate_read_names: false, - single_tag: Some("RX".to_string()), // Same as umi_tag + single_tag: Some(SamTag::RX), // Same as umi_tag clipping_attribute: None, read_group_id: "A".to_string(), sample: "s".to_string(), @@ -2684,7 +2622,7 @@ mod tests { store_sample_barcode_qualities: false, extract_umis_from_read_names: false, annotate_read_names: true, // Both features enabled - single_tag: Some("ZU".to_string()), + single_tag: Some("ZU".parse().expect("valid tag")), clipping_attribute: None, read_group_id: "A".to_string(), sample: "s".to_string(), @@ -3151,7 +3089,7 @@ mod tests { assert_eq!(records[1].sequence().as_ref(), b"GGGG"); // Both should have same UMI: AAAA-TTTT - let rx_tag = noodles::sam::alignment::record::data::field::Tag::from([b'R', b'X']); + let rx_tag = noodles::sam::alignment::record::data::field::Tag::from(SamTag::RX); let r1_umi = records[0].data().get(&rx_tag).expect("expected tag not found"); let r2_umi = records[1].data().get(&rx_tag).expect("expected tag not found"); diff --git a/src/lib/commands/group.rs b/src/lib/commands/group.rs index f2feea251..b1fe0f67e 100644 --- a/src/lib/commands/group.rs +++ b/src/lib/commands/group.rs @@ -31,6 +31,7 @@ use clap::Parser; use crossbeam_queue::SegQueue; use fgoxide::io::DelimFile; // MemoryEstimate is gated because it's only used in memory-debug blocks below +use crate::sam::SamTag; #[cfg(feature = "memory-debug")] use crate::unified_pipeline::MemoryEstimate; use crate::validation::validate_file_exists; @@ -989,9 +990,9 @@ impl Command for GroupReadsByUmi { let header = crate::commands::common::add_pg_record(header, command_line)?; // Tag constants per SAM specification - let raw_tag: [u8; 2] = *b"RX"; - let cell_tag = Tag::new(b'C', b'B'); - let assign_tag_bytes: [u8; 2] = *b"MI"; + let raw_tag: [u8; 2] = *SamTag::RX; + let cell_tag = Tag::from(SamTag::CB); + let assign_tag_bytes: [u8; 2] = *SamTag::MI; // Create filter configuration let filter_config = GroupFilterConfig { diff --git a/src/lib/commands/review.rs b/src/lib/commands/review.rs index 6e8333b38..270d173e7 100644 --- a/src/lib/commands/review.rs +++ b/src/lib/commands/review.rs @@ -8,6 +8,7 @@ use crate::commands::common::parse_bool; use crate::logging::OperationTimer; use crate::progress::ProgressTracker; use crate::reference::find_dict_path; +use crate::sam::SamTag; use crate::umi::extract_mi_base; use crate::validation::validate_file_exists; use crate::variant_review::{ @@ -623,7 +624,7 @@ impl Review { if self.has_non_reference_base(&record, variant)? { // Extract MI tag let mi_tag = - noodles::sam::alignment::record::data::field::Tag::from([b'M', b'I']); + noodles::sam::alignment::record::data::field::Tag::from(SamTag::MI); if let Some(noodles::sam::alignment::record_buf::data::field::Value::String( mi_bytes, )) = record.data().get(&mi_tag) @@ -682,7 +683,7 @@ impl Review { progress.log_if_needed(1); // Extract MI tag - let mi_tag = noodles::sam::alignment::record::data::field::Tag::from([b'M', b'I']); + let mi_tag = noodles::sam::alignment::record::data::field::Tag::from(SamTag::MI); if let Some(noodles::sam::alignment::record_buf::data::field::Value::String(mi_bytes)) = record.data().get(&mi_tag) { @@ -791,7 +792,7 @@ impl Review { let read_qual = self.get_quality_at_position(&record, variant.pos)?.unwrap_or(0); // Extract MI tag - let mi_tag = noodles::sam::alignment::record::data::field::Tag::from([b'M', b'I']); + let mi_tag = noodles::sam::alignment::record::data::field::Tag::from(SamTag::MI); let mi_str = match record.data().get(&mi_tag) { Some(noodles::sam::alignment::record_buf::data::field::Value::String( mi_bytes, @@ -831,7 +832,7 @@ impl Review { // Extract MI tag let mi_tag = - noodles::sam::alignment::record::data::field::Tag::from([b'M', b'I']); + noodles::sam::alignment::record::data::field::Tag::from(SamTag::MI); let mi_str = match record.data().get(&mi_tag) { Some( noodles::sam::alignment::record_buf::data::field::Value::String( diff --git a/src/lib/commands/shared_metrics.rs b/src/lib/commands/shared_metrics.rs index 7e9dbc431..8589ba1ad 100644 --- a/src/lib/commands/shared_metrics.rs +++ b/src/lib/commands/shared_metrics.rs @@ -7,6 +7,7 @@ use crate::bam_io::create_bam_reader; use crate::progress::ProgressTracker; +use crate::sam::SamTag; use crate::template::TemplateIterator; use anyhow::{Context, Result}; @@ -430,8 +431,8 @@ where let mut template_count = 0; let progress = ProgressTracker::new("Processed records").with_interval(1_000_000); let mut fraction_template_counts: Vec = vec![0; num_fractions]; - let mi_tag = Tag::new(b'M', b'I'); - let umi_tag = Tag::new(b'R', b'X'); + let mi_tag = Tag::from(SamTag::MI); + let umi_tag = Tag::from(SamTag::RX); for template in template_iter { let template = template?; diff --git a/src/lib/commands/simplex.rs b/src/lib/commands/simplex.rs index 2fe09a4dc..64374097e 100644 --- a/src/lib/commands/simplex.rs +++ b/src/lib/commands/simplex.rs @@ -27,6 +27,7 @@ use clap::Parser; use fgoxide::io::DelimFile; use fgumi_raw_bam::RawRecord; // RejectionTracker now used via ConsensusStatsOps trait in consensus_runner +use crate::sam::SamTag; use crate::vanilla_consensus_caller::{VanillaUmiConsensusCaller, VanillaUmiConsensusOptions}; use crossbeam_queue::SegQueue; @@ -249,7 +250,7 @@ impl Command for Simplex { // Use library name from header if no prefix is specified (like fgbio) let read_name_prefix = self.read_group.prefix_or_from_header(&header); - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); // Enable rejects tracking if rejects file is specified let track_rejects = self.rejects_opts.is_enabled(); @@ -473,7 +474,7 @@ impl Simplex { let trim = self.consensus.trim; let overlapping_enabled = self.overlapping.is_enabled(); let read_group_id = self.read_group.read_group_id.clone(); - let cell_tag = Tag::new(b'C', b'B'); + let cell_tag = Tag::from(SamTag::CB); let batch_size = 50; // MI groups per batch (reduced for memory efficiency) // Create options for consensus caller diff --git a/src/lib/commands/simulate/correct_reads.rs b/src/lib/commands/simulate/correct_reads.rs index 47c009f6d..e2699c43d 100644 --- a/src/lib/commands/simulate/correct_reads.rs +++ b/src/lib/commands/simulate/correct_reads.rs @@ -431,6 +431,7 @@ fn introduce_n_errors(umi: &str, n: usize, rng: &mut impl Rng) -> String { #[allow(clippy::naive_bytecount)] mod tests { use super::*; + use crate::sam::SamTag; use crate::simulate::create_rng; use noodles::sam::alignment::record::data::field::Tag; @@ -696,7 +697,7 @@ mod tests { assert_eq!(r1.name(), r2.name()); // Both should have the same RX tag - let rx_tag: Tag = [b'R', b'X'].into(); + let rx_tag: Tag = Tag::from(SamTag::RX); assert!(r1.data().get(&rx_tag).is_some()); assert!(r2.data().get(&rx_tag).is_some()); } diff --git a/src/lib/commands/sort.rs b/src/lib/commands/sort.rs index cb9d2f545..03ec3b372 100644 --- a/src/lib/commands/sort.rs +++ b/src/lib/commands/sort.rs @@ -22,6 +22,7 @@ use crate::bam_io::create_bam_reader; use crate::logging::OperationTimer; +use crate::sam::SamTag; use crate::sort::{QuerynameComparator, RawExternalSorter, SortOrder}; use crate::validation::validate_file_exists; use anyhow::{Result, bail}; @@ -309,7 +310,7 @@ pub(crate) fn parse_memory_reserve(s: &str) -> Result { /// Parse the cell tag for template-coordinate sort/verify, returning `None` /// for other sort orders. pub(crate) fn parse_cell_tag(order: SortOrderArg) -> Result> { - if matches!(order, SortOrderArg::TemplateCoordinate) { Ok(Some(*b"CB")) } else { Ok(None) } + if matches!(order, SortOrderArg::TemplateCoordinate) { Ok(Some(*SamTag::CB)) } else { Ok(None) } } /// Summary of sort-order verification: `(total_records, violations, first_violation)`. @@ -695,7 +696,7 @@ mod tests { } #[rstest] - #[case(SortOrderArg::TemplateCoordinate, Some(*b"CB"))] + #[case(SortOrderArg::TemplateCoordinate, Some(*SamTag::CB))] #[case(SortOrderArg::Coordinate, None)] #[case(SortOrderArg::Queryname, None)] fn test_parse_cell_tag(#[case] order: SortOrderArg, #[case] expected: Option<[u8; 2]>) { diff --git a/src/lib/commands/zipper.rs b/src/lib/commands/zipper.rs index 441ace3f4..00ed23620 100644 --- a/src/lib/commands/zipper.rs +++ b/src/lib/commands/zipper.rs @@ -54,7 +54,7 @@ use crate::logging::OperationTimer; use crate::progress::ProgressTracker; use crate::reference::find_dict_path; use crate::sam::{ - buf_value_to_smallest_signed_int, check_sort, revcomp_buf_value, reverse_buf_value, + SamTag, buf_value_to_smallest_signed_int, check_sort, revcomp_buf_value, reverse_buf_value, unclipped_five_prime_position, }; use crate::sort::{PA_TAG, PrimaryAlignmentInfo, bam_fields}; @@ -495,8 +495,8 @@ pub fn merge( // Normalize alignment integer tags (AS, XS) to Int8 to match fgbio's encoding. // When reading from SAM format, noodles may parse these tags with different types // (e.g., Int32 or UInt8). fgbio consistently uses signed int8 (SAM type code 'c'). - let as_tag = Tag::new(b'A', b'S'); - let xs_tag = Tag::new(b'X', b'S'); + let as_tag = Tag::from(SamTag::AS); + let xs_tag = Tag::from(SamTag::XS); for i in 0..mapped.read_count() { // Normalize AS tag if let Some(value) = mapped.records[i].data().get(&as_tag) { @@ -1394,20 +1394,20 @@ mod tests { // Check that tags from unmapped BAM were copied for rec in &records { // Should have RX tag from unmapped - assert!(rec.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::RX)).is_some()); // Should have xy tag from unmapped assert!(rec.data().get(&Tag::new(b'x', b'y')).is_some()); // Should have AS tag from mapped - assert!(rec.data().get(&Tag::new(b'A', b'S')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::AS)).is_some()); // Should have PG tag from mapped - assert!(rec.data().get(&Tag::new(b'P', b'G')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::PG)).is_some()); // Should have MC (mate CIGAR) and MQ (mate mapping quality) tags - assert!(rec.data().get(&Tag::new(b'M', b'C')).is_some(), "MC tag should be present"); - assert!(rec.data().get(&Tag::new(b'M', b'Q')).is_some(), "MQ tag should be present"); + assert!(rec.data().get(&Tag::from(SamTag::MC)).is_some(), "MC tag should be present"); + assert!(rec.data().get(&Tag::from(SamTag::MQ)).is_some(), "MQ tag should be present"); } Ok(()) @@ -1448,9 +1448,9 @@ mod tests { for rec in &records { // Should have tags from both unmapped and mapped - assert!(rec.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::RX)).is_some()); assert!(rec.data().get(&Tag::new(b'x', b'y')).is_some()); - assert!(rec.data().get(&Tag::new(b'A', b'S')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::AS)).is_some()); } Ok(()) @@ -1493,7 +1493,7 @@ mod tests { for rec in &records { // AS tag should be removed - assert!(rec.data().get(&Tag::new(b'A', b'S')).is_none()); + assert!(rec.data().get(&Tag::from(SamTag::AS)).is_none()); if rec.flags().is_first_segment() { // R1 is positive strand - no reversing/revcomping @@ -1736,14 +1736,14 @@ mod tests { .expect("expected R2 record not found"); // Check that MQ tags are set - let r1_mq = r1.data().get(&Tag::new(b'M', b'Q')); - let r2_mq = r2.data().get(&Tag::new(b'M', b'Q')); + let r1_mq = r1.data().get(&Tag::from(SamTag::MQ)); + let r2_mq = r2.data().get(&Tag::from(SamTag::MQ)); assert!(r1_mq.is_some(), "R1 should have MQ tag"); assert!(r2_mq.is_some(), "R2 should have MQ tag"); // Check that MC tags are set - let r1_mc = r1.data().get(&Tag::new(b'M', b'C')); - let r2_mc = r2.data().get(&Tag::new(b'M', b'C')); + let r1_mc = r1.data().get(&Tag::from(SamTag::MC)); + let r2_mc = r2.data().get(&Tag::from(SamTag::MC)); assert!(r1_mc.is_some(), "R1 should have MC tag"); assert!(r2_mc.is_some(), "R2 should have MC tag"); @@ -1935,7 +1935,7 @@ mod tests { // Check each record for rec in &records { // AS tag should be removed from all records - assert!(rec.data().get(&Tag::new(b'A', b'S')).is_none(), "AS tag should be removed"); + assert!(rec.data().get(&Tag::from(SamTag::AS)).is_none(), "AS tag should be removed"); if rec.flags().is_reverse_complemented() { // Negative strand - should be reversed/revcomped @@ -2086,9 +2086,9 @@ mod tests { // Verify all records have both unmapped and mapped tags for rec in &records { - assert!(rec.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::RX)).is_some()); assert!(rec.data().get(&Tag::new(b'x', b'y')).is_some()); - assert!(rec.data().get(&Tag::new(b'A', b'S')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::AS)).is_some()); } Ok(()) @@ -2130,13 +2130,13 @@ mod tests { RecordBuilder::new().sequence("ACGT").tag("PG", "old_pg").tag("XY", 123i32).build(); let mut dest_data = Data::default(); - dest_data.insert(Tag::new(b'P', b'G'), BufValue::from("new_pg".to_string())); + dest_data.insert(Tag::from(SamTag::PG), BufValue::from("new_pg".to_string())); let tag_info = TagInfo::new(vec![], vec![], vec![]); copy_tags(&src, &mut dest_data, &tag_info)?; // PG should not be overwritten - if let Some(BufValue::String(s)) = dest_data.get(&Tag::new(b'P', b'G')) { + if let Some(BufValue::String(s)) = dest_data.get(&Tag::from(SamTag::PG)) { assert_eq!(s.to_string(), "new_pg"); } @@ -2191,7 +2191,7 @@ mod tests { // All records should have the unmapped tags for rec in &records { - assert!(rec.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::RX)).is_some()); assert!(rec.data().get(&Tag::new(b'x', b'y')).is_some()); } @@ -2256,7 +2256,7 @@ mod tests { // All records should have the RX tag for rec in &records { - assert!(rec.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(rec.data().get(&Tag::from(SamTag::RX)).is_some()); } Ok(()) @@ -2293,7 +2293,7 @@ mod tests { let r2 = &records[0]; assert!(!r2.flags().is_first_segment()); - assert!(r2.data().get(&Tag::new(b'R', b'X')).is_some()); + assert!(r2.data().get(&Tag::from(SamTag::RX)).is_some()); Ok(()) } @@ -2705,7 +2705,7 @@ mod tests { assert_eq!(pa_info.pos2, 299, "pos2 should be R2's unclipped 5' position (reverse strand)"); // Verify RX tag was also copied - let rx_tag = Tag::new(b'R', b'X'); + let rx_tag = Tag::from(SamTag::RX); assert!( supp_record.data().get(&rx_tag).is_some(), "Supplementary should also have RX tag copied from unmapped" @@ -2942,11 +2942,11 @@ mod tests { assert_eq!(records.len(), 1); // AS=77 fits in i8 (-128..127), should be normalized to Int8 - let as_val = records[0].data().get(&Tag::new(b'A', b'S')).unwrap(); + let as_val = records[0].data().get(&Tag::from(SamTag::AS)).unwrap(); assert!(matches!(as_val, BufValue::Int8(77)), "AS=77 should be Int8, got {as_val:?}"); // XS=50 fits in i8, should be normalized to Int8 - let xs_val = records[0].data().get(&Tag::new(b'X', b'S')).unwrap(); + let xs_val = records[0].data().get(&Tag::from(SamTag::XS)).unwrap(); assert!(matches!(xs_val, BufValue::Int8(50)), "XS=50 should be Int8, got {xs_val:?}"); Ok(()) @@ -2983,11 +2983,11 @@ mod tests { assert_eq!(records.len(), 1); // AS=200 doesn't fit in i8, should be Int16 - let as_val = records[0].data().get(&Tag::new(b'A', b'S')).unwrap(); + let as_val = records[0].data().get(&Tag::from(SamTag::AS)).unwrap(); assert!(matches!(as_val, BufValue::Int16(200)), "AS=200 should be Int16, got {as_val:?}"); // XS=-200 doesn't fit in i8, should be Int16 - let xs_val = records[0].data().get(&Tag::new(b'X', b'S')).unwrap(); + let xs_val = records[0].data().get(&Tag::from(SamTag::XS)).unwrap(); assert!(matches!(xs_val, BufValue::Int16(-200)), "XS=-200 should be Int16, got {xs_val:?}"); Ok(()) @@ -3186,8 +3186,8 @@ mod tests { assert_eq!(r1_tlen, -r2_tlen, "R1 TLEN should be negation of R2 TLEN"); // MQ: R1 should have R2's mapping quality, and vice versa - let r1_mq = r1.data().get(&Tag::new(b'M', b'Q')); - let r2_mq = r2.data().get(&Tag::new(b'M', b'Q')); + let r1_mq = r1.data().get(&Tag::from(SamTag::MQ)); + let r2_mq = r2.data().get(&Tag::from(SamTag::MQ)); assert!( matches!(r1_mq, Some(BufValue::Int8(30))), "R1's MQ should be R2's mapq (30), got {r1_mq:?}" @@ -3198,12 +3198,12 @@ mod tests { ); // MC: R1 should have R2's CIGAR, and vice versa - if let Some(BufValue::String(mc)) = r1.data().get(&Tag::new(b'M', b'C')) { + if let Some(BufValue::String(mc)) = r1.data().get(&Tag::from(SamTag::MC)) { assert_eq!(mc.to_string(), "75M", "R1's MC should be R2's CIGAR"); } else { panic!("R1 should have MC tag"); } - if let Some(BufValue::String(mc)) = r2.data().get(&Tag::new(b'M', b'C')) { + if let Some(BufValue::String(mc)) = r2.data().get(&Tag::from(SamTag::MC)) { assert_eq!(mc.to_string(), "50M", "R2's MC should be R1's CIGAR"); } else { panic!("R2 should have MC tag"); @@ -3311,21 +3311,21 @@ mod tests { ); // MQ on supplementary should be R2 primary's mapping quality - let supp_mq = r1_supp.data().get(&Tag::new(b'M', b'Q')); + let supp_mq = r1_supp.data().get(&Tag::from(SamTag::MQ)); assert!( matches!(supp_mq, Some(BufValue::Int8(30))), "R1 supp MQ should be R2 primary's mapq (30), got {supp_mq:?}" ); // MC on supplementary should be R2 primary's CIGAR - if let Some(BufValue::String(mc)) = r1_supp.data().get(&Tag::new(b'M', b'C')) { + if let Some(BufValue::String(mc)) = r1_supp.data().get(&Tag::from(SamTag::MC)) { assert_eq!(mc.to_string(), "75M", "R1 supp MC should be R2 primary's CIGAR"); } else { panic!("R1 supp should have MC tag"); } // ms (mate score) on supplementary should be R2 primary's AS value - let supp_ms = r1_supp.data().get(&Tag::new(b'm', b's')); + let supp_ms = r1_supp.data().get(&Tag::from(SamTag::MS)); assert!( matches!(supp_ms, Some(BufValue::Int8(55))), "R1 supp ms should be R2 primary's AS (55), got {supp_ms:?}" @@ -3379,11 +3379,11 @@ mod tests { // Mapped read (R1) should NOT have MQ or MC (mate is unmapped) assert!( - r1.data().get(&Tag::new(b'M', b'Q')).is_none(), + r1.data().get(&Tag::from(SamTag::MQ)).is_none(), "Mapped read should not have MQ when mate is unmapped" ); assert!( - r1.data().get(&Tag::new(b'M', b'C')).is_none(), + r1.data().get(&Tag::from(SamTag::MC)).is_none(), "Mapped read should not have MC when mate is unmapped" ); @@ -3480,21 +3480,21 @@ mod tests { ); // MQ on R2 supplementary should be R1 primary's mapping quality - let supp_mq = r2_supp.data().get(&Tag::new(b'M', b'Q')); + let supp_mq = r2_supp.data().get(&Tag::from(SamTag::MQ)); assert!( matches!(supp_mq, Some(BufValue::Int8(40))), "R2 supp MQ should be R1 primary's mapq (40), got {supp_mq:?}" ); // MC on R2 supplementary should be R1 primary's CIGAR - if let Some(BufValue::String(mc)) = r2_supp.data().get(&Tag::new(b'M', b'C')) { + if let Some(BufValue::String(mc)) = r2_supp.data().get(&Tag::from(SamTag::MC)) { assert_eq!(mc.to_string(), "50M", "R2 supp MC should be R1 primary's CIGAR"); } else { panic!("R2 supp should have MC tag"); } // ms (mate score) should be R1 primary's AS value - let supp_ms = r2_supp.data().get(&Tag::new(b'm', b's')); + let supp_ms = r2_supp.data().get(&Tag::from(SamTag::MS)); assert!( matches!(supp_ms, Some(BufValue::Int8(77))), "R2 supp ms should be R1 primary's AS (77), got {supp_ms:?}" @@ -3529,11 +3529,11 @@ mod tests { for rec in &records { assert_eq!(rec.template_length(), 0, "TLEN should be 0 when both unmapped"); assert!( - rec.data().get(&Tag::new(b'M', b'Q')).is_none(), + rec.data().get(&Tag::from(SamTag::MQ)).is_none(), "MQ should not exist when both unmapped" ); assert!( - rec.data().get(&Tag::new(b'M', b'C')).is_none(), + rec.data().get(&Tag::from(SamTag::MC)).is_none(), "MC should not exist when both unmapped" ); } diff --git a/src/lib/grouper.rs b/src/lib/grouper.rs index a3450a26c..ec6d435e3 100644 --- a/src/lib/grouper.rs +++ b/src/lib/grouper.rs @@ -10,6 +10,7 @@ use std::io; use noodles::sam::alignment::RecordBuf; +use crate::sam::SamTag; use crate::sort::bam_fields; use crate::template::{Template, TemplateBatch}; use crate::unified_pipeline::{BatchWeight, DecodedRecord, Grouper, MemoryEstimate}; @@ -509,7 +510,7 @@ impl RecordPositionGrouper { && !flags.is_unmapped() && !flags.is_mate_unmapped() { - let mc_tag = Tag::from([b'M', b'C']); + let mc_tag = Tag::from(SamTag::MC); if record.data().get(&mc_tag).is_none() { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/src/lib/mi_group.rs b/src/lib/mi_group.rs index c38b71b74..bd55b350f 100644 --- a/src/lib/mi_group.rs +++ b/src/lib/mi_group.rs @@ -1130,6 +1130,7 @@ where #[allow(clippy::similar_names)] mod tests { use super::*; + use crate::sam::SamTag; use crate::sam::builder::RecordBuilder; use crate::umi::extract_mi_base; @@ -1719,7 +1720,7 @@ mod tests { Ok(make_raw_bam_with_two_tags("MI", "1", "CB", "TGCA")), ]; let mut iter = - RawMiGroupIterator::new(records.into_iter(), "MI").with_cell_tag(Some([b'C', b'B'])); + RawMiGroupIterator::new(records.into_iter(), "MI").with_cell_tag(Some(*SamTag::CB)); // First group: MI=1, CB=ACGT let result = iter.next().expect("iterator should yield item").expect("item should be Ok"); @@ -1759,7 +1760,7 @@ mod tests { Ok(make_raw_bam_with_two_tags("MI", "1", "CB", "ACGT")), ]; let mut iter = - RawMiGroupIterator::new(records.into_iter(), "MI").with_cell_tag(Some([b'C', b'B'])); + RawMiGroupIterator::new(records.into_iter(), "MI").with_cell_tag(Some(*SamTag::CB)); // First group: MI=1, no CB (key = "1\t") let result = iter.next().expect("iterator should yield item").expect("item should be Ok"); @@ -1786,7 +1787,7 @@ mod tests { let s = String::from_utf8_lossy(raw); extract_mi_base(&s).to_string() }) - .with_cell_tag(Some([b'C', b'B'])); + .with_cell_tag(Some(*SamTag::CB)); // All three have base MI "1", but different cells // First group: base MI=1, CB=ACGT (2 records: 1/A and 1/B) @@ -2040,7 +2041,7 @@ mod tests { DecodedRecord::from_raw_bytes(raw, key) } - let mut grouper = RawMiGrouper::new("MI", 10).with_cell_tag(Some([b'C', b'B'])); + let mut grouper = RawMiGrouper::new("MI", 10).with_cell_tag(Some(*SamTag::CB)); // Two records per (MI, CB) combination; MI=1,CB=ACGT and MI=1,CB=TGCA must be split. let records = vec![ diff --git a/src/lib/read_info.rs b/src/lib/read_info.rs index 804ff2af9..17bd35057 100644 --- a/src/lib/read_info.rs +++ b/src/lib/read_info.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use bstr::ByteSlice; +use crate::sam::SamTag; use crate::sam::record_utils::{ mate_unclipped_end, mate_unclipped_start, unclipped_five_prime_position, }; @@ -236,12 +237,13 @@ pub fn compute_group_key( let strand = u8::from(flags.is_reverse_complemented()); // Extract library index from RG tag - let library_idx = if let Some(DataValue::String(rg_bytes)) = record.data().get(b"RG") { - let rg_hash = LibraryIndex::hash_rg(rg_bytes); - library_index.get(rg_hash) - } else { - 0 // unknown - }; + let library_idx = + if let Some(DataValue::String(rg_bytes)) = record.data().get(&Tag::from(SamTag::RG)) { + let rg_hash = LibraryIndex::hash_rg(rg_bytes); + library_index.get(rg_hash) + } else { + 0 // unknown + }; // Extract cell barcode hash let cell_hash = if let Some(tag) = cell_tag { @@ -533,14 +535,14 @@ impl ReadInfo { // This matches fgbio's behavior where grouping uses the library name (LB field), // not the read group ID. // Uses Arc to avoid cloning strings - Arc::clone is just a reference count increment. - let library: Arc = if let Some(DataValue::String(rg_bytes)) = record.data().get(b"RG") - { - // Convert bytes to str for lookup without allocating a String - let rg_id = std::str::from_utf8(rg_bytes).unwrap_or("unknown"); - library_lookup.get(rg_id).cloned().unwrap_or_else(|| Arc::clone(&UNKNOWN_LIBRARY)) - } else { - Arc::clone(&UNKNOWN_LIBRARY) - }; + let library: Arc = + if let Some(DataValue::String(rg_bytes)) = record.data().get(&Tag::from(SamTag::RG)) { + // Convert bytes to str for lookup without allocating a String + let rg_id = std::str::from_utf8(rg_bytes).unwrap_or("unknown"); + library_lookup.get(rg_id).cloned().unwrap_or_else(|| Arc::clone(&UNKNOWN_LIBRARY)) + } else { + Arc::clone(&UNKNOWN_LIBRARY) + }; // Extract cell barcode let cell_barcode = if let Some(DataValue::String(cb_str)) = record.data().get(&cell_tag) { diff --git a/src/lib/sam/mod.rs b/src/lib/sam/mod.rs index 0cee77c3d..5b90eef69 100644 --- a/src/lib/sam/mod.rs +++ b/src/lib/sam/mod.rs @@ -9,6 +9,9 @@ pub use fgumi_sam::alignment_tags; // Re-export modules from fgumi-sam pub use fgumi_sam::{builder, record_utils}; +// Re-export SamTag newtype +pub use fgumi_sam::SamTag; + // Re-export top-level functions from fgumi-sam pub use fgumi_sam::{ buf_value_to_smallest_signed_int, check_sort, is_sorted, is_template_coordinate_sorted, diff --git a/src/lib/sort/keys.rs b/src/lib/sort/keys.rs index 437643023..df12f531b 100644 --- a/src/lib/sort/keys.rs +++ b/src/lib/sort/keys.rs @@ -27,13 +27,14 @@ use noodles::sam::alignment::record::data::field::Tag; use noodles::sam::alignment::record_buf::RecordBuf; use std::cmp::Ordering; +use crate::sam::SamTag; use crate::sort::bam_fields; use std::io::{Read, Write}; /// The PA (Primary Alignment) tag for secondary/supplementary reads. /// Stores template-coordinate sort key as B:i array (6 int32s). /// Added by `fgumi zipper` to enable proper template-coordinate sorting. -pub const PA_TAG: Tag = Tag::new(b'p', b'a'); +pub const PA_TAG: Tag = SamTag::PA.to_noodles_tag(); // ============================================================================ // Primary Alignment Info (PA tag) diff --git a/src/lib/sort/raw.rs b/src/lib/sort/raw.rs index a4e1cd77d..74bb37408 100644 --- a/src/lib/sort/raw.rs +++ b/src/lib/sort/raw.rs @@ -19,6 +19,8 @@ use crate::bam_io::create_raw_bam_reader; use crate::progress::ProgressTracker; +#[cfg(test)] +use crate::sam::SamTag; use crate::sort::inline_buffer::{RecordBuffer, TemplateKey, TemplateRecordBuffer}; use crate::sort::keys::{QuerynameComparator, SortOrder}; use crate::sort::read_ahead::RawReadAheadReader; @@ -134,7 +136,7 @@ impl LibraryLookup { #[cfg(test)] #[must_use] pub fn get_ordinal(&self, bam: &[u8]) -> u32 { - fgumi_raw_bam::tags::find_string_tag_in_record(bam, b"RG") + fgumi_raw_bam::tags::find_string_tag_in_record(bam, &SamTag::RG) .and_then(|rg| self.rg_to_ordinal.get(rg)) .copied() .unwrap_or(0) @@ -2229,7 +2231,7 @@ mod tests { #[case::absent(b"".as_slice(), None)] fn test_find_rg_tag(#[case] aux_data: &[u8], #[case] expected: Option<&[u8]>) { let bam = build_bam_with_aux(aux_data); - assert_eq!(fgumi_raw_bam::tags::find_string_tag_in_record(&bam, b"RG"), expected); + assert_eq!(fgumi_raw_bam::tags::find_string_tag_in_record(&bam, &SamTag::RG), expected); } #[test] @@ -2241,7 +2243,7 @@ mod tests { aux.extend_from_slice(b"RGZmygroup\0"); let bam = build_bam_with_aux(&aux); assert_eq!( - fgumi_raw_bam::tags::find_string_tag_in_record(&bam, b"RG"), + fgumi_raw_bam::tags::find_string_tag_in_record(&bam, &SamTag::RG), Some(b"mygroup".as_slice()) ); } @@ -2258,8 +2260,8 @@ mod tests { #[test] fn test_raw_sorter_cell_tag_builder() { - let sorter = RawExternalSorter::new(SortOrder::TemplateCoordinate).cell_tag(*b"CB"); - assert_eq!(sorter.cell_tag, Some(*b"CB")); + let sorter = RawExternalSorter::new(SortOrder::TemplateCoordinate).cell_tag(*SamTag::CB); + assert_eq!(sorter.cell_tag, Some(*SamTag::CB)); } // ======================================================================== diff --git a/src/lib/template.rs b/src/lib/template.rs index dcb73998e..7bb1a8d17 100644 --- a/src/lib/template.rs +++ b/src/lib/template.rs @@ -35,7 +35,7 @@ //! } //! ``` -use crate::sam::{record_utils, to_smallest_signed_int}; +use crate::sam::{SamTag, record_utils, to_smallest_signed_int}; use crate::unified_pipeline::MemoryEstimate; use anyhow::{Result, anyhow, bail}; use noodles::sam::alignment::record::Cigar; @@ -862,10 +862,10 @@ impl Template { return Ok(()); } - let mapq_tag = Tag::new(b'M', b'Q'); - let mate_cigar_tag = Tag::new(b'M', b'C'); - let mate_score_tag = Tag::new(b'm', b's'); - let align_score_tag = Tag::new(b'A', b'S'); + let mapq_tag = Tag::from(SamTag::MQ); + let mate_cigar_tag = Tag::from(SamTag::MC); + let mate_score_tag = Tag::from(SamTag::MS); + let align_score_tag = Tag::from(SamTag::AS); // Fix mate info for primary R1/R2 pair // Following htsjdk's SamPairUtil.setMateInfo exactly @@ -2319,8 +2319,8 @@ mod tests { /// This was a bug where only `Int32` AS values were recognized. #[test] fn test_fix_mate_info_sets_ms_tag_with_int8_as() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); let mut r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); r1.data_mut().insert(as_tag, BufValue::Int8(55)); // AS as Int8 (small value) @@ -2351,8 +2351,8 @@ mod tests { /// Tests that `fix_mate_info` correctly sets ms tag when AS is stored as `UInt8` #[test] fn test_fix_mate_info_sets_ms_tag_with_uint8_as() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); let mut r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); r1.data_mut().insert(as_tag, BufValue::UInt8(77)); // AS as UInt8 @@ -2383,8 +2383,8 @@ mod tests { /// Tests that `fix_mate_info` correctly sets ms tag when AS is stored as `Int16` #[test] fn test_fix_mate_info_sets_ms_tag_with_int16_as() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); let mut r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); r1.data_mut().insert(as_tag, BufValue::Int16(1000)); // AS as Int16 @@ -2415,8 +2415,8 @@ mod tests { /// Tests that `fix_mate_info` correctly sets ms tag when AS is stored as `Int32` #[test] fn test_fix_mate_info_sets_ms_tag_with_int32_as() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); let mut r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); r1.data_mut().insert(as_tag, BufValue::Int32(100_000)); // AS as Int32 @@ -2447,7 +2447,7 @@ mod tests { /// Tests that `fix_mate_info` does not set ms tag when AS is missing #[test] fn test_fix_mate_info_no_ms_tag_without_as() -> Result<()> { - let ms_tag = Tag::new(b'm', b's'); + let ms_tag = Tag::from(SamTag::MS); let r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); let r2 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ2, 200, 40); @@ -2469,8 +2469,8 @@ mod tests { /// Tests that `fix_mate_info` sets ms tag on supplementary alignments #[test] fn test_fix_mate_info_sets_ms_tag_on_supplementals() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); let mut r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); r1.data_mut().insert(as_tag, BufValue::Int8(55)); @@ -2869,10 +2869,10 @@ mod tests { /// The mate is R2 primary, so supplementary should get R2's info. #[test] fn test_fix_mate_info_sets_full_mate_info_on_r1_supplementals() -> Result<()> { - let mq_tag = Tag::new(b'M', b'Q'); - let mc_tag = Tag::new(b'M', b'C'); - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let mq_tag = Tag::from(SamTag::MQ); + let mc_tag = Tag::from(SamTag::MC); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); // R1 primary at pos=100, forward strand, TLEN=200 let mut r1 = create_mapped_record_with_flags( @@ -2968,10 +2968,10 @@ mod tests { /// The mate is R1 primary, so supplementary should get R1's info. #[test] fn test_fix_mate_info_sets_full_mate_info_on_r2_supplementals() -> Result<()> { - let mq_tag = Tag::new(b'M', b'Q'); - let mc_tag = Tag::new(b'M', b'C'); - let as_tag = Tag::new(b'A', b'S'); - let ms_tag = Tag::new(b'm', b's'); + let mq_tag = Tag::from(SamTag::MQ); + let mc_tag = Tag::from(SamTag::MC); + let as_tag = Tag::from(SamTag::AS); + let ms_tag = Tag::from(SamTag::MS); // R1 primary at pos=100, forward strand, TLEN=300 let mut r1 = create_mapped_record_with_flags( @@ -3054,7 +3054,7 @@ mod tests { /// When mate is unmapped, `mate_unmapped` flag should be set and MC tag should not be set. #[test] fn test_fix_mate_info_supplemental_with_unmapped_mate() -> Result<()> { - let mc_tag = Tag::new(b'M', b'C'); + let mc_tag = Tag::from(SamTag::MC); // R1 primary at pos=100, forward strand let r1 = create_mapped_record_with_flags( @@ -3189,7 +3189,7 @@ mod tests { /// Tests that `fix_mate_info` handles multiple supplementary alignments. #[test] fn test_fix_mate_info_multiple_supplementals() -> Result<()> { - let as_tag = Tag::new(b'A', b'S'); + let as_tag = Tag::from(SamTag::AS); // R1 primary, forward let r1 = create_mapped_record_with_flags( @@ -3590,7 +3590,7 @@ mod tests { /// Test `fix_mate_info` correctly sets mate CIGAR (MC tag) #[test] fn test_fix_mate_info_sets_mate_cigar() -> Result<()> { - let mc_tag = Tag::new(b'M', b'C'); + let mc_tag = Tag::from(SamTag::MC); let r1 = RecordBuilder::new() .name("read1") @@ -3645,8 +3645,8 @@ mod tests { /// Test `fix_mate_info` correctly handles both unmapped case #[test] fn test_fix_mate_info_both_unmapped() -> Result<()> { - let mq_tag = Tag::new(b'M', b'Q'); - let mc_tag = Tag::new(b'M', b'C'); + let mq_tag = Tag::from(SamTag::MQ); + let mc_tag = Tag::from(SamTag::MC); let mut r1 = create_test_record(b"read1", FLAG_PAIRED | FLAG_READ1 | FLAG_UNMAPPED); *r1.reference_sequence_id_mut() = Some(0); // Originally had a reference @@ -3685,8 +3685,8 @@ mod tests { /// Test `fix_mate_info` correctly handles one mapped, one unmapped case #[test] fn test_fix_mate_info_one_unmapped() -> Result<()> { - let mq_tag = Tag::new(b'M', b'Q'); - let mc_tag = Tag::new(b'M', b'C'); + let mq_tag = Tag::from(SamTag::MQ); + let mc_tag = Tag::from(SamTag::MC); // R1 is mapped let r1 = create_mapped_record(b"read1", FLAG_PAIRED | FLAG_READ1, 100, 30); diff --git a/src/lib/unified_pipeline/bam.rs b/src/lib/unified_pipeline/bam.rs index 709895e2c..0e5beef97 100644 --- a/src/lib/unified_pipeline/bam.rs +++ b/src/lib/unified_pipeline/bam.rs @@ -21,6 +21,8 @@ use crate::bgzf_reader::{BGZF_EOF, decompress_block_into, read_raw_blocks}; use crate::bgzf_writer::InlineBgzfCompressor; use crate::progress::ProgressTracker; use crate::reorder_buffer::ReorderBuffer; +use crate::sam::SamTag; +use noodles::sam::alignment::record::data::field::Tag; use super::base::{ ActiveSteps, BatchWeight, CompressedBlockBatch, DecodedRecord, DecompressedBatch, @@ -3822,9 +3824,8 @@ where // Build GroupKeyConfig from header if not provided let group_key_config = config.group_key_config.unwrap_or_else(|| { - use noodles::sam::alignment::record::data::field::Tag; let library_index = LibraryIndex::from_header(&header); - let cell_tag = Tag::from([b'C', b'B']); // Default cell tag + let cell_tag = Tag::from(SamTag::CB); GroupKeyConfig::new(library_index, cell_tag) }); @@ -3932,9 +3933,8 @@ where // Build GroupKeyConfig from input header if not provided let group_key_config = config.group_key_config.unwrap_or_else(|| { - use noodles::sam::alignment::record::data::field::Tag; let library_index = LibraryIndex::from_header(&input_header); - let cell_tag = Tag::from([b'C', b'B']); // Default cell tag + let cell_tag = Tag::from(SamTag::CB); GroupKeyConfig::new(library_index, cell_tag) }); @@ -4034,9 +4034,8 @@ where // Build GroupKeyConfig from input header if not provided let group_key_config = config.group_key_config.unwrap_or_else(|| { - use noodles::sam::alignment::record::data::field::Tag; let library_index = LibraryIndex::from_header(&input_header); - let cell_tag = Tag::from([b'C', b'B']); // Default cell tag + let cell_tag = Tag::from(SamTag::CB); GroupKeyConfig::new(library_index, cell_tag) }); @@ -4130,9 +4129,8 @@ where // Build GroupKeyConfig let group_key_config = config.group_key_config.unwrap_or_else(|| { - use noodles::sam::alignment::record::data::field::Tag; let library_index = LibraryIndex::from_header(&input_header); - let cell_tag = Tag::from([b'C', b'B']); + let cell_tag = Tag::from(SamTag::CB); GroupKeyConfig::new(library_index, cell_tag) }); @@ -4169,8 +4167,7 @@ mod tests { // Create minimal GroupKeyConfig for testing let header = Header::default(); let library_index = LibraryIndex::from_header(&header); - let cell_tag: [u8; 2] = [b'C', b'B']; - let group_key_config = GroupKeyConfig::new(library_index, cell_tag.into()); + let group_key_config = GroupKeyConfig::new(library_index, SamTag::CB.into()); BamPipelineState::new(config, input, output, group_key_config) } diff --git a/src/lib/unified_pipeline/base.rs b/src/lib/unified_pipeline/base.rs index 7741d786c..153857089 100644 --- a/src/lib/unified_pipeline/base.rs +++ b/src/lib/unified_pipeline/base.rs @@ -91,6 +91,7 @@ use super::deadlock::{DeadlockState, QueueSnapshot, check_deadlock_and_restore}; use crate::bgzf_reader::{RawBgzfBlock, decompress_block_into, read_raw_blocks}; use crate::read_info::LibraryIndex; use crate::reorder_buffer::ReorderBuffer; +use crate::sam::SamTag; use noodles::sam::alignment::RecordBuf; use noodles::sam::alignment::record::data::field::Tag; @@ -1638,7 +1639,7 @@ impl Default for GroupKeyConfig { fn default() -> Self { Self { library_index: Arc::new(LibraryIndex::default()), - cell_tag: Some(Tag::from([b'C', b'B'])), // Default cell barcode tag + cell_tag: Some(Tag::from(SamTag::CB)), // Default cell barcode tag raw_byte_mode: false, } } diff --git a/tests/integration/test_bgzf_eof.rs b/tests/integration/test_bgzf_eof.rs index c970b0a9b..2602f9cfd 100644 --- a/tests/integration/test_bgzf_eof.rs +++ b/tests/integration/test_bgzf_eof.rs @@ -39,7 +39,7 @@ fn write_grouped_bam(path: &Path, families: &[(&str, &[RecordBuf])]) { bam::io::Writer::new(fs::File::create(path).expect("Failed to create BAM file")); writer.write_header(&header).expect("Failed to write header"); - let mi_tag = Tag::new(b'M', b'I'); + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); for &(mi, records) in families { for record in records { let mut rec = record.clone(); @@ -117,7 +117,7 @@ fn create_duplex_pair( )] let tlen: i32 = if is_b_strand { -((read_len + 100) as i32) } else { (read_len + 100) as i32 }; - let mi = Tag::new(b'M', b'I'); + let mi = Tag::from(fgumi_lib::sam::SamTag::MI); let mut r1 = RecordBuilder::new() .name(name) diff --git a/tests/integration/test_downsample_command.rs b/tests/integration/test_downsample_command.rs index 9874421bb..25e8b72f2 100644 --- a/tests/integration/test_downsample_command.rs +++ b/tests/integration/test_downsample_command.rs @@ -13,7 +13,7 @@ use tempfile::TempDir; use crate::helpers::bam_generator::create_minimal_header; /// MI tag constant -const MI_TAG: Tag = Tag::new(b'M', b'I'); +const MI_TAG: Tag = fgumi_lib::sam::SamTag::MI.to_noodles_tag(); /// Create a grouped BAM file with MI tags (simulating output from group). fn create_grouped_bam(path: &PathBuf, families: Vec<(&str, usize)>) { diff --git a/tests/integration/test_duplex_command.rs b/tests/integration/test_duplex_command.rs index b91fb1591..016847b37 100644 --- a/tests/integration/test_duplex_command.rs +++ b/tests/integration/test_duplex_command.rs @@ -84,7 +84,7 @@ fn create_duplex_read_pair( .build(); // Add MI tag - let mi = Tag::new(b'M', b'I'); + let mi = Tag::from(fgumi_lib::sam::SamTag::MI); r1.data_mut().insert(mi, Value::from(mi_tag)); r2.data_mut().insert(mi, Value::from(mi_tag)); diff --git a/tests/integration/test_simplex_command.rs b/tests/integration/test_simplex_command.rs index 9d4f8a5b7..38e68b198 100644 --- a/tests/integration/test_simplex_command.rs +++ b/tests/integration/test_simplex_command.rs @@ -33,7 +33,7 @@ fn create_grouped_bam(path: &Path, families: Vec<(&str, Vec)>) { // Add MI tag for molecule identity (simplex expects grouped reads with MI) use noodles::sam::alignment::record::data::field::Tag; use noodles::sam::alignment::record_buf::data::field::Value; - let mi_tag = Tag::new(b'M', b'I'); + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); record.data_mut().insert(mi_tag, Value::from(mi)); writer.write_alignment_record(&header, &record).expect("Failed to write record"); } @@ -209,7 +209,7 @@ fn create_grouped_bam_with_header( for mut record in records { use noodles::sam::alignment::record::data::field::Tag; use noodles::sam::alignment::record_buf::data::field::Value; - let mi_tag = Tag::new(b'M', b'I'); + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); record.data_mut().insert(mi_tag, Value::from(mi)); writer.write_alignment_record(header, &record).expect("Failed to write record"); } diff --git a/tests/integration/test_streaming_input.rs b/tests/integration/test_streaming_input.rs index 863382361..4d10cc253 100644 --- a/tests/integration/test_streaming_input.rs +++ b/tests/integration/test_streaming_input.rs @@ -256,7 +256,7 @@ fn create_grouped_test_bam(path: &PathBuf) { writer.write_header(&header).expect("Failed to write header"); // Create grouped reads with MI tag - let mi_tag = Tag::new(b'M', b'I'); + let mi_tag = Tag::from(fgumi_lib::sam::SamTag::MI); let records = create_umi_family("AAAAAAAA", 5, "mol1", "ACGTACGT", 30); // Add MI tag to each record