use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum MemoError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Invalid memo file header: {0}")]
InvalidHeader(String),
#[error("Unsupported memo version: {0}")]
UnsupportedVersion(String),
#[error("Memo block index {index} out of range (available={available})")]
BlockIndexOutOfRange {
index: u32,
available: u32,
},
#[error("Missing memo block terminator (expected 0x1A 0x1A)")]
MissingTerminator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoVersion {
DBase3,
DBase4,
FoxPro,
}
const TERMINATOR_SEARCH_BOUND: usize = 1_000_000;
const DBASE4_ENTRY_MAGIC: [u8; 4] = [0xFF, 0xFF, 0x08, 0x00];
const DBASE4_ENTRY_HEADER_LEN: usize = 8;
const DEFAULT_BLOCK_SIZE: u32 = 512;
pub struct MemoFile {
handle: File,
block_size: u32,
version: MemoVersion,
next_block: u32,
encoding: &'static encoding_rs::Encoding,
}
impl std::fmt::Debug for MemoFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemoFile")
.field("block_size", &self.block_size)
.field("version", &self.version)
.field("next_block", &self.next_block)
.finish_non_exhaustive()
}
}
impl MemoFile {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, MemoError> {
let mut handle = File::open(path.as_ref())?;
let mut header = [0u8; 512];
handle.read_exact(&mut header)?;
let next_block_bytes: [u8; 4] = header[0..4]
.try_into()
.map_err(|_| MemoError::InvalidHeader("could not read next_block field".to_string()))?;
let next_block = u32::from_le_bytes(next_block_bytes);
let version_byte = header[16];
let version = match version_byte {
0x03 => MemoVersion::DBase4,
0x00 => {
return Err(MemoError::UnsupportedVersion(
"dBase III memo files not supported (use dBase IV format)".to_string(),
));
}
other => {
return Err(MemoError::UnsupportedVersion(format!(
"Unknown memo version byte 0x{:02X}",
other
)));
}
};
let block_size_bytes: [u8; 2] = header[20..22]
.try_into()
.map_err(|_| MemoError::InvalidHeader("could not read block_size field".to_string()))?;
let block_size_raw = u16::from_le_bytes(block_size_bytes) as u32;
let block_size = if block_size_raw == 0 {
DEFAULT_BLOCK_SIZE
} else {
block_size_raw
};
Ok(Self {
handle,
block_size,
version,
next_block,
encoding: encoding_rs::UTF_8,
})
}
pub fn set_encoding(&mut self, encoding: &'static encoding_rs::Encoding) {
self.encoding = encoding;
}
pub fn block_size(&self) -> u32 {
self.block_size
}
pub fn version(&self) -> MemoVersion {
self.version
}
pub fn next_block(&self) -> u32 {
self.next_block
}
pub fn read_block(&mut self, index: u32) -> Result<String, MemoError> {
if index >= self.next_block {
return Err(MemoError::BlockIndexOutOfRange {
index,
available: self.next_block,
});
}
let offset = u64::from(index) * u64::from(self.block_size);
self.handle.seek(SeekFrom::Start(offset))?;
let mut hdr = [0u8; DBASE4_ENTRY_HEADER_LEN];
self.handle.read_exact(&mut hdr)?;
if hdr[0..4] != DBASE4_ENTRY_MAGIC {
self.handle.seek(SeekFrom::Start(offset))?;
return self.read_block_terminator_search();
}
let length_bytes: [u8; 4] = hdr[4..8].try_into().map_err(|_| {
MemoError::InvalidHeader("could not read entry length field".to_string())
})?;
let length = u32::from_le_bytes(length_bytes) as usize;
let payload_len = length.saturating_sub(DBASE4_ENTRY_HEADER_LEN);
let mut payload = vec![0u8; payload_len];
self.handle.read_exact(&mut payload)?;
while payload.ends_with(&[0x1A]) {
payload.pop();
}
Ok(self
.encoding
.decode_without_bom_handling(&payload)
.0
.into_owned())
}
fn read_block_terminator_search(&mut self) -> Result<String, MemoError> {
let mut buf = Vec::new();
let mut chunk = [0u8; 512];
loop {
let n = self.handle.read(&mut chunk)?;
if n == 0 {
return Err(MemoError::MissingTerminator);
}
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = buf.windows(2).position(|w| w == [0x1A, 0x1A]) {
buf.truncate(pos);
return Ok(self
.encoding
.decode_without_bom_handling(&buf)
.0
.into_owned());
}
if buf.len() > TERMINATOR_SEARCH_BOUND {
return Err(MemoError::MissingTerminator);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn build_dbase4_fixture(path: &Path, blocks: &[&str]) -> io::Result<()> {
let mut file = File::create(path)?;
let mut header = [0u8; 512];
let next_block = (blocks.len() as u32) + 1;
header[0..4].copy_from_slice(&next_block.to_le_bytes());
header[16] = 0x03; header[20..22].copy_from_slice(&512u16.to_le_bytes());
file.write_all(&header)?;
for text in blocks {
let mut block = vec![0u8; 512];
block[0..4].copy_from_slice(&DBASE4_ENTRY_MAGIC);
let length = (DBASE4_ENTRY_HEADER_LEN + text.len() + 2) as u32;
block[4..8].copy_from_slice(&length.to_le_bytes());
let text_bytes = text.as_bytes();
block[8..8 + text_bytes.len()].copy_from_slice(text_bytes);
block[8 + text_bytes.len()] = 0x1A;
block[8 + text_bytes.len() + 1] = 0x1A;
file.write_all(&block)?;
}
Ok(())
}
#[test]
fn test_memo_internal_open_and_read() {
let path = std::env::temp_dir().join("oxigdal_memo_internal_open.dbt");
build_dbase4_fixture(&path, &["Hello", "World"]).expect("build fixture");
let mut memo = MemoFile::open(&path).expect("open memo");
assert_eq!(memo.version(), MemoVersion::DBase4);
assert_eq!(memo.block_size(), 512);
assert_eq!(memo.read_block(1).expect("read block 1"), "Hello");
assert_eq!(memo.read_block(2).expect("read block 2"), "World");
let _ = std::fs::remove_file(&path);
}
}