1use 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RunRecord {
23 pub run_id: u64,
25 pub config_id: u64,
27 pub rep: u64,
29 pub run_key: u64,
31 pub status: RunStatus,
33 pub text: String,
35}
36
37impl RunRecord {
38 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RunsCsv {
48 pub header: Option<Vec<String>>,
50 pub records: Vec<RunRecord>,
52 pub complete_bytes: u64,
54 pub file_bytes: u64,
56}
57
58impl RunsCsv {
59 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 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
113struct RecordLayout {
115 run_id: usize,
116 config_id: usize,
117 rep: usize,
118 run_key: usize,
119 status: usize,
120}
121
122impl RecordLayout {
123 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
179pub(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
204pub(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
213pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub(crate) enum RecordScan {
236 FieldStart,
238 Unquoted,
240 Quoted,
242 QuoteClosed,
244}
245
246impl RecordScan {
247 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#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SeriesScan {
262 pub path: PathBuf,
264 pub header: Option<String>,
266 pub segments: Vec<Range<u64>>,
268 pub complete_bytes: u64,
270 pub file_bytes: u64,
272 pub first_dropped_offset: Option<u64>,
274 pub kept_after_dropped: bool,
276}
277
278impl SeriesScan {
279 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
346fn 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#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct SeriesSegment {
355 pub path: PathBuf,
357 pub range: Range<u64>,
359 pub input_index: usize,
361}
362
363pub(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
400struct SegmentReader {
402 lines: io::Take<BufReader<File>>,
403 input_index: usize,
405 line: Vec<u8>,
407 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 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#[derive(Debug)]
444pub enum ReadError {
445 Io {
447 path: PathBuf,
449 source: io::Error,
451 },
452 Csv {
454 path: PathBuf,
456 record_number: usize,
458 source: CsvError,
460 },
461 MissingColumn {
463 path: PathBuf,
465 column: &'static str,
467 },
468 FieldCount {
470 path: PathBuf,
472 record_number: usize,
474 found: usize,
476 expected: usize,
478 },
479 BadField {
481 path: PathBuf,
483 record_number: usize,
485 column: String,
487 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 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}