Skip to main content

henad_explore/output/
read.rs

1//! Tables of an output directory read back, for a resume or a merge.
2//!
3//! A process that ends mid-write can leave a partial last record in `runs.csv` or `series.csv`, and series rows of a
4//! run whose row in `runs.csv` was never written. The readers here keep complete records only. A complete record
5//! ends in a line feed outside quotes.
6
7use std::cmp::Reverse;
8use std::collections::BinaryHeap;
9use std::fmt;
10use std::fs::File;
11use std::io::{self, BufRead as _, BufReader, Read as _, Seek as _, SeekFrom, Write};
12use std::ops::Range;
13use std::path::{Path, PathBuf};
14
15use henad_core::explore::outcome::RunStatus;
16use henad_core::export::csv::{CsvError, parse_records};
17
18use crate::output::runs_csv::ID_COLUMNS;
19
20/// One complete record of a `runs.csv`.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RunRecord {
23    /// Id the run is written with.
24    pub run_id: u64,
25    /// Config the run is a replicate of.
26    pub config_id: u64,
27    /// Replicate index of the run within its config.
28    pub rep: u64,
29    /// Hash that identifies the run's results.
30    pub run_key: u64,
31    /// Status the run ended with.
32    pub status: RunStatus,
33    /// Text of the record as written, its line ending included.
34    pub text: String,
35}
36
37impl RunRecord {
38    /// Returns the text of the record with its run id replaced by `run_id`.
39    pub fn renumbered(&self, run_id: u64) -> String {
40        let rest = self.text.find(',').map_or("\n", |comma| &self.text[comma..]);
41        format!("{run_id}{rest}")
42    }
43}
44
45/// Complete records of a `runs.csv`.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RunsCsv {
48    /// Column names before CSV escaping, `None` for a file with no complete header.
49    pub header: Option<Vec<String>>,
50    /// Records after the header, in file order.
51    pub records: Vec<RunRecord>,
52    /// Number of bytes up to the end of the file's last complete record.
53    pub complete_bytes: u64,
54    /// Size in bytes of the file, 0 for a missing file.
55    pub file_bytes: u64,
56}
57
58impl RunsCsv {
59    /// Reads the complete records of the `runs.csv` at `path`. A missing file has no header and no records.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ReadError`] when the file cannot be read, or a complete record is not a row of `runs.csv`.
64    pub fn read(path: &Path) -> Result<Self, ReadError> {
65        let io_error = |source| ReadError::Io {
66            path: path.to_owned(),
67            source,
68        };
69        let bytes = match std::fs::read(path) {
70            Ok(bytes) => bytes,
71            Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
72            Err(source) => return Err(io_error(source)),
73        };
74        let ends = record_ends(&bytes);
75        let complete_bytes = ends.last().copied().unwrap_or(0);
76        // A cut can split a character, so only the complete records need to be text.
77        let text = std::str::from_utf8(&bytes[..complete_bytes])
78            .map_err(|error| io_error(io::Error::new(io::ErrorKind::InvalidData, error)))?;
79        let mut table = Self {
80            header: None,
81            records: Vec::with_capacity(ends.len().saturating_sub(1)),
82            complete_bytes: complete_bytes as u64,
83            file_bytes: bytes.len() as u64,
84        };
85        let Some((&header_end, record_ends)) = ends.split_first() else {
86            return Ok(table);
87        };
88        let header = parse_one(text, 0..header_end, path, 1)?;
89        let layout = RecordLayout::find(&header, path)?;
90        let mut start = header_end;
91        for (index, &end) in record_ends.iter().enumerate() {
92            let record_number = index + 2;
93            let record_text = &text[start..end];
94            let fields = parse_one(text, start..end, path, record_number)?;
95            if fields.len() != header.len() {
96                return Err(ReadError::FieldCount {
97                    path: path.to_owned(),
98                    record_number,
99                    found: fields.len(),
100                    expected: header.len(),
101                });
102            }
103            table
104                .records
105                .push(layout.record(&header, &fields, record_text, path, record_number)?);
106            start = end;
107        }
108        table.header = Some(header);
109        Ok(table)
110    }
111}
112
113/// Positions of the columns of a `runs.csv` header that identify a run.
114struct RecordLayout {
115    run_id: usize,
116    config_id: usize,
117    rep: usize,
118    run_key: usize,
119    status: usize,
120}
121
122impl RecordLayout {
123    /// Finds the columns in `header`. The ids come first, and `status` is found from the end, since a parameter can
124    /// also be named `status`.
125    fn find(header: &[String], path: &Path) -> Result<Self, ReadError> {
126        let first = |column: &'static str| {
127            header
128                .iter()
129                .take(ID_COLUMNS.len())
130                .position(|name| name == column)
131                .ok_or_else(|| ReadError::MissingColumn {
132                    path: path.to_owned(),
133                    column,
134                })
135        };
136        Ok(Self {
137            run_id: first("run_id")?,
138            config_id: first("config_id")?,
139            rep: first("rep")?,
140            run_key: first("run_key")?,
141            status: header
142                .iter()
143                .rposition(|name| name == "status")
144                .ok_or_else(|| ReadError::MissingColumn {
145                    path: path.to_owned(),
146                    column: "status",
147                })?,
148        })
149    }
150
151    fn record(
152        &self,
153        header: &[String],
154        fields: &[String],
155        text: &str,
156        path: &Path,
157        record_number: usize,
158    ) -> Result<RunRecord, ReadError> {
159        let bad_field = |column: usize| ReadError::BadField {
160            path: path.to_owned(),
161            record_number,
162            column: header[column].clone(),
163            text: fields[column].clone(),
164        };
165        let number = |column: usize| fields[column].parse::<u64>().ok().ok_or_else(|| bad_field(column));
166        Ok(RunRecord {
167            run_id: number(self.run_id)?,
168            config_id: number(self.config_id)?,
169            rep: number(self.rep)?,
170            run_key: u64::from_str_radix(&fields[self.run_key], 16)
171                .ok()
172                .ok_or_else(|| bad_field(self.run_key))?,
173            status: fields[self.status].parse().ok().ok_or_else(|| bad_field(self.status))?,
174            text: text.to_owned(),
175        })
176    }
177}
178
179/// Returns the fields of the complete record that spans `record` in `text`.
180///
181/// `record_number` and `path` name the record in an error. The line of a [`CsvError`] counts from the start of `text`.
182pub(crate) fn parse_one(
183    text: &str,
184    record: Range<usize>,
185    path: &Path,
186    record_number: usize,
187) -> Result<Vec<String>, ReadError> {
188    parse_records(&text[record.clone()])
189        .map_err(|error| ReadError::Csv {
190            path: path.to_owned(),
191            record_number,
192            source: shifted(&error, text[..record.start].matches('\n').count()),
193        })?
194        .into_iter()
195        .next()
196        .ok_or_else(|| ReadError::FieldCount {
197            path: path.to_owned(),
198            record_number,
199            found: 0,
200            expected: 1,
201        })
202}
203
204/// Returns `error`, found in a text that starts after `lines` line feeds, with its line counted from the start of
205/// the whole text.
206pub(crate) fn shifted(error: &CsvError, lines: usize) -> CsvError {
207    match *error {
208        CsvError::UnterminatedQuote { line } => CsvError::UnterminatedQuote { line: line + lines },
209        CsvError::MisplacedQuote { line } => CsvError::MisplacedQuote { line: line + lines },
210    }
211}
212
213/// Returns the end of each complete record of `bytes`, just past its line feed.
214pub(crate) fn record_ends(bytes: &[u8]) -> Vec<usize> {
215    let mut ends = Vec::new();
216    let mut scan = RecordScan::FieldStart;
217    for (index, &byte) in bytes.iter().enumerate() {
218        let (next, record_ended) = scan.advance(byte);
219        scan = next;
220        if record_ended {
221            ends.push(index + 1);
222        }
223    }
224    ends
225}
226
227/// Position of a scan within a CSV record, in the grammar [`parse_records`] reads.
228///
229/// Note that a quote inside an unquoted field changes nothing. The record holding it ends at its line feed, and
230/// [`parse_records`] rejects it.
231///
232/// A quote that opens a field and is never closed makes the rest of the text one partial record. [`record_ends`]
233/// finds no end after it, and a reader of complete records leaves that record out without an error.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub(crate) enum RecordScan {
236    /// At the start of a field.
237    FieldStart,
238    /// Inside a field that does not open with a quote.
239    Unquoted,
240    /// Inside a quoted field.
241    Quoted,
242    /// Just past a quote inside a quoted field, which either closes the field or starts a doubled quote.
243    QuoteClosed,
244}
245
246impl RecordScan {
247    /// Returns the position after `byte`, and whether `byte` ends a record.
248    pub(crate) fn advance(self, byte: u8) -> (Self, bool) {
249        match (self, byte) {
250            (Self::Quoted, b'"') => (Self::QuoteClosed, false),
251            (Self::QuoteClosed | Self::FieldStart, b'"') | (Self::Quoted, _) => (Self::Quoted, false),
252            (_, b',') => (Self::FieldStart, false),
253            (_, b'\n') => (Self::FieldStart, true),
254            _ => (Self::Unquoted, false),
255        }
256    }
257}
258
259/// Layout of a `series.csv`: its header, and the stretches of complete lines in order of their run ids.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SeriesScan {
262    /// Path of the scanned file.
263    pub path: PathBuf,
264    /// Header line as written with its line ending, `None` for a file with no complete header.
265    pub header: Option<String>,
266    /// Byte ranges of the longest stretches of complete lines whose run ids never decrease, in file order.
267    pub segments: Vec<Range<u64>>,
268    /// Number of bytes up to the end of the file's last complete line.
269    pub complete_bytes: u64,
270    /// Size in bytes of the file, 0 for a missing file.
271    pub file_bytes: u64,
272    /// Offset of the first complete line the scan's rule left out.
273    pub first_dropped_offset: Option<u64>,
274    /// Whether a line that the rule kept comes after a line that it left out.
275    pub kept_after_dropped: bool,
276}
277
278impl SeriesScan {
279    /// Scans the `series.csv` at `path`, marking each complete line that `keep` rejects by its run id as left out. A
280    /// missing file has no header and no lines.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`ReadError`] when the file cannot be read, or a complete line does not start with a run id.
285    pub fn read(path: &Path, keep: impl Fn(u64) -> bool) -> Result<Self, ReadError> {
286        let io_error = |source| ReadError::Io {
287            path: path.to_owned(),
288            source,
289        };
290        let mut scan = Self {
291            path: path.to_owned(),
292            header: None,
293            segments: Vec::new(),
294            complete_bytes: 0,
295            file_bytes: 0,
296            first_dropped_offset: None,
297            kept_after_dropped: false,
298        };
299        let file = match File::open(path) {
300            Ok(file) => file,
301            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(scan),
302            Err(source) => return Err(io_error(source)),
303        };
304        scan.file_bytes = file.metadata().map_err(io_error)?.len();
305        let mut reader = BufReader::new(file);
306        let mut line = Vec::new();
307        if reader.read_until(b'\n', &mut line).map_err(io_error)? == 0 || !line.ends_with(b"\n") {
308            return Ok(scan);
309        }
310        scan.header = Some(String::from_utf8_lossy(&line).into_owned());
311        let mut offset = line.len() as u64;
312        scan.complete_bytes = offset;
313        let mut previous_id = None;
314        let mut record_number = 1;
315        loop {
316            line.clear();
317            let read = reader.read_until(b'\n', &mut line).map_err(io_error)?;
318            if read == 0 || !line.ends_with(b"\n") {
319                break;
320            }
321            record_number += 1;
322            let run_id = line_run_id(&line).ok_or_else(|| ReadError::BadField {
323                path: path.to_owned(),
324                record_number,
325                column: "run_id".to_owned(),
326                text: String::from_utf8_lossy(&line).trim_end().to_owned(),
327            })?;
328            let end = offset + read as u64;
329            match (previous_id, scan.segments.last_mut()) {
330                (Some(previous), Some(segment)) if run_id >= previous => segment.end = end,
331                _ => scan.segments.push(offset..end),
332            }
333            if keep(run_id) {
334                scan.kept_after_dropped |= scan.first_dropped_offset.is_some();
335            } else if scan.first_dropped_offset.is_none() {
336                scan.first_dropped_offset = Some(offset);
337            }
338            previous_id = Some(run_id);
339            offset = end;
340        }
341        scan.complete_bytes = offset;
342        Ok(scan)
343    }
344}
345
346/// Returns the run id at the start of a series line, or `None` when the line does not start with a run id.
347fn line_run_id(line: &[u8]) -> Option<u64> {
348    let comma = line.iter().position(|&byte| byte == b',')?;
349    std::str::from_utf8(&line[..comma]).ok()?.parse().ok()
350}
351
352/// Byte range of a series file to merge, and the index of its input directory.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct SeriesSegment {
355    /// Path of the series file.
356    pub path: PathBuf,
357    /// Byte range of the segment's lines in the file.
358    pub range: Range<u64>,
359    /// Index of the input directory the segment comes from, passed to the renumbering rule.
360    pub input_index: usize,
361}
362
363/// Writes `header`, then the lines of `segments` merged in order of their output run ids, to `dest`.
364///
365/// `renumber` maps a line's input index and run id to the run id it is written with, or to `None` to leave the line
366/// out. The lines of each segment must come in order of their new run ids. Lines of one run keep their order, and two
367/// segments with the same run id are taken in segment order.
368///
369/// # Errors
370///
371/// Returns the error of a read or a write, and [`io::ErrorKind::InvalidData`] for a line with no run id.
372pub(crate) fn merge_series(
373    dest: &mut dyn Write,
374    header: &str,
375    segments: &[SeriesSegment],
376    renumber: impl Fn(usize, u64) -> Option<u64>,
377) -> io::Result<()> {
378    dest.write_all(header.as_bytes())?;
379    let mut readers = segments
380        .iter()
381        .map(SegmentReader::open)
382        .collect::<io::Result<Vec<_>>>()?;
383    let mut queue = BinaryHeap::with_capacity(readers.len());
384    for (index, reader) in readers.iter_mut().enumerate() {
385        if let Some(run_id) = reader.advance(&renumber)? {
386            queue.push(Reverse((run_id, index)));
387        }
388    }
389    while let Some(Reverse((run_id, index))) = queue.pop() {
390        let reader = &mut readers[index];
391        write!(dest, "{run_id}")?;
392        dest.write_all(&reader.rest)?;
393        if let Some(next_id) = reader.advance(&renumber)? {
394            queue.push(Reverse((next_id, index)));
395        }
396    }
397    Ok(())
398}
399
400/// Reader of the lines of one segment.
401struct SegmentReader {
402    lines: io::Take<BufReader<File>>,
403    /// Index of the input directory the segment comes from.
404    input_index: usize,
405    /// Line being read.
406    line: Vec<u8>,
407    /// Text of the current line after its run id, from its first comma to its line ending.
408    rest: Vec<u8>,
409}
410
411impl SegmentReader {
412    fn open(segment: &SeriesSegment) -> io::Result<Self> {
413        let mut file = File::open(&segment.path)?;
414        file.seek(SeekFrom::Start(segment.range.start))?;
415        Ok(Self {
416            lines: BufReader::new(file).take(segment.range.end - segment.range.start),
417            input_index: segment.input_index,
418            line: Vec::new(),
419            rest: Vec::new(),
420        })
421    }
422
423    /// Reads up to the next line `renumber` keeps, and returns its new run id, or `None` at the end of the segment.
424    fn advance(&mut self, renumber: &impl Fn(usize, u64) -> Option<u64>) -> io::Result<Option<u64>> {
425        loop {
426            self.line.clear();
427            if self.lines.read_until(b'\n', &mut self.line)? == 0 {
428                return Ok(None);
429            }
430            let comma = self.line.iter().position(|&byte| byte == b',');
431            let run_id = line_run_id(&self.line)
432                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "a series line has no run id"))?;
433            if let (Some(new_id), Some(comma)) = (renumber(self.input_index, run_id), comma) {
434                self.rest.clear();
435                self.rest.extend_from_slice(&self.line[comma..]);
436                return Ok(Some(new_id));
437            }
438        }
439    }
440}
441
442/// A table that cannot be read back.
443#[derive(Debug)]
444pub enum ReadError {
445    /// Reading `path` failed.
446    Io {
447        /// Path of the table, or its bare file name for a table read without a path.
448        path: PathBuf,
449        /// Error from the reader, of kind `InvalidData` for text that is not UTF-8.
450        source: io::Error,
451    },
452    /// Record `record_number` of `path` is not valid CSV.
453    Csv {
454        /// Path of the table, or its bare file name for a table read without a path.
455        path: PathBuf,
456        /// Number of the record, counting the header as record 1.
457        record_number: usize,
458        /// Error from the CSV parser.
459        source: CsvError,
460    },
461    /// A header of `path` without the column `column`.
462    MissingColumn {
463        /// Path of the table, or its bare file name for a table read without a path.
464        path: PathBuf,
465        /// Name of the missing column.
466        column: &'static str,
467    },
468    /// Record `record_number` of `path` with a different number of fields from its header.
469    FieldCount {
470        /// Path of the table, or its bare file name for a table read without a path.
471        path: PathBuf,
472        /// Number of the record, counting the header as record 1.
473        record_number: usize,
474        /// Number of fields in the record.
475        found: usize,
476        /// Number of fields in the header.
477        expected: usize,
478    },
479    /// Field `column` of record `record_number` of `path`, holding `text` that the column cannot accept.
480    BadField {
481        /// Path of the table, or its bare file name for a table read without a path.
482        path: PathBuf,
483        /// Number of the record, counting the header as record 1.
484        record_number: usize,
485        /// Name of the column holding the field, or `value` for a stat value that a result set reads from `series.csv`.
486        column: String,
487        /// Text of the field, or of the whole line where a `series.csv` run id cannot be read.
488        text: String,
489    },
490}
491
492impl fmt::Display for ReadError {
493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494        match self {
495            Self::Io { path, .. } => write!(f, "cannot read '{}'", path.display()),
496            Self::Csv {
497                path, record_number, ..
498            } => write!(f, "record {record_number} of '{}' is not valid CSV", path.display()),
499            Self::MissingColumn { path, column } => write!(f, "'{}' has no '{column}' column", path.display()),
500            Self::FieldCount {
501                path,
502                record_number,
503                found,
504                expected,
505            } => write!(
506                f,
507                "record {record_number} of '{}' has {found} fields, expected {expected}",
508                path.display()
509            ),
510            Self::BadField {
511                path,
512                record_number,
513                column,
514                text,
515            } => write!(
516                f,
517                "record {record_number} of '{}' has invalid value '{text}' in column '{column}'",
518                path.display()
519            ),
520        }
521    }
522}
523
524impl std::error::Error for ReadError {
525    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
526        match self {
527            Self::Io { source, .. } => Some(source),
528            Self::Csv { source, .. } => Some(source),
529            Self::MissingColumn { .. } | Self::FieldCount { .. } | Self::BadField { .. } => None,
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use std::fs;
537
538    use henad_core::explore::outcome::RunStatus;
539    use henad_core::export::csv::CsvError;
540
541    use super::{ReadError, RunsCsv, SeriesScan, SeriesSegment, merge_series, record_ends};
542    use crate::tests::support::ScratchDir;
543
544    const RUNS: &str = "\
545run_id,config_id,block,rep,seed,run_key,rate,status,note
5460,0,0,0,11,00000000000000aa,0.1,ok,
5471,0,0,1,12,00000000000000ab,0.1,panicked,\"while stepping,
548 it panicked\"
5492,1,0,0,11,00000000000000ba,0.2,ok,
5503,1,0,1,12,00000000000000bb,0.2,timed_";
551
552    /// Returns the length of `text` up to the end of its last line feed.
553    fn table_end(text: &str) -> usize {
554        text.rfind('\n').map_or(0, |index| index + 1)
555    }
556
557    #[test]
558    fn a_partial_last_record_is_left_out() {
559        let scratch = ScratchDir::new("partial-runs");
560        fs::create_dir_all(scratch.path()).expect("a scratch directory");
561        let path = scratch.path().join("runs.csv");
562        fs::write(&path, RUNS).expect("the table writes");
563        let table = RunsCsv::read(&path).expect("a valid runs.csv");
564        let ids: Vec<(u64, u64, u64, RunStatus)> = table
565            .records
566            .iter()
567            .map(|record| (record.run_id, record.config_id, record.rep, record.status))
568            .collect();
569        assert_eq!(
570            ids,
571            [
572                (0, 0, 0, RunStatus::Ok),
573                (1, 0, 1, RunStatus::Panicked),
574                (2, 1, 0, RunStatus::Ok)
575            ]
576        );
577        assert_eq!(table.records[2].run_key, 0xba);
578        assert_eq!(table.file_bytes, RUNS.len() as u64);
579        assert_eq!(
580            &RUNS[table.complete_bytes as usize..],
581            "3,1,0,1,12,00000000000000bb,0.2,timed_"
582        );
583        assert_eq!(
584            table.records[1].renumbered(7),
585            "7,0,0,1,12,00000000000000ab,0.1,panicked,\"while stepping,\n it panicked\"\n",
586            "a quoted line feed stays inside its record"
587        );
588
589        let cut_in_quotes = &RUNS[..RUNS.find("\n it").expect("the quoted line feed") + 1];
590        fs::write(&path, cut_in_quotes).expect("the table writes");
591        let table = RunsCsv::read(&path).expect("a valid runs.csv");
592        assert_eq!(table.records.len(), 1, "a line feed inside quotes ends no record");
593
594        let mut split_character = RUNS.as_bytes()[..table_end(RUNS)].to_vec();
595        split_character.extend_from_slice(&"4,2,0,0,11,00000000000000ca,0.3,ok,\u{e9}".as_bytes()[..36]);
596        fs::write(&path, &split_character).expect("the table writes");
597        let table = RunsCsv::read(&path).expect("a character cut in two is in the partial record");
598        assert_eq!(table.records.len(), 3);
599
600        let missing = RunsCsv::read(&scratch.path().join("absent.csv")).expect("a missing table reads as empty");
601        assert_eq!(
602            (missing.header, missing.records.len(), missing.file_bytes),
603            (None, 0, 0)
604        );
605    }
606
607    #[test]
608    fn a_stray_quote_is_refused_instead_of_hiding_the_records_after_it() {
609        let scratch = ScratchDir::new("stray-quote");
610        fs::create_dir_all(scratch.path()).expect("a scratch directory");
611        let path = scratch.path().join("runs.csv");
612        let stray = RUNS.replace("00000000000000ba,0.2,ok", "00000000000000ba,0.2\",ok");
613        fs::write(&path, &stray).expect("the table writes");
614        let error = RunsCsv::read(&path).expect_err("a quote inside an unquoted field");
615        assert!(
616            matches!(
617                error,
618                ReadError::Csv {
619                    record_number: 4,
620                    source: CsvError::MisplacedQuote { line: 5 },
621                    ..
622                }
623            ),
624            "the line counts the quoted line feed before it: {error:?}"
625        );
626
627        let text = "a,b\n\"say \"\"hi\"\"\",\"x\ny\"\n1,2";
628        assert_eq!(
629            record_ends(text.as_bytes()),
630            [4, 23],
631            "doubled and multi-line quotes stay in their field"
632        );
633    }
634
635    #[test]
636    fn series_segments_merge_in_run_order() {
637        let scratch = ScratchDir::new("series-merge");
638        fs::create_dir_all(scratch.path()).expect("a scratch directory");
639        let first = scratch.path().join("first.csv");
640        let second = scratch.path().join("second.csv");
641        fs::write(&first, "run_id,tick,Cells\n0,0,1\n0,5,2\n4,0,1\n1,0,7\n1,5,8\n9,0,3").expect("the table writes");
642        fs::write(&second, "run_id,tick,Cells\n2,0,4\n3,0,5\n").expect("the table writes");
643
644        let scan = SeriesScan::read(&first, |run_id| run_id != 4).expect("a valid series");
645        assert_eq!(scan.header.as_deref(), Some("run_id,tick,Cells\n"));
646        assert_eq!(scan.segments.len(), 2, "run 1 follows run 4");
647        assert!(scan.kept_after_dropped);
648        assert_eq!(scan.complete_bytes, scan.file_bytes - "9,0,3".len() as u64);
649        let other = SeriesScan::read(&second, |_| true).expect("a valid series");
650        assert_eq!((other.first_dropped_offset, other.kept_after_dropped), (None, false));
651
652        let mut segments: Vec<SeriesSegment> = scan
653            .segments
654            .iter()
655            .map(|range| SeriesSegment {
656                path: first.clone(),
657                range: range.clone(),
658                input_index: 0,
659            })
660            .collect();
661        segments.extend(other.segments.iter().map(|range| SeriesSegment {
662            path: second.clone(),
663            range: range.clone(),
664            input_index: 1,
665        }));
666        let mut merged = Vec::new();
667        merge_series(&mut merged, "run_id,tick,Cells\n", &segments, |input_index, run_id| {
668            (run_id != 4).then_some(run_id * 10 + input_index as u64)
669        })
670        .expect("the segments merge");
671        assert_eq!(
672            String::from_utf8(merged).expect("the lines are UTF-8"),
673            "run_id,tick,Cells\n0,0,1\n0,5,2\n10,0,7\n10,5,8\n21,0,4\n31,0,5\n"
674        );
675    }
676}