1use 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}