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