use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
use super::hand_history::{HandHistory, OpenHandHistoryWrapper};
const PREVIEW_LEN: usize = 160;
#[derive(Debug, thiserror::Error)]
pub enum ReaderError {
#[error("I/O error on line {line}: {source}")]
Io {
line: usize,
#[source]
source: io::Error,
},
#[error("parse error on line {line}: {source}\n record preview: {preview}")]
Parse {
line: usize,
preview: String,
#[source]
source: serde_json::Error,
},
#[error("failed to access {path}: {source}")]
Open {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("in {path}: {source}")]
InFile {
path: PathBuf,
#[source]
source: Box<ReaderError>,
},
}
pub struct HandReader {
state: State,
}
enum State {
Done,
Single(Stream),
Chain {
pending: VecDeque<PathBuf>,
current: Option<(PathBuf, Stream)>,
},
}
impl HandReader {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, ReaderError> {
let path = path.as_ref();
if path.is_dir() {
let pending = enumerate_ohh_files(path)?;
Ok(Self {
state: State::Chain {
pending,
current: None,
},
})
} else {
let stream = open_stream(path)?;
Ok(Self {
state: State::Single(stream),
})
}
}
pub fn from_reader<R: BufRead + 'static>(reader: R) -> Self {
Self {
state: State::Single(Stream::new(Box::new(reader))),
}
}
}
impl Iterator for HandReader {
type Item = Result<HandHistory, ReaderError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match &mut self.state {
State::Done => return None,
State::Single(stream) => return stream.next_hand(),
State::Chain { pending, current } => {
if let Some((path, stream)) = current.as_mut() {
if let Some(item) = stream.next_hand() {
return Some(item.map_err(|e| ReaderError::InFile {
path: path.clone(),
source: Box::new(e),
}));
}
*current = None;
}
match pending.pop_front() {
None => {
self.state = State::Done;
return None;
}
Some(path) => match open_stream(&path) {
Ok(stream) => *current = Some((path, stream)),
Err(e) => return Some(Err(e)),
},
}
}
}
}
}
}
pub fn has_ohh_extension(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.is_some_and(|ext| ext.eq_ignore_ascii_case("ohh"))
}
pub fn ohh_files_in_dir(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && has_ohh_extension(p))
.collect();
entries.sort();
Ok(entries)
}
fn enumerate_ohh_files(dir: &Path) -> Result<VecDeque<PathBuf>, ReaderError> {
ohh_files_in_dir(dir)
.map(VecDeque::from)
.map_err(|source| ReaderError::Open {
path: dir.to_path_buf(),
source,
})
}
fn open_stream(path: &Path) -> Result<Stream, ReaderError> {
let file = File::open(path).map_err(|source| ReaderError::Open {
path: path.to_path_buf(),
source,
})?;
Ok(Stream::new(Box::new(BufReader::new(file))))
}
struct Stream {
inner: Box<dyn BufRead>,
line: usize,
scratch: String,
record: String,
record_start: usize,
done: bool,
}
impl Stream {
fn new(inner: Box<dyn BufRead>) -> Self {
Self {
inner,
line: 0,
scratch: String::new(),
record: String::new(),
record_start: 0,
done: false,
}
}
fn next_hand(&mut self) -> Option<Result<HandHistory, ReaderError>> {
if self.done {
return None;
}
match self.read_record() {
Ok(true) => Some(self.parse_current()),
Ok(false) => {
self.done = true;
None
}
Err(e) => {
self.done = true;
Some(Err(e))
}
}
}
fn read_record(&mut self) -> Result<bool, ReaderError> {
self.record.clear();
self.record_start = 0;
loop {
self.scratch.clear();
let bytes =
self.inner
.read_line(&mut self.scratch)
.map_err(|source| ReaderError::Io {
line: self.line + 1,
source,
})?;
if bytes == 0 {
return Ok(!self.record.is_empty());
}
self.line += 1;
if self.scratch.trim().is_empty() {
if self.record.is_empty() {
continue;
}
return Ok(true);
}
if self.record.is_empty() {
self.record_start = self.line;
}
self.record.push_str(&self.scratch);
}
}
fn parse_current(&self) -> Result<HandHistory, ReaderError> {
let trimmed = self.record.trim();
serde_json::from_str::<OpenHandHistoryWrapper>(trimmed)
.map(|w| w.ohh)
.map_err(|source| ReaderError::Parse {
line: self.record_start,
preview: record_preview(trimmed),
source,
})
}
}
fn record_preview(record: &str) -> String {
let flat = record.replace('\n', " ");
let mut out = String::new();
for (i, c) in flat.chars().enumerate() {
if i >= PREVIEW_LEN {
out.push('…');
break;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::open_hand_history::{GameType, HandHistory, PlayerObj};
use std::io::{Cursor, Write};
use tempfile::{NamedTempFile, tempdir};
fn sample_hand(id: &str) -> HandHistory {
HandHistory {
spec_version: "1.4.7".into(),
site_name: "Site".into(),
network_name: "Net".into(),
internal_version: "1.0".into(),
tournament: false,
tournament_info: None,
game_number: id.into(),
start_date_utc: None,
table_name: "T".into(),
table_handle: None,
table_skin: None,
game_type: GameType::Holdem,
bet_limit: None,
table_size: 2,
currency: "USD".into(),
dealer_seat: 1,
small_blind_amount: 1.0,
big_blind_amount: 2.0,
ante_amount: 0.0,
hero_player_id: None,
players: vec![PlayerObj {
id: 1,
seat: 1,
name: "p".into(),
display: None,
starting_stack: 100.0,
player_bounty: None,
is_sitting_out: None,
}],
rounds: vec![],
pots: vec![],
tournament_bounties: None,
}
}
fn valid_blob(ids: &[&str]) -> Vec<u8> {
let mut buf = Vec::new();
for id in ids {
super::super::write_hand(&mut buf, sample_hand(id)).unwrap();
}
buf
}
fn ids(hands: Vec<HandHistory>) -> Vec<String> {
hands.into_iter().map(|h| h.game_number).collect()
}
fn reader_over(bytes: Vec<u8>) -> HandReader {
HandReader::from_reader(Cursor::new(bytes))
}
#[test]
fn empty_input_yields_no_hands() {
assert!(
reader_over(vec![])
.collect::<Result<Vec<_>, _>>()
.unwrap()
.is_empty()
);
}
#[test]
fn blank_only_input_yields_no_hands() {
assert!(
reader_over(b"\n\n\n".to_vec())
.collect::<Result<Vec<_>, _>>()
.unwrap()
.is_empty()
);
}
#[test]
fn reads_single_hand() {
let r = reader_over(valid_blob(&["42"]));
assert_eq!(ids(r.collect::<Result<Vec<_>, _>>().unwrap()), vec!["42"]);
}
#[test]
fn reads_many_hands_in_order() {
let r = reader_over(valid_blob(&["1", "2", "3"]));
assert_eq!(
ids(r.collect::<Result<Vec<_>, _>>().unwrap()),
vec!["1", "2", "3"]
);
}
#[test]
fn tolerates_extra_blank_lines_between_records() {
let mut blob = Vec::new();
super::super::write_hand(&mut blob, sample_hand("1")).unwrap();
blob.extend_from_slice(b"\n\n\n");
super::super::write_hand(&mut blob, sample_hand("2")).unwrap();
assert_eq!(
ids(reader_over(blob).collect::<Result<Vec<_>, _>>().unwrap()),
vec!["1", "2"]
);
}
#[test]
fn tolerates_leading_blank_lines() {
let mut blob: Vec<u8> = b"\n\n".to_vec();
blob.extend(valid_blob(&["1"]));
assert_eq!(
ids(reader_over(blob).collect::<Result<Vec<_>, _>>().unwrap()),
vec!["1"]
);
}
#[test]
fn tolerates_final_record_without_trailing_blank() {
let mut blob = serde_json::to_vec(&OpenHandHistoryWrapper {
ohh: sample_hand("9"),
})
.unwrap();
blob.push(b'\n');
assert_eq!(
ids(reader_over(blob).collect::<Result<Vec<_>, _>>().unwrap()),
vec!["9"]
);
}
#[test]
fn parses_pretty_printed_records() {
let a = serde_json::to_string_pretty(&OpenHandHistoryWrapper {
ohh: sample_hand("a"),
})
.unwrap();
let b = serde_json::to_string_pretty(&OpenHandHistoryWrapper {
ohh: sample_hand("b"),
})
.unwrap();
let blob = format!("{a}\n\n{b}\n").into_bytes();
assert_eq!(
ids(reader_over(blob).collect::<Result<Vec<_>, _>>().unwrap()),
vec!["a", "b"]
);
}
#[test]
fn parse_error_reports_line_and_does_not_halt_iteration() {
let mut blob = Vec::new();
super::super::write_hand(&mut blob, sample_hand("1")).unwrap();
blob.extend_from_slice(b"not valid json\n\n");
super::super::write_hand(&mut blob, sample_hand("3")).unwrap();
let results: Vec<_> = reader_over(blob).collect();
assert_eq!(results.len(), 3, "expected ok, err, ok");
assert_eq!(results[0].as_ref().unwrap().game_number, "1");
match results[1].as_ref() {
Err(ReaderError::Parse { line, preview, .. }) => {
assert_eq!(*line, 3);
assert!(preview.contains("not valid json"));
}
other => panic!("expected parse error, got {other:?}"),
}
assert_eq!(results[2].as_ref().unwrap().game_number, "3");
}
#[test]
fn open_file_reads_hands() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(&valid_blob(&["x", "y"])).unwrap();
let hands = HandReader::open(f.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(ids(hands), vec!["x", "y"]);
}
#[test]
fn open_directory_chains_files_in_sorted_order() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("b.ohh"), valid_blob(&["beta"])).unwrap();
std::fs::write(dir.path().join("a.ohh"), valid_blob(&["alpha1", "alpha2"])).unwrap();
std::fs::write(dir.path().join("report.md"), b"# skip me").unwrap();
std::fs::write(dir.path().join("skip.json"), b"not OHH").unwrap();
let hands = HandReader::open(dir.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(ids(hands), vec!["alpha1", "alpha2", "beta"]);
}
#[test]
fn directory_errors_are_annotated_with_filename() {
let dir = tempdir().unwrap();
let good = dir.path().join("good.ohh");
let bad = dir.path().join("zbad.ohh"); std::fs::write(&good, valid_blob(&["g"])).unwrap();
std::fs::write(&bad, b"not valid json\n\n").unwrap();
let results: Vec<_> = HandReader::open(dir.path()).unwrap().collect();
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_ref().unwrap().game_number, "g");
match results[1].as_ref() {
Err(ReaderError::InFile { path, source }) => {
assert_eq!(path, &bad);
assert!(matches!(**source, ReaderError::Parse { .. }));
}
other => panic!("expected InFile/Parse, got {other:?}"),
}
}
#[test]
fn open_missing_path_returns_open_error() {
match HandReader::open("/definitely/does/not/exist.ohh") {
Ok(_) => panic!("expected error, got Ok"),
Err(ReaderError::Open { path, .. }) => {
assert_eq!(path, Path::new("/definitely/does/not/exist.ohh"));
}
Err(other) => panic!("expected Open, got {other:?}"),
}
}
#[test]
fn open_empty_directory_yields_no_hands() {
let dir = tempdir().unwrap();
let hands = HandReader::open(dir.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(hands.is_empty());
}
#[test]
fn ohh_files_in_dir_filters_and_sorts() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("b.ohh"), b"").unwrap();
std::fs::write(dir.path().join("a.ohh"), b"").unwrap();
std::fs::write(dir.path().join("skip.json"), b"").unwrap();
std::fs::write(dir.path().join("README.md"), b"").unwrap();
let files = ohh_files_in_dir(dir.path()).unwrap();
let names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["a.ohh", "b.ohh"]);
}
#[test]
fn ohh_files_in_dir_missing_directory_is_error() {
assert!(ohh_files_in_dir(Path::new("/does/not/exist")).is_err());
}
#[test]
fn has_ohh_extension_is_case_insensitive() {
assert!(has_ohh_extension(Path::new("hand.ohh")));
assert!(has_ohh_extension(Path::new("hand.OHH")));
assert!(has_ohh_extension(Path::new("/tmp/a.OhH")));
assert!(!has_ohh_extension(Path::new("hand.json")));
assert!(!has_ohh_extension(Path::new("hand")));
}
#[test]
fn record_preview_flattens_newlines_and_truncates() {
assert_eq!(record_preview("a\nb\nc"), "a b c");
let long = "x".repeat(PREVIEW_LEN + 50);
let p = record_preview(&long);
assert!(p.ends_with('…'));
assert_eq!(p.chars().count(), PREVIEW_LEN + 1);
}
}