use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rs_poker::open_hand_history::{HandHistory, OpenHandHistoryWrapper, ohh_files_in_dir};
use crate::tui::state::GameLogEntry;
#[derive(Debug, thiserror::Error)]
pub enum HandStoreError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}
struct FileEntry {
path: PathBuf,
file: Option<File>,
}
struct Inner {
files: Vec<FileEntry>,
offsets: Vec<(usize, u64)>,
}
#[derive(Clone)]
pub struct HandStore(Arc<Mutex<Inner>>);
impl HandStore {
pub fn none() -> Self {
Self(Arc::new(Mutex::new(Inner {
files: Vec::new(),
offsets: Vec::new(),
})))
}
pub fn new(path: PathBuf) -> Self {
Self(Arc::new(Mutex::new(Inner {
files: vec![FileEntry { path, file: None }],
offsets: Vec::new(),
})))
}
pub fn from_existing(path: &Path) -> Result<Self, HandStoreError> {
let offsets = Self::scan_file_offsets(path)?;
let indexed: Vec<(usize, u64)> = offsets.into_iter().map(|o| (0, o)).collect();
Ok(Self(Arc::new(Mutex::new(Inner {
files: vec![FileEntry {
path: path.to_path_buf(),
file: None,
}],
offsets: indexed,
}))))
}
pub fn from_existing_dir(dir: &Path) -> Result<Self, HandStoreError> {
let entries = ohh_files_in_dir(dir)?;
let mut files = Vec::new();
let mut offsets = Vec::new();
for path in entries {
let file_idx = files.len();
let file_offsets = Self::scan_file_offsets(&path)?;
for o in file_offsets {
offsets.push((file_idx, o));
}
files.push(FileEntry { path, file: None });
}
Ok(Self(Arc::new(Mutex::new(Inner { files, offsets }))))
}
fn scan_file_offsets(path: &Path) -> Result<Vec<u64>, HandStoreError> {
let file = File::open(path)?;
let reader = BufReader::new(&file);
let mut offsets = Vec::new();
let mut pos: u64 = 0;
let mut in_record = false;
for line in reader.lines() {
let line = line?;
let line_bytes = line.len() as u64 + 1; if !line.trim().is_empty() {
if !in_record {
offsets.push(pos);
in_record = true;
}
} else {
in_record = false;
}
pos += line_bytes;
}
Ok(offsets)
}
pub fn push_offset(&self, offset: u64) {
let mut inner = self.0.lock().unwrap();
inner.offsets.push((0, offset));
}
pub fn fetch(&self, game_number: usize) -> Result<Option<HandHistory>, HandStoreError> {
let mut inner = self.0.lock().unwrap();
if inner.files.is_empty() {
return Ok(None);
}
let idx = game_number.saturating_sub(1);
if idx >= inner.offsets.len() {
return Ok(None);
}
let (file_idx, offset) = inner.offsets[idx];
let entry = &mut inner.files[file_idx];
let file = match entry.file {
Some(ref mut f) => f,
None => {
entry.file = Some(File::open(&entry.path)?);
entry.file.as_mut().unwrap()
}
};
file.seek(SeekFrom::Start(offset))?;
let mut reader = BufReader::new(&*file);
let mut line = String::new();
reader.read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
let wrapper: OpenHandHistoryWrapper = serde_json::from_str(trimmed)?;
Ok(Some(wrapper.ohh))
}
pub fn len(&self) -> usize {
self.0.lock().unwrap().offsets.len()
}
pub fn fetch_entry(&self, game_number: usize) -> Result<Option<GameLogEntry>, HandStoreError> {
let hand = match self.fetch(game_number)? {
Some(h) => h,
None => return Ok(None),
};
Ok(Some(GameLogEntry::from_hand(game_number, &hand)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rs_poker::open_hand_history::{GameType, OpenHandHistoryWrapper};
use std::io::Write;
use tempfile::NamedTempFile;
fn make_test_hand(game_number: &str) -> HandHistory {
HandHistory {
spec_version: "1.4.7".into(),
site_name: "test".into(),
network_name: "test".into(),
internal_version: "1.0".into(),
tournament: false,
tournament_info: None,
game_number: game_number.into(),
start_date_utc: None,
table_name: "test".into(),
table_handle: None,
table_skin: None,
game_type: GameType::Holdem,
bet_limit: None,
table_size: 2,
currency: "USD".into(),
dealer_seat: 0,
small_blind_amount: 5.0,
big_blind_amount: 10.0,
ante_amount: 0.0,
hero_player_id: None,
players: vec![],
rounds: vec![],
pots: vec![],
tournament_bounties: None,
}
}
fn write_hand(file: &mut std::fs::File, hand: HandHistory) -> u64 {
use std::io::Seek;
let offset = file.stream_position().unwrap();
let wrapped = OpenHandHistoryWrapper { ohh: hand };
serde_json::to_writer(&mut *file, &wrapped).unwrap();
writeln!(file).unwrap();
writeln!(file).unwrap();
offset
}
#[test]
fn test_none_store_fetch_returns_none() {
let store = HandStore::none();
assert_eq!(store.len(), 0);
let result = store.fetch(1).unwrap();
assert!(result.is_none());
}
#[test]
fn test_new_store_starts_empty() {
let store = HandStore::new(PathBuf::from("/tmp/nonexistent.ohh"));
assert_eq!(store.len(), 0);
let result = store.fetch(1).unwrap();
assert!(result.is_none());
}
#[test]
fn test_push_offset_and_fetch() {
let mut tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let offset1 = write_hand(tmp.as_file_mut(), make_test_hand("1"));
let offset2 = write_hand(tmp.as_file_mut(), make_test_hand("2"));
let store = HandStore::new(path);
store.push_offset(offset1);
store.push_offset(offset2);
assert_eq!(store.len(), 2);
let hand1 = store.fetch(1).unwrap().expect("should find game 1");
assert_eq!(hand1.game_number, "1");
let hand2 = store.fetch(2).unwrap().expect("should find game 2");
assert_eq!(hand2.game_number, "2");
assert!(store.fetch(3).unwrap().is_none());
}
#[test]
fn test_from_existing_scans_file() {
let mut tmp = NamedTempFile::new().unwrap();
write_hand(tmp.as_file_mut(), make_test_hand("10"));
write_hand(tmp.as_file_mut(), make_test_hand("20"));
write_hand(tmp.as_file_mut(), make_test_hand("30"));
let store = HandStore::from_existing(tmp.path()).unwrap();
assert_eq!(store.len(), 3);
let hand1 = store.fetch(1).unwrap().expect("game 1");
assert_eq!(hand1.game_number, "10");
let hand3 = store.fetch(3).unwrap().expect("game 3");
assert_eq!(hand3.game_number, "30");
}
#[test]
fn test_clone_shares_state() {
let store = HandStore::new(PathBuf::from("/tmp/test.ohh"));
let clone = store.clone();
store.push_offset(0);
store.push_offset(100);
assert_eq!(clone.len(), 2);
}
#[test]
fn test_fetch_game_zero_returns_none() {
let store = HandStore::none();
assert!(store.fetch(0).unwrap().is_none());
}
fn write_hand_to_path(path: &Path, hands: &[(&str,)]) {
let mut file = File::create(path).unwrap();
for (game_num,) in hands {
let wrapped = OpenHandHistoryWrapper {
ohh: make_test_hand(game_num),
};
serde_json::to_writer(&mut file, &wrapped).unwrap();
writeln!(file).unwrap();
writeln!(file).unwrap();
}
}
#[test]
fn test_from_existing_dir() {
let dir = tempfile::tempdir().unwrap();
write_hand_to_path(&dir.path().join("a.ohh"), &[("1",), ("2",)]);
write_hand_to_path(&dir.path().join("b.ohh"), &[("3",), ("4",)]);
let store = HandStore::from_existing_dir(dir.path()).unwrap();
assert_eq!(store.len(), 4);
let h1 = store.fetch(1).unwrap().expect("game 1");
assert_eq!(h1.game_number, "1");
let h2 = store.fetch(2).unwrap().expect("game 2");
assert_eq!(h2.game_number, "2");
let h3 = store.fetch(3).unwrap().expect("game 3");
assert_eq!(h3.game_number, "3");
let h4 = store.fetch(4).unwrap().expect("game 4");
assert_eq!(h4.game_number, "4");
assert!(store.fetch(5).unwrap().is_none());
}
#[test]
fn test_from_existing_dir_empty() {
let dir = tempfile::tempdir().unwrap();
let store = HandStore::from_existing_dir(dir.path()).unwrap();
assert_eq!(store.len(), 0);
}
#[test]
fn test_from_existing_dir_skips_non_ohh_files() {
let dir = tempfile::tempdir().unwrap();
write_hand_to_path(&dir.path().join("a.ohh"), &[("1",), ("2",)]);
std::fs::write(dir.path().join("results.json"), "{ \"unrelated\": true }").unwrap();
std::fs::write(dir.path().join("report.md"), "# Report\n").unwrap();
std::fs::write(dir.path().join("hands.jsonl"), "{}\n").unwrap();
let store = HandStore::from_existing_dir(dir.path()).unwrap();
assert_eq!(store.len(), 2);
let h1 = store.fetch(1).unwrap().expect("game 1");
assert_eq!(h1.game_number, "1");
}
}