mod convert;
mod namebase;
mod search;
mod sink;
mod walk;
#[cfg(test)]
mod alloc;
#[cfg(test)]
mod real;
#[cfg(test)]
mod tests;
pub use convert::{ConvertStats, DEFAULT_BATCH, convert_parallel, for_each_game, for_each_range};
pub use namebase::{Entities, Found, Name, Via};
pub use search::{
AllOf, Filter, Hit, IdSet, Match, PositionQuery, PositionSearch, Range, Scan, SearchStats, any_player,
for_each_position_key, scan, scan_range,
};
pub use sink::{GameRef, GameSink};
pub use walk::{GameBuf, MovesBuf, Names};
pub use cbvault_format::error::{Error, Result};
use std::path::{Path, PathBuf};
use cbvault_format::cbh::Headers as CbhHeaders;
use cbvault_format::cbh::moves::GameMoves;
use cbvault_format::cbh::{Annotations, Entity, Flags, GameHeader, GameHeaderRef, Wide};
use cbvault_format::error::Role;
use cbvault_format::file::DbFile;
use cbvault_format::game::RecordKind;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Generation {
Classic,
TwoCbh,
Unknown,
}
impl Generation {
pub const fn as_str(self) -> &'static str {
match self {
Generation::Classic => "classic",
Generation::TwoCbh => "2CBH",
Generation::Unknown => "unknown",
}
}
}
impl std::fmt::Display for Generation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
const CLASSIC_MEMBERS: [&str; 19] = [
".cbh", ".cbg", ".cba", ".cbp", ".cbt", ".cbc", ".cbs", ".cbe", ".cbj", ".cbl", ".cbtt", ".cbm", ".flags", ".cit",
".cib", ".cit2", ".cib2", ".cbb", ".cbgi",
];
const TWOCBH_MEMBERS: [&str; 6] = [".2cbh", ".2cbg", ".2cba", ".2lid", ".2lgd", ".2lcd"];
const MANDATORY: [(&str, Role); 7] = [
(".cbh", Role::Headers),
(".cbg", Role::Moves),
(".cba", Role::Annotations),
(".cbp", Role::Entities),
(".cbt", Role::Entities),
(".cbc", Role::Entities),
(".cbs", Role::Entities),
];
pub(crate) fn sibling(stem: &Path, ext: &str) -> PathBuf {
let mut name = stem.as_os_str().to_owned();
name.push(ext);
let exact = PathBuf::from(name);
if exact.symlink_metadata().is_ok() {
return exact;
}
let (Some(dir), Some(want)) = (exact.parent(), exact.file_name()) else { return exact };
let want = want.to_string_lossy().to_ascii_lowercase();
let Ok(entries) = std::fs::read_dir(dir) else { return exact };
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().to_ascii_lowercase() == want {
return entry.path();
}
}
exact
}
fn stem_of(path: &Path) -> PathBuf {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return path.to_owned() };
let ext = format!(".{}", ext.to_ascii_lowercase());
let member = CLASSIC_MEMBERS.contains(&ext.as_str()) || TWOCBH_MEMBERS.contains(&ext.as_str());
if member { path.with_extension("") } else { path.to_owned() }
}
pub fn generation_of(path: impl AsRef<Path>) -> Generation {
let stem = stem_of(path.as_ref());
if [".cbh", ".cbg"].iter().any(|e| sibling(&stem, e).symlink_metadata().is_ok()) {
return Generation::Classic;
}
if [".2cbh", ".2cbg"].iter().any(|e| sibling(&stem, e).symlink_metadata().is_ok()) {
return Generation::TwoCbh;
}
Generation::Unknown
}
#[derive(Clone, Debug, Default)]
pub struct Members {
pub headers: PathBuf,
pub moves: PathBuf,
pub annotations: PathBuf,
pub players: PathBuf,
pub tournaments: PathBuf,
pub annotators: PathBuf,
pub sources: PathBuf,
pub wide: Option<PathBuf>,
pub teams: Option<PathBuf>,
pub flags: Option<PathBuf>,
pub tournament_tree: Option<PathBuf>,
pub cross_table: Option<PathBuf>,
}
impl Members {
pub fn optional(&self) -> Vec<(&'static str, &Path)> {
let found = [
(".cbj", &self.wide),
(".cbe", &self.teams),
(".flags", &self.flags),
(".cbtt", &self.tournament_tree),
(".cbl", &self.cross_table),
];
found.into_iter().filter_map(|(ext, path)| path.as_deref().map(|p| (ext, p))).collect()
}
pub fn optional_names(&self) -> Vec<&'static str> {
self.optional().into_iter().map(|(ext, _)| ext).collect()
}
}
#[derive(Debug)]
pub struct Database {
base: PathBuf,
generation: Generation,
members: Members,
headers: CbhHeaders,
entities: Entities,
wide: Option<Wide>,
flags: Option<Flags>,
moves: std::sync::OnceLock<DbFile>,
annotations: std::sync::OnceLock<Annotations>,
format_entities: std::sync::OnceLock<cbvault_format::cbh::Entities>,
}
impl Database {
pub fn open(path: impl AsRef<Path>) -> Result<Database> {
let stem = stem_of(path.as_ref());
match generation_of(&stem) {
Generation::Classic => {}
_ => return Err(Error::MissingFile { path: sibling(&stem, ".cbh"), role: Role::Headers }),
}
for (ext, role) in MANDATORY {
let path = sibling(&stem, ext);
if path.symlink_metadata().is_err() {
return Err(Error::MissingFile { path, role });
}
}
let headers = CbhHeaders::open(&stem)?;
let entities = Entities::open(&stem)?;
let wide = Wide::open_auto(&stem);
let flags = match Flags::open(&stem) {
Ok(flags) => Some(flags),
Err(Error::MissingFile { .. }) => None,
Err(Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let members = Members {
headers: sibling(&stem, ".cbh"),
moves: sibling(&stem, ".cbg"),
annotations: sibling(&stem, ".cba"),
players: sibling(&stem, ".cbp"),
tournaments: sibling(&stem, ".cbt"),
annotators: sibling(&stem, ".cbc"),
sources: sibling(&stem, ".cbs"),
wide: present(&stem, ".cbj"),
teams: entities.team_count().gt(&0).then(|| sibling(&stem, ".cbe")),
flags: flags.as_ref().map(|_| sibling(&stem, ".flags")),
tournament_tree: present(&stem, ".cbtt"),
cross_table: present(&stem, ".cbl"),
};
Ok(Database {
base: stem,
generation: Generation::Classic,
members,
headers,
entities,
wide,
flags,
moves: std::sync::OnceLock::new(),
annotations: std::sync::OnceLock::new(),
format_entities: std::sync::OnceLock::new(),
})
}
pub fn base(&self) -> &Path {
&self.base
}
pub fn generation(&self) -> Generation {
self.generation
}
pub fn members(&self) -> &Members {
&self.members
}
pub fn records(&self) -> u32 {
self.headers.records()
}
pub fn headers_file(&self) -> &CbhHeaders {
&self.headers
}
pub fn entities(&self) -> &Entities {
&self.entities
}
pub fn wide(&self) -> Option<&Wide> {
self.wide.as_ref()
}
pub fn flags(&self) -> Option<&Flags> {
self.flags.as_ref()
}
pub(crate) fn moves(&self) -> Result<&DbFile> {
if let Some(file) = self.moves.get() {
return Ok(file);
}
let file = DbFile::open(self.members.moves.clone())?;
Ok(self.moves.get_or_init(|| file))
}
pub(crate) fn annotations(&self) -> Result<&Annotations> {
if let Some(file) = self.annotations.get() {
return Ok(file);
}
let file = Annotations::open(&self.base)?;
Ok(self.annotations.get_or_init(|| file))
}
pub(crate) fn format_entities(&self) -> Result<&cbvault_format::cbh::Entities> {
if let Some(entities) = self.format_entities.get() {
return Ok(entities);
}
let entities = cbvault_format::cbh::Entities::open(&self.base)?;
Ok(self.format_entities.get_or_init(|| entities))
}
}
fn present(stem: &Path, ext: &str) -> Option<PathBuf> {
let path = sibling(stem, ext);
path.symlink_metadata().is_ok().then_some(path)
}
impl std::fmt::Debug for HeaderRef<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HeaderRef")
.field("id", &self.id)
.field("white", &String::from_utf8_lossy(self.white.last()))
.field("black", &String::from_utf8_lossy(self.black.last()))
.field("event", &String::from_utf8_lossy(self.event.last()))
.finish()
}
}
#[derive(Clone, Copy)]
pub struct HeaderRef<'a> {
pub id: u32,
pub header: GameHeader,
pub white: Name<'a>,
pub black: Name<'a>,
pub event: Name<'a>,
pub site: Name<'a>,
pub annotator: Name<'a>,
pub source: Name<'a>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HeaderStats {
pub games: u64,
pub texts: u64,
pub deleted: u64,
pub unknown: u64,
}
impl Default for Records<'_> {
fn default() -> Records<'static> {
Records::Mapped(&[])
}
}
enum Records<'db> {
Mapped(&'db [u8]),
Owned(Vec<u8>),
}
impl Records<'_> {
fn get(&self, first: u32, index: u32) -> Option<GameHeader> {
let at = index as usize * 46;
let bytes: &[u8; 46] = self.as_slice().get(at..at + 46)?.try_into().ok()?;
Some(GameHeader::from_bytes(first + index, bytes))
}
fn as_slice(&self) -> &[u8] {
match self {
Records::Mapped(slice) => slice,
Records::Owned(bytes) => bytes,
}
}
}
const LIST_BATCH: u32 = 8192;
#[derive(Debug)]
struct Memo<'db> {
white: Option<(u32, Name<'db>)>,
black: Option<(u32, Name<'db>)>,
event: Option<(u32, Name<'db>)>,
annotator: Option<(u32, Name<'db>)>,
source: Option<(u32, Name<'db>)>,
}
impl<'db> Memo<'db> {
fn new() -> Memo<'db> {
Memo { white: None, black: None, event: None, annotator: None, source: None }
}
fn resolve(&mut self, entities: &'db Entities, entity: Entity, id: u32) -> Result<Name<'db>> {
let slot = match (entity, id) {
(Entity::Player, _) if self.white.as_ref().is_some_and(|(seen, _)| *seen == id) => &mut self.white,
(Entity::Player, _) if self.black.as_ref().is_some_and(|(seen, _)| *seen == id) => &mut self.black,
(Entity::Player, _) => &mut self.white,
(Entity::Tournament, _) => &mut self.event,
(Entity::Annotator, _) => &mut self.annotator,
_ => &mut self.source,
};
if let Some((seen, name)) = *slot
&& seen == id
{
return Ok(name);
}
let name = entities.name(entity, id)?.unwrap_or(BLANK);
*slot = Some((id, name));
Ok(name)
}
}
const BLANK: Name<'static> = Name::blank();
pub struct Headers<'db> {
db: &'db Database,
at: u32,
last: u32,
batch: Records<'db>,
batch_first: u32,
batch_len: u32,
index: u32,
memo: Memo<'db>,
stats: HeaderStats,
}
impl<'db> Headers<'db> {
pub fn new(db: &'db Database) -> Headers<'db> {
Headers {
db,
at: 1,
last: db.records(),
batch: Records::Owned(Vec::new()),
batch_first: 1,
batch_len: 0,
index: 0,
memo: Memo::new(),
stats: HeaderStats::default(),
}
}
pub fn stats(&self) -> HeaderStats {
self.stats
}
pub fn count(&self) -> u64 {
self.stats.games
}
pub fn try_for_each(&mut self, mut f: impl FnMut(HeaderRef<'db>) -> Result<()>) -> Result<HeaderStats> {
for header in self.by_ref() {
f(header?)?;
}
Ok(self.stats)
}
fn fill(&mut self) -> Result<()> {
let count = LIST_BATCH.min(self.last - self.at + 1);
let bytes = count as usize * 46;
let at = u64::from(self.at) * 46;
let file = self.db.headers.db_file();
self.batch_first = self.at;
self.batch_len = count;
self.index = 0;
self.at += count;
self.batch = match file.slice_at(at, bytes) {
Some(slice) => Records::Mapped(slice),
None => {
let mut owned = match std::mem::replace(&mut self.batch, Records::Mapped(&[])) {
Records::Owned(bytes) => bytes,
Records::Mapped(_) => Vec::new(),
};
owned.clear();
owned.resize(bytes, 0);
self.db.headers.read_records(self.batch_first, count, &mut owned)?;
Records::Owned(owned)
}
};
Ok(())
}
}
impl<'db> Iterator for Headers<'db> {
type Item = Result<HeaderRef<'db>>;
fn next(&mut self) -> Option<Result<HeaderRef<'db>>> {
let db: &'db Database = self.db;
loop {
if self.index >= self.batch_len {
if self.at > self.last {
return None;
}
if let Err(e) = self.fill() {
return Some(Err(e));
}
}
let index = self.index;
self.index += 1;
let Some(header) = self.batch.get(self.batch_first, index) else { continue };
match header.kind() {
RecordKind::Game => {}
RecordKind::Text => {
self.stats.texts += 1;
continue;
}
RecordKind::Analysis | RecordKind::Unknown(_) => {
self.stats.unknown += 1;
continue;
}
}
if header.is_deleted() {
self.stats.deleted += 1;
continue;
}
let entities = db.entities();
let resolved = (|| -> Result<HeaderRef<'db>> {
let white = self.memo.resolve(entities, Entity::Player, header.white())?;
let black = self.memo.resolve(entities, Entity::Player, header.black())?;
let tournament = self.memo.resolve(entities, Entity::Tournament, header.tournament())?;
let event = Name::of(tournament.last());
let site = Name::of(tournament.first());
let annotator = self.memo.resolve(entities, Entity::Annotator, header.annotator())?;
let source = self.memo.resolve(entities, Entity::Source, header.source())?;
Ok(HeaderRef { id: header.id(), header, white, black, event, site, annotator, source })
})();
match resolved {
Ok(item) => {
self.stats.games += 1;
return Some(Ok(item));
}
Err(e) => return Some(Err(e)),
}
}
}
}
impl Database {
pub fn headers(&self) -> Headers<'_> {
Headers::new(self)
}
pub fn game_count(&self) -> Result<u64> {
let mut headers = self.headers();
let stats = {
for _ in headers.by_ref() {}
headers.stats()
};
Ok(stats.games)
}
pub fn header_ref(&self, id: u32) -> Result<GameHeaderRef<'_>> {
if id == 0 || id > self.headers.records() {
return Err(Error::NoSuchGame { id });
}
let at = u64::from(id) * 46;
let bytes = self
.headers
.db_file()
.slice_at(at, 46)
.and_then(|slice| <&[u8; 46]>::try_from(slice).ok())
.ok_or_else(|| Error::corrupt(self.headers.path(), at, "header record out of range"))?;
Ok(GameHeaderRef::from_bytes(id, bytes))
}
pub fn move_offset(&self, header: &GameHeaderRef<'_>) -> Result<u64> {
self.offset_of(header.id(), header.moves_offset(), header.annotations_offset())
}
pub fn offset_of(&self, id: u32, moves: u32, annotations: u32) -> Result<u64> {
let short = (moves, annotations);
Ok(match &self.wide {
Some(wide) => wide.offsets(id, short)?.0,
None => u64::from(short.0),
})
}
pub fn game<'a>(&'a self, id: u32, buf: &'a mut GameBuf) -> Result<GameRef<'a>> {
self.game_with(id, buf, buf.wants_annotations())
}
pub fn game_with<'a>(&'a self, id: u32, buf: &'a mut GameBuf, want_annotations: bool) -> Result<GameRef<'a>> {
let header = self.header_ref(id)?;
let at = self.move_offset(&header)?;
let mut record = Vec::new();
let bytes = convert::move_record(self.moves()?, at, &mut record)?;
let game = GameMoves::parse(&self.members.moves, bytes)?;
buf.walk(id, at, &game)?;
walk::resolve_names(&header, &self.entities, buf)?;
buf.view(id, header, self, want_annotations)
}
pub fn game_pgn(&self, id: u32, buf: &mut GameBuf) -> Result<String> {
self.game_pgn_with(id, buf, buf.wants_annotations())
}
pub fn game_pgn_with(&self, id: u32, buf: &mut GameBuf, want_annotations: bool) -> Result<String> {
let header = self.header_ref(id)?;
if !matches!(header.kind(), RecordKind::Game) {
return Err(Error::corrupt(self.base(), u64::from(id), format!("record {id} is not a game")));
}
let at = self.move_offset(&header)?;
let mut record = Vec::new();
let bytes = convert::move_record(self.moves()?, at, &mut record)?;
let game = GameMoves::parse(&self.members.moves, bytes)?;
let anns = if want_annotations && header.annotations_offset() != 0 {
Some(self.annotations()?.of_ref(&header, self.wide(), buf.annotation_scratch())?)
} else {
None
};
let entities = self.format_entities()?;
let mut writer = crate::pgn::PgnWriter::new();
let mut out = Vec::new();
writer
.write_game(&mut out, &header, entities, &game, anns.as_ref())
.map_err(|e| Error::corrupt(self.base(), u64::from(id), e.to_string()))?;
String::from_utf8(out).map_err(|e| Error::corrupt(self.base(), u64::from(id), e.to_string()))
}
pub fn record_batch<'a>(&'a self, first: u32, last: u32) -> Result<RecordBatch<'a>> {
RecordBatch::open(self, first, last)
}
}
pub struct RecordBatch<'a> {
records: Records<'a>,
first: u32,
index: u32,
len: u32,
}
impl<'a> RecordBatch<'a> {
fn open(db: &'a Database, first: u32, last: u32) -> Result<RecordBatch<'a>> {
let total = db.records();
let first = first.max(1);
let last = last.min(total);
if first > last {
return Ok(RecordBatch { records: Records::Mapped(&[]), first, index: 0, len: 0 });
}
let count = last - first + 1;
let bytes = count as usize * 46;
let at = u64::from(first) * 46;
let file = db.headers.db_file();
let records = match file.slice_at(at, bytes) {
Some(slice) => Records::Mapped(slice),
None => {
let mut owned = vec![0u8; bytes];
db.headers.read_records(first, count, &mut owned)?;
Records::Owned(owned)
}
};
Ok(RecordBatch { records, first, index: 0, len: count })
}
pub fn ids(&self) -> std::ops::RangeInclusive<u32> {
self.first..=self.first + self.len.saturating_sub(1)
}
pub fn len(&self) -> u32 {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
}
impl Iterator for RecordBatch<'_> {
type Item = GameHeader;
fn next(&mut self) -> Option<GameHeader> {
if self.index >= self.len {
return None;
}
let index = self.index;
self.index += 1;
self.records.get(self.first, index)
}
}