Skip to main content

document_svg/document/
fastx.rs

1//! Bounded FASTA and FASTQ sequence/quality previews.
2//!
3//! Sequence data is displayed as inert table text.  The converter never runs
4//! aligners or interprets identifiers as paths; it reports length, GC ratio,
5//! ambiguity and (for FASTQ) printable quality-code ranges.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::table::{TableAlign, TableData, convert_table_pages};
12
13const MAX_FASTX_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_FASTX_LINES: usize = 5_000_000;
15const MAX_FASTX_LINE_BYTES: usize = 1 << 20;
16const MAX_FASTX_RECORDS: usize = 100_000;
17const MAX_FASTX_BASES: usize = 100_000_000;
18const MAX_FASTX_RECORD_BASES: usize = 10_000_000;
19const MAX_FASTX_PREVIEW: usize = 512;
20
21pub(crate) fn looks_like_fasta_prefix(prefix: &[u8]) -> bool {
22    let Ok(text) = std::str::from_utf8(prefix) else {
23        return false;
24    };
25    text.lines()
26        .map(str::trim)
27        .find(|line| !line.is_empty())
28        .is_some_and(|line| line.starts_with('>'))
29}
30
31pub(crate) fn looks_like_fastq_prefix(prefix: &[u8]) -> bool {
32    let Ok(text) = std::str::from_utf8(prefix) else {
33        return false;
34    };
35    let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
36    let Some(header) = lines.next() else {
37        return false;
38    };
39    let Some(sequence) = lines.next() else {
40        return false;
41    };
42    let Some(plus) = lines.next() else {
43        return false;
44    };
45    let Some(quality) = lines.next() else {
46        return false;
47    };
48    header.starts_with('@')
49        && !sequence.is_empty()
50        && plus.starts_with('+')
51        && quality.len() >= sequence.len()
52}
53
54pub(crate) fn convert_fasta(
55    path: &Path,
56    options: &ConvertOptions,
57    sink: &mut dyn PageConsumer,
58) -> Result<Vec<String>> {
59    convert(path, options, sink, SequenceFormat::Fasta)
60}
61
62pub(crate) fn convert_fastq(
63    path: &Path,
64    options: &ConvertOptions,
65    sink: &mut dyn PageConsumer,
66) -> Result<Vec<String>> {
67    convert(path, options, sink, SequenceFormat::Fastq)
68}
69
70#[derive(Clone, Copy)]
71enum SequenceFormat {
72    Fasta,
73    Fastq,
74}
75
76fn convert(
77    path: &Path,
78    options: &ConvertOptions,
79    sink: &mut dyn PageConsumer,
80    format: SequenceFormat,
81) -> Result<Vec<String>> {
82    let bytes = read_limited_file(
83        path,
84        options.max_input_bytes.min(MAX_FASTX_BYTES),
85        "sequence input",
86    )?;
87    let text = String::from_utf8(bytes).map_err(|error| {
88        Error::InvalidInput(format!("sequence input must be UTF-8/ASCII: {error}"))
89    })?;
90    let (mut table, warnings) = match format {
91        SequenceFormat::Fasta => parse_fasta(&text)?,
92        SequenceFormat::Fastq => parse_fastq(&text)?,
93    };
94    let format_name = match format {
95        SequenceFormat::Fasta => "fasta",
96        SequenceFormat::Fastq => "fastq",
97    };
98    let mut page_sink = SequencePageSink {
99        inner: sink,
100        format_name,
101        warnings: &warnings,
102    };
103    convert_table_pages(&mut table, format_name, options, &mut page_sink)?;
104    Ok(warnings)
105}
106
107struct SequencePageSink<'a> {
108    inner: &'a mut dyn PageConsumer,
109    format_name: &'static str,
110    warnings: &'a [String],
111}
112
113impl PageConsumer for SequencePageSink<'_> {
114    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
115        page.source_format = self.format_name.into();
116        page.title = format!("{} sequence records", self.format_name.to_ascii_uppercase());
117        page.description =
118            "Sequence text is displayed inertly; no alignment or execution is performed".into();
119        for warning in self.warnings {
120            page.warn(warning.clone());
121        }
122        self.inner.consume(page)
123    }
124}
125
126fn parse_fasta(text: &str) -> Result<(TableData, Vec<String>)> {
127    let lines = checked_lines(text)?;
128    let mut rows = Vec::new();
129    let mut warnings = Vec::new();
130    let mut id = None::<String>;
131    let mut description = String::new();
132    let mut sequence = String::new();
133    let mut total_bases = 0usize;
134    let mut truncated_preview = false;
135    for line in lines {
136        let line = line.trim();
137        if line.is_empty() {
138            continue;
139        }
140        if let Some(header) = line.strip_prefix('>') {
141            if let Some(previous_id) = id.take() {
142                let row = sequence_row(
143                    &previous_id,
144                    &description,
145                    &sequence,
146                    None,
147                    &mut truncated_preview,
148                )?;
149                total_bases = total_bases
150                    .checked_add(sequence.len())
151                    .ok_or_else(|| Error::LimitExceeded("FASTA base count overflowed".into()))?;
152                rows.push(row);
153                sequence.clear();
154            }
155            if rows.len() >= MAX_FASTX_RECORDS {
156                return Err(Error::LimitExceeded(format!(
157                    "FASTA exceeds {MAX_FASTX_RECORDS} records"
158                )));
159            }
160            let mut parts = header.splitn(2, char::is_whitespace);
161            let name = parts.next().unwrap_or_default();
162            if name.is_empty() {
163                return Err(Error::InvalidInput(
164                    "FASTA definition line has no identifier".into(),
165                ));
166            }
167            id = Some(name.to_owned());
168            description = parts.next().unwrap_or_default().trim().to_owned();
169        } else {
170            if id.is_none() {
171                return Err(Error::InvalidInput(
172                    "FASTA sequence appears before a definition line".into(),
173                ));
174            }
175            append_sequence(&mut sequence, line, "FASTA")?;
176            if sequence.len() > MAX_FASTX_RECORD_BASES {
177                return Err(Error::LimitExceeded(format!(
178                    "FASTA record exceeds {MAX_FASTX_RECORD_BASES} bases"
179                )));
180            }
181        }
182    }
183    if let Some(previous_id) = id {
184        let row = sequence_row(
185            &previous_id,
186            &description,
187            &sequence,
188            None,
189            &mut truncated_preview,
190        )?;
191        total_bases = total_bases
192            .checked_add(sequence.len())
193            .ok_or_else(|| Error::LimitExceeded("FASTA base count overflowed".into()))?;
194        rows.push(row);
195    }
196    if total_bases > MAX_FASTX_BASES {
197        return Err(Error::LimitExceeded(format!(
198            "FASTA exceeds {MAX_FASTX_BASES} bases"
199        )));
200    }
201    if rows.is_empty() {
202        return Err(Error::InvalidInput(
203            "FASTA contains no sequence records".into(),
204        ));
205    }
206    if truncated_preview {
207        warnings.push(format!(
208            "FASTA sequence text was truncated to {MAX_FASTX_PREVIEW} characters per record"
209        ));
210    }
211    Ok((sequence_table(rows, SequenceFormat::Fasta), warnings))
212}
213
214fn parse_fastq(text: &str) -> Result<(TableData, Vec<String>)> {
215    let lines = checked_lines(text)?;
216    let mut index = 0usize;
217    let mut rows = Vec::new();
218    let mut warnings = Vec::new();
219    let mut total_bases = 0usize;
220    let mut truncated_preview = false;
221    while index < lines.len() {
222        while index < lines.len() && lines[index].trim().is_empty() {
223            index += 1;
224        }
225        if index >= lines.len() {
226            break;
227        }
228        let header = lines[index].trim();
229        if !header.starts_with('@') {
230            return Err(Error::InvalidInput(format!(
231                "FASTQ record {} must start with '@'",
232                rows.len() + 1
233            )));
234        }
235        index += 1;
236        let mut sequence = String::new();
237        while index < lines.len() && !lines[index].trim_start().starts_with('+') {
238            append_sequence(&mut sequence, lines[index].trim(), "FASTQ")?;
239            index += 1;
240            if sequence.len() > MAX_FASTX_RECORD_BASES {
241                return Err(Error::LimitExceeded(format!(
242                    "FASTQ record exceeds {MAX_FASTX_RECORD_BASES} bases"
243                )));
244            }
245        }
246        if index >= lines.len() {
247            return Err(Error::InvalidInput(format!(
248                "FASTQ record {} is missing '+' line",
249                rows.len() + 1
250            )));
251        }
252        index += 1;
253        let mut quality = String::new();
254        while index < lines.len() && quality.len() < sequence.len() {
255            let line = lines[index].trim();
256            if !line.bytes().all(|byte| (33..=126).contains(&byte)) {
257                return Err(Error::InvalidInput(
258                    "FASTQ quality contains a non-printable character".into(),
259                ));
260            }
261            quality.push_str(line);
262            index += 1;
263            if quality.len() > sequence.len() {
264                return Err(Error::InvalidInput(format!(
265                    "FASTQ quality length exceeds sequence length in record {}",
266                    rows.len() + 1
267                )));
268            }
269        }
270        if quality.len() != sequence.len() {
271            return Err(Error::InvalidInput(format!(
272                "FASTQ quality length does not match sequence length in record {}",
273                rows.len() + 1
274            )));
275        }
276        let header = header.strip_prefix('@').unwrap_or_default();
277        let mut parts = header.splitn(2, char::is_whitespace);
278        let name = parts.next().unwrap_or_default();
279        if name.is_empty() {
280            return Err(Error::InvalidInput(
281                "FASTQ definition line has no identifier".into(),
282            ));
283        }
284        let description = parts.next().unwrap_or_default().trim();
285        let row = sequence_row(
286            name,
287            description,
288            &sequence,
289            Some(&quality),
290            &mut truncated_preview,
291        )?;
292        total_bases = total_bases
293            .checked_add(sequence.len())
294            .ok_or_else(|| Error::LimitExceeded("FASTQ base count overflowed".into()))?;
295        rows.push(row);
296        if rows.len() > MAX_FASTX_RECORDS {
297            return Err(Error::LimitExceeded(format!(
298                "FASTQ exceeds {MAX_FASTX_RECORDS} records"
299            )));
300        }
301    }
302    if total_bases > MAX_FASTX_BASES {
303        return Err(Error::LimitExceeded(format!(
304            "FASTQ exceeds {MAX_FASTX_BASES} bases"
305        )));
306    }
307    if rows.is_empty() {
308        return Err(Error::InvalidInput(
309            "FASTQ contains no sequence records".into(),
310        ));
311    }
312    if truncated_preview {
313        warnings.push(format!(
314            "FASTQ sequence text was truncated to {MAX_FASTX_PREVIEW} characters per record"
315        ));
316    }
317    Ok((sequence_table(rows, SequenceFormat::Fastq), warnings))
318}
319
320fn checked_lines(text: &str) -> Result<Vec<&str>> {
321    let lines = text.lines().collect::<Vec<_>>();
322    if lines.len() > MAX_FASTX_LINES {
323        return Err(Error::LimitExceeded(format!(
324            "sequence input exceeds {MAX_FASTX_LINES} lines"
325        )));
326    }
327    if lines.iter().any(|line| line.len() > MAX_FASTX_LINE_BYTES) {
328        return Err(Error::LimitExceeded(format!(
329            "sequence line exceeds {MAX_FASTX_LINE_BYTES} bytes"
330        )));
331    }
332    Ok(lines)
333}
334
335fn append_sequence(sequence: &mut String, line: &str, format: &str) -> Result<()> {
336    for byte in line.bytes() {
337        if !byte.is_ascii_alphabetic() && !matches!(byte, b'-' | b'*' | b'?' | b'.') {
338            return Err(Error::InvalidInput(format!(
339                "{format} sequence contains invalid character {:?}",
340                byte as char
341            )));
342        }
343        sequence.push(byte as char);
344    }
345    Ok(())
346}
347
348fn sequence_row(
349    id: &str,
350    description: &str,
351    sequence: &str,
352    quality: Option<&str>,
353    truncated: &mut bool,
354) -> Result<Vec<String>> {
355    if sequence.is_empty() {
356        return Err(Error::InvalidInput(format!(
357            "sequence record {id:?} is empty"
358        )));
359    }
360    let length = sequence.len();
361    let gc = sequence
362        .bytes()
363        .filter(|byte| matches!(byte.to_ascii_uppercase(), b'G' | b'C'))
364        .count();
365    let canonical = sequence.bytes().filter(u8::is_ascii_alphabetic).count();
366    let gc_ratio = if canonical == 0 {
367        "n/a".into()
368    } else {
369        format!("{:.2}%", gc as f64 * 100.0 / canonical as f64)
370    };
371    let ambiguous = sequence
372        .bytes()
373        .filter(|byte| !matches!(byte.to_ascii_uppercase(), b'A' | b'C' | b'G' | b'T' | b'U'))
374        .count();
375    let preview = if sequence.len() > MAX_FASTX_PREVIEW {
376        *truncated = true;
377        format!("{}…", &sequence[..MAX_FASTX_PREVIEW])
378    } else {
379        sequence.to_owned()
380    };
381    let mut row = vec![
382        id.to_owned(),
383        description.to_owned(),
384        length.to_string(),
385        gc_ratio,
386        ambiguous.to_string(),
387        preview,
388    ];
389    if let Some(quality) = quality {
390        let min = quality.bytes().min().unwrap_or(0);
391        let max = quality.bytes().max().unwrap_or(0);
392        row.insert(5, format!("{}..{}", min, max));
393    }
394    Ok(row)
395}
396
397fn sequence_table(rows: Vec<Vec<String>>, format: SequenceFormat) -> TableData {
398    let headers = match format {
399        SequenceFormat::Fasta => vec!["ID", "Description", "Length", "GC", "Ambiguous", "Sequence"],
400        SequenceFormat::Fastq => vec![
401            "ID",
402            "Description",
403            "Length",
404            "GC",
405            "Ambiguous",
406            "Quality ASCII range",
407            "Sequence",
408        ],
409    };
410    let width = headers.len();
411    let mut alignments = vec![TableAlign::Left; width];
412    for index in [2usize, 3, 4] {
413        alignments[index] = TableAlign::Right;
414    }
415    TableData {
416        headers: headers.into_iter().map(str::to_owned).collect(),
417        rows,
418        alignments,
419        raw_source: String::new(),
420    }
421}