Skip to main content

scripting/
serializer.rs

1use std::collections::HashMap;
2
3use finance::split::Split;
4use finance::transaction::Transaction;
5
6use crate::format::{
7    AccountData, BASE_OFFSET, CommodityData, ContextType, ENTITY_HEADER_SIZE, EntityFlags,
8    EntityHeader, EntityType, GLOBAL_HEADER_SIZE, GlobalHeader, OUTPUT_HEADER_SIZE, Operation,
9    OutputHeader, SplitData, TAG_DATA_SIZE, TagData, TransactionData,
10};
11
12fn transaction_to_data(tx: &Transaction) -> TransactionData {
13    TransactionData {
14        post_date: tx.post_date.timestamp_millis(),
15        enter_date: tx.enter_date.timestamp_millis(),
16        split_count: 0,
17        tag_count: 0,
18        is_multi_currency: 0,
19        reserved: [0; 23],
20    }
21}
22
23fn split_to_data(split: &Split, account_name_offset: u32, account_name_len: u32) -> SplitData {
24    SplitData {
25        account_id: *split.account_id.as_bytes(),
26        commodity_id: *split.commodity_id.as_bytes(),
27        value_num: split.value_num,
28        value_denom: split.value_denom,
29        reconcile_state: split.reconcile_state.map_or(0, u8::from),
30        reserved: [0; 7],
31        reconcile_date: split
32            .reconcile_date
33            .map_or(0, |d: chrono::DateTime<chrono::Utc>| d.timestamp_millis()),
34        account_name_offset,
35        account_name_len,
36    }
37}
38
39pub struct MemorySerializer {
40    context_type: ContextType,
41    primary_entity_type: EntityType,
42    primary_entity_idx: u32,
43    entities: Vec<SerializedEntity>,
44    strings_pool: Vec<u8>,
45    string_cache: HashMap<String, (u32, u16)>,
46}
47
48struct SerializedEntity {
49    header: EntityHeader,
50    data: Vec<u8>,
51}
52
53/// Header fields shared by every `add_*` entry: identity, parenting, and
54/// the two flag bits (`is_primary`, `is_context`). Pulled out so each
55/// add_* fn signature stays under clippy's `too_many_arguments` cap.
56#[derive(Debug, Clone, Copy)]
57pub struct EntityHeaderArgs {
58    pub id: [u8; 16],
59    pub parent_idx: i32,
60    pub is_primary: bool,
61    pub is_context: bool,
62}
63
64/// Body fields specific to a transaction entity. Combined with
65/// [`EntityHeaderArgs`] by [`MemorySerializer::add_transaction`].
66#[derive(Debug, Clone, Copy)]
67pub struct TransactionArgs {
68    pub post_date: i64,
69    pub enter_date: i64,
70    pub split_count: u32,
71    pub tag_count: u32,
72    pub is_multi_currency: bool,
73}
74
75/// Body fields specific to a split entity. `account_name` borrows from the
76/// caller and is interned into the strings pool by
77/// [`MemorySerializer::add_split`] so a trigger script can read the posting
78/// account's display name via `SPLIT-ACCOUNT-NAME`.
79#[derive(Debug, Clone, Copy)]
80pub struct SplitArgs<'a> {
81    pub account_id: [u8; 16],
82    pub commodity_id: [u8; 16],
83    pub value_num: i64,
84    pub value_denom: i64,
85    pub reconcile_state: u8,
86    pub reconcile_date: i64,
87    pub account_name: &'a str,
88}
89
90/// Body fields specific to an account entity. The `name` and `path`
91/// borrow from the caller's scope and are interned into the strings
92/// pool by [`MemorySerializer::add_account`].
93#[derive(Debug, Clone, Copy)]
94pub struct AccountArgs<'a> {
95    pub parent_account_id: [u8; 16],
96    pub name: &'a str,
97    pub path: &'a str,
98    pub tag_count: u32,
99}
100
101/// Body fields specific to a commodity entity.
102#[derive(Debug, Clone, Copy)]
103pub struct CommodityArgs<'a> {
104    pub symbol: &'a str,
105    pub name: &'a str,
106    pub tag_count: u32,
107}
108
109/// Aggregate args for `add_transaction_from`: the source `Transaction`
110/// plus the runtime-supplied counters and primary flag the test harness
111/// can't infer from the row.
112#[derive(Debug, Clone, Copy)]
113pub struct TransactionFromArgs<'a> {
114    pub transaction: &'a Transaction,
115    pub is_primary: bool,
116    pub split_count: u32,
117    pub tag_count: u32,
118    pub is_multi_currency: bool,
119}
120
121impl Default for MemorySerializer {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl MemorySerializer {
128    #[must_use]
129    pub fn new() -> Self {
130        Self {
131            context_type: ContextType::EntityCreate,
132            primary_entity_type: EntityType::Transaction,
133            primary_entity_idx: 0,
134            entities: Vec::new(),
135            strings_pool: Vec::new(),
136            string_cache: HashMap::new(),
137        }
138    }
139
140    pub fn set_context(&mut self, context_type: ContextType, primary_entity_type: EntityType) {
141        self.context_type = context_type;
142        self.primary_entity_type = primary_entity_type;
143    }
144
145    pub fn set_primary(&mut self, entity_idx: u32) {
146        self.primary_entity_idx = entity_idx;
147    }
148
149    pub fn add_string(&mut self, s: &str) -> (u32, u16) {
150        if let Some(&cached) = self.string_cache.get(s) {
151            return cached;
152        }
153        let offset = self.strings_pool.len() as u32;
154        let len = s.len() as u16;
155        self.strings_pool.extend_from_slice(s.as_bytes());
156        self.string_cache.insert(s.to_string(), (offset, len));
157        (offset, len)
158    }
159
160    pub fn add_transaction(&mut self, header: EntityHeaderArgs, args: TransactionArgs) -> u32 {
161        let flags = EntityFlags::make(header.is_primary, header.is_context);
162        let data = TransactionData {
163            post_date: args.post_date,
164            enter_date: args.enter_date,
165            split_count: args.split_count,
166            tag_count: args.tag_count,
167            is_multi_currency: u8::from(args.is_multi_currency),
168            reserved: [0; 23],
169        };
170        let entity_header = EntityHeader::new(
171            EntityType::Transaction,
172            Operation::Nop,
173            flags,
174            header.id,
175            header.parent_idx,
176            0,
177            data.to_bytes().len() as u32,
178        );
179        let idx = self.entities.len() as u32;
180        self.entities.push(SerializedEntity {
181            header: entity_header,
182            data: data.to_bytes().to_vec(),
183        });
184        idx
185    }
186
187    pub fn add_split(&mut self, header: EntityHeaderArgs, args: SplitArgs) -> u32 {
188        let flags = EntityFlags::make(header.is_primary, header.is_context);
189        let (name_offset, _) = self.add_string(args.account_name);
190        let data = SplitData {
191            account_id: args.account_id,
192            commodity_id: args.commodity_id,
193            value_num: args.value_num,
194            value_denom: args.value_denom,
195            reconcile_state: args.reconcile_state,
196            reserved: [0; 7],
197            reconcile_date: args.reconcile_date,
198            account_name_offset: name_offset,
199            // The format gives the split account name a u32 length; take the full
200            // byte length rather than `add_string`'s u16 (which would wrap a name
201            // longer than 65535 bytes while the field claims u32 capacity).
202            account_name_len: args.account_name.len() as u32,
203        };
204        let entity_header = EntityHeader::new(
205            EntityType::Split,
206            Operation::Nop,
207            flags,
208            header.id,
209            header.parent_idx,
210            0,
211            data.to_bytes().len() as u32,
212        );
213        let idx = self.entities.len() as u32;
214        self.entities.push(SerializedEntity {
215            header: entity_header,
216            data: data.to_bytes().to_vec(),
217        });
218        idx
219    }
220
221    pub fn add_tag(
222        &mut self,
223        id: [u8; 16],
224        parent_idx: i32,
225        is_primary: bool,
226        is_context: bool,
227        name: &str,
228        value: &str,
229    ) -> u32 {
230        let flags = EntityFlags::make(is_primary, is_context);
231        let (name_offset, name_len) = self.add_string(name);
232        let (value_offset, value_len) = self.add_string(value);
233        let data = TagData {
234            name_offset,
235            value_offset,
236            name_len,
237            value_len,
238            reserved: [0; 4],
239        };
240        let header = EntityHeader::new(
241            EntityType::Tag,
242            Operation::Nop,
243            flags,
244            id,
245            parent_idx,
246            0,
247            TAG_DATA_SIZE as u32,
248        );
249        let idx = self.entities.len() as u32;
250        self.entities.push(SerializedEntity {
251            header,
252            data: data.to_bytes().to_vec(),
253        });
254        idx
255    }
256
257    pub fn add_account(&mut self, header: EntityHeaderArgs, args: AccountArgs<'_>) -> u32 {
258        let flags = EntityFlags::make(header.is_primary, header.is_context);
259        let (name_offset, name_len) = self.add_string(args.name);
260        let (path_offset, path_len) = self.add_string(args.path);
261        let data = AccountData {
262            parent_account_id: args.parent_account_id,
263            name_offset,
264            path_offset,
265            tag_count: args.tag_count,
266            name_len,
267            path_len,
268            reserved: [0; 16],
269        };
270        let entity_header = EntityHeader::new(
271            EntityType::Account,
272            Operation::Nop,
273            flags,
274            header.id,
275            header.parent_idx,
276            0,
277            data.to_bytes().len() as u32,
278        );
279        let idx = self.entities.len() as u32;
280        self.entities.push(SerializedEntity {
281            header: entity_header,
282            data: data.to_bytes().to_vec(),
283        });
284        idx
285    }
286
287    pub fn add_commodity(&mut self, header: EntityHeaderArgs, args: CommodityArgs<'_>) -> u32 {
288        let flags = EntityFlags::make(header.is_primary, header.is_context);
289        let (symbol_offset, symbol_len) = self.add_string(args.symbol);
290        let (name_offset, name_len) = self.add_string(args.name);
291        let data = CommodityData {
292            symbol_offset,
293            name_offset,
294            tag_count: args.tag_count,
295            symbol_len,
296            name_len,
297            reserved: [0; 16],
298        };
299        let entity_header = EntityHeader::new(
300            EntityType::Commodity,
301            Operation::Nop,
302            flags,
303            header.id,
304            header.parent_idx,
305            0,
306            data.to_bytes().len() as u32,
307        );
308        let idx = self.entities.len() as u32;
309        self.entities.push(SerializedEntity {
310            header: entity_header,
311            data: data.to_bytes().to_vec(),
312        });
313        idx
314    }
315
316    #[must_use]
317    pub fn entity_count(&self) -> u32 {
318        self.entities.len() as u32
319    }
320
321    pub fn add_transaction_from(&mut self, args: TransactionFromArgs<'_>) -> u32 {
322        let flags = EntityFlags::make(args.is_primary, false);
323        let mut data = transaction_to_data(args.transaction);
324        data.split_count = args.split_count;
325        data.tag_count = args.tag_count;
326        data.is_multi_currency = u8::from(args.is_multi_currency);
327        let header = EntityHeader::new(
328            EntityType::Transaction,
329            Operation::Nop,
330            flags,
331            *args.transaction.id.as_bytes(),
332            -1,
333            0,
334            data.to_bytes().len() as u32,
335        );
336        let idx = self.entities.len() as u32;
337        self.entities.push(SerializedEntity {
338            header,
339            data: data.to_bytes().to_vec(),
340        });
341        idx
342    }
343
344    pub fn add_split_from(&mut self, split: &Split, parent_idx: i32, account_name: &str) -> u32 {
345        let (name_offset, _) = self.add_string(account_name);
346        let data = split_to_data(split, name_offset, account_name.len() as u32);
347        let header = EntityHeader::new(
348            EntityType::Split,
349            Operation::Nop,
350            0,
351            *split.id.as_bytes(),
352            parent_idx,
353            0,
354            data.to_bytes().len() as u32,
355        );
356        let idx = self.entities.len() as u32;
357        self.entities.push(SerializedEntity {
358            header,
359            data: data.to_bytes().to_vec(),
360        });
361        idx
362    }
363
364    #[must_use]
365    pub fn finalize(mut self, output_size: u32) -> Vec<u8> {
366        let entity_count = self.entities.len() as u32;
367        let entities_offset = BASE_OFFSET + GLOBAL_HEADER_SIZE as u32;
368
369        let mut entities_total_size = 0u32;
370        for entity in &self.entities {
371            entities_total_size += ENTITY_HEADER_SIZE as u32 + entity.data.len() as u32;
372        }
373
374        let strings_pool_offset = entities_offset + entities_total_size;
375        let strings_pool_size = self.strings_pool.len() as u32;
376        let output_offset = strings_pool_offset + strings_pool_size;
377
378        let output_header = OutputHeader::new(entity_count);
379
380        let mut global_header = GlobalHeader::new(
381            self.context_type,
382            self.primary_entity_type,
383            entity_count,
384            self.primary_entity_idx,
385        );
386        global_header.entities_offset = entities_offset;
387        global_header.strings_pool_offset = strings_pool_offset;
388        global_header.strings_pool_size = strings_pool_size;
389        global_header.output_offset = output_offset;
390        global_header.output_size = output_size;
391
392        let total_size = GLOBAL_HEADER_SIZE
393            + entities_total_size as usize
394            + strings_pool_size as usize
395            + output_size as usize;
396        let mut buffer = vec![0u8; total_size];
397
398        buffer[..GLOBAL_HEADER_SIZE].copy_from_slice(global_header.as_bytes());
399
400        let headers_total = entity_count as usize * ENTITY_HEADER_SIZE;
401        let mut data_offset = entities_offset + headers_total as u32;
402        let mut write_pos = GLOBAL_HEADER_SIZE;
403
404        for entity in &mut self.entities {
405            entity.header.data_offset = data_offset;
406            data_offset += entity.data.len() as u32;
407        }
408
409        for entity in &self.entities {
410            let header_bytes = entity.header.to_bytes();
411            buffer[write_pos..write_pos + ENTITY_HEADER_SIZE].copy_from_slice(&header_bytes);
412            write_pos += ENTITY_HEADER_SIZE;
413        }
414
415        for entity in &self.entities {
416            buffer[write_pos..write_pos + entity.data.len()].copy_from_slice(&entity.data);
417            write_pos += entity.data.len();
418        }
419
420        buffer[write_pos..write_pos + self.strings_pool.len()].copy_from_slice(&self.strings_pool);
421        write_pos += self.strings_pool.len();
422
423        buffer[write_pos..write_pos + OUTPUT_HEADER_SIZE]
424            .copy_from_slice(&output_header.to_bytes());
425
426        buffer
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::format::MAGIC_NOMI;
434
435    #[test]
436    fn test_serializer_basic() {
437        let mut serializer = MemorySerializer::new();
438        serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
439
440        let tx_id = [1u8; 16];
441        let tx_idx = serializer.add_transaction(
442            EntityHeaderArgs {
443                id: tx_id,
444                parent_idx: -1,
445                is_primary: true,
446                is_context: false,
447            },
448            TransactionArgs {
449                post_date: 1000,
450                enter_date: 2000,
451                split_count: 2,
452                tag_count: 1,
453                is_multi_currency: false,
454            },
455        );
456        serializer.set_primary(tx_idx);
457
458        let split_id = [2u8; 16];
459        let account_id = [3u8; 16];
460        let commodity_id = [4u8; 16];
461        serializer.add_split(
462            EntityHeaderArgs {
463                id: split_id,
464                parent_idx: tx_idx as i32,
465                is_primary: false,
466                is_context: false,
467            },
468            SplitArgs {
469                account_id,
470                commodity_id,
471                value_num: -5000,
472                value_denom: 100,
473                reconcile_state: 0,
474                reconcile_date: 0,
475                account_name: "Assets:Test",
476            },
477        );
478
479        let tag_id = [5u8; 16];
480        serializer.add_tag(
481            tag_id,
482            tx_idx as i32,
483            false,
484            false,
485            "note",
486            "test transaction",
487        );
488
489        assert_eq!(serializer.entity_count(), 3);
490
491        let buffer = serializer.finalize(1024);
492
493        let header = GlobalHeader::from_bytes(&buffer).unwrap();
494        assert_eq!(header.magic, MAGIC_NOMI);
495        assert_eq!(header.input_entity_count, 3);
496        assert_eq!(header.context_type, ContextType::EntityCreate as u8);
497        assert_eq!(header.primary_entity_type, EntityType::Transaction as u8);
498    }
499
500    #[test]
501    fn split_account_name_round_trips_through_strings_pool() {
502        let mut ser = MemorySerializer::new();
503        ser.set_context(ContextType::EntityCreate, EntityType::Transaction);
504        let tx_idx = ser.add_transaction(
505            EntityHeaderArgs {
506                id: [1u8; 16],
507                parent_idx: -1,
508                is_primary: true,
509                is_context: false,
510            },
511            TransactionArgs {
512                post_date: 0,
513                enter_date: 0,
514                split_count: 1,
515                tag_count: 0,
516                is_multi_currency: false,
517            },
518        );
519        ser.set_primary(tx_idx);
520        ser.add_split(
521            EntityHeaderArgs {
522                id: [2u8; 16],
523                parent_idx: tx_idx as i32,
524                is_primary: false,
525                is_context: false,
526            },
527            SplitArgs {
528                account_id: [3u8; 16],
529                commodity_id: [4u8; 16],
530                value_num: -5000,
531                value_denom: 100,
532                reconcile_state: 0,
533                reconcile_date: 0,
534                account_name: "Metro",
535            },
536        );
537        let buf = ser.finalize(1024);
538
539        let header = GlobalHeader::from_bytes(&buf).unwrap();
540        // Header offsets are absolute wasm addresses (BASE_OFFSET-relative); the
541        // raw buffer starts at BASE_OFFSET, so index it after subtracting that.
542        let base = BASE_OFFSET as usize;
543        // Entity 1 is the split (entity 0 is the transaction).
544        let split_header_off = header.entities_offset as usize - base + ENTITY_HEADER_SIZE;
545        let split_header = EntityHeader::from_bytes(&buf[split_header_off..]).unwrap();
546        let split =
547            SplitData::from_bytes(&buf[split_header.data_offset as usize - base..]).unwrap();
548
549        let name_start =
550            header.strings_pool_offset as usize - base + split.account_name_offset as usize;
551        let name = &buf[name_start..name_start + split.account_name_len as usize];
552        assert_eq!(name, b"Metro");
553    }
554
555    #[test]
556    fn test_string_deduplication() {
557        let mut serializer = MemorySerializer::new();
558        let (offset1, len1) = serializer.add_string("test");
559        let (offset2, len2) = serializer.add_string("test");
560        let (offset3, _) = serializer.add_string("other");
561
562        assert_eq!(offset1, offset2);
563        assert_eq!(len1, len2);
564        assert_ne!(offset1, offset3);
565    }
566}