Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/fgumi-consensus/src/duplex_caller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions crates/fgumi-sam/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down
2 changes: 2 additions & 0 deletions crates/fgumi-sam/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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;
Expand Down
271 changes: 271 additions & 0 deletions crates/fgumi-sam/src/tag.rs
Original file line number Diff line number Diff line change
@@ -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::<SamTag>();
//! 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])
Comment thread
nh13 marked this conversation as resolved.
}

/// 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<SamTag> for [u8; 2] {
fn from(tag: SamTag) -> [u8; 2] {
tag.0
}
}

impl From<SamTag> 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<SamTag> 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<Self> {
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<Self> {
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::<SamTag>().unwrap(), SamTag::RX);
assert_eq!("XT".parse::<SamTag>().unwrap(), SamTag::XT);
}

#[test]
fn test_parse_invalid_length() {
assert!("R".parse::<SamTag>().is_err());
assert!("RXX".parse::<SamTag>().is_err());
assert!("".parse::<SamTag>().is_err());
}

#[test]
fn test_parse_invalid_non_ascii() {
assert!("R\x01".parse::<SamTag>().is_err());
assert!(" X".parse::<SamTag>().is_err());
}

#[test]
fn test_parse_rejects_non_aux_tag_syntax() {
// Punctuation in second byte
assert!("A!".parse::<SamTag>().is_err());
// Digit in first byte
assert!("1A".parse::<SamTag>().is_err());
// Punctuation in first byte
assert!("!A".parse::<SamTag>().is_err());
// Digit in second byte is allowed
assert!("A0".parse::<SamTag>().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");
}
}
5 changes: 3 additions & 2 deletions src/lib/commands/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/commands/compare/bams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<i64> {
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.
Expand Down
Loading
Loading