Skip to main content

argenus/
paf.rs

1//! PAF (Pairwise mApping Format) Parser Module
2//!
3//! Provides parsing capabilities for minimap2 PAF alignment output.
4//! PAF is a text-based format containing alignment information.
5//!
6//! # PAF Format (12 mandatory columns)
7//! ```text
8//! Col  Type    Description
9//! 1    string  Query sequence name
10//! 2    int     Query sequence length
11//! 3    int     Query start (0-based)
12//! 4    int     Query end
13//! 5    char    Relative strand: '+' or '-'
14//! 6    string  Target sequence name
15//! 7    int     Target sequence length
16//! 8    int     Target start
17//! 9    int     Target end
18//! 10   int     Number of matching bases
19//! 11   int     Alignment block length
20//! 12   int     Mapping quality (0-255; 255 for missing)
21//! ```
22//!
23//! # Example Usage
24//! ```no_run
25//! use argenus::paf::PafReader;
26//!
27//! let mut reader = PafReader::open("alignment.paf").unwrap();
28//! while let Some(record) = reader.read_next().unwrap() {
29//!     println!("{} -> {} ({:.1}% identity)",
30//!              record.query_name, record.target_name, record.calculate_identity());
31//! }
32//! ```
33
34use anyhow::{Context, Result};
35use std::fs::File;
36use std::io::{BufRead, BufReader};
37use std::path::Path;
38
39// ============================================================================
40// PAF Record
41// ============================================================================
42
43/// A single PAF alignment record.
44///
45/// Contains the 12 mandatory PAF columns parsed from a single line.
46/// Optional tags (columns 13+) are not currently parsed.
47// A complete 12-column PAF record. The read-filter path consumes only a subset
48// (query_name/target fields + block_len via calculate_identity); the remaining
49// columns are still parsed so the record is a faithful representation of the line.
50#[derive(Debug, Clone)]
51#[allow(dead_code)]
52pub struct PafRecord {
53    /// Query sequence name (column 1).
54    pub query_name: String,
55    /// Query sequence length (column 2).
56    pub query_len: usize,
57    /// Query start position, 0-based (column 3).
58    pub query_start: usize,
59    /// Query end position (column 4).
60    pub query_end: usize,
61    /// Relative strand: '+' or '-' (column 5).
62    pub strand: char,
63    /// Target sequence name (column 6).
64    pub target_name: String,
65    /// Target sequence length (column 7).
66    pub target_len: usize,
67    /// Target start position (column 8).
68    pub target_start: usize,
69    /// Target end position (column 9).
70    pub target_end: usize,
71    /// Number of matching bases (column 10).
72    pub matches: usize,
73    /// Alignment block length (column 11).
74    pub block_len: usize,
75}
76
77impl PafRecord {
78    /// Parses a PAF record from a tab-separated line.
79    ///
80    /// # Arguments
81    /// * `line` - A single line from a PAF file
82    ///
83    /// # Returns
84    /// A parsed PafRecord, or an error if the line is malformed.
85    ///
86    /// # Errors
87    /// Returns an error if:
88    /// - The line has fewer than 12 fields
89    /// - Any numeric field cannot be parsed
90    pub fn parse_line(line: &str) -> Result<Self> {
91        let fields: Vec<&str> = line.split('\t').collect();
92        if fields.len() < 12 {
93            anyhow::bail!("Invalid PAF line: fewer than 12 fields");
94        }
95
96        Ok(Self {
97            query_name: fields[0].to_string(),
98            query_len: fields[1].parse().context("Invalid query length")?,
99            query_start: fields[2].parse().context("Invalid query start")?,
100            query_end: fields[3].parse().context("Invalid query end")?,
101            strand: fields[4].chars().next().unwrap_or('+'),
102            target_name: fields[5].to_string(),
103            target_len: fields[6].parse().context("Invalid target length")?,
104            target_start: fields[7].parse().context("Invalid target start")?,
105            target_end: fields[8].parse().context("Invalid target end")?,
106            matches: fields[9].parse().context("Invalid matches count")?,
107            block_len: fields[10].parse().context("Invalid block length")?,
108        })
109    }
110
111    /// Calculates alignment identity percentage.
112    ///
113    /// Identity = (matching bases / alignment block length) × 100
114    ///
115    /// # Returns
116    /// Identity percentage (0-100), or 0 if block_len is 0.
117    pub fn calculate_identity(&self) -> f64 {
118        if self.block_len == 0 {
119            return 0.0;
120        }
121        (self.matches as f64 / self.block_len as f64) * 100.0
122    }
123
124    /// Calculates target coverage percentage.
125    ///
126    /// Coverage = (aligned target length / total target length) × 100
127    ///
128    /// # Returns
129    /// Coverage percentage (0-100), or 0 if target_len is 0.
130    #[allow(dead_code)]
131    pub fn calculate_coverage(&self) -> f64 {
132        if self.target_len == 0 {
133            return 0.0;
134        }
135        ((self.target_end - self.target_start) as f64 / self.target_len as f64) * 100.0
136    }
137}
138
139// ============================================================================
140// PAF Reader
141// ============================================================================
142
143/// Sequential reader for PAF format files.
144///
145/// Provides efficient line-by-line reading with internal buffering.
146/// Implements Iterator for convenient use in for loops.
147pub struct PafReader {
148    reader: BufReader<File>,
149    line_buf: String,
150}
151
152impl PafReader {
153    /// Opens a PAF file for reading.
154    ///
155    /// # Arguments
156    /// * `path` - Path to the PAF file
157    ///
158    /// # Returns
159    /// A new PafReader, or an error if the file cannot be opened.
160    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
161        let file = File::open(path.as_ref())
162            .with_context(|| format!("Failed to open PAF: {}", path.as_ref().display()))?;
163        Ok(Self {
164            reader: BufReader::with_capacity(1024 * 1024, file),
165            line_buf: String::with_capacity(512),
166        })
167    }
168
169    /// Reads the next PAF record from the file.
170    ///
171    /// Skips empty lines automatically.
172    ///
173    /// # Returns
174    /// - `Ok(Some(record))` - Successfully read a record
175    /// - `Ok(None)` - End of file reached
176    /// - `Err(e)` - I/O or parsing error
177    pub fn read_next(&mut self) -> Result<Option<PafRecord>> {
178        self.line_buf.clear();
179        if self.reader.read_line(&mut self.line_buf)? == 0 {
180            return Ok(None);
181        }
182
183        let line = self.line_buf.trim_end();
184        if line.is_empty() {
185            return self.read_next(); // Skip empty lines
186        }
187
188        Ok(Some(PafRecord::parse_line(line)?))
189    }
190}
191
192impl Iterator for PafReader {
193    type Item = Result<PafRecord>;
194
195    fn next(&mut self) -> Option<Self::Item> {
196        match self.read_next() {
197            Ok(Some(record)) => Some(Ok(record)),
198            Ok(None) => None,
199            Err(e) => Some(Err(e)),
200        }
201    }
202}
203
204// ============================================================================
205// Tests
206// ============================================================================
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_parse_paf_line() {
214        let line = "read1\t150\t10\t140\t+\tgene1\t1000\t100\t230\t120\t130\t60";
215        let record = PafRecord::parse_line(line).unwrap();
216
217        assert_eq!(record.query_name, "read1");
218        assert_eq!(record.query_len, 150);
219        assert_eq!(record.query_start, 10);
220        assert_eq!(record.query_end, 140);
221        assert_eq!(record.strand, '+');
222        assert_eq!(record.target_name, "gene1");
223        assert_eq!(record.target_len, 1000);
224        assert_eq!(record.matches, 120);
225        assert_eq!(record.block_len, 130);
226    }
227
228    #[test]
229    fn test_calculate_identity() {
230        let record = PafRecord {
231            query_name: "test".to_string(),
232            query_len: 100,
233            query_start: 0,
234            query_end: 100,
235            strand: '+',
236            target_name: "ref".to_string(),
237            target_len: 100,
238            target_start: 0,
239            target_end: 100,
240            matches: 95,
241            block_len: 100,
242        };
243
244        assert_eq!(record.calculate_identity(), 95.0);
245    }
246
247    #[test]
248    fn test_calculate_coverage() {
249        let record = PafRecord {
250            query_name: "test".to_string(),
251            query_len: 100,
252            query_start: 0,
253            query_end: 100,
254            strand: '+',
255            target_name: "ref".to_string(),
256            target_len: 200,
257            target_start: 0,
258            target_end: 100,
259            matches: 100,
260            block_len: 100,
261        };
262
263        assert_eq!(record.calculate_coverage(), 50.0);
264    }
265
266    #[test]
267    fn test_invalid_paf_line() {
268        let line = "incomplete\tline";
269        assert!(PafRecord::parse_line(line).is_err());
270    }
271}