Skip to main content

dino_seq/
fasta.rs

1use std::collections::{HashMap, HashSet};
2use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
3use std::ops::Range;
4
5use crate::error::{FastqError, FastqPosition, Result};
6#[cfg(feature = "bgzf")]
7use crate::{BgzfDecodedBlockReader, BgzfIndex, BgzfIndexEntry, BgzfSeekReader, BgzfVirtualOffset};
8use memchr::memchr;
9
10const DEFAULT_BATCH_RECORDS: usize = 1024;
11const DEFAULT_READER_BUFFER_SIZE: usize = 64 * 1024;
12const REFERENCE_BATCH_RECORDS: usize = 16;
13const REFERENCE_READER_BUFFER_SIZE: usize = 256 * 1024;
14const REFERENCE_EXPECTED_SEQ_LEN: usize = 1024 * 1024;
15const TWO_LINE_STREAM_BUFFER_SIZE: usize = 64 * 1024;
16const INDEXED_FETCH_SCRATCH_SIZE: usize = 64 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19struct ByteRange {
20    start: u32,
21    end: u32,
22}
23
24impl ByteRange {
25    fn to_usize(self) -> Range<usize> {
26        self.start as usize..self.end as usize
27    }
28}
29
30/// Configuration for FASTA readers.
31#[derive(Debug, Clone)]
32pub struct FastaConfig {
33    /// Maximum number of records returned per batch.
34    ///
35    /// Values below 1 are raised to 1. Larger batches reduce caller overhead
36    /// but keep more sequence bytes resident until the next batch.
37    pub batch_records: usize,
38    /// Input buffer size used by streaming readers.
39    ///
40    /// Values below 1024 are raised to 1024. Larger buffers can reduce I/O
41    /// calls for compressed or high-latency streams; smaller buffers may improve
42    /// cache behavior on simple in-memory inputs.
43    pub buffer_size: usize,
44    /// Expected sequence length used as a preallocation hint.
45    ///
46    /// This is only a hint for reusable scratch buffers. It does not reject
47    /// longer records.
48    pub expected_seq_len: usize,
49}
50
51impl Default for FastaConfig {
52    fn default() -> Self {
53        Self {
54            batch_records: DEFAULT_BATCH_RECORDS,
55            buffer_size: DEFAULT_READER_BUFFER_SIZE,
56            expected_seq_len: 0,
57        }
58    }
59}
60
61impl FastaConfig {
62    /// Return parser settings tuned for chromosome-scale reference FASTA records.
63    pub fn reference() -> Self {
64        Self {
65            batch_records: REFERENCE_BATCH_RECORDS,
66            buffer_size: REFERENCE_READER_BUFFER_SIZE,
67            expected_seq_len: REFERENCE_EXPECTED_SEQ_LEN,
68        }
69    }
70
71    /// Return a copy configured for strict two-line FASTA-oriented streaming.
72    pub fn two_line(mut self) -> Self {
73        self.buffer_size = TWO_LINE_STREAM_BUFFER_SIZE;
74        self
75    }
76}
77
78/// Byte ranges for one FASTA record within a batch.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FastaRecordRef {
81    /// Header line, including the leading `>` and excluding the newline.
82    pub name: Range<u32>,
83    /// Sequence bytes with multiline FASTA sequence lines concatenated.
84    pub seq: Range<u32>,
85}
86
87/// Borrowed view of one FASTA record inside a [`FastaBatch`].
88#[derive(Debug, Clone, Copy)]
89pub struct FastaRecord<'a> {
90    bytes: &'a [u8],
91    record: &'a FastaRecordRef,
92    id_token: ByteRange,
93}
94
95impl<'a> FastaRecord<'a> {
96    /// Return the header line including the leading `>`.
97    pub fn name(self) -> &'a [u8] {
98        &self.bytes[to_usize(self.record.name.clone())]
99    }
100
101    /// Return the header line without a leading `>`.
102    pub fn name_without_gt(self) -> &'a [u8] {
103        let name = self.name();
104        name.strip_prefix(b">").unwrap_or(name)
105    }
106
107    /// Return the first whitespace-delimited identifier token.
108    pub fn id_token(self) -> &'a [u8] {
109        &self.bytes[self.id_token.to_usize()]
110    }
111
112    /// Return the concatenated sequence bytes.
113    pub fn seq(self) -> &'a [u8] {
114        &self.bytes[to_usize(self.record.seq.clone())]
115    }
116}
117
118/// Borrowed FASTA record passed to [`FastaReader::visit_records`].
119#[derive(Debug, Clone, Copy)]
120pub struct FastaVisitRecord<'a> {
121    name: &'a [u8],
122    seq: &'a [u8],
123}
124
125impl<'a> FastaVisitRecord<'a> {
126    /// Return the header line including the leading `>`.
127    pub fn name(self) -> &'a [u8] {
128        self.name
129    }
130
131    /// Return the header line without a leading `>`.
132    pub fn name_without_gt(self) -> &'a [u8] {
133        self.name.strip_prefix(b">").unwrap_or(self.name)
134    }
135
136    /// Return the first whitespace-delimited identifier token.
137    pub fn id_token(self) -> &'a [u8] {
138        let name = self.name_without_gt();
139        let end = name
140            .iter()
141            .position(u8::is_ascii_whitespace)
142            .unwrap_or(name.len());
143        &name[..end]
144    }
145
146    /// Return the concatenated sequence bytes.
147    pub fn seq(self) -> &'a [u8] {
148        self.seq
149    }
150}
151
152const FASTA_STATS_CHECKSUM_INIT: u64 = 0xcbf2_9ce4_8422_2325;
153
154/// Aggregate statistics for sequence-only FASTA workloads.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct FastaStats {
157    /// Number of records observed.
158    pub records: u64,
159    /// Number of sequence bases observed.
160    pub bases: u64,
161    /// Lightweight deterministic checksum over record shape and edge bases.
162    pub checksum: u64,
163}
164
165/// Detected FASTA physical layout for resident inputs.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum FastaShape {
168    /// Empty input or only blank lines.
169    Empty,
170    /// Every non-empty record has exactly one header line and one sequence line.
171    TwoLine,
172    /// At least one record is multiline, blank-separated, or otherwise requires
173    /// the robust FASTA parser.
174    Multiline,
175}
176
177/// One `.fai`-style FASTA index entry.
178///
179/// `offset`, `line_bases`, and `line_width` follow the SAMtools `.fai`
180/// convention over the uncompressed FASTA byte stream. When built from BGZF
181/// input, `virtual_offset` stores the BGZF virtual offset for `offset`.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct FastaIndexEntry {
184    /// Reference name, using the first whitespace-delimited token after `>`.
185    pub name: Vec<u8>,
186    /// Number of bases in the reference sequence.
187    pub len: u64,
188    /// Uncompressed byte offset of the first sequence byte.
189    pub offset: u64,
190    /// Number of bases per full sequence line.
191    pub line_bases: u64,
192    /// Number of bytes per full sequence line, including line ending bytes.
193    pub line_width: u64,
194    /// BGZF virtual offset for `offset`, when the index was built from BGZF.
195    #[cfg(feature = "bgzf")]
196    pub virtual_offset: Option<BgzfVirtualOffset>,
197}
198
199impl FastaIndexEntry {
200    /// Return the uncompressed FASTA byte offset for a zero-based sequence
201    /// position.
202    ///
203    /// Coordinates are 0-based and do not include FASTA line separators.
204    pub fn sequence_offset(&self, pos: u64) -> Result<u64> {
205        if pos > self.len {
206            return Err(FastqError::Format(
207                "FASTA sequence position exceeds reference length".into(),
208            ));
209        }
210        if pos == self.len {
211            return self.sequence_end_offset();
212        }
213        if self.line_bases == 0 {
214            return Err(FastqError::Format(
215                "FASTA index entry has zero line_bases for non-empty sequence".into(),
216            ));
217        }
218        let line = pos / self.line_bases;
219        let in_line = pos % self.line_bases;
220        let line_offset = line.checked_mul(self.line_width).ok_or_else(|| {
221            FastqError::Format("FASTA index offset calculation overflowed".into())
222        })?;
223        self.offset
224            .checked_add(line_offset)
225            .and_then(|offset| offset.checked_add(in_line))
226            .ok_or_else(|| FastqError::Format("FASTA index offset calculation overflowed".into()))
227    }
228
229    /// Return physical FASTA byte spans covering a zero-based half-open
230    /// sequence range.
231    ///
232    /// Each returned span points only at sequence bytes and excludes physical
233    /// newline bytes.
234    pub fn sequence_spans(&self, range: Range<u64>) -> Result<Vec<Range<u64>>> {
235        self.validate_range(range.clone())?;
236        let mut spans = Vec::new();
237        let mut pos = range.start;
238        while pos < range.end {
239            if self.line_bases == 0 {
240                return Err(FastqError::Format(
241                    "FASTA index entry has zero line_bases for non-empty range".into(),
242                ));
243            }
244            let in_line = pos % self.line_bases;
245            let take = (range.end - pos).min(self.line_bases - in_line);
246            let start = self.sequence_offset(pos)?;
247            let end = start.checked_add(take).ok_or_else(|| {
248                FastqError::Format("FASTA index span calculation overflowed".into())
249            })?;
250            spans.push(start..end);
251            pos += take;
252        }
253        Ok(spans)
254    }
255
256    fn sequence_end_offset(&self) -> Result<u64> {
257        if self.len == 0 {
258            return Ok(self.offset);
259        }
260        self.sequence_offset(self.len - 1).map(|offset| offset + 1)
261    }
262
263    fn validate_range(&self, range: Range<u64>) -> Result<()> {
264        if range.start > range.end {
265            return Err(FastqError::Format(
266                "FASTA range start must be <= end".into(),
267            ));
268        }
269        if range.end > self.len {
270            return Err(FastqError::Format(
271                "FASTA range end exceeds reference length".into(),
272            ));
273        }
274        Ok(())
275    }
276}
277
278/// Configuration for planning balanced indexed-FASTA reference partitions.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub struct FastaPartitionConfig {
281    /// Target number of partitions to produce.
282    ///
283    /// Values below 1 are raised to 1. Empty references are skipped.
284    pub target_partitions: usize,
285    /// Number of neighboring bases each partition should include in its
286    /// fetch range on both sides of the core range.
287    ///
288    /// For k-mer or minimizer ingest, callers typically use the maximum
289    /// lookaround needed by their k/window shape.
290    pub overlap_bases: u64,
291}
292
293impl FastaPartitionConfig {
294    /// Create a partition planner configuration.
295    pub fn new(target_partitions: usize, overlap_bases: u64) -> Self {
296        Self {
297            target_partitions,
298            overlap_bases,
299        }
300    }
301}
302
303/// One planned reference partition over indexed FASTA sequence coordinates.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct FastaPartition {
306    /// Zero-based partition index in output order.
307    pub partition_index: usize,
308    /// Reference name from the FASTA index.
309    pub name: Vec<u8>,
310    /// Core half-open sequence range assigned to this partition.
311    pub core: Range<u64>,
312    /// Half-open sequence range to fetch, expanded by overlap and clamped to
313    /// the reference bounds.
314    pub fetch: Range<u64>,
315}
316
317impl FastaPartition {
318    /// Return the number of non-overlap bases assigned to this partition.
319    pub fn core_len(&self) -> u64 {
320        self.core.end - self.core.start
321    }
322
323    /// Return the zero-based offset of the first fetched base relative to the
324    /// first core base.
325    pub fn core_offset_in_fetch(&self) -> u64 {
326        self.core.start - self.fetch.start
327    }
328}
329
330/// Plan balanced reference partitions from a FASTA index.
331///
332/// The planner splits long references into approximately balanced chunks.
333/// `core` ranges are disjoint and cover every non-empty reference; `fetch`
334/// ranges include the requested overlap, clamped to each reference. The result
335/// may contain more partitions than `target_partitions` when the index contains
336/// many short references.
337pub fn plan_fasta_partitions(
338    index: &FastaIndex,
339    config: FastaPartitionConfig,
340) -> Result<Vec<FastaPartition>> {
341    let target_partitions = config.target_partitions.max(1);
342    let total_bases = index.entries().iter().try_fold(0_u64, |acc, entry| {
343        acc.checked_add(entry.len)
344            .ok_or_else(|| FastqError::Format("FASTA partition total length overflowed".into()))
345    })?;
346    if total_bases == 0 {
347        return Ok(Vec::new());
348    }
349    let target_partitions_u64 = u64::try_from(target_partitions)
350        .map_err(|_| FastqError::Format("FASTA partition count exceeds u64 range".into()))?;
351    let target_bases = total_bases.div_ceil(target_partitions_u64).max(1);
352    let mut partitions = Vec::new();
353
354    for entry in index.entries() {
355        let mut start = 0_u64;
356        while start < entry.len {
357            let remaining = entry.len - start;
358            let take = remaining.min(target_bases);
359            let core = start..start + take;
360            let fetch_start = core.start.saturating_sub(config.overlap_bases);
361            let fetch_end = core.end.saturating_add(config.overlap_bases).min(entry.len);
362            partitions.push(FastaPartition {
363                partition_index: partitions.len(),
364                name: entry.name.clone(),
365                core: core.clone(),
366                fetch: fetch_start..fetch_end,
367            });
368            start = core.end;
369        }
370    }
371
372    Ok(partitions)
373}
374
375/// Owned reference sequence chunk from an indexed FASTA source.
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct FastaReferenceChunk {
378    /// Reference name from the FASTA index.
379    pub name: Vec<u8>,
380    /// Zero-based sequence offset of `seq[0]` within the reference.
381    pub global_offset: u64,
382    /// Sequence bytes for this chunk.
383    pub seq: Vec<u8>,
384}
385
386/// Borrowed reference sequence chunk from an indexed FASTA source.
387#[derive(Debug, Clone, Copy)]
388pub struct FastaReferenceChunkRef<'a> {
389    /// Reference name from the FASTA index.
390    pub name: &'a [u8],
391    /// Zero-based sequence offset of `seq[0]` within the reference.
392    pub global_offset: u64,
393    /// Sequence bytes for this chunk.
394    pub seq: &'a [u8],
395}
396
397/// Sink trait for borrowed indexed FASTA reference chunks.
398pub trait FastaReferenceChunkSink {
399    /// Consume one borrowed reference chunk.
400    fn chunk(&mut self, chunk: FastaReferenceChunkRef<'_>) -> Result<()>;
401}
402
403impl<F> FastaReferenceChunkSink for F
404where
405    F: FnMut(FastaReferenceChunkRef<'_>) -> Result<()>,
406{
407    fn chunk(&mut self, chunk: FastaReferenceChunkRef<'_>) -> Result<()> {
408        self(chunk)
409    }
410}
411
412/// Iterator over owned chunks from an [`IndexedFastaReader`].
413pub struct FastaReferenceChunks<'a, R> {
414    reader: &'a mut IndexedFastaReader<R>,
415    name: Vec<u8>,
416    entry: FastaIndexEntry,
417    next_offset: u64,
418    end: u64,
419    chunk_bases: u64,
420    initialized: bool,
421}
422
423/// Iterator over owned chunks from a [`BgzfIndexedFastaReader`].
424#[cfg(feature = "bgzf")]
425pub struct BgzfFastaReferenceChunks<'a, R> {
426    reader: &'a mut BgzfIndexedFastaReader<R>,
427    name: Vec<u8>,
428    entry: FastaIndexEntry,
429    next_offset: u64,
430    end: u64,
431    chunk_bases: u64,
432    initialized: bool,
433}
434
435impl<R: Read + Seek> Iterator for FastaReferenceChunks<'_, R> {
436    type Item = Result<FastaReferenceChunk>;
437
438    fn next(&mut self) -> Option<Self::Item> {
439        if self.next_offset >= self.end {
440            return None;
441        }
442        let start = self.next_offset;
443        if !self.initialized {
444            if let Err(err) =
445                seek_indexed_sequence_start(&mut self.reader.inner, &self.entry, start)
446            {
447                self.next_offset = self.end;
448                return Some(Err(err));
449            }
450            self.initialized = true;
451        }
452        let capacity = indexed_chunk_capacity(start, self.end, self.chunk_bases);
453        let mut seq = match capacity {
454            Ok(capacity) => Vec::with_capacity(capacity),
455            Err(err) => {
456                self.next_offset = self.end;
457                return Some(Err(err));
458            }
459        };
460        match read_next_indexed_sequence_chunk(
461            &mut self.reader.inner,
462            &self.entry,
463            start,
464            self.end,
465            self.chunk_bases,
466            &mut seq,
467            &mut self.reader.scratch,
468        ) {
469            Ok(next_offset) => {
470                self.next_offset = next_offset;
471                Some(Ok(FastaReferenceChunk {
472                    name: self.name.clone(),
473                    global_offset: start,
474                    seq,
475                }))
476            }
477            Err(err) => {
478                self.next_offset = self.end;
479                Some(Err(err))
480            }
481        }
482    }
483}
484
485#[cfg(feature = "bgzf")]
486impl<R: Read + Seek> Iterator for BgzfFastaReferenceChunks<'_, R> {
487    type Item = Result<FastaReferenceChunk>;
488
489    fn next(&mut self) -> Option<Self::Item> {
490        if self.next_offset >= self.end {
491            return None;
492        }
493        let start = self.next_offset;
494        if !self.initialized {
495            if let Err(err) = seek_bgzf_indexed_sequence_start(
496                &mut self.reader.inner,
497                &self.reader.bgzf_index,
498                &self.entry,
499                start,
500            ) {
501                self.next_offset = self.end;
502                return Some(Err(err));
503            }
504            self.initialized = true;
505        }
506        let capacity = indexed_chunk_capacity(start, self.end, self.chunk_bases);
507        let mut seq = match capacity {
508            Ok(capacity) => Vec::with_capacity(capacity),
509            Err(err) => {
510                self.next_offset = self.end;
511                return Some(Err(err));
512            }
513        };
514        match read_next_indexed_sequence_chunk(
515            &mut self.reader.inner,
516            &self.entry,
517            start,
518            self.end,
519            self.chunk_bases,
520            &mut seq,
521            &mut self.reader.scratch,
522        ) {
523            Ok(next_offset) => {
524                self.next_offset = next_offset;
525                Some(Ok(FastaReferenceChunk {
526                    name: self.name.clone(),
527                    global_offset: start,
528                    seq,
529                }))
530            }
531            Err(err) => {
532                self.next_offset = self.end;
533                Some(Err(err))
534            }
535        }
536    }
537}
538
539/// A `.fai`-style FASTA index.
540#[derive(Debug, Clone, Default, PartialEq, Eq)]
541pub struct FastaIndex {
542    entries: Vec<FastaIndexEntry>,
543    name_to_index: HashMap<Vec<u8>, usize>,
544}
545
546impl FastaIndex {
547    /// Return all index entries in FASTA order.
548    pub fn entries(&self) -> &[FastaIndexEntry] {
549        &self.entries
550    }
551
552    /// Return whether the index contains no references.
553    pub fn is_empty(&self) -> bool {
554        self.entries.is_empty()
555    }
556
557    /// Return the number of references in the index.
558    pub fn len(&self) -> usize {
559        self.entries.len()
560    }
561
562    /// Find an entry by reference name bytes.
563    pub fn get(&self, name: &[u8]) -> Option<&FastaIndexEntry> {
564        self.name_to_index
565            .get(name)
566            .and_then(|&idx| self.entries.get(idx))
567    }
568
569    /// Render the standard five-column `.fai` representation.
570    pub fn to_fai_string(&self) -> String {
571        let mut out = String::new();
572        for entry in &self.entries {
573            out.push_str(&String::from_utf8_lossy(&entry.name));
574            out.push('\t');
575            out.push_str(&entry.len.to_string());
576            out.push('\t');
577            out.push_str(&entry.offset.to_string());
578            out.push('\t');
579            out.push_str(&entry.line_bases.to_string());
580            out.push('\t');
581            out.push_str(&entry.line_width.to_string());
582            out.push('\n');
583        }
584        out
585    }
586
587    /// Parse a standard five-column `.fai` index from bytes.
588    pub fn from_fai_bytes(bytes: &[u8]) -> Result<Self> {
589        let mut entries = Vec::new();
590        let mut seen = HashSet::new();
591        for (line_idx, line) in bytes.split(|&b| b == b'\n').enumerate() {
592            let line = trim_line(line);
593            if line.is_empty() {
594                continue;
595            }
596            let fields = line.split(|&b| b == b'\t').collect::<Vec<_>>();
597            if fields.len() != 5 {
598                return Err(FastqError::Format(format!(
599                    "invalid .fai line {}: expected 5 tab-delimited fields",
600                    line_idx + 1
601                )));
602            }
603            if fields[0].is_empty() {
604                return Err(FastqError::Format(format!(
605                    "invalid .fai line {}: empty reference name",
606                    line_idx + 1
607                )));
608            }
609            if !seen.insert(fields[0].to_vec()) {
610                return Err(FastqError::Format(format!(
611                    "invalid .fai line {}: duplicate reference name",
612                    line_idx + 1
613                )));
614            }
615            let len = parse_fai_u64(fields[1], line_idx + 1, "length")?;
616            let offset = parse_fai_u64(fields[2], line_idx + 1, "offset")?;
617            let line_bases = parse_fai_u64(fields[3], line_idx + 1, "line_bases")?;
618            let line_width = parse_fai_u64(fields[4], line_idx + 1, "line_width")?;
619            if len > 0 && line_bases == 0 {
620                return Err(FastqError::Format(format!(
621                    "invalid .fai line {}: non-empty reference has zero line_bases",
622                    line_idx + 1
623                )));
624            }
625            if line_width < line_bases {
626                return Err(FastqError::Format(format!(
627                    "invalid .fai line {}: line_width is smaller than line_bases",
628                    line_idx + 1
629                )));
630            }
631            entries.push(FastaIndexEntry {
632                name: fields[0].to_vec(),
633                len,
634                offset,
635                line_bases,
636                line_width,
637                #[cfg(feature = "bgzf")]
638                virtual_offset: None,
639            });
640        }
641        Ok(Self::from_entries(entries))
642    }
643
644    /// Parse a standard five-column `.fai` index from UTF-8 text.
645    pub fn from_fai_str(text: &str) -> Result<Self> {
646        Self::from_fai_bytes(text.as_bytes())
647    }
648
649    /// Parse a standard five-column `.fai` index from a reader.
650    pub fn from_fai_read<R: Read>(mut reader: R) -> Result<Self> {
651        let mut bytes = Vec::new();
652        reader.read_to_end(&mut bytes)?;
653        Self::from_fai_bytes(&bytes)
654    }
655
656    fn from_entries(entries: Vec<FastaIndexEntry>) -> Self {
657        let name_to_index = entries
658            .iter()
659            .enumerate()
660            .map(|(idx, entry)| (entry.name.clone(), idx))
661            .collect();
662        Self {
663            entries,
664            name_to_index,
665        }
666    }
667}
668
669fn parse_fai_u64(value: &[u8], line: usize, field: &str) -> Result<u64> {
670    let text = std::str::from_utf8(value).map_err(|_| {
671        FastqError::Format(format!(
672            "invalid .fai line {line}: {field} is not valid UTF-8"
673        ))
674    })?;
675    text.parse::<u64>().map_err(|_| {
676        FastqError::Format(format!(
677            "invalid .fai line {line}: {field} is not an unsigned integer"
678        ))
679    })
680}
681
682fn indexed_physical_window(entry: &FastaIndexEntry, range: Range<u64>) -> Result<Range<u64>> {
683    entry.validate_range(range.clone())?;
684    let start = entry.sequence_offset(range.start)?;
685    let end = if range.start == range.end {
686        start
687    } else {
688        entry
689            .sequence_offset(range.end - 1)?
690            .checked_add(1)
691            .ok_or_else(|| FastqError::Format("FASTA index span calculation overflowed".into()))?
692    };
693    if end < start {
694        return Err(FastqError::Format(
695            "FASTA index physical range is invalid".into(),
696        ));
697    }
698    Ok(start..end)
699}
700
701fn copy_indexed_sequence_window<R: Read>(
702    reader: &mut R,
703    entry: &FastaIndexEntry,
704    mut physical_offset: u64,
705    mut len: u64,
706    expected_bases: usize,
707    out: &mut Vec<u8>,
708    scratch: &mut Vec<u8>,
709) -> Result<()> {
710    if len == 0 {
711        return Ok(());
712    }
713    if entry.line_width == 0 {
714        return Err(FastqError::Format(
715            "FASTA index entry has zero line_width for non-empty range".into(),
716        ));
717    }
718    if scratch.len() < INDEXED_FETCH_SCRATCH_SIZE {
719        scratch.resize(INDEXED_FETCH_SCRATCH_SIZE, 0);
720    }
721    while len > 0 {
722        let take = usize::try_from(len.min(scratch.len() as u64))
723            .map_err(|_| FastqError::Format("FASTA fetch span exceeds usize range".into()))?;
724        reader.read_exact(&mut scratch[..take])?;
725        let mut cursor = 0;
726        while cursor < take {
727            let cursor_offset = physical_offset.checked_add(cursor as u64).ok_or_else(|| {
728                FastqError::Format("FASTA fetch physical offset overflowed".into())
729            })?;
730            let rel = cursor_offset.checked_sub(entry.offset).ok_or_else(|| {
731                FastqError::Format("FASTA index physical offset precedes sequence offset".into())
732            })?;
733            let in_line = rel % entry.line_width;
734            let remaining = take - cursor;
735            if in_line >= entry.line_bases {
736                let skip = (entry.line_width - in_line).min(remaining as u64);
737                cursor += usize::try_from(skip).map_err(|_| {
738                    FastqError::Format("FASTA fetch span exceeds usize range".into())
739                })?;
740                continue;
741            }
742
743            let run = (entry.line_bases - in_line).min(remaining as u64);
744            let run = usize::try_from(run)
745                .map_err(|_| FastqError::Format("FASTA fetch span exceeds usize range".into()))?;
746            out.extend_from_slice(&scratch[cursor..cursor + run]);
747            if out.len() > expected_bases {
748                return Err(FastqError::Format(
749                    "FASTA fetch produced more bases than expected".into(),
750                ));
751            }
752            cursor += run;
753        }
754        physical_offset = physical_offset
755            .checked_add(take as u64)
756            .ok_or_else(|| FastqError::Format("FASTA fetch physical offset overflowed".into()))?;
757        len -= take as u64;
758    }
759    if out.len() != expected_bases {
760        return Err(FastqError::Format(
761            "FASTA fetch produced fewer bases than expected".into(),
762        ));
763    }
764    Ok(())
765}
766
767fn seek_indexed_sequence_start<R: Seek>(
768    reader: &mut R,
769    entry: &FastaIndexEntry,
770    seq_pos: u64,
771) -> Result<()> {
772    let offset = entry.sequence_offset(seq_pos)?;
773    reader.seek(SeekFrom::Start(offset))?;
774    Ok(())
775}
776
777#[cfg(feature = "bgzf")]
778fn seek_bgzf_indexed_sequence_start<R: Read + Seek>(
779    reader: &mut BgzfSeekReader<R>,
780    bgzf_index: &BgzfIndex,
781    entry: &FastaIndexEntry,
782    seq_pos: u64,
783) -> Result<()> {
784    let offset = entry.sequence_offset(seq_pos)?;
785    let virtual_offset = bgzf_index
786        .virtual_offset_for_uncompressed_offset(offset)?
787        .ok_or_else(|| FastqError::Bgzf("BGZF span offset is not indexed".into()))?;
788    reader.seek_virtual_offset(virtual_offset)?;
789    Ok(())
790}
791
792fn indexed_chunk_capacity(start: u64, end: u64, chunk_bases: u64) -> Result<usize> {
793    usize::try_from(end.saturating_sub(start).min(chunk_bases.max(1)))
794        .map_err(|_| FastqError::Format("FASTA chunk length exceeds usize range".into()))
795}
796
797fn read_next_indexed_sequence_chunk<R: Read>(
798    reader: &mut R,
799    entry: &FastaIndexEntry,
800    start: u64,
801    end: u64,
802    chunk_bases: u64,
803    out: &mut Vec<u8>,
804    scratch: &mut Vec<u8>,
805) -> Result<u64> {
806    if entry.line_bases == 0 {
807        return Err(FastqError::Format(
808            "FASTA index entry has zero line_bases for non-empty range".into(),
809        ));
810    }
811    if entry.line_width < entry.line_bases {
812        return Err(FastqError::Format(
813            "FASTA index entry has line_width smaller than line_bases".into(),
814        ));
815    }
816
817    let chunk_end = start.saturating_add(chunk_bases.max(1)).min(end);
818    out.clear();
819    out.reserve(indexed_chunk_capacity(start, chunk_end, chunk_bases)?);
820
821    let mut pos = start;
822    while pos < chunk_end {
823        let in_line = pos % entry.line_bases;
824        let take = (chunk_end - pos).min(entry.line_bases - in_line);
825        read_exact_into_vec(reader, out, take)?;
826        pos += take;
827        if pos < end && pos.is_multiple_of(entry.line_bases) {
828            skip_indexed_separator(reader, entry.line_width - entry.line_bases, scratch)?;
829        }
830    }
831    Ok(pos)
832}
833
834fn read_exact_into_vec<R: Read>(reader: &mut R, out: &mut Vec<u8>, len: u64) -> Result<()> {
835    let len = usize::try_from(len)
836        .map_err(|_| FastqError::Format("FASTA sequence run exceeds usize range".into()))?;
837    let old_len = out.len();
838    out.resize(old_len + len, 0);
839    reader.read_exact(&mut out[old_len..])?;
840    Ok(())
841}
842
843fn skip_indexed_separator<R: Read>(
844    reader: &mut R,
845    mut len: u64,
846    scratch: &mut Vec<u8>,
847) -> Result<()> {
848    if len == 0 {
849        return Ok(());
850    }
851    if scratch.len() < INDEXED_FETCH_SCRATCH_SIZE {
852        scratch.resize(INDEXED_FETCH_SCRATCH_SIZE, 0);
853    }
854    while len > 0 {
855        let take = usize::try_from(len.min(scratch.len() as u64))
856            .map_err(|_| FastqError::Format("FASTA separator span exceeds usize range".into()))?;
857        reader.read_exact(&mut scratch[..take])?;
858        len -= take as u64;
859    }
860    Ok(())
861}
862
863struct IndexedSequenceStream<'a, R> {
864    reader: &'a mut R,
865    scratch: &'a mut Vec<u8>,
866    entry: &'a FastaIndexEntry,
867    next_offset: u64,
868    end: u64,
869    chunk_bases: u64,
870}
871
872impl<R: Read> IndexedSequenceStream<'_, R> {
873    fn next_chunk_into(&mut self, out: &mut Vec<u8>) -> Result<Option<u64>> {
874        if self.next_offset >= self.end {
875            return Ok(None);
876        }
877        let start = self.next_offset;
878        self.next_offset = read_next_indexed_sequence_chunk(
879            self.reader,
880            self.entry,
881            start,
882            self.end,
883            self.chunk_bases,
884            out,
885            self.scratch,
886        )?;
887        Ok(Some(start))
888    }
889}
890
891fn stream_indexed_reference_chunks_into<R, S>(
892    stream: &mut IndexedSequenceStream<'_, R>,
893    name: &[u8],
894    out: &mut Vec<u8>,
895    sink: &mut S,
896) -> Result<()>
897where
898    R: Read,
899    S: FastaReferenceChunkSink,
900{
901    while let Some(start) = stream.next_chunk_into(out)? {
902        sink.chunk(FastaReferenceChunkRef {
903            name,
904            global_offset: start,
905            seq: out,
906        })?;
907    }
908    Ok(())
909}
910
911/// Seekable FASTA reader backed by a `.fai` index.
912pub struct IndexedFastaReader<R> {
913    inner: R,
914    index: FastaIndex,
915    scratch: Vec<u8>,
916}
917
918impl<R: Read + Seek> IndexedFastaReader<R> {
919    /// Create a seekable FASTA reader from an input stream and index.
920    pub fn new(inner: R, index: FastaIndex) -> Self {
921        Self {
922            inner,
923            index,
924            scratch: Vec::new(),
925        }
926    }
927
928    /// Return the loaded FASTA index.
929    pub fn index(&self) -> &FastaIndex {
930        &self.index
931    }
932
933    /// Fetch a zero-based half-open sequence range into `out`.
934    pub fn fetch_into(&mut self, name: &[u8], range: Range<u64>, out: &mut Vec<u8>) -> Result<()> {
935        let entry = self.index.get(name).ok_or_else(|| {
936            FastqError::Format(format!(
937                "FASTA reference not found in index: {}",
938                String::from_utf8_lossy(name)
939            ))
940        })?;
941        let window = indexed_physical_window(entry, range.clone())?;
942        out.clear();
943        let expected_bases = usize::try_from(range.end - range.start).map_err(|_| {
944            FastqError::Format("FASTA fetch range length exceeds usize range".into())
945        })?;
946        out.reserve(expected_bases);
947        self.inner.seek(SeekFrom::Start(window.start))?;
948        copy_indexed_sequence_window(
949            &mut self.inner,
950            entry,
951            window.start,
952            window.end - window.start,
953            expected_bases,
954            out,
955            &mut self.scratch,
956        )?;
957        Ok(())
958    }
959
960    /// Fetch a zero-based half-open sequence range into an owned buffer.
961    pub fn fetch(&mut self, name: &[u8], range: Range<u64>) -> Result<Vec<u8>> {
962        let mut out = Vec::new();
963        self.fetch_into(name, range, &mut out)?;
964        Ok(out)
965    }
966
967    /// Stream owned sequence chunks for one reference range.
968    ///
969    /// `chunk_bases` values below 1 are raised to 1. Chunks are yielded as
970    /// owned buffers so callers can move them to worker threads after each
971    /// iterator step.
972    pub fn reference_chunks(
973        &mut self,
974        name: &[u8],
975        range: Range<u64>,
976        chunk_bases: u64,
977    ) -> Result<FastaReferenceChunks<'_, R>> {
978        let entry = self.index.get(name).ok_or_else(|| {
979            FastqError::Format(format!(
980                "FASTA reference not found in index: {}",
981                String::from_utf8_lossy(name)
982            ))
983        })?;
984        entry.validate_range(range.clone())?;
985        let entry = entry.clone();
986        Ok(FastaReferenceChunks {
987            reader: self,
988            name: name.to_vec(),
989            entry,
990            next_offset: range.start,
991            end: range.end,
992            chunk_bases: chunk_bases.max(1),
993            initialized: false,
994        })
995    }
996
997    /// Stream borrowed sequence chunks into a sink using caller-owned storage.
998    ///
999    /// `chunk_bases` values below 1 are raised to 1. `out` is cleared and
1000    /// reused for each chunk, and the borrowed chunk passed to `sink` is valid
1001    /// only until the next sink call or until this method returns.
1002    pub fn reference_chunks_into<S>(
1003        &mut self,
1004        name: &[u8],
1005        range: Range<u64>,
1006        chunk_bases: u64,
1007        out: &mut Vec<u8>,
1008        sink: &mut S,
1009    ) -> Result<()>
1010    where
1011        S: FastaReferenceChunkSink,
1012    {
1013        let entry = self.index.get(name).ok_or_else(|| {
1014            FastqError::Format(format!(
1015                "FASTA reference not found in index: {}",
1016                String::from_utf8_lossy(name)
1017            ))
1018        })?;
1019        entry.validate_range(range.clone())?;
1020        if range.start < range.end {
1021            seek_indexed_sequence_start(&mut self.inner, entry, range.start)?;
1022        }
1023        let mut stream = IndexedSequenceStream {
1024            reader: &mut self.inner,
1025            scratch: &mut self.scratch,
1026            entry,
1027            next_offset: range.start,
1028            end: range.end,
1029            chunk_bases,
1030        };
1031        stream_indexed_reference_chunks_into(&mut stream, name, out, sink)
1032    }
1033
1034    /// Fetch a planned partition into an owned reference chunk.
1035    pub fn fetch_partition(&mut self, partition: &FastaPartition) -> Result<FastaReferenceChunk> {
1036        self.fetch(&partition.name, partition.fetch.clone())
1037            .map(|seq| FastaReferenceChunk {
1038                name: partition.name.clone(),
1039                global_offset: partition.fetch.start,
1040                seq,
1041            })
1042    }
1043
1044    /// Return the wrapped reader and index.
1045    pub fn into_inner(self) -> (R, FastaIndex) {
1046        (self.inner, self.index)
1047    }
1048}
1049
1050/// Seekable BGZF-compressed FASTA reader backed by `.fai` and BGZF block
1051/// indexes.
1052#[cfg(feature = "bgzf")]
1053pub struct BgzfIndexedFastaReader<R> {
1054    inner: BgzfSeekReader<R>,
1055    fasta_index: FastaIndex,
1056    bgzf_index: BgzfIndex,
1057    scratch: Vec<u8>,
1058}
1059
1060#[cfg(feature = "bgzf")]
1061impl<R: Read + Seek> BgzfIndexedFastaReader<R> {
1062    /// Create a BGZF FASTA random-access reader.
1063    pub fn new(inner: R, fasta_index: FastaIndex, bgzf_index: BgzfIndex) -> Self {
1064        Self {
1065            inner: BgzfSeekReader::new(inner),
1066            fasta_index,
1067            bgzf_index,
1068            scratch: Vec::new(),
1069        }
1070    }
1071
1072    /// Return the loaded FASTA index.
1073    pub fn fasta_index(&self) -> &FastaIndex {
1074        &self.fasta_index
1075    }
1076
1077    /// Return the loaded BGZF index.
1078    pub fn bgzf_index(&self) -> &BgzfIndex {
1079        &self.bgzf_index
1080    }
1081
1082    /// Fetch a zero-based half-open sequence range into `out`.
1083    pub fn fetch_into(&mut self, name: &[u8], range: Range<u64>, out: &mut Vec<u8>) -> Result<()> {
1084        let entry = self.fasta_index.get(name).ok_or_else(|| {
1085            FastqError::Format(format!(
1086                "FASTA reference not found in index: {}",
1087                String::from_utf8_lossy(name)
1088            ))
1089        })?;
1090        let window = indexed_physical_window(entry, range.clone())?;
1091        out.clear();
1092        let expected_bases = usize::try_from(range.end - range.start).map_err(|_| {
1093            FastqError::Format("FASTA fetch range length exceeds usize range".into())
1094        })?;
1095        out.reserve(expected_bases);
1096        if expected_bases == 0 {
1097            return Ok(());
1098        }
1099        let virtual_offset = self
1100            .bgzf_index
1101            .virtual_offset_for_uncompressed_offset(window.start)?
1102            .ok_or_else(|| FastqError::Bgzf("BGZF span offset is not indexed".into()))?;
1103        self.inner.seek_virtual_offset(virtual_offset)?;
1104        copy_indexed_sequence_window(
1105            &mut self.inner,
1106            entry,
1107            window.start,
1108            window.end - window.start,
1109            expected_bases,
1110            out,
1111            &mut self.scratch,
1112        )?;
1113        Ok(())
1114    }
1115
1116    /// Fetch a zero-based half-open sequence range into an owned buffer.
1117    pub fn fetch(&mut self, name: &[u8], range: Range<u64>) -> Result<Vec<u8>> {
1118        let mut out = Vec::new();
1119        self.fetch_into(name, range, &mut out)?;
1120        Ok(out)
1121    }
1122
1123    /// Stream owned sequence chunks for one reference range.
1124    ///
1125    /// `chunk_bases` values below 1 are raised to 1. Chunks are yielded as
1126    /// owned buffers so callers can move them to worker threads after each
1127    /// iterator step.
1128    pub fn reference_chunks(
1129        &mut self,
1130        name: &[u8],
1131        range: Range<u64>,
1132        chunk_bases: u64,
1133    ) -> Result<BgzfFastaReferenceChunks<'_, R>> {
1134        let entry = self.fasta_index.get(name).ok_or_else(|| {
1135            FastqError::Format(format!(
1136                "FASTA reference not found in index: {}",
1137                String::from_utf8_lossy(name)
1138            ))
1139        })?;
1140        entry.validate_range(range.clone())?;
1141        let entry = entry.clone();
1142        Ok(BgzfFastaReferenceChunks {
1143            reader: self,
1144            name: name.to_vec(),
1145            entry,
1146            next_offset: range.start,
1147            end: range.end,
1148            chunk_bases: chunk_bases.max(1),
1149            initialized: false,
1150        })
1151    }
1152
1153    /// Stream borrowed sequence chunks into a sink using caller-owned storage.
1154    ///
1155    /// `chunk_bases` values below 1 are raised to 1. `out` is cleared and
1156    /// reused for each chunk, and the borrowed chunk passed to `sink` is valid
1157    /// only until the next sink call or until this method returns.
1158    pub fn reference_chunks_into<S>(
1159        &mut self,
1160        name: &[u8],
1161        range: Range<u64>,
1162        chunk_bases: u64,
1163        out: &mut Vec<u8>,
1164        sink: &mut S,
1165    ) -> Result<()>
1166    where
1167        S: FastaReferenceChunkSink,
1168    {
1169        let entry = self.fasta_index.get(name).ok_or_else(|| {
1170            FastqError::Format(format!(
1171                "FASTA reference not found in index: {}",
1172                String::from_utf8_lossy(name)
1173            ))
1174        })?;
1175        entry.validate_range(range.clone())?;
1176        if range.start < range.end {
1177            seek_bgzf_indexed_sequence_start(
1178                &mut self.inner,
1179                &self.bgzf_index,
1180                entry,
1181                range.start,
1182            )?;
1183        }
1184        let mut stream = IndexedSequenceStream {
1185            reader: &mut self.inner,
1186            scratch: &mut self.scratch,
1187            entry,
1188            next_offset: range.start,
1189            end: range.end,
1190            chunk_bases,
1191        };
1192        stream_indexed_reference_chunks_into(&mut stream, name, out, sink)
1193    }
1194
1195    /// Fetch a planned partition into an owned reference chunk.
1196    pub fn fetch_partition(&mut self, partition: &FastaPartition) -> Result<FastaReferenceChunk> {
1197        self.fetch(&partition.name, partition.fetch.clone())
1198            .map(|seq| FastaReferenceChunk {
1199                name: partition.name.clone(),
1200                global_offset: partition.fetch.start,
1201                seq,
1202            })
1203    }
1204}
1205
1206impl Default for FastaStats {
1207    fn default() -> Self {
1208        Self {
1209            records: 0,
1210            bases: 0,
1211            checksum: FASTA_STATS_CHECKSUM_INIT,
1212        }
1213    }
1214}
1215
1216impl FastaStats {
1217    /// Observe one sequence.
1218    pub fn observe_sequence(&mut self, seq: &[u8]) {
1219        self.observe_sequence_parts(
1220            seq.len() as u64,
1221            seq.first().copied().unwrap_or_default(),
1222            seq.last().copied().unwrap_or_default(),
1223        );
1224    }
1225
1226    fn observe_sequence_parts(&mut self, len: u64, first: u8, last: u8) {
1227        self.records += 1;
1228        self.bases += len;
1229        self.checksum ^= len;
1230        self.checksum = self
1231            .checksum
1232            .rotate_left(5)
1233            .wrapping_mul(0x0000_0100_0000_01b3);
1234        self.checksum ^= first as u64;
1235        self.checksum ^= (last as u64) << 8;
1236    }
1237}
1238
1239/// Sink trait for FASTA record visitors.
1240pub trait FastaRecordSink {
1241    /// Consume one borrowed FASTA record.
1242    fn record(&mut self, record: FastaVisitRecord<'_>) -> Result<()>;
1243}
1244
1245impl<F> FastaRecordSink for F
1246where
1247    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
1248{
1249    fn record(&mut self, record: FastaVisitRecord<'_>) -> Result<()> {
1250        self(record)
1251    }
1252}
1253
1254/// A batch of borrowed FASTA records.
1255#[derive(Debug)]
1256pub struct FastaBatch<'a> {
1257    bytes: &'a [u8],
1258    records: &'a [FastaRecordRef],
1259    id_tokens: &'a [ByteRange],
1260    first_record_index: u64,
1261}
1262
1263impl<'a> FastaBatch<'a> {
1264    /// Number of records in the batch.
1265    pub fn len(&self) -> usize {
1266        self.records.len()
1267    }
1268
1269    /// Whether the batch has no records.
1270    pub fn is_empty(&self) -> bool {
1271        self.records.is_empty()
1272    }
1273
1274    /// Raw bytes backing this batch.
1275    pub fn bytes(&self) -> &'a [u8] {
1276        self.bytes
1277    }
1278
1279    /// Record ranges within [`bytes`](Self::bytes).
1280    pub fn record_refs(&self) -> &'a [FastaRecordRef] {
1281        self.records
1282    }
1283
1284    /// Zero-based index of the first record in this batch.
1285    pub fn first_record_index(&self) -> u64 {
1286        self.first_record_index
1287    }
1288
1289    /// Iterate borrowed records in this batch.
1290    pub fn records(&self) -> impl Iterator<Item = FastaRecord<'_>> {
1291        self.records
1292            .iter()
1293            .zip(self.id_tokens.iter().cloned())
1294            .map(|(record, id_token)| FastaRecord {
1295                bytes: self.bytes,
1296                record,
1297                id_token,
1298            })
1299    }
1300
1301    /// Copy this borrowed batch into an owned transferable batch.
1302    pub fn to_owned_batch(&self) -> OwnedFastaBatch {
1303        OwnedFastaBatch {
1304            bytes: self.bytes.to_vec(),
1305            records: self.records.to_vec(),
1306            id_tokens: self.id_tokens.to_vec(),
1307            first_record_index: self.first_record_index,
1308        }
1309    }
1310}
1311
1312/// Owned FASTA batch that can be moved to worker threads.
1313#[derive(Debug, Clone, PartialEq, Eq)]
1314pub struct OwnedFastaBatch {
1315    bytes: Vec<u8>,
1316    records: Vec<FastaRecordRef>,
1317    id_tokens: Vec<ByteRange>,
1318    first_record_index: u64,
1319}
1320
1321impl OwnedFastaBatch {
1322    /// Number of records in the batch.
1323    pub fn len(&self) -> usize {
1324        self.records.len()
1325    }
1326
1327    /// Whether the batch has no records.
1328    pub fn is_empty(&self) -> bool {
1329        self.records.is_empty()
1330    }
1331
1332    /// Raw bytes backing this batch.
1333    pub fn bytes(&self) -> &[u8] {
1334        &self.bytes
1335    }
1336
1337    /// Record ranges within [`bytes`](Self::bytes).
1338    pub fn record_refs(&self) -> &[FastaRecordRef] {
1339        &self.records
1340    }
1341
1342    /// Zero-based index of the first record in this batch.
1343    pub fn first_record_index(&self) -> u64 {
1344        self.first_record_index
1345    }
1346
1347    /// Iterate borrowed records in this owned batch.
1348    pub fn records(&self) -> impl Iterator<Item = OwnedFastaRecord<'_>> {
1349        self.records
1350            .iter()
1351            .zip(self.id_tokens.iter().cloned())
1352            .map(|(record, id_token)| OwnedFastaRecord {
1353                bytes: &self.bytes,
1354                record,
1355                id_token,
1356            })
1357    }
1358}
1359
1360/// Borrowed record view over an [`OwnedFastaBatch`].
1361#[derive(Debug, Clone, Copy)]
1362pub struct OwnedFastaRecord<'a> {
1363    bytes: &'a [u8],
1364    record: &'a FastaRecordRef,
1365    id_token: ByteRange,
1366}
1367
1368impl<'a> OwnedFastaRecord<'a> {
1369    /// Return the header line including the leading `>`.
1370    pub fn name(self) -> &'a [u8] {
1371        &self.bytes[to_usize(self.record.name.clone())]
1372    }
1373
1374    /// Return the header line without a leading `>`.
1375    pub fn name_without_gt(self) -> &'a [u8] {
1376        let name = self.name();
1377        name.strip_prefix(b">").unwrap_or(name)
1378    }
1379
1380    /// Return the first whitespace-delimited identifier token.
1381    pub fn id_token(self) -> &'a [u8] {
1382        &self.bytes[self.id_token.to_usize()]
1383    }
1384
1385    /// Return the concatenated sequence bytes.
1386    pub fn seq(self) -> &'a [u8] {
1387        &self.bytes[to_usize(self.record.seq.clone())]
1388    }
1389}
1390
1391/// Slab-style streaming FASTA reader over any [`Read`] input.
1392///
1393/// The reader accepts ordinary multiline FASTA. Header lines must begin with
1394/// `>`, sequence lines are concatenated without newline bytes, and blank lines
1395/// before the first record or between records are ignored.
1396#[derive(Debug)]
1397pub struct FastaReader<R> {
1398    reader: BufReader<R>,
1399    config: FastaConfig,
1400    bytes: Vec<u8>,
1401    records: Vec<FastaRecordRef>,
1402    id_tokens: Vec<ByteRange>,
1403    line: Vec<u8>,
1404    pending_header: Option<PendingHeader>,
1405    byte_offset: u64,
1406    record_index: u64,
1407    eof: bool,
1408}
1409
1410#[derive(Debug)]
1411struct PendingHeader {
1412    bytes: Vec<u8>,
1413    byte_offset: u64,
1414}
1415
1416impl<R: Read> FastaReader<R> {
1417    /// Create a reader with [`FastaConfig::default`].
1418    pub fn new(reader: R) -> Self {
1419        Self::with_config(reader, FastaConfig::default())
1420    }
1421
1422    /// Create a reader with explicit configuration.
1423    pub fn with_config(reader: R, config: FastaConfig) -> Self {
1424        let config = FastaConfig {
1425            batch_records: config.batch_records.max(1),
1426            buffer_size: config.buffer_size.max(1024),
1427            expected_seq_len: config.expected_seq_len,
1428        };
1429        let seq_capacity = config
1430            .batch_records
1431            .saturating_mul(config.expected_seq_len)
1432            .min(8 * 1024 * 1024);
1433        Self {
1434            reader: BufReader::with_capacity(config.buffer_size, reader),
1435            bytes: Vec::with_capacity(seq_capacity),
1436            records: Vec::with_capacity(config.batch_records),
1437            id_tokens: Vec::with_capacity(config.batch_records),
1438            config,
1439            line: Vec::new(),
1440            pending_header: None,
1441            byte_offset: 0,
1442            record_index: 0,
1443            eof: false,
1444        }
1445    }
1446
1447    /// Return the wrapped reader.
1448    pub fn into_inner(self) -> R {
1449        self.reader.into_inner()
1450    }
1451
1452    /// Read the next batch of FASTA records.
1453    ///
1454    /// Returns `Ok(None)` at EOF. A malformed non-empty line before the first
1455    /// header is reported as a format error.
1456    pub fn next_batch(&mut self) -> Result<Option<FastaBatch<'_>>> {
1457        self.bytes.clear();
1458        self.records.clear();
1459        self.id_tokens.clear();
1460        let first_record_index = self.record_index;
1461
1462        while self.records.len() < self.config.batch_records {
1463            let Some(header) = self.next_header()? else {
1464                break;
1465            };
1466            self.push_record(header)?;
1467        }
1468
1469        if self.records.is_empty() {
1470            return Ok(None);
1471        }
1472
1473        self.record_index += self.records.len() as u64;
1474        Ok(Some(FastaBatch {
1475            bytes: &self.bytes,
1476            records: &self.records,
1477            id_tokens: &self.id_tokens,
1478            first_record_index,
1479        }))
1480    }
1481
1482    /// Read the next FASTA batch into an owned transferable buffer.
1483    pub fn next_owned_batch(&mut self) -> Result<Option<OwnedFastaBatch>> {
1484        self.next_batch()
1485            .map(|batch| batch.map(|batch| batch.to_owned_batch()))
1486    }
1487
1488    /// Visit every FASTA record in the stream.
1489    ///
1490    /// This is a convenience path for single-pass consumers. It avoids the batch
1491    /// record and identifier side tables used by [`next_batch`](Self::next_batch).
1492    pub fn visit_records<F>(&mut self, mut visit: F) -> Result<()>
1493    where
1494        F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
1495    {
1496        self.visit_records_with_sink(&mut visit)
1497    }
1498
1499    /// Visit every FASTA record using a sink implementation.
1500    pub fn visit_records_with_sink<S>(&mut self, sink: &mut S) -> Result<()>
1501    where
1502        S: FastaRecordSink,
1503    {
1504        while let Some(header) = self.next_header()? {
1505            self.visit_record(header, sink)?;
1506            self.record_index += 1;
1507        }
1508        Ok(())
1509    }
1510
1511    /// Count all records and bases in an ordinary FASTA stream.
1512    ///
1513    /// This uses the robust multiline FASTA parser, so it accepts wrapped
1514    /// sequence records and blank lines in the same way as
1515    /// [`next_batch`](Self::next_batch).
1516    pub fn stats(&mut self) -> Result<FastaStats> {
1517        let mut stats = FastaStats::default();
1518        while let Some(header) = self.next_header()? {
1519            self.observe_record_stats(header, &mut stats)?;
1520            self.record_index += 1;
1521        }
1522        Ok(stats)
1523    }
1524
1525    fn observe_record_stats(
1526        &mut self,
1527        _header: PendingHeader,
1528        stats: &mut FastaStats,
1529    ) -> Result<()> {
1530        let record_index = self.record_index;
1531        let mut bases = 0_u64;
1532        let mut first = 0_u8;
1533        let mut last = 0_u8;
1534
1535        loop {
1536            let line_start = self.byte_offset;
1537            let n = self.read_line()?;
1538            if n == 0 {
1539                self.eof = true;
1540                break;
1541            }
1542            let trimmed = trim_line(&self.line);
1543            if trimmed.starts_with(b">") {
1544                validate_header(trimmed, line_start, record_index + 1)?;
1545                self.pending_header = Some(PendingHeader {
1546                    bytes: trimmed.to_vec(),
1547                    byte_offset: line_start,
1548                });
1549                break;
1550            }
1551            if trimmed.is_empty() {
1552                continue;
1553            }
1554            if bases == 0 {
1555                first = trimmed[0];
1556            }
1557            last = *trimmed.last().unwrap_or(&0);
1558            bases += trimmed.len() as u64;
1559        }
1560
1561        stats.observe_sequence_parts(bases, first, last);
1562        Ok(())
1563    }
1564
1565    fn visit_record<S>(&mut self, header: PendingHeader, sink: &mut S) -> Result<()>
1566    where
1567        S: FastaRecordSink,
1568    {
1569        let record_index = self.record_index;
1570        self.bytes.clear();
1571
1572        loop {
1573            let line_start = self.byte_offset;
1574            let n = self.read_line()?;
1575            if n == 0 {
1576                self.eof = true;
1577                break;
1578            }
1579            let trimmed = trim_line(&self.line);
1580            if trimmed.starts_with(b">") {
1581                validate_header(trimmed, line_start, record_index + 1)?;
1582                self.pending_header = Some(PendingHeader {
1583                    bytes: trimmed.to_vec(),
1584                    byte_offset: line_start,
1585                });
1586                break;
1587            }
1588            if trimmed.is_empty() {
1589                continue;
1590            }
1591            self.bytes.extend_from_slice(trimmed);
1592        }
1593
1594        let _ = header.byte_offset;
1595        sink.record(FastaVisitRecord {
1596            name: &header.bytes,
1597            seq: &self.bytes,
1598        })
1599    }
1600
1601    fn next_header(&mut self) -> Result<Option<PendingHeader>> {
1602        if let Some(header) = self.pending_header.take() {
1603            return Ok(Some(header));
1604        }
1605        if self.eof {
1606            return Ok(None);
1607        }
1608
1609        loop {
1610            let line_start = self.byte_offset;
1611            let n = self.read_line()?;
1612            if n == 0 {
1613                self.eof = true;
1614                return Ok(None);
1615            }
1616            let trimmed = trim_line(&self.line);
1617            if trimmed.is_empty() {
1618                continue;
1619            }
1620            if !trimmed.starts_with(b">") {
1621                return Err(format_at(
1622                    "FASTA record header must start with `>`",
1623                    line_start,
1624                    self.record_index,
1625                ));
1626            }
1627            validate_header(trimmed, line_start, self.record_index)?;
1628            return Ok(Some(PendingHeader {
1629                bytes: trimmed.to_vec(),
1630                byte_offset: line_start,
1631            }));
1632        }
1633    }
1634
1635    fn push_record(&mut self, header: PendingHeader) -> Result<()> {
1636        let record_index = self.record_index + self.records.len() as u64;
1637        let name_start = checked_u32(self.bytes.len())?;
1638        self.bytes.extend_from_slice(&header.bytes);
1639        let name_end = checked_u32(self.bytes.len())?;
1640        let id_token = id_token_range(&self.bytes, name_start..name_end)?;
1641        let seq_start = checked_u32(self.bytes.len())?;
1642
1643        loop {
1644            let line_start = self.byte_offset;
1645            let n = self.read_line()?;
1646            if n == 0 {
1647                self.eof = true;
1648                break;
1649            }
1650            let trimmed = trim_line(&self.line);
1651            if trimmed.starts_with(b">") {
1652                validate_header(trimmed, line_start, record_index + 1)?;
1653                self.pending_header = Some(PendingHeader {
1654                    bytes: trimmed.to_vec(),
1655                    byte_offset: line_start,
1656                });
1657                break;
1658            }
1659            if trimmed.is_empty() {
1660                continue;
1661            }
1662            self.bytes.extend_from_slice(trimmed);
1663        }
1664
1665        let seq_end = checked_u32(self.bytes.len())?;
1666        self.records.push(FastaRecordRef {
1667            name: name_start..name_end,
1668            seq: seq_start..seq_end,
1669        });
1670        self.id_tokens.push(id_token);
1671        let _ = header.byte_offset;
1672        Ok(())
1673    }
1674
1675    fn read_line(&mut self) -> Result<usize> {
1676        self.line.clear();
1677        let n = self.reader.read_until(b'\n', &mut self.line)?;
1678        self.byte_offset += n as u64;
1679        Ok(n)
1680    }
1681}
1682
1683/// Count records and bases from an ordinary FASTA stream.
1684///
1685/// Unlike [`count_two_line_fasta_read`], this accepts wrapped/multiline FASTA
1686/// using the robust [`FastaReader`] parser.
1687pub fn count_fasta_read<R: Read>(reader: R) -> Result<FastaStats> {
1688    let mut reader = BufReader::with_capacity(DEFAULT_READER_BUFFER_SIZE, reader);
1689    count_fasta_bufread(&mut reader)
1690}
1691
1692fn count_fasta_bufread<R: BufRead>(reader: &mut R) -> Result<FastaStats> {
1693    let mut scanner = FastaCountScanner::default();
1694    let mut carry = Vec::new();
1695    let mut carry_start = 0_u64;
1696    let mut byte_offset = 0_u64;
1697
1698    loop {
1699        let available = reader.fill_buf()?;
1700        if available.is_empty() {
1701            break;
1702        }
1703
1704        let mut consumed = 0;
1705        while consumed < available.len() {
1706            let line_start = byte_offset + consumed as u64;
1707            let Some(relative_newline) = memchr(b'\n', &available[consumed..]) else {
1708                if carry.is_empty() {
1709                    carry_start = line_start;
1710                }
1711                carry.extend_from_slice(&available[consumed..]);
1712                consumed = available.len();
1713                break;
1714            };
1715
1716            let line_end = consumed + relative_newline;
1717            if carry.is_empty() {
1718                scanner.observe_line(trim_line(&available[consumed..line_end]), line_start)?;
1719            } else {
1720                carry.extend_from_slice(&available[consumed..line_end]);
1721                scanner.observe_line(trim_line(&carry), carry_start)?;
1722                carry.clear();
1723            }
1724            consumed = line_end + 1;
1725        }
1726
1727        reader.consume(consumed);
1728        byte_offset += consumed as u64;
1729    }
1730
1731    if !carry.is_empty() {
1732        scanner.observe_line(trim_line(&carry), carry_start)?;
1733    }
1734
1735    scanner.finish()
1736}
1737
1738#[derive(Debug, Default)]
1739struct FastaCountScanner {
1740    stats: FastaStats,
1741    in_record: bool,
1742    record_bases: u64,
1743    first_base: u8,
1744    last_base: u8,
1745    record_index: u64,
1746}
1747
1748impl FastaCountScanner {
1749    fn observe_line(&mut self, line: &[u8], line_start: u64) -> Result<()> {
1750        if line.is_empty() {
1751            return Ok(());
1752        }
1753
1754        if line.starts_with(b">") {
1755            self.finish_record();
1756            validate_header(line, line_start, self.record_index)?;
1757            self.in_record = true;
1758            return Ok(());
1759        }
1760
1761        if !self.in_record {
1762            return Err(format_at(
1763                "FASTA record header must start with `>`",
1764                line_start,
1765                self.record_index,
1766            ));
1767        }
1768
1769        if self.record_bases == 0 {
1770            self.first_base = line[0];
1771        }
1772        self.last_base = line.last().copied().unwrap_or_default();
1773        self.record_bases += line.len() as u64;
1774        Ok(())
1775    }
1776
1777    fn finish(mut self) -> Result<FastaStats> {
1778        self.finish_record();
1779        Ok(self.stats)
1780    }
1781
1782    fn finish_record(&mut self) {
1783        if !self.in_record {
1784            return;
1785        }
1786        self.stats
1787            .observe_sequence_parts(self.record_bases, self.first_base, self.last_base);
1788        self.record_index += 1;
1789        self.in_record = false;
1790        self.record_bases = 0;
1791        self.first_base = 0;
1792        self.last_base = 0;
1793    }
1794}
1795
1796/// Count records and bases from resident FASTA bytes.
1797///
1798/// This accepts ordinary wrapped/multiline FASTA and shares validation behavior
1799/// with [`visit_fasta_bytes`].
1800pub fn count_fasta_bytes(bytes: &[u8]) -> Result<FastaStats> {
1801    let mut scanner = FastaCountScanner::default();
1802    let mut cursor = 0;
1803
1804    while cursor < bytes.len() {
1805        let line_start = cursor as u64;
1806        let line = next_trimmed_line(bytes, &mut cursor);
1807        scanner.observe_line(line, line_start)?;
1808    }
1809
1810    scanner.finish()
1811}
1812
1813/// Build a `.fai`-style index over an ordinary FASTA stream.
1814///
1815/// The resulting offsets are byte offsets in the uncompressed FASTA stream.
1816/// Sequence records must use consistent wrapping: every non-final sequence line
1817/// for a record must have the same base count and byte width.
1818pub fn build_fasta_index<R: Read>(reader: R) -> Result<FastaIndex> {
1819    let mut builder = FastaIndexBuilder::default();
1820    let mut reader = BufReader::new(reader);
1821    build_fasta_index_bufread(&mut reader, &mut builder)?;
1822    Ok(builder.finish())
1823}
1824
1825/// Build a `.fai`-style index over a complete BGZF-compressed FASTA stream.
1826///
1827/// The standard `.fai` offsets remain uncompressed byte offsets. Each entry also
1828/// carries a BGZF virtual offset for the first sequence byte, allowing callers
1829/// to pair the index with [`crate::BgzfSeekReader`].
1830#[cfg(feature = "bgzf")]
1831pub fn build_fasta_index_bgzf<R: Read>(reader: R) -> Result<FastaIndex> {
1832    let mut block_reader = BgzfDecodedBlockReader::new(reader);
1833    let mut builder = FastaIndexBuilder::default();
1834    let mut bgzf_entries = Vec::new();
1835    let mut line = Vec::new();
1836
1837    while let Some(block) = block_reader.next_block()? {
1838        bgzf_entries.push(block.index_entry());
1839        observe_fasta_index_chunk(block.bytes(), &mut line, &mut builder)?;
1840    }
1841    if !line.is_empty() {
1842        builder.observe_physical_line(&line)?;
1843    }
1844    builder.finish_current()?;
1845
1846    let mut index = builder.finish();
1847    for entry in &mut index.entries {
1848        entry.virtual_offset = bgzf_virtual_offset_for(&bgzf_entries, entry.offset)?;
1849    }
1850    Ok(index)
1851}
1852
1853#[cfg(feature = "bgzf")]
1854fn bgzf_virtual_offset_for(
1855    entries: &[BgzfIndexEntry],
1856    offset: u64,
1857) -> Result<Option<BgzfVirtualOffset>> {
1858    let idx = entries.partition_point(|entry| entry.uncompressed_offset <= offset);
1859    let Some(entry) = idx.checked_sub(1).and_then(|idx| entries.get(idx)) else {
1860        return Ok(None);
1861    };
1862    entry.virtual_offset_for(offset)
1863}
1864
1865#[derive(Debug, Default)]
1866struct FastaIndexBuilder {
1867    entries: Vec<FastaIndexEntry>,
1868    seen_names: HashSet<Vec<u8>>,
1869    current: Option<FastaIndexRecord>,
1870    byte_offset: u64,
1871}
1872
1873#[derive(Debug)]
1874struct FastaIndexRecord {
1875    name: Vec<u8>,
1876    len: u64,
1877    offset: Option<u64>,
1878    line_bases: Option<u64>,
1879    line_width: Option<u64>,
1880    last_line_bases: Option<u64>,
1881    last_line_width: Option<u64>,
1882    record_index: u64,
1883}
1884
1885fn build_fasta_index_bufread<R: BufRead>(
1886    reader: &mut R,
1887    builder: &mut FastaIndexBuilder,
1888) -> Result<()> {
1889    let mut line = Vec::new();
1890    loop {
1891        let available = reader.fill_buf()?;
1892        if available.is_empty() {
1893            break;
1894        }
1895        let consumed = observe_fasta_index_chunk(available, &mut line, builder)?;
1896        reader.consume(consumed);
1897    }
1898    if !line.is_empty() {
1899        builder.observe_physical_line(&line)?;
1900    }
1901    builder.finish_current()?;
1902    Ok(())
1903}
1904
1905fn observe_fasta_index_chunk(
1906    bytes: &[u8],
1907    carry: &mut Vec<u8>,
1908    builder: &mut FastaIndexBuilder,
1909) -> Result<usize> {
1910    let mut consumed = 0;
1911    while consumed < bytes.len() {
1912        let Some(relative_newline) = memchr(b'\n', &bytes[consumed..]) else {
1913            carry.extend_from_slice(&bytes[consumed..]);
1914            return Ok(bytes.len());
1915        };
1916        let line_end = consumed + relative_newline + 1;
1917        if carry.is_empty() {
1918            builder.observe_physical_line(&bytes[consumed..line_end])?;
1919        } else {
1920            carry.extend_from_slice(&bytes[consumed..line_end]);
1921            builder.observe_physical_line(carry)?;
1922            carry.clear();
1923        }
1924        consumed = line_end;
1925    }
1926    Ok(consumed)
1927}
1928
1929impl FastaIndexBuilder {
1930    fn observe_physical_line(&mut self, line: &[u8]) -> Result<()> {
1931        let line_start = self.byte_offset;
1932        self.byte_offset += line.len() as u64;
1933        let trimmed = trim_line(line);
1934        if trimmed.is_empty() {
1935            self.observe_blank(line_start)
1936        } else if trimmed.starts_with(b">") {
1937            self.start_record(trimmed, line_start)
1938        } else {
1939            self.observe_sequence_line(trimmed.len() as u64, line.len() as u64, line_start)
1940        }
1941    }
1942
1943    fn start_record(&mut self, header: &[u8], byte_offset: u64) -> Result<()> {
1944        self.finish_current()?;
1945        validate_header(header, byte_offset, self.entries.len() as u64)?;
1946        let name = fasta_index_name(header);
1947        if self.seen_names.contains(name) {
1948            return Err(format_at(
1949                "duplicate FASTA index reference name",
1950                byte_offset,
1951                self.entries.len() as u64,
1952            ));
1953        }
1954        self.seen_names.insert(name.to_vec());
1955        self.current = Some(FastaIndexRecord {
1956            name: name.to_vec(),
1957            len: 0,
1958            offset: None,
1959            line_bases: None,
1960            line_width: None,
1961            last_line_bases: None,
1962            last_line_width: None,
1963            record_index: self.entries.len() as u64,
1964        });
1965        Ok(())
1966    }
1967
1968    fn observe_blank(&mut self, byte_offset: u64) -> Result<()> {
1969        if self
1970            .current
1971            .as_ref()
1972            .and_then(|record| record.offset)
1973            .is_some()
1974        {
1975            return Err(format_at(
1976                "FASTA index does not support blank sequence lines",
1977                byte_offset,
1978                self.current
1979                    .as_ref()
1980                    .map_or(0, |record| record.record_index),
1981            ));
1982        }
1983        Ok(())
1984    }
1985
1986    fn observe_sequence_line(&mut self, bases: u64, width: u64, byte_offset: u64) -> Result<()> {
1987        let Some(record) = self.current.as_mut() else {
1988            return Err(format_at(
1989                "FASTA record header must start with `>`",
1990                byte_offset,
1991                self.entries.len() as u64,
1992            ));
1993        };
1994        if record.offset.is_none() {
1995            record.offset = Some(byte_offset);
1996            record.line_bases = Some(bases);
1997            record.line_width = Some(width);
1998        } else if let (Some(last_bases), Some(last_width)) =
1999            (record.last_line_bases, record.last_line_width)
2000        {
2001            let expected_bases = record.line_bases.unwrap_or(last_bases);
2002            let expected_width = record.line_width.unwrap_or(last_width);
2003            if bases > expected_bases {
2004                return Err(format_at(
2005                    "FASTA final sequence line is longer than the first sequence line",
2006                    byte_offset,
2007                    record.record_index,
2008                ));
2009            }
2010            if last_bases != expected_bases || last_width != expected_width {
2011                return Err(format_at(
2012                    "non-final FASTA sequence line has inconsistent wrapping",
2013                    byte_offset,
2014                    record.record_index,
2015                ));
2016            }
2017        }
2018        record.len += bases;
2019        record.last_line_bases = Some(bases);
2020        record.last_line_width = Some(width);
2021        Ok(())
2022    }
2023
2024    fn finish_current(&mut self) -> Result<()> {
2025        let Some(record) = self.current.take() else {
2026            return Ok(());
2027        };
2028        let offset = record.offset.unwrap_or(self.byte_offset);
2029        let line_bases = record.line_bases.unwrap_or(0);
2030        let line_width = record.line_width.unwrap_or(0);
2031        self.entries.push(FastaIndexEntry {
2032            name: record.name,
2033            len: record.len,
2034            offset,
2035            line_bases,
2036            line_width,
2037            #[cfg(feature = "bgzf")]
2038            virtual_offset: None,
2039        });
2040        Ok(())
2041    }
2042
2043    fn finish(mut self) -> FastaIndex {
2044        let name_to_index = self
2045            .entries
2046            .iter()
2047            .enumerate()
2048            .map(|(idx, entry)| (entry.name.clone(), idx))
2049            .collect();
2050        FastaIndex {
2051            entries: std::mem::take(&mut self.entries),
2052            name_to_index,
2053        }
2054    }
2055}
2056
2057fn fasta_index_name(header: &[u8]) -> &[u8] {
2058    let name = header.strip_prefix(b">").unwrap_or(header);
2059    let end = name
2060        .iter()
2061        .position(u8::is_ascii_whitespace)
2062        .unwrap_or(name.len());
2063    &name[..end]
2064}
2065
2066/// Visit records from an already resident FASTA byte slice.
2067///
2068/// This path is intended for memory-mapped files, cached datasets, and other
2069/// callers that already own a complete FASTA byte buffer. Single-line
2070/// sequences are borrowed directly from the input. Multiline sequences are
2071/// folded into one reusable scratch buffer before the visitor is called.
2072///
2073/// Returns the number of visited records.
2074pub fn visit_fasta_bytes<F>(bytes: &[u8], mut visit: F) -> Result<u64>
2075where
2076    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2077{
2078    let mut cursor = 0;
2079    let mut pending_header = None;
2080    let mut record_index = 0;
2081    let mut folded = Vec::new();
2082
2083    while let Some((header_offset, header)) =
2084        take_next_header(bytes, &mut cursor, &mut pending_header, record_index)?
2085    {
2086        validate_header(header, header_offset, record_index)?;
2087
2088        folded.clear();
2089        let mut first_seq = None;
2090
2091        loop {
2092            if cursor >= bytes.len() {
2093                break;
2094            }
2095            let line_start = cursor as u64;
2096            let line = next_trimmed_line(bytes, &mut cursor);
2097            if line.starts_with(b">") {
2098                validate_header(line, line_start, record_index + 1)?;
2099                pending_header = Some((line_start, line));
2100                break;
2101            }
2102            if line.is_empty() {
2103                continue;
2104            }
2105
2106            first_seq = Some(line);
2107            break;
2108        }
2109
2110        let Some(first_seq) = first_seq else {
2111            visit(FastaVisitRecord {
2112                name: header,
2113                seq: b"",
2114            })?;
2115            record_index += 1;
2116            continue;
2117        };
2118
2119        if cursor >= bytes.len() || bytes[cursor] == b'>' {
2120            visit(FastaVisitRecord {
2121                name: header,
2122                seq: first_seq,
2123            })?;
2124            record_index += 1;
2125            continue;
2126        }
2127
2128        let mut seq_line_count = 1_usize;
2129        loop {
2130            if cursor >= bytes.len() {
2131                break;
2132            }
2133            let line_start = cursor as u64;
2134            let line = next_trimmed_line(bytes, &mut cursor);
2135            if line.starts_with(b">") {
2136                validate_header(line, line_start, record_index + 1)?;
2137                pending_header = Some((line_start, line));
2138                break;
2139            }
2140            if line.is_empty() {
2141                continue;
2142            }
2143
2144            seq_line_count += 1;
2145            if seq_line_count == 2 {
2146                folded.extend_from_slice(first_seq);
2147            }
2148            folded.extend_from_slice(line);
2149        }
2150
2151        let seq = if seq_line_count == 1 {
2152            first_seq
2153        } else {
2154            &folded
2155        };
2156        visit(FastaVisitRecord { name: header, seq })?;
2157        record_index += 1;
2158    }
2159
2160    Ok(record_index)
2161}
2162
2163/// Detect whether resident FASTA bytes are strict two-line FASTA.
2164///
2165/// The detector validates headers enough to reject non-FASTA leading content
2166/// and empty headers. It returns [`FastaShape::Multiline`] for valid FASTA that
2167/// needs the robust parser, including blank lines between records.
2168pub fn detect_fasta_shape(bytes: &[u8]) -> Result<FastaShape> {
2169    let mut cursor = 0;
2170    let mut saw_record = false;
2171
2172    while cursor < bytes.len() {
2173        let header_offset = cursor as u64;
2174        let header = next_trimmed_line(bytes, &mut cursor);
2175        if header.is_empty() {
2176            if saw_record {
2177                return Ok(FastaShape::Multiline);
2178            }
2179            continue;
2180        }
2181        if !header.starts_with(b">") {
2182            return Err(format_at(
2183                "FASTA record header must start with `>`",
2184                header_offset,
2185                0,
2186            ));
2187        }
2188        validate_header(header, header_offset, 0)?;
2189        saw_record = true;
2190
2191        let mut seq_lines = 0_usize;
2192        while cursor < bytes.len() {
2193            let checkpoint = cursor;
2194            let line = next_trimmed_line(bytes, &mut cursor);
2195            if line.starts_with(b">") {
2196                cursor = checkpoint;
2197                break;
2198            }
2199            if line.is_empty() {
2200                return Ok(FastaShape::Multiline);
2201            }
2202            seq_lines += 1;
2203            if seq_lines > 1 {
2204                return Ok(FastaShape::Multiline);
2205            }
2206        }
2207        if seq_lines != 1 {
2208            return Ok(FastaShape::Multiline);
2209        }
2210    }
2211
2212    if saw_record {
2213        Ok(FastaShape::TwoLine)
2214    } else {
2215        Ok(FastaShape::Empty)
2216    }
2217}
2218
2219/// Visit resident FASTA bytes with automatic two-line fast-path detection.
2220pub fn visit_fasta_bytes_auto<F>(bytes: &[u8], visit: F) -> Result<u64>
2221where
2222    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2223{
2224    match detect_fasta_shape(bytes)? {
2225        FastaShape::TwoLine => visit_two_line_fasta_bytes(bytes, visit),
2226        FastaShape::Empty | FastaShape::Multiline => visit_fasta_bytes(bytes, visit),
2227    }
2228}
2229
2230/// Count strict two-line FASTA records from resident bytes.
2231///
2232/// This is the lightest resident path for canonical `>header\nsequence\n`
2233/// FASTA when callers only need record counts, total bases, and a deterministic
2234/// shape checksum. It validates the same strict two-line structure as
2235/// [`visit_two_line_fasta_bytes`].
2236pub fn count_two_line_fasta_bytes(bytes: &[u8]) -> Result<FastaStats> {
2237    let mut cursor = 0;
2238    let mut record_index = 0;
2239    let mut stats = FastaStats::default();
2240
2241    while cursor < bytes.len() {
2242        let header_offset = cursor as u64;
2243        if bytes[cursor] != b'>' {
2244            return Err(format_at(
2245                "two-line FASTA record header must start with `>`",
2246                header_offset,
2247                record_index,
2248            ));
2249        }
2250        let header = next_trimmed_line(bytes, &mut cursor);
2251        validate_header(header, header_offset, record_index)?;
2252        if cursor >= bytes.len() {
2253            return Err(format_at(
2254                "two-line FASTA record is missing a sequence line",
2255                cursor as u64,
2256                record_index,
2257            ));
2258        }
2259
2260        let seq_offset = cursor as u64;
2261        let seq = next_trimmed_line(bytes, &mut cursor);
2262        if seq.is_empty() || seq.starts_with(b">") {
2263            return Err(format_at(
2264                "two-line FASTA record is missing a sequence line",
2265                seq_offset,
2266                record_index,
2267            ));
2268        }
2269        if cursor < bytes.len() && bytes[cursor] != b'>' {
2270            return Err(format_at(
2271                "two-line FASTA sequence must be followed by a header",
2272                cursor as u64,
2273                record_index + 1,
2274            ));
2275        }
2276
2277        stats.observe_sequence(seq);
2278        record_index += 1;
2279    }
2280
2281    Ok(stats)
2282}
2283
2284/// Visit records from a resident, strict two-line FASTA byte slice.
2285///
2286/// This is the fastest resident FASTA path for canonical files shaped as
2287/// `>header\nsequence\n` repeated. It rejects blank lines and multiline
2288/// sequence records. Use [`visit_fasta_bytes`] when ordinary multiline FASTA
2289/// support is required.
2290///
2291/// Returns the number of visited records.
2292pub fn visit_two_line_fasta_bytes<F>(bytes: &[u8], mut visit: F) -> Result<u64>
2293where
2294    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2295{
2296    let mut cursor = 0;
2297    let mut record_index = 0;
2298
2299    while cursor < bytes.len() {
2300        let header_offset = cursor as u64;
2301        if bytes[cursor] != b'>' {
2302            return Err(format_at(
2303                "two-line FASTA record header must start with `>`",
2304                header_offset,
2305                record_index,
2306            ));
2307        }
2308        let header = next_trimmed_line(bytes, &mut cursor);
2309        validate_header(header, header_offset, record_index)?;
2310        if cursor >= bytes.len() {
2311            return Err(format_at(
2312                "two-line FASTA record is missing a sequence line",
2313                cursor as u64,
2314                record_index,
2315            ));
2316        }
2317
2318        let seq_offset = cursor as u64;
2319        let seq = next_trimmed_line(bytes, &mut cursor);
2320        if seq.is_empty() || seq.starts_with(b">") {
2321            return Err(format_at(
2322                "two-line FASTA record is missing a sequence line",
2323                seq_offset,
2324                record_index,
2325            ));
2326        }
2327        if cursor < bytes.len() && bytes[cursor] != b'>' {
2328            return Err(format_at(
2329                "two-line FASTA sequence must be followed by a header",
2330                cursor as u64,
2331                record_index + 1,
2332            ));
2333        }
2334
2335        visit(FastaVisitRecord { name: header, seq })?;
2336        record_index += 1;
2337    }
2338
2339    Ok(record_index)
2340}
2341
2342/// Visit records from a strict two-line FASTA stream.
2343///
2344/// This path parses canonical `>header\nsequence\n` FASTA directly from a
2345/// buffered stream. Complete sequence lines are borrowed from the read buffer;
2346/// only headers and chunk-boundary fragments are copied. It rejects blank lines
2347/// and multiline sequence records. Use [`FastaReader`] or [`visit_fasta_bytes`]
2348/// when ordinary multiline FASTA support is required.
2349///
2350/// Returns the number of visited records.
2351pub fn visit_two_line_fasta_read<R, F>(reader: R, visit: F) -> Result<u64>
2352where
2353    R: Read,
2354    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2355{
2356    let mut reader = BufReader::with_capacity(TWO_LINE_STREAM_BUFFER_SIZE, reader);
2357    visit_two_line_fasta_bufread(&mut reader, visit)
2358}
2359
2360/// Count strict two-line FASTA records from a stream.
2361///
2362/// This is the lightest path for workloads that only need record counts, total
2363/// bases, and a deterministic shape checksum. It validates the canonical
2364/// `>header\nsequence\n` structure and rejects multiline FASTA.
2365pub fn count_two_line_fasta_read<R: Read>(reader: R) -> Result<FastaStats> {
2366    let mut reader = BufReader::with_capacity(TWO_LINE_STREAM_BUFFER_SIZE, reader);
2367    count_two_line_fasta_bufread(&mut reader)
2368}
2369
2370fn visit_two_line_fasta_bufread<R, F>(reader: &mut R, mut visit: F) -> Result<u64>
2371where
2372    R: BufRead,
2373    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2374{
2375    let mut state = TwoLineStreamState::Header;
2376    let mut record_index = 0;
2377    let mut header = Vec::new();
2378    let mut carry = Vec::new();
2379
2380    loop {
2381        let available = reader.fill_buf()?;
2382        if available.is_empty() {
2383            break;
2384        }
2385
2386        let mut consumed = 0;
2387        while consumed < available.len() {
2388            let Some(relative_newline) = memchr(b'\n', &available[consumed..]) else {
2389                carry.extend_from_slice(&available[consumed..]);
2390                consumed = available.len();
2391                break;
2392            };
2393            let line_end = consumed + relative_newline;
2394            let line = &available[consumed..line_end];
2395            process_two_line_stream_line(
2396                line,
2397                &mut carry,
2398                &mut state,
2399                &mut header,
2400                &mut record_index,
2401                &mut visit,
2402            )?;
2403            consumed = line_end + 1;
2404        }
2405        reader.consume(consumed);
2406    }
2407
2408    if !carry.is_empty() {
2409        process_two_line_stream_line(
2410            b"",
2411            &mut carry,
2412            &mut state,
2413            &mut header,
2414            &mut record_index,
2415            &mut visit,
2416        )?;
2417    }
2418
2419    match state {
2420        TwoLineStreamState::Header => Ok(record_index),
2421        TwoLineStreamState::Seq => Err(format_at(
2422            "two-line FASTA record is missing a sequence line",
2423            0,
2424            record_index,
2425        )),
2426    }
2427}
2428
2429fn count_two_line_fasta_bufread<R: BufRead>(reader: &mut R) -> Result<FastaStats> {
2430    let mut stats = FastaStats::default();
2431    let mut state = TwoLineStreamState::Header;
2432    let mut record_index = 0;
2433    let mut carry = Vec::new();
2434
2435    loop {
2436        let available = reader.fill_buf()?;
2437        if available.is_empty() {
2438            break;
2439        }
2440
2441        let mut consumed = 0;
2442        while consumed < available.len() {
2443            let Some(relative_newline) = memchr(b'\n', &available[consumed..]) else {
2444                carry.extend_from_slice(&available[consumed..]);
2445                consumed = available.len();
2446                break;
2447            };
2448            let line_end = consumed + relative_newline;
2449            let line = &available[consumed..line_end];
2450            process_two_line_count_line(
2451                line,
2452                &mut carry,
2453                &mut state,
2454                &mut record_index,
2455                &mut stats,
2456            )?;
2457            consumed = line_end + 1;
2458        }
2459        reader.consume(consumed);
2460    }
2461
2462    if !carry.is_empty() {
2463        process_two_line_count_line(b"", &mut carry, &mut state, &mut record_index, &mut stats)?;
2464    }
2465
2466    if state == TwoLineStreamState::Seq {
2467        return Err(format_at(
2468            "two-line FASTA record is missing a sequence line",
2469            0,
2470            record_index,
2471        ));
2472    }
2473    Ok(stats)
2474}
2475
2476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2477enum TwoLineStreamState {
2478    Header,
2479    Seq,
2480}
2481
2482fn process_two_line_stream_line<F>(
2483    line: &[u8],
2484    carry: &mut Vec<u8>,
2485    state: &mut TwoLineStreamState,
2486    header: &mut Vec<u8>,
2487    record_index: &mut u64,
2488    visit: &mut F,
2489) -> Result<()>
2490where
2491    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2492{
2493    if carry.is_empty() {
2494        process_complete_two_line_stream_line(line, state, header, record_index, visit)
2495    } else {
2496        carry.extend_from_slice(line);
2497        let owned_line = trim_line(carry);
2498        process_complete_two_line_stream_line(owned_line, state, header, record_index, visit)?;
2499        carry.clear();
2500        Ok(())
2501    }
2502}
2503
2504fn process_complete_two_line_stream_line<F>(
2505    line: &[u8],
2506    state: &mut TwoLineStreamState,
2507    header: &mut Vec<u8>,
2508    record_index: &mut u64,
2509    visit: &mut F,
2510) -> Result<()>
2511where
2512    F: FnMut(FastaVisitRecord<'_>) -> Result<()>,
2513{
2514    let line = trim_line(line);
2515    if *state == TwoLineStreamState::Header {
2516        if !line.starts_with(b">") {
2517            return Err(format_at(
2518                "two-line FASTA record header must start with `>`",
2519                0,
2520                *record_index,
2521            ));
2522        }
2523        validate_header(line, 0, *record_index)?;
2524        header.clear();
2525        header.extend_from_slice(line);
2526        *state = TwoLineStreamState::Seq;
2527        return Ok(());
2528    }
2529
2530    if line.is_empty() || line.starts_with(b">") {
2531        return Err(format_at(
2532            "two-line FASTA record is missing a sequence line",
2533            0,
2534            *record_index,
2535        ));
2536    }
2537    visit(FastaVisitRecord {
2538        name: header,
2539        seq: line,
2540    })?;
2541    *record_index += 1;
2542    *state = TwoLineStreamState::Header;
2543    Ok(())
2544}
2545
2546fn process_two_line_count_line(
2547    line: &[u8],
2548    carry: &mut Vec<u8>,
2549    state: &mut TwoLineStreamState,
2550    record_index: &mut u64,
2551    stats: &mut FastaStats,
2552) -> Result<()> {
2553    if carry.is_empty() {
2554        process_complete_two_line_count_line(line, state, record_index, stats)
2555    } else {
2556        carry.extend_from_slice(line);
2557        let owned_line = trim_line(carry);
2558        process_complete_two_line_count_line(owned_line, state, record_index, stats)?;
2559        carry.clear();
2560        Ok(())
2561    }
2562}
2563
2564fn process_complete_two_line_count_line(
2565    line: &[u8],
2566    state: &mut TwoLineStreamState,
2567    record_index: &mut u64,
2568    stats: &mut FastaStats,
2569) -> Result<()> {
2570    let line = trim_line(line);
2571    if *state == TwoLineStreamState::Header {
2572        if !line.starts_with(b">") {
2573            return Err(format_at(
2574                "two-line FASTA record header must start with `>`",
2575                0,
2576                *record_index,
2577            ));
2578        }
2579        validate_header(line, 0, *record_index)?;
2580        *state = TwoLineStreamState::Seq;
2581        return Ok(());
2582    }
2583
2584    if line.is_empty() || line.starts_with(b">") {
2585        return Err(format_at(
2586            "two-line FASTA record is missing a sequence line",
2587            0,
2588            *record_index,
2589        ));
2590    }
2591    stats.observe_sequence(line);
2592    *record_index += 1;
2593    *state = TwoLineStreamState::Header;
2594    Ok(())
2595}
2596
2597fn take_next_header<'a>(
2598    bytes: &'a [u8],
2599    cursor: &mut usize,
2600    pending_header: &mut Option<(u64, &'a [u8])>,
2601    record_index: u64,
2602) -> Result<Option<(u64, &'a [u8])>> {
2603    if let Some(header) = pending_header.take() {
2604        return Ok(Some(header));
2605    }
2606
2607    while *cursor < bytes.len() {
2608        let line_start = *cursor as u64;
2609        let line = next_trimmed_line(bytes, cursor);
2610        if line.is_empty() {
2611            continue;
2612        }
2613        if !line.starts_with(b">") {
2614            return Err(format_at(
2615                "FASTA record header must start with `>`",
2616                line_start,
2617                record_index,
2618            ));
2619        }
2620        return Ok(Some((line_start, line)));
2621    }
2622
2623    Ok(None)
2624}
2625
2626fn next_trimmed_line<'a>(bytes: &'a [u8], cursor: &mut usize) -> &'a [u8] {
2627    let start = *cursor;
2628    match memchr(b'\n', &bytes[start..]) {
2629        Some(relative) => {
2630            let end = start + relative;
2631            *cursor = end + 1;
2632            trim_line(&bytes[start..end])
2633        }
2634        None => {
2635            *cursor = bytes.len();
2636            trim_line(&bytes[start..])
2637        }
2638    }
2639}
2640
2641fn trim_line(line: &[u8]) -> &[u8] {
2642    let line = line.strip_suffix(b"\n").unwrap_or(line);
2643    line.strip_suffix(b"\r").unwrap_or(line)
2644}
2645
2646fn validate_header(header: &[u8], byte_offset: u64, record_index: u64) -> Result<()> {
2647    if header.len() == 1 || fasta_index_name(header).is_empty() {
2648        return Err(format_at("empty FASTA id", byte_offset, record_index));
2649    }
2650    Ok(())
2651}
2652
2653fn id_token_range(bytes: &[u8], name: Range<u32>) -> Result<ByteRange> {
2654    let name_range = to_usize(name.clone());
2655    let header = bytes
2656        .get(name_range)
2657        .ok_or_else(|| FastqError::Format("FASTA header byte range exceeds batch buffer".into()))?;
2658    let without_gt = header.strip_prefix(b">").unwrap_or(header);
2659    let token_end = without_gt
2660        .iter()
2661        .position(u8::is_ascii_whitespace)
2662        .unwrap_or(without_gt.len());
2663    let start = name.start + u32::from(header.starts_with(b">"));
2664    let token_end_u32 = u32::try_from(token_end)
2665        .map_err(|_| FastqError::Format("FASTA id token range exceeds u32 range".into()))?;
2666    let end = start
2667        .checked_add(token_end_u32)
2668        .ok_or_else(|| FastqError::Format("FASTA id token range overflowed".into()))?;
2669    Ok(ByteRange { start, end })
2670}
2671
2672fn checked_u32(value: usize) -> Result<u32> {
2673    u32::try_from(value)
2674        .map_err(|_| FastqError::Format("FASTA batch byte offsets exceed u32 range".into()))
2675}
2676
2677fn format_at(message: impl Into<String>, byte_offset: u64, record_index: u64) -> FastqError {
2678    FastqError::FormatAt {
2679        message: message.into(),
2680        position: FastqPosition::new(byte_offset, record_index, 0),
2681    }
2682}
2683
2684fn to_usize(range: Range<u32>) -> Range<usize> {
2685    range.start as usize..range.end as usize
2686}
2687
2688#[cfg(test)]
2689mod tests;