Skip to main content

rapidgzip_core/
config.rs

1use crate::backend::{
2    DirectOutput, decode_source, decode_source_with_index, decode_stream, decode_stream_with_index,
3};
4use crate::format::FormatSelection;
5use crate::gzip::StreamCursor;
6use crate::reader;
7use crate::runtime::RuntimeState;
8use crate::{
9    Analysis, AnalyzeOptions, DecodeError, DecodeReport, DecoderReader, DeflateIndex, Format,
10    IndexDecodeError, IndexOptions, IndexedDecodeReport, IndexingDecoderReader, IndexingError,
11    ReadAt,
12};
13use std::error::Error;
14use std::fmt::{self, Display, Formatter};
15use std::fs::File;
16use std::io::{self, Read, Write};
17use std::num::NonZeroUsize;
18use std::path::Path;
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21
22const MIB: usize = 1024 * 1024;
23
24/// Invalid decoder configuration.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct ConfigError(&'static str);
27
28impl Display for ConfigError {
29    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
30        formatter.write_str(self.0)
31    }
32}
33
34impl Error for ConfigError {}
35
36#[derive(Clone, Debug)]
37pub(crate) struct Config {
38    pub(crate) decoder_threads: usize,
39    pub(crate) decoded_chunk_size: usize,
40    pub(crate) input_page_size: usize,
41    pub(crate) compressed_chunk_size: usize,
42    pub(crate) in_flight_chunks: usize,
43    pub(crate) output_limit: Option<u64>,
44    pub(crate) expected_uncompressed_size: Option<u64>,
45    pub(crate) count_lines: bool,
46    pub(crate) format: FormatSelection,
47}
48
49impl Config {
50    /// Checks a proposed decoded-output handoff against both configured bounds.
51    pub(crate) fn checked_output_total(
52        &self,
53        current: u64,
54        additional: usize,
55    ) -> Result<u64, DecodeError> {
56        let Some(actual) = current.checked_add(additional as u64) else {
57            return match (self.expected_uncompressed_size, self.output_limit) {
58                (Some(expected), Some(limit)) if limit < expected => {
59                    Err(DecodeError::OutputLimitExceeded { limit })
60                }
61                (Some(expected), _) => Err(DecodeError::UnexpectedOutputSize {
62                    expected,
63                    actual: u64::MAX,
64                }),
65                (None, limit) => Err(DecodeError::OutputLimitExceeded {
66                    limit: limit.unwrap_or(u64::MAX),
67                }),
68            };
69        };
70        let expectation_crossed = self
71            .expected_uncompressed_size
72            .is_some_and(|expected| actual > expected);
73        let limit_crossed = self.output_limit.is_some_and(|limit| actual > limit);
74
75        if expectation_crossed
76            && (!limit_crossed
77                || self.expected_uncompressed_size.expect("checked as some")
78                    <= self.output_limit.expect("checked as some"))
79        {
80            return Err(DecodeError::UnexpectedOutputSize {
81                expected: self.expected_uncompressed_size.expect("checked as some"),
82                actual,
83            });
84        }
85        if limit_crossed {
86            return Err(DecodeError::OutputLimitExceeded {
87                limit: self.output_limit.unwrap_or(u64::MAX),
88            });
89        }
90        Ok(actual)
91    }
92
93    /// Confirms an exact output expectation after framing verification.
94    pub(crate) fn verify_expected_output(&self, actual: u64) -> Result<(), DecodeError> {
95        if let Some(expected) = self.expected_uncompressed_size {
96            if expected != actual {
97                return Err(DecodeError::UnexpectedOutputSize { expected, actual });
98            }
99        }
100        Ok(())
101    }
102}
103
104/// Builder for an immutable, reusable [`Decoder`].
105///
106/// Defaults use [`std::thread::available_parallelism`] as the maximum decoder
107/// budget, 4 MiB decoded chunks, 1 MiB positional input pages and compressed
108/// grid spacing, `decoder_threads + 2` in-flight chunks, strict gzip framing,
109/// no output limit or exact-size expectation, and no line counting. The defaults favor
110/// throughput; applications with tight memory budgets can
111/// reduce the worker budget, decoded chunk size, or in-flight count.
112#[derive(Clone, Debug)]
113pub struct DecoderBuilder {
114    config: Config,
115}
116
117impl Default for DecoderBuilder {
118    fn default() -> Self {
119        let decoder_threads = std::thread::available_parallelism()
120            .map(NonZeroUsize::get)
121            .unwrap_or(1);
122        Self {
123            config: Config {
124                decoder_threads,
125                decoded_chunk_size: 4 * MIB,
126                input_page_size: MIB,
127                compressed_chunk_size: MIB,
128                in_flight_chunks: decoder_threads.saturating_add(2),
129                output_limit: None,
130                expected_uncompressed_size: None,
131                count_lines: false,
132                format: FormatSelection::default(),
133            },
134        }
135    }
136}
137
138impl DecoderBuilder {
139    /// Sets the maximum decoder-worker budget.
140    ///
141    /// Individual paths may use fewer active workers when the input exposes
142    /// less parallelism or when a larger speculative window would reduce
143    /// throughput through memory pressure.
144    ///
145    /// This also resets the in-flight chunk count to `threads + 2`. Call
146    /// [`DecoderBuilder::in_flight_chunks`] afterward to override that value.
147    pub const fn decoder_threads(mut self, threads: usize) -> Self {
148        self.config.decoder_threads = threads;
149        self.config.in_flight_chunks = threads.saturating_add(2);
150        self
151    }
152
153    /// Sets the target decoded chunk size in bytes.
154    ///
155    /// Larger chunks reduce handoff overhead but can increase per-worker and
156    /// queued memory. The value must be non-zero and fit in zlib's `uInt`.
157    pub const fn decoded_chunk_size(mut self, bytes: usize) -> Self {
158        self.config.decoded_chunk_size = bytes;
159        self
160    }
161
162    /// Sets the positional input page size in bytes.
163    ///
164    /// The value must be non-zero and fit in zlib's `uInt`. A streaming cursor
165    /// retains at least two bytes so format detection can span short reads.
166    pub const fn input_page_size(mut self, bytes: usize) -> Self {
167        self.config.input_page_size = bytes;
168        self
169    }
170
171    /// Sets the target spacing between speculative chunk starts.
172    ///
173    /// The current estimated-grid decoder requires at least 1 MiB to keep its
174    /// independently discovered boundaries strongly validated.
175    pub const fn compressed_chunk_size(mut self, bytes: usize) -> Self {
176        self.config.compressed_chunk_size = bytes;
177        self
178    }
179
180    /// Sets the maximum number of decoded chunks awaiting consumption.
181    ///
182    /// This bounds reader backpressure and ordered-result buffering at the
183    /// final handoff. The value must be non-zero.
184    pub const fn in_flight_chunks(mut self, count: usize) -> Self {
185        self.config.in_flight_chunks = count;
186        self
187    }
188
189    /// Sets or clears the total decoded-output limit in bytes.
190    ///
191    /// On overflow, decoding returns [`DecodeError::OutputLimitExceeded`]
192    /// before emitting bytes beyond the limit. Output already emitted remains
193    /// visible to the caller.
194    pub const fn output_limit(mut self, bytes: Option<u64>) -> Self {
195        self.config.output_limit = bytes;
196        self
197    }
198
199    /// Requires exactly `bytes` of decoded output, or clears the expectation.
200    ///
201    /// Unlike [`Self::output_limit`], this is both an upper and lower bound.
202    /// An overrun is rejected before the offending chunk is emitted, and an
203    /// underrun is rejected after the selected container is complete.
204    pub const fn expected_uncompressed_size(mut self, bytes: Option<u64>) -> Self {
205        self.config.expected_uncompressed_size = bytes;
206        self
207    }
208
209    /// Enables or disables counting newline bytes in the decoded output.
210    ///
211    /// The final count is returned through [`DecodeReport::line_count`]. When
212    /// an index is collected by the same operation, every retained checkpoint
213    /// is also annotated with the number of preceding newlines and the index
214    /// records the total. This metadata enables
215    /// [`crate::IndexedReader::seek_to_line`] and gztool version 1 export.
216    ///
217    /// Counting is disabled by default. When disabled, output is not scanned
218    /// and reports and newly built indexes carry no line metadata.
219    pub const fn count_lines(mut self, enabled: bool) -> Self {
220        self.config.count_lines = enabled;
221        self
222    }
223
224    /// Selects the container framing explicitly.
225    ///
226    /// The default is [`Format::Gzip`], preserving strict gzip behavior.
227    /// Select [`Format::RawDeflate`] explicitly because an unwrapped stream has
228    /// no magic bytes and is never safe to guess.
229    pub const fn format(mut self, format: Format) -> Self {
230        self.config.format = FormatSelection::Explicit(format);
231        self
232    }
233
234    /// Detects gzip or zlib framing from an exact two-byte prefix.
235    ///
236    /// Raw DEFLATE is never auto-detected. An unrecognized prefix produces
237    /// [`DecodeError::UnrecognizedFormat`].
238    pub const fn auto_detect_format(mut self) -> Self {
239        self.config.format = FormatSelection::Auto;
240        self
241    }
242
243    /// Validates the configuration and creates a reusable decoder.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`ConfigError`] when a size or count violates the constraints
248    /// documented on its setter.
249    pub fn build(self) -> Result<Decoder, ConfigError> {
250        if self.config.decoder_threads == 0 {
251            return Err(ConfigError("decoder_threads must be non-zero"));
252        }
253        if self.config.decoded_chunk_size == 0 {
254            return Err(ConfigError("decoded_chunk_size must be non-zero"));
255        }
256        if self.config.decoded_chunk_size > u32::MAX as usize {
257            return Err(ConfigError("decoded_chunk_size must fit zlib's uInt"));
258        }
259        if self.config.input_page_size == 0 {
260            return Err(ConfigError("input_page_size must be non-zero"));
261        }
262        if self.config.input_page_size > u32::MAX as usize {
263            return Err(ConfigError("input_page_size must fit zlib's uInt"));
264        }
265        if self.config.compressed_chunk_size < MIB {
266            return Err(ConfigError("compressed_chunk_size must be at least 1 MiB"));
267        }
268        if self.config.in_flight_chunks == 0 {
269            return Err(ConfigError("in_flight_chunks must be non-zero"));
270        }
271        Ok(Decoder {
272            config: self.config,
273        })
274    }
275}
276
277/// Immutable, reusable decompressor configuration.
278#[derive(Clone, Debug)]
279pub struct Decoder {
280    pub(crate) config: Config,
281}
282
283impl Decoder {
284    /// Creates a builder initialized with the [`DecoderBuilder`] defaults.
285    pub fn builder() -> DecoderBuilder {
286        DecoderBuilder::default()
287    }
288
289    /// Decodes the selected container into `output` and performs all available
290    /// integrity checks.
291    ///
292    /// The writer is used only by the calling thread and need not implement
293    /// [`Send`].
294    ///
295    /// The compressed source must keep its length and contents stable for the
296    /// duration of this call. On error, `output` can contain a verified prefix;
297    /// writes are not rolled back.
298    pub fn decode<R, W>(&self, source: &R, output: &mut W) -> Result<DecodeReport, DecodeError>
299    where
300        R: ReadAt + ?Sized,
301        W: Write,
302    {
303        let cancelled = AtomicBool::new(false);
304        let mut sink = DirectOutput::new(output);
305        let runtime = RuntimeState::new(self.config.decoder_threads);
306        decode_source(source, &self.config, &cancelled, &mut sink, &runtime)
307    }
308
309    /// Analyzes every DEFLATE block using bounded default retention limits.
310    ///
311    /// The walk is sequential because each block depends on its predecessor
312    /// history. It validates the same container headers, checksums, sizes, and
313    /// trailing-data rules as decoding while retaining only one 32 KiB output
314    /// window. Use [`Self::analyze_with_options`] to change result limits or
315    /// retain individual predecessor-window references.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`DecodeError`] for input, framing, DEFLATE, integrity, output
320    /// expectation, or typed analysis-budget failures.
321    pub fn analyze<R>(&self, source: &R) -> Result<Analysis, DecodeError>
322    where
323        R: ReadAt + ?Sized,
324    {
325        self.analyze_with_options(source, AnalyzeOptions::default())
326    }
327
328    /// Analyzes every DEFLATE block with explicit retention limits.
329    ///
330    /// Detailed back-reference retention is input-wide. Exact summaries remain
331    /// available when that budget is exhausted, and each block records its
332    /// omitted detail count.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`DecodeError`] for input, framing, DEFLATE, integrity, output
337    /// expectation, or typed analysis-budget failures.
338    pub fn analyze_with_options<R>(
339        &self,
340        source: &R,
341        options: AnalyzeOptions,
342    ) -> Result<Analysis, DecodeError>
343    where
344        R: ReadAt + ?Sized,
345    {
346        crate::analyze::analyze_source(source, &self.config, options)
347    }
348
349    /// Analyzes a non-seekable compressed stream with default limits.
350    ///
351    /// Input and decompressed history remain bounded; unlike positional
352    /// analysis, a stream cannot be re-read if the caller later requests more
353    /// retained detail.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`DecodeError`] for input, framing, DEFLATE, integrity, output
358    /// expectation, or typed analysis-budget failures.
359    pub fn analyze_stream<R>(&self, source: R) -> Result<Analysis, DecodeError>
360    where
361        R: Read,
362    {
363        self.analyze_stream_with_options(source, AnalyzeOptions::default())
364    }
365
366    /// Analyzes a non-seekable compressed stream with explicit limits.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`DecodeError`] for input, framing, DEFLATE, integrity, output
371    /// expectation, or typed analysis-budget failures.
372    pub fn analyze_stream_with_options<R>(
373        &self,
374        source: R,
375        options: AnalyzeOptions,
376    ) -> Result<Analysis, DecodeError>
377    where
378        R: Read,
379    {
380        crate::analyze::analyze_stream(source, &self.config, options)
381    }
382
383    /// Decodes the selected container while collecting a random-access index.
384    ///
385    /// Index construction is explicit per operation. Ordinary [`Self::decode`]
386    /// calls therefore retain their small [`Copy`] report and perform no
387    /// checkpoint-window work. On error, `output` can contain a verified
388    /// prefix; writes are not rolled back.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`IndexingError::Decode`] for source, framing, DEFLATE,
393    /// verification, output, or limit failures, and [`IndexingError::Index`]
394    /// when a checkpoint window cannot be stored or the final index is invalid.
395    pub fn decode_with_index<R, W>(
396        &self,
397        source: &R,
398        output: &mut W,
399        options: IndexOptions,
400    ) -> Result<IndexedDecodeReport, IndexingError>
401    where
402        R: ReadAt + ?Sized,
403        W: Write,
404    {
405        let cancelled = AtomicBool::new(false);
406        let mut sink = DirectOutput::new(output);
407        let runtime = RuntimeState::new(self.config.decoder_threads);
408        decode_source_with_index(
409            source,
410            &self.config,
411            &cancelled,
412            &mut sink,
413            &runtime,
414            options,
415        )
416    }
417
418    /// Decodes through a caller-supplied random-access index.
419    ///
420    /// Every indexed span runs plain zlib-rs inflation from an authoritative
421    /// checkpoint. The index is validated against the selected format and
422    /// source before workers start; each worker must then reach the next
423    /// checkpoint's exact compressed-bit and decompressed-byte offsets.
424    /// Invalid or mismatched indexes are errors and never silently select an
425    /// unindexed fallback.
426    ///
427    /// Worker output is handed off in bounded chunks, so a sparse index does
428    /// not cause an entire decompressed span to be allocated. Empty gzip
429    /// members remain explicit spans and are fully verified.
430    ///
431    /// When [`DecoderBuilder::count_lines`] is enabled, imported per-checkpoint
432    /// and total line counters are recomputed from final ordered output and a
433    /// mismatch is rejected. Without line counting, line metadata remains
434    /// caller-supplied navigation data and is not authenticated.
435    ///
436    /// # Examples
437    ///
438    /// ```no_run
439    /// use rapidgzip_core::{Decoder, DeflateIndex};
440    /// use std::fs::File;
441    /// use std::io;
442    ///
443    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
444    /// let mut serialized = File::open("reads.fastq.gz.rgzidx")?;
445    /// let index = DeflateIndex::read_native(&mut serialized)?;
446    /// let source = File::open("reads.fastq.gz")?;
447    /// let report = Decoder::default().decode_from_index(
448    ///     &source,
449    ///     &mut io::sink(),
450    ///     &index,
451    /// )?;
452    /// assert!(report.member_count >= 1);
453    /// # Ok(())
454    /// # }
455    /// ```
456    ///
457    /// # Errors
458    ///
459    /// Returns [`IndexDecodeError::Index`] for invalid or source-mismatched
460    /// metadata, [`IndexDecodeError::FormatMismatch`] when the builder and
461    /// index select different containers, or [`IndexDecodeError::Decode`] for
462    /// input, DEFLATE, verification, output, limit, or worker failures.
463    pub fn decode_from_index<R, W>(
464        &self,
465        source: &R,
466        output: &mut W,
467        index: &DeflateIndex,
468    ) -> Result<DecodeReport, IndexDecodeError>
469    where
470        R: ReadAt + ?Sized,
471        W: Write,
472    {
473        let plan = crate::indexed_parallel::IndexedPlan::build(source, &self.config, index)?;
474        let cancelled = AtomicBool::new(false);
475        let mut sink = DirectOutput::new(output);
476        let runtime = RuntimeState::new(self.config.decoder_threads);
477        crate::indexed_parallel::decode(
478            source,
479            &self.config,
480            &cancelled,
481            &mut sink,
482            index,
483            &plan,
484            &runtime,
485        )
486        .map_err(IndexDecodeError::from)
487    }
488
489    /// Starts decoding an owned positional source and returns `Read + Send`
490    /// decompressed output.
491    ///
492    /// Initial selected framing is validated before the background coordinator
493    /// is spawned. Later decoding failures are returned as [`std::io::Error`]
494    /// values by [`std::io::Read`], or as [`DecodeError`] by
495    /// [`DecoderReader::finish`].
496    pub fn reader<R>(&self, source: R) -> Result<DecoderReader, DecodeError>
497    where
498        R: ReadAt + 'static,
499    {
500        crate::backend::validate_initial_source(&source, &self.config)?;
501        reader::spawn(source, self.config.clone())
502    }
503
504    /// Starts positional decoding with index construction and returns owned
505    /// `Read + Send` decompressed output.
506    ///
507    /// The returned [`IndexingDecoderReader`] exposes the same telemetry and
508    /// dynamic worker controls as [`DecoderReader`]. Its index becomes
509    /// available only after verified EOF, either through
510    /// [`IndexingDecoderReader::report`] or [`IndexingDecoderReader::finish`].
511    ///
512    /// # Errors
513    ///
514    /// Returns an initial source or framing failure. Later decode and
515    /// index failures are reported by [`Read::read`] and preserved in typed
516    /// form by [`IndexingDecoderReader::finish`].
517    pub fn reader_with_index<R>(
518        &self,
519        source: R,
520        options: IndexOptions,
521    ) -> Result<IndexingDecoderReader, DecodeError>
522    where
523        R: ReadAt + 'static,
524    {
525        crate::backend::validate_initial_source(&source, &self.config)?;
526        reader::spawn_indexed(source, self.config.clone(), options)
527    }
528
529    /// Starts full-stream decoding through an existing index and returns
530    /// owned `Read + Send` output.
531    ///
532    /// The [`Arc`] permits a large index and its stored windows to be shared
533    /// with the background coordinator without cloning them. Validation is
534    /// completed before any thread is spawned. Later failures are returned by
535    /// [`Read::read`] and preserved by [`DecoderReader::finish`]. The reader's
536    /// [`crate::DecoderHandle`] exposes the same telemetry and dynamic worker
537    /// ceiling as every other positional parallel path.
538    ///
539    /// # Examples
540    ///
541    /// ```no_run
542    /// use rapidgzip_core::{Decoder, DeflateIndex};
543    /// use std::fs::File;
544    /// use std::io;
545    /// use std::sync::Arc;
546    ///
547    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
548    /// let mut serialized = File::open("reads.fastq.gz.rgzidx")?;
549    /// let index = Arc::new(DeflateIndex::read_native(&mut serialized)?);
550    /// let mut reader = Decoder::default().reader_from_index(
551    ///     File::open("reads.fastq.gz")?,
552    ///     index,
553    /// )?;
554    /// io::copy(&mut reader, &mut io::sink())?;
555    /// reader.finish()?;
556    /// # Ok(())
557    /// # }
558    /// ```
559    ///
560    /// # Errors
561    ///
562    /// Returns a strict index, format, or initial source validation error, or
563    /// a coordinator-thread creation failure.
564    pub fn reader_from_index<R>(
565        &self,
566        source: R,
567        index: Arc<DeflateIndex>,
568    ) -> Result<DecoderReader, IndexDecodeError>
569    where
570        R: ReadAt + 'static,
571    {
572        let plan = crate::indexed_parallel::IndexedPlan::build(&source, &self.config, &index)?;
573        reader::spawn_from_index(source, self.config.clone(), index, plan)
574            .map_err(IndexDecodeError::from)
575    }
576
577    /// Decodes the selected format from a non-seekable source.
578    ///
579    /// This is the push interface for input that cannot be read positionally,
580    /// such as standard input, a FIFO, a process substitution, or a socket. It
581    /// mirrors [`Decoder::decode`], including the writer being used only by the
582    /// calling thread.
583    ///
584    /// Validation is identical to [`Decoder::decode`], including gzip CRC32 and
585    /// ISIZE, zlib Adler-32, raw-DEFLATE structural completion, trailing-data
586    /// rejection, and configured output bounds. The source is read once in
587    /// order, so decoding uses one calling thread regardless of
588    /// [`DecoderBuilder::decoder_threads`]. The returned report retains the
589    /// configured worker budget, just like [`Decoder::decode`].
590    ///
591    /// Input memory is bounded by one [`DecoderBuilder::input_page_size`]
592    /// window; nothing is spooled.
593    ///
594    /// # Examples
595    ///
596    /// ```no_run
597    /// use rapidgzip_core::Decoder;
598    /// use std::io;
599    ///
600    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
601    /// let decoder = Decoder::default();
602    /// let report = decoder.decode_stream(io::stdin(), &mut io::sink())?;
603    /// println!("completed {} framing units", report.member_count);
604    /// # Ok(())
605    /// # }
606    /// ```
607    ///
608    /// # Errors
609    ///
610    /// Returns the first framing, DEFLATE, verification, input, or output-limit
611    /// failure. On error, `output` can contain a verified prefix; writes are not
612    /// rolled back.
613    pub fn decode_stream<R, W>(
614        &self,
615        source: R,
616        output: &mut W,
617    ) -> Result<DecodeReport, DecodeError>
618    where
619        R: Read,
620        W: Write,
621    {
622        let cancelled = AtomicBool::new(false);
623        let mut sink = DirectOutput::new(output);
624        let runtime = RuntimeState::new(self.config.decoder_threads);
625        let mut cursor = StreamCursor::new(source, self.config.input_page_size);
626        decode_stream(&mut cursor, &self.config, &cancelled, &mut sink, &runtime)
627    }
628
629    /// Decodes non-seekable input while collecting a coarse but valid index.
630    ///
631    /// A forward-only source does not expose independently discoverable
632    /// interior block boundaries, so the resulting index records gzip member
633    /// starts or the single zlib/raw stream start. It can later seek a stable
634    /// positional copy of the same compressed bytes.
635    ///
636    /// # Errors
637    ///
638    /// Returns [`IndexingError`] for the same decode failures as
639    /// [`Self::decode_stream`] or for index construction and validation errors.
640    pub fn decode_stream_with_index<R, W>(
641        &self,
642        source: R,
643        output: &mut W,
644        options: IndexOptions,
645    ) -> Result<IndexedDecodeReport, IndexingError>
646    where
647        R: Read,
648        W: Write,
649    {
650        let cancelled = AtomicBool::new(false);
651        let mut sink = DirectOutput::new(output);
652        let runtime = RuntimeState::new(self.config.decoder_threads);
653        let mut cursor = StreamCursor::new(source, self.config.input_page_size);
654        decode_stream_with_index(
655            &mut cursor,
656            &self.config,
657            &cancelled,
658            &mut sink,
659            &runtime,
660            options,
661        )
662    }
663
664    /// Starts decoding an owned non-seekable source and returns `Read + Send`
665    /// decompressed output.
666    ///
667    /// This is the pull counterpart to [`Decoder::decode_stream`] and mirrors
668    /// [`Decoder::reader`], returning the same [`DecoderReader`] so it can still
669    /// be handed to a parser as `Box<dyn Read + Send>`.
670    ///
671    /// One initial source read is used for best-effort fail-fast header
672    /// validation. A short read can defer validation until [`std::io::Read`];
673    /// later failures are returned as [`std::io::Error`] values by that method,
674    /// or as [`DecodeError`] by [`DecoderReader::finish`].
675    ///
676    /// [`DecoderReader::stats`] reports [`crate::DecoderPath::Sequential`], the
677    /// builder-supplied configured worker budget, an effective target of one,
678    /// and zero spawned decoder or auxiliary threads. Decoding occurs in the
679    /// caller's `read`, so dropping the reader immediately drops the source and
680    /// cannot strand a coordinator blocked on input.
681    ///
682    /// # Examples
683    ///
684    /// ```no_run
685    /// use rapidgzip_core::Decoder;
686    /// use std::io::{self, Read};
687    ///
688    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
689    /// let decoder = Decoder::default();
690    /// let reader = decoder.stream_reader(io::stdin())?;
691    ///
692    /// // Still Read + Send, so a parser can own it.
693    /// let mut parser_input: Box<dyn Read + Send> = Box::new(reader);
694    /// io::copy(&mut parser_input, &mut io::sink())?;
695    /// # Ok(())
696    /// # }
697    /// ```
698    ///
699    /// # Errors
700    ///
701    /// Returns an input failure, or a framing failure detectable from the
702    /// best-effort initial read. A short read can defer a framing failure until
703    /// the returned reader is consumed.
704    pub fn stream_reader<R>(&self, source: R) -> Result<DecoderReader, DecodeError>
705    where
706        R: Read + Send + 'static,
707    {
708        reader::spawn_stream(source, self.config.clone())
709    }
710
711    /// Starts pull-driven decoding of a non-seekable source while collecting
712    /// a framing-start index.
713    ///
714    /// Like [`Self::stream_reader`], this runs synchronously in the caller's
715    /// `read` calls and spawns no coordinator or decoder worker. The returned
716    /// reader remains `Read + Send` and publishes the index only at verified
717    /// EOF.
718    ///
719    /// # Errors
720    ///
721    /// Returns an input failure or an initial framing failure. Later failures
722    /// are returned by [`Read::read`] or [`IndexingDecoderReader::finish`].
723    pub fn stream_reader_with_index<R>(
724        &self,
725        source: R,
726        options: IndexOptions,
727    ) -> Result<IndexingDecoderReader, DecodeError>
728    where
729        R: Read + Send + 'static,
730    {
731        reader::spawn_stream_indexed(source, self.config.clone(), options)
732    }
733
734    /// Opens and decodes the selected format from a filesystem path.
735    ///
736    /// This is the push counterpart to [`Decoder::open`]. A regular file uses
737    /// positional decoding; a non-regular path accepted by [`File::open`], such
738    /// as a FIFO or character device, uses [`Decoder::decode_stream`]. The
739    /// writer remains on the calling thread in both cases and need not implement
740    /// [`Send`].
741    ///
742    /// # Errors
743    ///
744    /// Returns the first open, framing, DEFLATE, verification, input, output,
745    /// or output-limit failure. On error, `output` can contain a verified
746    /// prefix; writes are not rolled back.
747    pub fn decode_path<P, W>(&self, path: P, output: &mut W) -> Result<DecodeReport, DecodeError>
748    where
749        P: AsRef<Path>,
750        W: Write,
751    {
752        let file = File::open(path).map_err(|error| DecodeError::input_io(0, error))?;
753        if supports_positional_reads(&file) {
754            self.decode(&file, output)
755        } else {
756            self.decode_stream(file, output)
757        }
758    }
759
760    /// Opens a compressed file and returns a `Read + Send` decompressed stream.
761    ///
762    /// A regular file is owned by the returned reader and accessed positionally
763    /// through every decode path. A non-regular path accepted by [`File::open`],
764    /// such as a FIFO or character device, is routed to
765    /// [`Decoder::stream_reader`] instead and decoded sequentially with the same
766    /// verification. Such a path previously failed, so no successful call
767    /// changes behaviour.
768    ///
769    /// # Errors
770    ///
771    /// Returns an input failure, or a framing failure detected while opening
772    /// the reader. Further decoding and verification failures are returned by
773    /// [`std::io::Read`] or [`DecoderReader::finish`].
774    pub fn open<P: AsRef<Path>>(&self, path: P) -> Result<DecoderReader, DecodeError> {
775        let file = File::open(path).map_err(|error| DecodeError::input_io(0, error))?;
776        if supports_positional_reads(&file) {
777            self.reader(file)
778        } else {
779            self.stream_reader(file)
780        }
781    }
782}
783
784/// Reports whether an opened file satisfies the stable-length positional-read
785/// contract required by the parallel decoder.
786fn supports_positional_reads(file: &File) -> bool {
787    file.metadata()
788        .is_ok_and(|metadata| metadata.file_type().is_file())
789}
790
791impl Default for Decoder {
792    fn default() -> Self {
793        DecoderBuilder::default()
794            .build()
795            .expect("the default decoder configuration is valid")
796    }
797}
798
799impl From<ConfigError> for io::Error {
800    fn from(error: ConfigError) -> Self {
801        Self::new(io::ErrorKind::InvalidInput, error)
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::{Decoder, MIB, supports_positional_reads};
808
809    #[test]
810    fn rejects_speculative_grid_smaller_than_one_mibibyte() {
811        let error = Decoder::builder()
812            .compressed_chunk_size(MIB - 1)
813            .build()
814            .unwrap_err();
815        assert_eq!(
816            error.to_string(),
817            "compressed_chunk_size must be at least 1 MiB"
818        );
819    }
820
821    #[test]
822    fn regular_files_satisfy_the_positional_contract() {
823        let file = std::fs::File::open(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap();
824        assert!(supports_positional_reads(&file));
825    }
826
827    #[cfg(unix)]
828    #[test]
829    fn seekable_non_regular_files_still_use_streaming() {
830        let device = std::fs::File::open("/dev/null").unwrap();
831        assert!(!supports_positional_reads(&device));
832    }
833}