Skip to main content

ycd_reader/
lib.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::io::{self, BufRead, BufReader, Read, Seek};
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use strum_macros::{AsRefStr, EnumString};
8
9const DIGITS_PER_BLOCK: usize = 19;
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, AsRefStr, EnumString)]
12pub enum YcdHeaderInfoElem {
13    FileVersion,
14    Base,
15    FirstDigits,
16    TotalDigits,
17    TotalBlocks,
18    Blocksize,
19    BlockID,
20    EndHeader,
21}
22
23#[derive(Debug, Clone, Eq, PartialEq)]
24pub struct YcdProcessUnit {
25    pub process_no: i64,
26    pub start_digit: i64,
27    pub value: String,
28}
29
30impl YcdProcessUnit {
31    pub fn new(process_no: i64, start_digit: i64, value: String) -> Self {
32        Self {
33            process_no,
34            start_digit,
35            value,
36        }
37    }
38}
39
40struct YcdMetadata {
41    header: HashMap<YcdHeaderInfoElem, String>,
42    data_offset: u64,
43    digit_length: i64,
44    digit_start: i64,
45}
46
47#[derive(Debug)]
48pub struct YcdSeqBlockStream {
49    process_unit_size: usize,
50    file_stream: BufReader<File>,
51    digit_length: i64,
52    digit_start: i64,
53    decoded_digits: i64,
54    next_process_no: i64,
55    next_start_digit: i64,
56    current_process_unit: Option<YcdProcessUnit>,
57    surplus_digit_str: String,
58}
59
60impl YcdSeqBlockStream {
61    pub fn new<P: AsRef<Path>>(file_name: P, unit_size: i32) -> io::Result<Self> {
62        let process_unit_size = validate_unit_size(unit_size)?;
63        let path = file_name.as_ref();
64        let metadata = parse_metadata(path)?;
65        let mut file_stream = BufReader::new(File::open(path)?);
66        file_stream.seek(io::SeekFrom::Start(metadata.data_offset))?;
67
68        Ok(Self {
69            process_unit_size,
70            file_stream,
71            digit_length: metadata.digit_length,
72            digit_start: metadata.digit_start,
73            decoded_digits: 0,
74            next_process_no: 1,
75            next_start_digit: metadata.digit_start,
76            current_process_unit: None,
77            surplus_digit_str: String::new(),
78        })
79    }
80
81    /// Open a YCD file and begin sequential reading from an arbitrary 1-based digit position.
82    ///
83    /// `start_position` is the 1-based absolute digit index at which reading should start.
84    /// It must lie within the range covered by this file
85    /// (`digit_start .. digit_start + digit_length - 1`, inclusive).
86    ///
87    /// The `unit_size` and iteration interface are identical to [`Self::new`].
88    pub fn new_from<P: AsRef<Path>>(
89        file_name: P,
90        unit_size: i32,
91        start_position: i64,
92    ) -> io::Result<Self> {
93        let process_unit_size = validate_unit_size(unit_size)?;
94        let path = file_name.as_ref();
95        let metadata = parse_metadata(path)?;
96
97        let file_end = metadata
98            .digit_start
99            .checked_add(metadata.digit_length)
100            .and_then(|e| e.checked_sub(1))
101            .ok_or_else(|| invalid_data("Digit position overflow"))?;
102        if start_position < metadata.digit_start || start_position > file_end {
103            return Err(io::Error::new(
104                io::ErrorKind::InvalidInput,
105                format!(
106                    "Start position {start_position} is outside the file's range \
107                     [{}, {file_end}]",
108                    metadata.digit_start
109                ),
110            ));
111        }
112
113        let local_start = usize::try_from(
114            start_position
115                .checked_sub(metadata.digit_start)
116                .ok_or_else(|| invalid_data("local_start underflow"))?,
117        )
118        .map_err(|_| invalid_data("local_start overflows usize"))?;
119
120        let block_index = local_start / DIGITS_PER_BLOCK;
121        let offset_in_block = local_start % DIGITS_PER_BLOCK;
122
123        let seek_pos = metadata
124            .data_offset
125            .checked_add(
126                u64::try_from(block_index)
127                    .ok()
128                    .and_then(|bi| bi.checked_mul(8))
129                    .ok_or_else(|| invalid_data("Seek offset overflow"))?,
130            )
131            .ok_or_else(|| invalid_data("Seek offset overflow"))?;
132
133        let mut file_stream = BufReader::new(File::open(path)?);
134        file_stream.seek(io::SeekFrom::Start(seek_pos))?;
135
136        let mut decoded_digits = (block_index * DIGITS_PER_BLOCK) as i64;
137        let mut surplus_digit_str = String::new();
138
139        if offset_in_block > 0 {
140            let mut buffer = [0_u8; 8];
141            file_stream.read_exact(&mut buffer)?;
142            let number = u64::from_le_bytes(buffer);
143            let digits = format!("{number:019}");
144            if digits.len() != DIGITS_PER_BLOCK {
145                return Err(invalid_data(
146                    "A compressed block contains more than 19 decimal digits",
147                ));
148            }
149
150            let remaining = usize::try_from(metadata.digit_length - decoded_digits)
151                .map_err(|_| invalid_data("Invalid remaining digit count"))?;
152            let take = remaining.min(DIGITS_PER_BLOCK);
153            decoded_digits += take as i64;
154
155            surplus_digit_str.push_str(&digits[offset_in_block..take]);
156        }
157
158        Ok(Self {
159            process_unit_size,
160            file_stream,
161            digit_length: metadata.digit_length,
162            digit_start: metadata.digit_start,
163            decoded_digits,
164            next_process_no: 1,
165            next_start_digit: start_position,
166            current_process_unit: None,
167            surplus_digit_str,
168        })
169    }
170
171    pub fn has_next(&self) -> bool {
172        self.decoded_digits < self.digit_length || !self.surplus_digit_str.is_empty()
173    }
174
175    #[allow(clippy::should_implement_trait)]
176    pub fn next(&mut self) -> io::Result<&YcdProcessUnit> {
177        if !self.has_next() {
178            return Err(no_more_data_error());
179        }
180
181        let value = self.take_digits(self.process_unit_size)?;
182        let process_no = self.next_process_no;
183        let start_digit = self.next_start_digit;
184
185        self.next_process_no = self
186            .next_process_no
187            .checked_add(1)
188            .ok_or_else(|| invalid_data("Process number overflow"))?;
189        self.next_start_digit = self
190            .next_start_digit
191            .checked_add(value.len() as i64)
192            .ok_or_else(|| invalid_data("Digit position overflow"))?;
193        self.current_process_unit = Some(YcdProcessUnit::new(process_no, start_digit, value));
194
195        Ok(self
196            .current_process_unit
197            .as_ref()
198            .expect("unit was assigned"))
199    }
200
201    fn take_digits(&mut self, maximum: usize) -> io::Result<String> {
202        while self.surplus_digit_str.len() < maximum && self.decoded_digits < self.digit_length {
203            let block = self.read_digit_block()?;
204            self.surplus_digit_str.push_str(&block);
205        }
206
207        let take = maximum.min(self.surplus_digit_str.len());
208        let remainder = self.surplus_digit_str.split_off(take);
209        Ok(std::mem::replace(&mut self.surplus_digit_str, remainder))
210    }
211
212    fn read_digit_block(&mut self) -> io::Result<String> {
213        let mut buffer = [0_u8; 8];
214        self.file_stream.read_exact(&mut buffer)?;
215
216        let number = u64::from_le_bytes(buffer);
217        let digits = format!("{number:019}");
218        if digits.len() != DIGITS_PER_BLOCK {
219            return Err(invalid_data(
220                "A compressed block contains more than 19 decimal digits",
221            ));
222        }
223
224        let remaining = usize::try_from(self.digit_length - self.decoded_digits)
225            .map_err(|_| invalid_data("Invalid remaining digit count"))?;
226        let take = remaining.min(DIGITS_PER_BLOCK);
227        self.decoded_digits += take as i64;
228
229        Ok(digits[..take].to_string())
230    }
231}
232
233#[derive(Debug)]
234pub struct YcdMultiFileStream {
235    process_unit_size: usize,
236    streams: Vec<YcdSeqBlockStream>,
237    current_stream: usize,
238    next_process_no: i64,
239    next_start_digit: i64,
240    current_process_unit: Option<YcdProcessUnit>,
241}
242
243impl YcdMultiFileStream {
244    pub fn new<P: AsRef<Path>>(file_names: &[P], unit_size: i32) -> io::Result<Self> {
245        let process_unit_size = validate_unit_size(unit_size)?;
246        if file_names.is_empty() {
247            return Err(io::Error::new(
248                io::ErrorKind::InvalidInput,
249                "At least one YCD file is required",
250            ));
251        }
252
253        let mut streams = Vec::with_capacity(file_names.len());
254        for file_name in file_names {
255            streams.push(YcdSeqBlockStream::new(file_name, unit_size)?);
256        }
257
258        for pair in streams.windows(2) {
259            let expected_start = pair[0]
260                .digit_start
261                .checked_add(pair[0].digit_length)
262                .ok_or_else(|| invalid_data("Digit position overflow"))?;
263            if pair[1].digit_start != expected_start {
264                return Err(io::Error::new(
265                    io::ErrorKind::InvalidInput,
266                    format!(
267                        "YCD files are not contiguous: expected digit {expected_start}, found {}",
268                        pair[1].digit_start
269                    ),
270                ));
271            }
272        }
273
274        let next_start_digit = streams[0].digit_start;
275        Ok(Self {
276            process_unit_size,
277            streams,
278            current_stream: 0,
279            next_process_no: 1,
280            next_start_digit,
281            current_process_unit: None,
282        })
283    }
284
285    /// Open a contiguous list of YCD files and begin sequential reading from
286    /// an arbitrary 1-based digit position.
287    ///
288    /// `start_position` is the 1-based absolute digit index at which reading
289    /// should start.  It must lie within the combined range of all files in
290    /// the list.  All files in `file_names` are validated for header
291    /// correctness and list continuity before any payload I/O begins.
292    ///
293    /// Files that end before `start_position` are skipped entirely; only the
294    /// file that contains `start_position` (and all subsequent files) are
295    /// opened for streaming.
296    ///
297    /// The `unit_size` and iteration interface are identical to [`Self::new`].
298    pub fn new_from<P: AsRef<Path>>(
299        file_names: &[P],
300        unit_size: i32,
301        start_position: i64,
302    ) -> io::Result<Self> {
303        let process_unit_size = validate_unit_size(unit_size)?;
304        if file_names.is_empty() {
305            return Err(io::Error::new(
306                io::ErrorKind::InvalidInput,
307                "At least one YCD file is required",
308            ));
309        }
310        if start_position < 1 {
311            return Err(io::Error::new(
312                io::ErrorKind::InvalidInput,
313                "Start position must be >= 1",
314            ));
315        }
316
317        let file_infos = collect_file_infos(file_names)?;
318
319        let list_start = file_infos[0].file_start as i64;
320        let last = file_infos
321            .last()
322            .expect("non-empty after collect_file_infos");
323        let list_end = last
324            .file_start
325            .checked_add(last.file_length)
326            .and_then(|e| e.checked_sub(1))
327            .ok_or_else(|| invalid_data("Digit range end overflow"))? as i64;
328
329        if start_position < list_start || start_position > list_end {
330            return Err(io::Error::new(
331                io::ErrorKind::InvalidInput,
332                format!(
333                    "Start position {start_position} is outside the available range \
334                     [{list_start}, {list_end}]"
335                ),
336            ));
337        }
338
339        let start_file_idx = file_infos
340            .partition_point(|fi| fi.file_start + fi.file_length - 1 < start_position as usize);
341
342        let mut streams = Vec::with_capacity(file_names.len() - start_file_idx);
343        for (i, file_name) in file_names[start_file_idx..].iter().enumerate() {
344            if i == 0 {
345                streams.push(YcdSeqBlockStream::new_from(
346                    file_name,
347                    unit_size,
348                    start_position,
349                )?);
350            } else {
351                streams.push(YcdSeqBlockStream::new(file_name, unit_size)?);
352            }
353        }
354
355        Ok(Self {
356            process_unit_size,
357            streams,
358            current_stream: 0,
359            next_process_no: 1,
360            next_start_digit: start_position,
361            current_process_unit: None,
362        })
363    }
364
365    pub fn has_next(&self) -> bool {
366        self.streams[self.current_stream..]
367            .iter()
368            .any(YcdSeqBlockStream::has_next)
369    }
370
371    #[allow(clippy::should_implement_trait)]
372    pub fn next(&mut self) -> io::Result<&YcdProcessUnit> {
373        if !self.has_next() {
374            return Err(no_more_data_error());
375        }
376
377        let mut value = String::with_capacity(self.process_unit_size);
378        while value.len() < self.process_unit_size && self.current_stream < self.streams.len() {
379            let remaining = self.process_unit_size - value.len();
380            let stream = &mut self.streams[self.current_stream];
381            value.push_str(&stream.take_digits(remaining)?);
382
383            if !stream.has_next() {
384                self.current_stream += 1;
385            }
386        }
387
388        let process_no = self.next_process_no;
389        let start_digit = self.next_start_digit;
390        self.next_process_no = self
391            .next_process_no
392            .checked_add(1)
393            .ok_or_else(|| invalid_data("Process number overflow"))?;
394        self.next_start_digit = self
395            .next_start_digit
396            .checked_add(value.len() as i64)
397            .ok_or_else(|| invalid_data("Digit position overflow"))?;
398        self.current_process_unit = Some(YcdProcessUnit::new(process_no, start_digit, value));
399
400        Ok(self
401            .current_process_unit
402            .as_ref()
403            .expect("unit was assigned"))
404    }
405}
406
407/// One entry in a [`YcdIndex`], representing a single YCD file.
408///
409/// The snapshot fields (`file_size`, `modified`) are recorded at index-build
410/// time and compared against the filesystem when a file is actually read.
411/// Any mismatch causes `read_digits` to return an explicit "stale index" error
412/// rather than silently reading potentially incorrect data.
413#[derive(Debug, Clone)]
414pub struct YcdIndexEntry {
415    /// Absolute path to the YCD file.
416    pub path: PathBuf,
417    /// Byte offset at which compressed 8-byte blocks start.
418    pub data_offset: u64,
419    /// 1-based absolute digit position of this file's first digit.
420    pub file_start: usize,
421    /// Total number of digits stored in this file.
422    pub file_length: usize,
423    /// File size in bytes recorded at index-build time.
424    pub file_size: u64,
425    /// Last-modified time recorded at index-build time.
426    ///
427    /// On platforms where `std::fs::Metadata::modified()` is unavailable,
428    /// this field is set to `SystemTime::UNIX_EPOCH` at build time and
429    /// `read_digits` will also read `UNIX_EPOCH` for the current mtime,
430    /// so the comparison will always succeed.  On those platforms stale-index
431    /// detection relies solely on `file_size`.
432    pub modified: SystemTime,
433}
434
435/// An in-memory index over a contiguous set of YCD files.
436///
437/// Building the index reads every file's header once and stores the
438/// resulting metadata.  Subsequent `read_digits` calls use binary search
439/// to locate the relevant file(s) and seek directly to the target block,
440/// skipping every other file entirely.
441///
442/// # Stale-index detection
443///
444/// Each time a file is actually read, its current `file_size` and
445/// last-modified time are compared against the snapshot taken at build
446/// time.  If any difference is detected, `read_digits` returns
447/// `io::ErrorKind::InvalidData` with an explicit "stale index" message.
448/// There is no silent fallback to a full-scan path.
449///
450/// # Example
451///
452/// ```rust,no_run
453/// use std::io;
454/// use ycd_reader::YcdIndex;
455///
456/// fn main() -> io::Result<()> {
457///     let files = [
458///         "Pi - Dec - Chudnovsky - 0.ycd",
459///         "Pi - Dec - Chudnovsky - 1.ycd",
460///     ];
461///
462///     // Build the index once (reads all headers).
463///     let index = YcdIndex::build(&files)?;
464///
465///     // Fast random-access — only the relevant file(s) are opened.
466///     let digits = index.read_digits(999_995, 20)?;
467///     assert_eq!(digits, "45815130927562832084");
468///
469///     Ok(())
470/// }
471/// ```
472#[derive(Debug, Clone)]
473pub struct YcdIndex {
474    entries: Vec<YcdIndexEntry>,
475}
476
477impl YcdIndex {
478    /// Build an index from an ordered, contiguous slice of YCD file paths.
479    ///
480    /// Every file's header is read and validated (base-10 constraint,
481    /// contiguity, no duplicates, no gaps).  On success the index is ready
482    /// for `read_digits` calls.
483    ///
484    /// # Errors
485    ///
486    /// Returns the same errors as [`YcdFileUtil::read_digits`] for header and
487    /// continuity problems.  Additionally returns `InvalidInput` when `files`
488    /// is empty.
489    pub fn build<P: AsRef<Path>>(files: &[P]) -> io::Result<Self> {
490        if files.is_empty() {
491            return Err(io::Error::new(
492                io::ErrorKind::InvalidInput,
493                "File list must not be empty",
494            ));
495        }
496
497        let raw_infos = collect_file_infos(files)?;
498        let mut entries = Vec::with_capacity(files.len());
499
500        for (path, fi) in files.iter().zip(raw_infos.iter()) {
501            let fs_meta = std::fs::metadata(path.as_ref())?;
502            let file_size = fs_meta.len();
503            let modified = fs_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
504
505            entries.push(YcdIndexEntry {
506                path: std::fs::canonicalize(path.as_ref())?,
507                data_offset: fi.data_offset,
508                file_start: fi.file_start,
509                file_length: fi.file_length,
510                file_size,
511                modified,
512            });
513        }
514
515        Ok(Self { entries })
516    }
517
518    /// Discard the current index and rebuild it from a new file list.
519    ///
520    /// On success `self` is replaced with the freshly built index.  If the
521    /// rebuild fails, `self` is left unchanged.
522    pub fn rebuild<P: AsRef<Path>>(&mut self, files: &[P]) -> io::Result<()> {
523        *self = Self::build(files)?;
524        Ok(())
525    }
526
527    /// Returns the number of YCD files in the index.
528    pub fn len(&self) -> usize {
529        self.entries.len()
530    }
531
532    /// Returns `true` if the index contains no files.
533    pub fn is_empty(&self) -> bool {
534        self.entries.is_empty()
535    }
536
537    /// Returns a slice of all index entries in digit order.
538    pub fn entries(&self) -> &[YcdIndexEntry] {
539        &self.entries
540    }
541
542    /// Read exactly `length` decimal digits starting at 1-based position
543    /// `one_based_start_position`.
544    ///
545    /// Binary search locates the first file that contains the start position.
546    /// Only the file(s) actually needed are opened; all other files are
547    /// skipped entirely.
548    ///
549    /// Before reading each file, its current `file_size` and last-modified
550    /// time are compared against the index snapshot.  A mismatch returns
551    /// `io::ErrorKind::InvalidData` with an "index is stale" message.
552    ///
553    /// # Errors
554    ///
555    /// | Condition | `io::ErrorKind` |
556    /// |---|---|
557    /// | Index is empty, position 0, or length 0 | `InvalidInput` |
558    /// | Start or end position outside the indexed range | `InvalidInput` |
559    /// | `start + length` overflows `usize` | `InvalidData` |
560    /// | File size or mtime differs from index snapshot | `InvalidData` |
561    /// | File does not exist | `NotFound` |
562    /// | Payload truncated within logical range | `UnexpectedEof` |
563    /// | Output string pre-allocation failure | `Other` |
564    pub fn read_digits(
565        &self,
566        one_based_start_position: usize,
567        length: usize,
568    ) -> io::Result<String> {
569        if self.entries.is_empty() {
570            return Err(io::Error::new(
571                io::ErrorKind::InvalidInput,
572                "Index is empty",
573            ));
574        }
575        if one_based_start_position == 0 {
576            return Err(io::Error::new(
577                io::ErrorKind::InvalidInput,
578                "Start position must be >= 1",
579            ));
580        }
581        if length == 0 {
582            return Err(io::Error::new(
583                io::ErrorKind::InvalidInput,
584                "Length must be >= 1",
585            ));
586        }
587
588        let list_start = self.entries[0].file_start;
589        let last = self.entries.last().expect("non-empty");
590        let list_end = last
591            .file_start
592            .checked_add(last.file_length)
593            .and_then(|e| e.checked_sub(1))
594            .ok_or_else(|| invalid_data("Digit range end overflow"))?;
595
596        if one_based_start_position < list_start || one_based_start_position > list_end {
597            return Err(io::Error::new(
598                io::ErrorKind::InvalidInput,
599                format!(
600                    "Start position {one_based_start_position} is outside the available range \
601                     [{list_start}, {list_end}]"
602                ),
603            ));
604        }
605
606        let end_position = one_based_start_position
607            .checked_add(length)
608            .ok_or_else(|| invalid_data("End position overflow (start + length)"))?
609            .checked_sub(1)
610            .expect("length >= 1");
611
612        if end_position > list_end {
613            return Err(io::Error::new(
614                io::ErrorKind::InvalidInput,
615                format!(
616                    "Requested range ends at {end_position} which exceeds the available \
617                     range end {list_end}"
618                ),
619            ));
620        }
621
622        let mut result = String::new();
623        result.try_reserve_exact(length).map_err(io::Error::other)?;
624
625        let start_idx = self
626            .entries
627            .partition_point(|e| e.file_start + e.file_length - 1 < one_based_start_position);
628
629        let mut remaining = length;
630
631        for entry in &self.entries[start_idx..] {
632            if remaining == 0 {
633                break;
634            }
635
636            let fs_meta = std::fs::metadata(&entry.path)?;
637            if fs_meta.len() != entry.file_size {
638                return Err(invalid_data(format!(
639                    "Index is stale: file size of '{}' changed \
640                     (expected {} bytes, found {} bytes)",
641                    entry.path.display(),
642                    entry.file_size,
643                    fs_meta.len(),
644                )));
645            }
646            let current_modified = fs_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
647            if current_modified != entry.modified {
648                return Err(invalid_data(format!(
649                    "Index is stale: last-modified time of '{}' changed",
650                    entry.path.display(),
651                )));
652            }
653
654            let digits_read = length - remaining;
655            let current_abs = one_based_start_position
656                .checked_add(digits_read)
657                .expect("validated end_position <= list_end");
658
659            let local_start = current_abs
660                .checked_sub(entry.file_start)
661                .ok_or_else(|| invalid_data("local_start underflow"))?;
662            let available = entry
663                .file_length
664                .checked_sub(local_start)
665                .ok_or_else(|| invalid_data("local_start exceeds file length"))?;
666            let to_take = available.min(remaining);
667
668            let (block_index, offset_in_block, blocks_to_read) =
669                compute_seek_params(local_start, to_take);
670
671            let seek_pos = entry
672                .data_offset
673                .checked_add(
674                    u64::try_from(block_index)
675                        .ok()
676                        .and_then(|bi| bi.checked_mul(8))
677                        .ok_or_else(|| invalid_data("Seek offset overflow"))?,
678                )
679                .ok_or_else(|| invalid_data("Seek offset overflow"))?;
680
681            let mut reader = BufReader::new(File::open(&entry.path)?);
682            reader.seek(io::SeekFrom::Start(seek_pos))?;
683
684            let mut skip = offset_in_block;
685            let mut taken = 0usize;
686
687            for _ in 0..blocks_to_read {
688                if taken >= to_take {
689                    break;
690                }
691
692                let mut buf = [0_u8; 8];
693                reader.read_exact(&mut buf).map_err(|e| match e.kind() {
694                    io::ErrorKind::UnexpectedEof => io::Error::new(
695                        io::ErrorKind::UnexpectedEof,
696                        "YCD payload is shorter than the logical digit range",
697                    ),
698                    _ => e,
699                })?;
700
701                let number = u64::from_le_bytes(buf);
702                let block_str = format!("{number:019}");
703                if block_str.len() != DIGITS_PER_BLOCK {
704                    return Err(invalid_data(
705                        "A compressed block contains more than 19 decimal digits",
706                    ));
707                }
708
709                let usable = &block_str[skip..];
710                skip = 0;
711
712                let can_take = usable.len().min(to_take - taken);
713                result.push_str(&usable[..can_take]);
714                taken += can_take;
715            }
716
717            remaining -= to_take;
718        }
719
720        debug_assert_eq!(result.len(), length);
721        Ok(result)
722    }
723}
724
725/// Per-file metadata used by [`YcdFileUtil::read_digits`].
726struct FileInfo {
727    /// Byte offset at which compressed 8-byte blocks start.
728    data_offset: u64,
729    /// 1-based absolute digit position of this file's first digit.
730    file_start: usize,
731    /// Total number of digits stored in this file.
732    file_length: usize,
733}
734
735/// Compute the compressed-block access parameters for a direct seek into a YCD payload.
736///
737/// Given a 0-based `local_start` offset within a file and the number of digits
738/// to read (`length`), returns `(block_index, offset_in_block, blocks_to_read)` where:
739///
740/// - `block_index`: 0-based index of the first 8-byte block to read.
741/// - `offset_in_block`: digits to skip at the start of the first decoded block.
742/// - `blocks_to_read`: the minimum number of blocks that cover the requested range.
743///
744/// This function has no side effects and is exposed for unit-testing the direct-seek logic.
745pub fn compute_seek_params(local_start: usize, length: usize) -> (usize, usize, usize) {
746    let block_index = local_start / DIGITS_PER_BLOCK;
747    let offset_in_block = local_start % DIGITS_PER_BLOCK;
748    let blocks_to_read = (offset_in_block + length).div_ceil(DIGITS_PER_BLOCK);
749    (block_index, offset_in_block, blocks_to_read)
750}
751
752pub struct YcdFileUtil;
753
754impl YcdFileUtil {
755    pub fn get_header_size<P: AsRef<Path>>(file_name: P) -> io::Result<i32> {
756        i32::try_from(parse_metadata(file_name.as_ref())?.data_offset)
757            .map_err(|_| invalid_data("YCD header is too large"))
758    }
759
760    pub fn get_ycd_header<P: AsRef<Path>>(
761        file_name: P,
762    ) -> io::Result<HashMap<YcdHeaderInfoElem, String>> {
763        Ok(parse_metadata(file_name.as_ref())?.header)
764    }
765
766    /// Read exactly `length` decimal digits of Pi starting at 1-based position
767    /// `one_based_start_position` from the concatenation of the given YCD files.
768    ///
769    /// # Arguments
770    ///
771    /// * `files` — An ordered, contiguous slice of YCD file paths (BlockID order).
772    ///   The first file need not have BlockID 0; absolute positions are derived
773    ///   from each file's header.
774    /// * `one_based_start_position` — 1-based index of the first digit to return.
775    ///   Position 1 is the first decimal digit (the digit immediately after "3.").
776    ///   The integer part, sign, and decimal point are never included.
777    /// * `length` — Number of digits to return. The result string is exactly
778    ///   `length` bytes of ASCII digits.
779    ///
780    /// # Returns
781    ///
782    /// A [`String`] containing exactly `length` ASCII decimal digits on success.
783    ///
784    /// # Errors
785    ///
786    /// | Condition | `io::ErrorKind` |
787    /// |---|---|
788    /// | Empty file list, position 0, or length 0 | `InvalidInput` |
789    /// | Start position out of range, or end exceeds range | `InvalidInput` |
790    /// | Gap, duplicate, or reversed files in list | `InvalidInput` |
791    /// | Invalid header, non-base-10, or corrupt compressed value | `InvalidData` |
792    /// | Position, offset, or end calculation overflow | `InvalidData` |
793    /// | File does not exist | `NotFound` |
794    /// | Payload truncated within logical range | `UnexpectedEof` |
795    /// | Output string pre-allocation failure | `Other` |
796    ///
797    /// # Notes
798    ///
799    /// * Files with `TotalDigits == 0` are treated as having exactly `Blocksize`
800    ///   digits. A "shortened" final file (where the actual payload is smaller than
801    ///   `Blocksize`) cannot be detected via the header alone; payload truncation
802    ///   within the logical range is reported as `UnexpectedEof`.
803    /// * The entire file list is validated before any I/O on the payload begins.
804    /// * Only the compressed blocks that cover the requested range are decoded;
805    ///   no byte before the target block is read.
806    /// * A large `length` requires the same amount of heap memory for the result.
807    pub fn read_digits<P: AsRef<Path>>(
808        files: &[P],
809        one_based_start_position: usize,
810        length: usize,
811    ) -> io::Result<String> {
812        // --- Basic argument validation ---
813        if files.is_empty() {
814            return Err(io::Error::new(
815                io::ErrorKind::InvalidInput,
816                "File list must not be empty",
817            ));
818        }
819        if one_based_start_position == 0 {
820            return Err(io::Error::new(
821                io::ErrorKind::InvalidInput,
822                "Start position must be >= 1",
823            ));
824        }
825        if length == 0 {
826            return Err(io::Error::new(
827                io::ErrorKind::InvalidInput,
828                "Length must be >= 1",
829            ));
830        }
831
832        // --- Parse and validate all file metadata up front ---
833        let file_infos = collect_file_infos(files)?;
834
835        // --- Compute available range ---
836        let list_start = file_infos[0].file_start;
837        let last = file_infos
838            .last()
839            .expect("non-empty after collect_file_infos");
840        let list_end = last
841            .file_start
842            .checked_add(last.file_length)
843            .and_then(|e| e.checked_sub(1))
844            .ok_or_else(|| invalid_data("Digit range end overflow"))?;
845
846        if one_based_start_position < list_start || one_based_start_position > list_end {
847            return Err(io::Error::new(
848                io::ErrorKind::InvalidInput,
849                format!(
850                    "Start position {one_based_start_position} is outside the available range \
851                     [{list_start}, {list_end}]"
852                ),
853            ));
854        }
855
856        // end_position is the 1-based index of the last digit we want (inclusive).
857        let end_position = one_based_start_position
858            .checked_add(length)
859            .ok_or_else(|| invalid_data("End position overflow (start + length)"))?
860            .checked_sub(1)
861            .expect("length >= 1 so this cannot underflow");
862
863        if end_position > list_end {
864            return Err(io::Error::new(
865                io::ErrorKind::InvalidInput,
866                format!(
867                    "Requested range ends at {end_position} which exceeds the available \
868                     range end {list_end}"
869                ),
870            ));
871        }
872
873        // --- Pre-allocate output ---
874        let mut result = String::new();
875        result.try_reserve_exact(length).map_err(io::Error::other)?;
876
877        // --- Find the first file that contains one_based_start_position ---
878        // partition_point returns the index of the first element for which the predicate is false.
879        // We want the first file whose last digit >= one_based_start_position.
880        let start_file_idx = file_infos.partition_point(|fi| {
881            // file's last digit = fi.file_start + fi.file_length - 1
882            fi.file_start + fi.file_length - 1 < one_based_start_position
883        });
884
885        // --- Read from each needed file ---
886        let mut remaining = length;
887
888        for (idx, fi) in file_infos[start_file_idx..].iter().enumerate() {
889            if remaining == 0 {
890                break;
891            }
892
893            // Absolute position of the digit we want to start reading from in this file.
894            let digits_read = length - remaining;
895            let current_abs = one_based_start_position
896                .checked_add(digits_read)
897                .expect("already validated end_position <= list_end so no overflow here");
898
899            // 0-based offset within this file.
900            let local_start = current_abs
901                .checked_sub(fi.file_start)
902                .ok_or_else(|| invalid_data("local_start underflow"))?;
903
904            // For subsequent files (idx > 0) local_start must be 0.
905            // For the first file, local_start may be anywhere inside the file.
906            let available = fi
907                .file_length
908                .checked_sub(local_start)
909                .ok_or_else(|| invalid_data("local_start exceeds file length"))?;
910            let to_take = available.min(remaining);
911
912            // Compute block-level seek parameters.
913            let (block_index, offset_in_block, blocks_to_read) =
914                compute_seek_params(local_start, to_take);
915
916            // Seek offset in bytes from the start of the file.
917            let seek_pos = fi
918                .data_offset
919                .checked_add(
920                    u64::try_from(block_index)
921                        .ok()
922                        .and_then(|bi| bi.checked_mul(8))
923                        .ok_or_else(|| invalid_data("Seek offset overflow"))?,
924                )
925                .ok_or_else(|| invalid_data("Seek offset overflow"))?;
926
927            // Open the file corresponding to this FileInfo.
928            // file_infos[start_file_idx + idx] corresponds to files[start_file_idx + idx].
929            let path = files[start_file_idx + idx].as_ref();
930            let mut reader = BufReader::new(File::open(path)?);
931            reader.seek(io::SeekFrom::Start(seek_pos))?;
932
933            // Decode compressed blocks, skipping the unwanted leading digits in the first block.
934            let mut skip = offset_in_block;
935            let mut taken = 0usize;
936
937            for _ in 0..blocks_to_read {
938                if taken >= to_take {
939                    break;
940                }
941
942                let mut buf = [0_u8; 8];
943                reader.read_exact(&mut buf).map_err(|e| match e.kind() {
944                    io::ErrorKind::UnexpectedEof => io::Error::new(
945                        io::ErrorKind::UnexpectedEof,
946                        "YCD payload is shorter than the logical digit range",
947                    ),
948                    _ => e,
949                })?;
950
951                let number = u64::from_le_bytes(buf);
952                let block_str = format!("{number:019}");
953                if block_str.len() != DIGITS_PER_BLOCK {
954                    return Err(invalid_data(
955                        "A compressed block contains more than 19 decimal digits",
956                    ));
957                }
958
959                // Skip unwanted leading digits in the first block.
960                let usable = &block_str[skip..];
961                skip = 0;
962
963                let can_take = usable.len().min(to_take - taken);
964                result.push_str(&usable[..can_take]);
965                taken += can_take;
966            }
967
968            remaining -= to_take;
969        }
970
971        debug_assert_eq!(result.len(), length);
972        Ok(result)
973    }
974}
975
976fn validate_unit_size(unit_size: i32) -> io::Result<usize> {
977    if unit_size < DIGITS_PER_BLOCK as i32 {
978        return Err(io::Error::new(
979            io::ErrorKind::InvalidInput,
980            "Unit size must be at least 19",
981        ));
982    }
983    Ok(unit_size as usize)
984}
985
986fn parse_metadata(path: &Path) -> io::Result<YcdMetadata> {
987    let mut reader = BufReader::new(File::open(path)?);
988    let mut header = HashMap::new();
989    let mut line = Vec::new();
990
991    loop {
992        line.clear();
993        if reader.read_until(b'\n', &mut line)? == 0 {
994            return Err(invalid_data("Missing EndHeader marker"));
995        }
996
997        let text = std::str::from_utf8(&line)
998            .map_err(|_| invalid_data("YCD header is not valid UTF-8"))?;
999        let text = text.trim_end_matches(&['\r', '\n'][..]);
1000        if text == YcdHeaderInfoElem::EndHeader.as_ref() {
1001            break;
1002        }
1003        if text.is_empty() || text.starts_with('#') {
1004            continue;
1005        }
1006
1007        let Some((name, value)) = text.split_once(':') else {
1008            continue;
1009        };
1010        if let Some(key) = header_key(name.trim()) {
1011            header.insert(key, value.trim().to_string());
1012        }
1013    }
1014
1015    consume_data_marker(&mut reader)?;
1016    let data_offset = reader.stream_position()?;
1017    let block_size = parse_positive_i64(&header, YcdHeaderInfoElem::Blocksize)?;
1018    let block_id = parse_nonnegative_i64(&header, YcdHeaderInfoElem::BlockID)?;
1019
1020    let version = required_value(&header, YcdHeaderInfoElem::FileVersion)?;
1021    if version.is_empty() {
1022        return Err(invalid_data("FileVersion must not be empty"));
1023    }
1024    if required_value(&header, YcdHeaderInfoElem::FirstDigits)?.is_empty() {
1025        return Err(invalid_data("FirstDigits must not be empty"));
1026    }
1027
1028    let base = parse_nonnegative_i64(&header, YcdHeaderInfoElem::Base)?;
1029    if base != 10 {
1030        return Err(invalid_data("Only base 10 YCD files are supported"));
1031    }
1032    let total_digits = parse_nonnegative_i64(&header, YcdHeaderInfoElem::TotalDigits)?;
1033    if header.contains_key(&YcdHeaderInfoElem::TotalBlocks) {
1034        parse_nonnegative_i64(&header, YcdHeaderInfoElem::TotalBlocks)?;
1035    }
1036
1037    let digit_offset = block_size
1038        .checked_mul(block_id)
1039        .ok_or_else(|| invalid_data("Digit position overflow"))?;
1040    let digit_length = if total_digits == 0 {
1041        block_size
1042    } else {
1043        let remaining = total_digits
1044            .checked_sub(digit_offset)
1045            .ok_or_else(|| invalid_data("BlockID starts beyond TotalDigits"))?;
1046        if remaining == 0 {
1047            return Err(invalid_data("BlockID starts beyond TotalDigits"));
1048        }
1049        remaining.min(block_size)
1050    };
1051    let digit_start = digit_offset
1052        .checked_add(1)
1053        .ok_or_else(|| invalid_data("Digit position overflow"))?;
1054
1055    Ok(YcdMetadata {
1056        header,
1057        data_offset,
1058        digit_length,
1059        digit_start,
1060    })
1061}
1062
1063fn consume_data_marker(reader: &mut BufReader<File>) -> io::Result<()> {
1064    let first = read_marker_byte(reader)?;
1065    match first {
1066        0 => Ok(()),
1067        b'\n' => require_nul(reader),
1068        b'\r' => {
1069            if read_marker_byte(reader)? != b'\n' {
1070                return Err(invalid_data("Invalid line ending after EndHeader"));
1071            }
1072            require_nul(reader)
1073        }
1074        _ => Err(invalid_data("Missing NUL data marker after EndHeader")),
1075    }
1076}
1077
1078fn require_nul(reader: &mut BufReader<File>) -> io::Result<()> {
1079    if read_marker_byte(reader)? == 0 {
1080        Ok(())
1081    } else {
1082        Err(invalid_data("Missing NUL data marker after EndHeader"))
1083    }
1084}
1085
1086fn read_marker_byte(reader: &mut BufReader<File>) -> io::Result<u8> {
1087    let mut byte = [0_u8; 1];
1088    reader
1089        .read_exact(&mut byte)
1090        .map_err(|error| match error.kind() {
1091            io::ErrorKind::UnexpectedEof => invalid_data("Incomplete YCD header"),
1092            _ => error,
1093        })?;
1094    Ok(byte[0])
1095}
1096
1097fn header_key(name: &str) -> Option<YcdHeaderInfoElem> {
1098    match name {
1099        "FileVersion" => Some(YcdHeaderInfoElem::FileVersion),
1100        "Base" => Some(YcdHeaderInfoElem::Base),
1101        "FirstDigits" => Some(YcdHeaderInfoElem::FirstDigits),
1102        "TotalDigits" => Some(YcdHeaderInfoElem::TotalDigits),
1103        "TotalBlocks" => Some(YcdHeaderInfoElem::TotalBlocks),
1104        "Blocksize" => Some(YcdHeaderInfoElem::Blocksize),
1105        "BlockID" => Some(YcdHeaderInfoElem::BlockID),
1106        _ => None,
1107    }
1108}
1109
1110fn required_value(
1111    header: &HashMap<YcdHeaderInfoElem, String>,
1112    key: YcdHeaderInfoElem,
1113) -> io::Result<&str> {
1114    header
1115        .get(&key)
1116        .map(String::as_str)
1117        .ok_or_else(|| invalid_data(format!("Missing required header field: {}", key.as_ref())))
1118}
1119
1120fn parse_positive_i64(
1121    header: &HashMap<YcdHeaderInfoElem, String>,
1122    key: YcdHeaderInfoElem,
1123) -> io::Result<i64> {
1124    let value = parse_nonnegative_i64(header, key)?;
1125    if value == 0 {
1126        return Err(invalid_data(format!("{} must be positive", key.as_ref())));
1127    }
1128    Ok(value)
1129}
1130
1131fn parse_nonnegative_i64(
1132    header: &HashMap<YcdHeaderInfoElem, String>,
1133    key: YcdHeaderInfoElem,
1134) -> io::Result<i64> {
1135    required_value(header, key)?
1136        .parse::<i64>()
1137        .map_err(|_| invalid_data(format!("{} must be a nonnegative integer", key.as_ref())))
1138        .and_then(|value| {
1139            if value < 0 {
1140                Err(invalid_data(format!(
1141                    "{} must be a nonnegative integer",
1142                    key.as_ref()
1143                )))
1144            } else {
1145                Ok(value)
1146            }
1147        })
1148}
1149
1150/// Parse metadata for every file in `files`, validate header correctness and
1151/// list continuity, and return a `Vec<FileInfo>` in the same order.
1152///
1153/// All files are validated regardless of whether the requested range touches them.
1154fn collect_file_infos<P: AsRef<Path>>(files: &[P]) -> io::Result<Vec<FileInfo>> {
1155    let mut infos: Vec<FileInfo> = Vec::with_capacity(files.len());
1156
1157    for path in files {
1158        let meta = parse_metadata(path.as_ref())?;
1159
1160        let file_start = usize::try_from(meta.digit_start)
1161            .map_err(|_| invalid_data("Digit start position overflows usize"))?;
1162        let file_length = usize::try_from(meta.digit_length)
1163            .map_err(|_| invalid_data("Digit length overflows usize"))?;
1164
1165        if let Some(prev) = infos.last() {
1166            let expected = prev
1167                .file_start
1168                .checked_add(prev.file_length)
1169                .ok_or_else(|| invalid_data("Digit position overflow in continuity check"))?;
1170            if file_start != expected {
1171                return Err(io::Error::new(
1172                    io::ErrorKind::InvalidInput,
1173                    format!(
1174                        "YCD files are not contiguous: expected start {expected}, found {file_start}"
1175                    ),
1176                ));
1177            }
1178        }
1179
1180        infos.push(FileInfo {
1181            data_offset: meta.data_offset,
1182            file_start,
1183            file_length,
1184        });
1185    }
1186
1187    Ok(infos)
1188}
1189
1190fn invalid_data(message: impl Into<String>) -> io::Error {
1191    io::Error::new(io::ErrorKind::InvalidData, message.into())
1192}
1193
1194fn no_more_data_error() -> io::Error {
1195    io::Error::new(io::ErrorKind::UnexpectedEof, "No more data to read")
1196}