Lines
69.04 %
Functions
27.42 %
Branches
100 %
use crate::error::HookError;
use crate::format::{
AccountData, CommodityData, ENTITY_HEADER_SIZE, EntityHeader, EntityType, OUTPUT_HEADER_SIZE,
Operation, OutputHeader, SplitData, TagData, TransactionData,
};
pub struct OutputParser<'a> {
data: &'a [u8],
header: OutputHeader,
strings_base: usize,
}
#[derive(Debug, Clone)]
pub struct ParsedEntity {
pub entity_type: EntityType,
pub operation: Operation,
pub flags: u8,
pub id: [u8; 16],
pub parent_idx: i32,
pub data: EntityData,
pub enum EntityData {
Transaction {
post_date: i64,
enter_date: i64,
split_count: u32,
tag_count: u32,
is_multi_currency: bool,
},
Split {
account_id: [u8; 16],
commodity_id: [u8; 16],
value_num: i64,
value_denom: i64,
reconcile_state: u8,
reconcile_date: i64,
Tag {
name: String,
value: String,
Account {
parent_account_id: [u8; 16],
path: String,
Commodity {
symbol: String,
fraction: u32,
Unknown(Vec<u8>),
impl<'a> OutputParser<'a> {
pub fn new(data: &'a [u8], strings_base: usize) -> Result<Self, HookError> {
if data.len() < OUTPUT_HEADER_SIZE {
return Err(HookError::Parse("Output too small for header".to_string()));
let header = OutputHeader::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid output header magic".to_string()))?;
Ok(Self {
data,
header,
strings_base,
})
#[must_use]
pub fn entity_count(&self) -> u32 {
self.header.output_entity_count
pub fn output_start_idx(&self) -> u32 {
self.header.output_start_idx
fn read_string(&self, offset: u32, len: u16) -> Result<String, HookError> {
// strings_base is buffer end, offset is distance from end to string start
let start = self.strings_base.saturating_sub(offset as usize);
let end = start + len as usize;
if end > self.data.len() || start > self.strings_base {
return Err(HookError::Parse(format!(
"String offset {offset} + len {len} out of bounds"
)));
String::from_utf8(self.data[start..end].to_vec())
.map_err(|e| HookError::Parse(format!("Invalid UTF-8 string: {e}")))
pub fn entities(&self) -> impl Iterator<Item = Result<ParsedEntity, HookError>> + '_ {
let entity_count = { self.header.output_entity_count };
(0..entity_count).map(move |i| self.parse_entity(i))
fn entity_header_offset(&self, index: u32) -> Result<usize, HookError> {
let mut offset = OUTPUT_HEADER_SIZE;
for _ in 0..index {
if offset + ENTITY_HEADER_SIZE > self.data.len() {
return Err(HookError::Parse("Entity header out of bounds".to_string()));
let header = EntityHeader::from_bytes(&self.data[offset..])
.ok_or_else(|| HookError::Parse("Failed to parse entity header".to_string()))?;
offset += ENTITY_HEADER_SIZE + header.data_size as usize;
Ok(offset)
fn parse_entity(&self, index: u32) -> Result<ParsedEntity, HookError> {
let header_start = self.entity_header_offset(index)?;
let header_end = header_start + ENTITY_HEADER_SIZE;
if header_end > self.data.len() {
"Entity header {index} out of bounds"
let header = EntityHeader::from_bytes(&self.data[header_start..])
let entity_type_val = { header.entity_type };
let operation_val = { header.operation };
let flags = { header.flags };
let id = { header.id };
let parent_idx = { header.parent_idx };
let data_size = { header.data_size };
let entity_type = EntityType::try_from(entity_type_val)
.map_err(|()| HookError::Parse(format!("Unknown entity type: {entity_type_val}")))?;
let operation = Operation::try_from(operation_val)
.map_err(|()| HookError::Parse(format!("Unknown operation: {operation_val}")))?;
let data_start = header_end;
let data_end = data_start + data_size as usize;
if data_end > self.data.len() {
return Err(HookError::Parse("Entity data out of bounds".to_string()));
let data_bytes = &self.data[data_start..data_end];
let data = self.parse_entity_data(entity_type, data_bytes)?;
Ok(ParsedEntity {
entity_type,
operation,
flags,
id,
parent_idx,
fn parse_entity_data(
&self,
entity_type: EntityType,
data: &[u8],
) -> Result<EntityData, HookError> {
match entity_type {
EntityType::Transaction => {
let tx = TransactionData::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid transaction data".to_string()))?;
Ok(EntityData::Transaction {
post_date: tx.post_date,
enter_date: tx.enter_date,
split_count: tx.split_count,
tag_count: tx.tag_count,
is_multi_currency: tx.is_multi_currency != 0,
EntityType::Split => {
let split = SplitData::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid split data".to_string()))?;
Ok(EntityData::Split {
account_id: split.account_id,
commodity_id: split.commodity_id,
value_num: split.value_num,
value_denom: split.value_denom,
reconcile_state: split.reconcile_state,
reconcile_date: split.reconcile_date,
EntityType::Tag => {
let tag = TagData::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid tag data".to_string()))?;
let name_offset = { tag.name_offset };
let name_len = { tag.name_len };
let value_offset = { tag.value_offset };
let value_len = { tag.value_len };
let name = self.read_string(name_offset, name_len)?;
let value = self.read_string(value_offset, value_len)?;
Ok(EntityData::Tag { name, value })
EntityType::Account => {
let account = AccountData::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid account data".to_string()))?;
let name_offset = { account.name_offset };
let name_len = { account.name_len };
let path_offset = { account.path_offset };
let path_len = { account.path_len };
let path = self.read_string(path_offset, path_len)?;
Ok(EntityData::Account {
parent_account_id: account.parent_account_id,
name,
path,
tag_count: account.tag_count,
EntityType::Commodity => {
let commodity = CommodityData::from_bytes(data)
.ok_or_else(|| HookError::Parse("Invalid commodity data".to_string()))?;
let symbol_offset = { commodity.symbol_offset };
let symbol_len = { commodity.symbol_len };
let name_offset = { commodity.name_offset };
let name_len = { commodity.name_len };
let symbol = self.read_string(symbol_offset, symbol_len)?;
Ok(EntityData::Commodity {
symbol,
fraction: commodity.fraction,
tag_count: commodity.tag_count,
_ => Ok(EntityData::Unknown(data.to_vec())),
#[cfg(test)]
mod tests {
use super::*;
use crate::format::{EntityFlags, Operation, TAG_DATA_SIZE};
#[test]
fn test_parse_empty_output() {
let mut buffer = vec![0u8; OUTPUT_HEADER_SIZE + 100];
let header = OutputHeader::new(0);
buffer[..OUTPUT_HEADER_SIZE].copy_from_slice(&header.to_bytes());
let parser = OutputParser::new(&buffer, 0).unwrap();
assert_eq!(parser.entity_count(), 0);
assert_eq!(parser.entities().count(), 0);
fn test_parse_tag_entity() {
// Strings are written at the end of buffer, growing down
// Offsets are distance from buffer end to string start
let name_str = b"note";
let value_str = b"test value";
let entity_start = OUTPUT_HEADER_SIZE;
let data_start = entity_start + ENTITY_HEADER_SIZE;
let entity_end = data_start + TAG_DATA_SIZE;
// Buffer layout: [OutputHeader][EntityHeader][TagData][...padding...][value_str][name_str]
let buffer_len = entity_end + 50; // Some space for strings at end
let mut buffer = vec![0u8; buffer_len];
// Write strings at end of buffer
let name_pos = buffer_len - name_str.len();
let value_pos = name_pos - value_str.len();
buffer[name_pos..].copy_from_slice(name_str);
buffer[value_pos..value_pos + value_str.len()].copy_from_slice(value_str);
// Offsets are distance from buffer end
let name_offset = buffer_len - name_pos; // 4
let value_offset = buffer_len - value_pos; // 14
let mut header = OutputHeader::new(5);
header.output_entity_count = 1;
header.strings_offset = buffer_len as u32; // Buffer end
let entity_header = EntityHeader::new(
EntityType::Tag,
Operation::Create,
EntityFlags::make(false, false),
[0u8; 16],
0,
TAG_DATA_SIZE as u32,
);
buffer[entity_start..entity_start + ENTITY_HEADER_SIZE]
.copy_from_slice(&entity_header.to_bytes());
let tag_data = TagData {
name_offset: name_offset as u32,
value_offset: value_offset as u32,
name_len: name_str.len() as u16,
value_len: value_str.len() as u16,
reserved: [0; 4],
buffer[data_start..data_start + TAG_DATA_SIZE].copy_from_slice(&tag_data.to_bytes());
let parser = OutputParser::new(&buffer, buffer_len).unwrap();
let entities: Vec<_> = parser.entities().collect();
assert_eq!(entities.len(), 1);
let entity = entities[0].as_ref().unwrap();
assert_eq!(entity.entity_type, EntityType::Tag);
assert_eq!(entity.operation, Operation::Create);
if let EntityData::Tag { name, value } = &entity.data {
assert_eq!(name, "note");
assert_eq!(value, "test value");
} else {
panic!("Expected Tag data");