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}