1#![forbid(unsafe_code)]
36#![warn(missing_docs)]
37#![allow(clippy::missing_const_for_fn)]
39
40mod cache;
42mod convert;
43mod editor;
44mod helpers;
45mod utils;
46
47pub mod types;
49
50mod api;
52mod parsed_ledger;
53
54pub use api::{balances, format, parse, query, validate_source, version};
56pub use api::{hash_sources, parse_multi_file, query_multi_file, validate_multi_file};
57
58#[cfg(feature = "completions")]
59pub use api::bql_completions;
60
61#[cfg(feature = "plugins")]
62pub use api::{list_plugins, run_plugin};
63
64pub use api::expand_pads;
65pub use parsed_ledger::{Ledger, ParsedLedger};
66
67use wasm_bindgen::prelude::*;
68
69#[wasm_bindgen(typescript_custom_section)]
74const TS_TYPES: &'static str = r#"
75/** Error severity level. */
76export type Severity = 'error' | 'warning';
77
78/** Error with source location information. */
79export interface BeancountError {
80 message: string;
81 line?: number;
82 column?: number;
83 severity: Severity;
84}
85
86/** Amount with number and currency. */
87export interface Amount {
88 number: string;
89 currency: string;
90}
91
92/** Posting cost specification. */
93export interface PostingCost {
94 number_per?: string;
95 currency?: string;
96 date?: string;
97 label?: string;
98}
99
100/** A posting within a transaction. */
101export interface Posting {
102 account: string;
103 units?: Amount;
104 cost?: PostingCost;
105 price?: Amount;
106}
107
108/** Base directive with date. */
109interface BaseDirective {
110 date: string;
111}
112
113/** Transaction directive. */
114export interface TransactionDirective extends BaseDirective {
115 type: 'transaction';
116 flag: string;
117 payee?: string;
118 narration?: string;
119 tags: string[];
120 links: string[];
121 postings: Posting[];
122}
123
124/** Balance assertion directive. */
125export interface BalanceDirective extends BaseDirective {
126 type: 'balance';
127 account: string;
128 amount: Amount;
129}
130
131/** Open account directive. */
132export interface OpenDirective extends BaseDirective {
133 type: 'open';
134 account: string;
135 currencies: string[];
136 booking?: string;
137}
138
139/** Close account directive. */
140export interface CloseDirective extends BaseDirective {
141 type: 'close';
142 account: string;
143}
144
145/** All directive types. */
146export type Directive =
147 | TransactionDirective
148 | BalanceDirective
149 | OpenDirective
150 | CloseDirective
151 | { type: 'commodity'; date: string; currency: string }
152 | { type: 'pad'; date: string; account: string; source_account: string }
153 | { type: 'event'; date: string; event_type: string; value: string }
154 | { type: 'note'; date: string; account: string; comment: string }
155 | { type: 'document'; date: string; account: string; path: string }
156 | { type: 'price'; date: string; currency: string; amount: Amount }
157 | { type: 'query'; date: string; name: string; query_string: string }
158 | { type: 'custom'; date: string; custom_type: string };
159
160/** Ledger options. */
161export interface LedgerOptions {
162 operating_currencies: string[];
163 title?: string;
164}
165
166/** Parsed ledger. */
167export interface Ledger {
168 directives: Directive[];
169 options: LedgerOptions;
170}
171
172/** Result of parsing a Beancount file. */
173export interface ParseResult {
174 ledger?: Ledger;
175 errors: BeancountError[];
176}
177
178/** Result of validation. */
179export interface ValidationResult {
180 valid: boolean;
181 errors: BeancountError[];
182}
183
184/** Cell value in query results. */
185export type CellValue =
186 | null
187 | string
188 | number
189 | boolean
190 | Amount
191 | { units: Amount; cost?: { number: string; currency: string; date?: string; label?: string } }
192 | { positions: Array<{ units: Amount }> }
193 | string[];
194
195/** Result of a BQL query. */
196export interface QueryResult {
197 columns: string[];
198 rows: CellValue[][];
199 errors: BeancountError[];
200}
201
202/** Result of formatting. */
203export interface FormatResult {
204 formatted?: string;
205 errors: BeancountError[];
206}
207
208/** Result of pad expansion. */
209export interface PadResult {
210 directives: Directive[];
211 padding_transactions: Directive[];
212 errors: BeancountError[];
213}
214
215/** Result of running a plugin. */
216export interface PluginResult {
217 directives: Directive[];
218 errors: BeancountError[];
219}
220
221/** Plugin information. */
222export interface PluginInfo {
223 name: string;
224 description: string;
225}
226
227/** BQL completion suggestion. */
228export interface Completion {
229 text: string;
230 category: string;
231 description?: string;
232}
233
234/** Result of BQL completion request. */
235export interface CompletionResult {
236 completions: Completion[];
237 context: string;
238}
239
240// =============================================================================
241// Editor Integration Types (LSP-like features)
242// =============================================================================
243
244/** The kind of a completion item. */
245export type EditorCompletionKind = 'keyword' | 'account' | 'accountsegment' | 'currency' | 'payee' | 'date' | 'text';
246
247/** A completion item for Beancount source editing. */
248export interface EditorCompletion {
249 label: string;
250 kind: EditorCompletionKind;
251 detail?: string;
252 insertText?: string;
253}
254
255/** Result of an editor completion request. */
256export interface EditorCompletionResult {
257 completions: EditorCompletion[];
258 context: string;
259}
260
261/** A range in the document. */
262export interface EditorRange {
263 start_line: number;
264 start_character: number;
265 end_line: number;
266 end_character: number;
267}
268
269/** Hover information for a symbol. */
270export interface EditorHoverInfo {
271 contents: string;
272 range?: EditorRange;
273}
274
275/** A location in the document. */
276export interface EditorLocation {
277 line: number;
278 character: number;
279}
280
281/** The kind of a symbol. */
282export type SymbolKind = 'transaction' | 'account' | 'balance' | 'commodity' | 'posting' | 'pad' | 'event' | 'note' | 'document' | 'price' | 'query' | 'custom';
283
284/** A document symbol for the outline view. */
285export interface EditorDocumentSymbol {
286 name: string;
287 detail?: string;
288 kind: SymbolKind;
289 range: EditorRange;
290 children?: EditorDocumentSymbol[];
291 deprecated?: boolean;
292}
293
294/** The kind of reference. */
295export type ReferenceKind = 'account' | 'currency' | 'payee';
296
297/** A reference to a symbol in the document. */
298export interface EditorReference {
299 range: EditorRange;
300 kind: ReferenceKind;
301 is_definition: boolean;
302 context?: string;
303}
304
305/** Result of a find-references request. */
306export interface EditorReferencesResult {
307 symbol: string;
308 kind: ReferenceKind;
309 references: EditorReference[];
310}
311
312/**
313 * A parsed and validated ledger that caches the parse result.
314 * Use this class when you need to perform multiple operations on the same
315 * source without re-parsing each time.
316 */
317export class ParsedLedger {
318 constructor(source: string);
319 free(): void;
320
321 /** Check if the ledger is valid (no parse or validation errors). */
322 isValid(): boolean;
323
324 /** Get all errors (parse + validation). */
325 getErrors(): BeancountError[];
326
327 /** Get parse errors only. */
328 getParseErrors(): BeancountError[];
329
330 /** Get validation errors only. */
331 getValidationErrors(): BeancountError[];
332
333 /** Get the parsed directives. */
334 getDirectives(): Directive[];
335
336 /** Get the ledger options. */
337 getOptions(): LedgerOptions;
338
339 /** Get the number of directives. */
340 directiveCount(): number;
341
342 /** Run a BQL query on this ledger. */
343 query(queryStr: string): QueryResult;
344
345 /** Get account balances (shorthand for query("BALANCES")). */
346 balances(): QueryResult;
347
348 /** Format the ledger source. */
349 format(): FormatResult;
350
351 /** Expand pad directives. */
352 expandPads(): PadResult;
353
354 /** Run a native plugin on this ledger. */
355 runPlugin(pluginName: string): PluginResult;
356
357 // =========================================================================
358 // Editor Integration (LSP-like features)
359 // =========================================================================
360
361 /** Get completions at the given position. */
362 getCompletions(line: number, character: number): EditorCompletionResult;
363
364 /** Get hover information at the given position. */
365 getHoverInfo(line: number, character: number): EditorHoverInfo | null;
366
367 /** Get the definition location for the symbol at the given position. */
368 getDefinition(line: number, character: number): EditorLocation | null;
369
370 /** Get all document symbols for the outline view. */
371 getDocumentSymbols(): EditorDocumentSymbol[];
372
373 /** Find all references to the symbol at the given position. */
374 getReferences(line: number, character: number): EditorReferencesResult | null;
375
376 /** Serialize this ledger to a compact binary blob for caching. */
377 serialize(): Uint8Array;
378
379 /**
380 * Restore a ParsedLedger from cached bytes.
381 * The source must be the same text used when the cache was created.
382 * Throws if the bytes are invalid or from a different library version.
383 */
384 static fromCache(bytes: Uint8Array, source: string): ParsedLedger;
385}
386
387/**
388 * A fully processed multi-file ledger for queries and validation.
389 * Use this class for ledgers spanning multiple files with include directives.
390 * For single-file ledgers with editor features, use ParsedLedger instead.
391 */
392export class Ledger {
393 free(): void;
394
395 /** Create from multiple files with include resolution. */
396 static fromFiles(files: FileMap, entryPoint: string): Ledger;
397
398 /** Check if the ledger is valid (no errors). */
399 isValid(): boolean;
400
401 /** Get all errors. */
402 getErrors(): BeancountError[];
403
404 /** Get the parsed directives. */
405 getDirectives(): Directive[];
406
407 /** Get the ledger options. */
408 getOptions(): LedgerOptions;
409
410 /** Get the number of directives. */
411 directiveCount(): number;
412
413 /** Run a BQL query on this ledger. */
414 query(queryStr: string): QueryResult;
415
416 /** Get account balances (shorthand for query("BALANCES")). */
417 balances(): QueryResult;
418
419 /** Expand pad directives. */
420 expandPads(): PadResult;
421
422 /** Run a native plugin on this ledger. */
423 runPlugin(pluginName: string): PluginResult;
424
425 /** Get completions using cross-file data. Pass the source of the file being edited. */
426 getCompletions(source: string, line: number, character: number): EditorCompletionResult;
427
428 /** Serialize this ledger to a compact binary blob for caching. */
429 serialize(): Uint8Array;
430
431 /**
432 * Restore a Ledger from cached bytes.
433 * Throws if the bytes are invalid or from a different library version.
434 */
435 static fromCache(bytes: Uint8Array): Ledger;
436}
437
438// =============================================================================
439// Multi-File API (for WASM environments without filesystem access)
440// =============================================================================
441
442/** Map of file paths to their contents. */
443export type FileMap = Record<string, string>;
444
445/**
446 * Parse multiple Beancount files with include resolution.
447 *
448 * @param files - Object mapping file paths to their contents
449 * @param entryPoint - The main file to start loading from (must exist in files)
450 * @returns ParseResult with the combined ledger from all files
451 *
452 * @example
453 * const result = parseMultiFile({
454 * "main.beancount": 'include "accounts.beancount"',
455 * "accounts.beancount": "2024-01-01 open Assets:Bank USD"
456 * }, "main.beancount");
457 */
458export function parseMultiFile(files: FileMap, entryPoint: string): ParseResult;
459
460/**
461 * Validate multiple Beancount files with include resolution.
462 *
463 * @param files - Object mapping file paths to their contents
464 * @param entryPoint - The main file to start loading from (must exist in files)
465 * @returns ValidationResult indicating whether the combined ledger is valid
466 */
467export function validateMultiFile(files: FileMap, entryPoint: string): ValidationResult;
468
469/**
470 * Run a BQL query on multiple Beancount files.
471 *
472 * @param files - Object mapping file paths to their contents
473 * @param entryPoint - The main file to start loading from (must exist in files)
474 * @param query - The BQL query string to execute
475 * @returns QueryResult with columns, rows, and any errors
476 */
477export function queryMultiFile(files: FileMap, entryPoint: string, query: string): QueryResult;
478
479/**
480 * Compute a SHA-256 fingerprint of one or more source strings.
481 *
482 * Returns a lowercase hex string. Store alongside serialized ledger bytes
483 * and compare on next load; if the fingerprint changed, discard the cache.
484 *
485 * @param sources - Array of source strings
486 * @returns Lowercase hex SHA-256 hash
487 */
488export function hashSources(sources: string[]): string;
489"#;
490
491#[wasm_bindgen(start)]
500pub fn init() {
501 console_error_panic_hook::set_once();
503}
504
505#[cfg(test)]
510mod tests {
511 use super::*;
512 use rustledger_parser::parse as parse_beancount;
513 use rustledger_validate::validate as validate_ledger;
514
515 #[test]
516 fn test_parse_simple() {
517 let source = r#"
5182024-01-01 open Assets:Bank USD
519
5202024-01-15 * "Coffee Shop" "Morning coffee"
521 Expenses:Food:Coffee 5.00 USD
522 Assets:Bank -5.00 USD
523"#;
524
525 let result = parse_beancount(source);
526 assert!(result.errors.is_empty());
527 assert_eq!(result.directives.len(), 2);
528 }
529
530 #[test]
531 fn test_version() {
532 let v = version();
533 assert!(!v.is_empty());
534 }
535
536 #[test]
537 fn test_load_and_book() {
538 use helpers::load_and_book;
539
540 let source = r#"
5422024-01-01 open Assets:Bank USD
5432024-01-01 open Expenses:Food USD
544
5452024-01-15 * "Coffee"
546 Expenses:Food 5.00 USD
547 Assets:Bank -5.00 USD
548"#;
549 let load = load_and_book(source);
550 assert!(load.errors.is_empty());
551 assert_eq!(load.directives.len(), 3);
552
553 let source = r#"
5552024-01-01 open Assets:Bank USD
556
5572024-01-15 * "Coffee"
558 Expenses:Food 5.00 USD
559 Assets:Bank -5.00 USD
560"#;
561 let load = load_and_book(source);
562 assert!(load.errors.is_empty()); let validation_errors = validate_ledger(&load.directives);
564 assert!(
565 !validation_errors.is_empty(),
566 "should detect Expenses:Food not opened"
567 );
568 }
569
570 #[test]
575 fn test_multi_file_include_resolution() {
576 use rustledger_loader::{Loader, VirtualFileSystem};
577 use std::path::Path;
578
579 let mut vfs = VirtualFileSystem::new();
580 vfs.add_file(
581 "main.beancount",
582 r#"
583include "accounts.beancount"
584
5852024-01-15 * "Coffee"
586 Expenses:Food 5.00 USD
587 Assets:Bank -5.00 USD
588"#,
589 );
590 vfs.add_file(
591 "accounts.beancount",
592 r"
5932024-01-01 open Assets:Bank USD
5942024-01-01 open Expenses:Food USD
595",
596 );
597
598 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
599 let result = loader.load(Path::new("main.beancount")).unwrap();
600
601 assert!(result.errors.is_empty(), "should have no errors");
602 assert_eq!(result.directives.len(), 3);
604 }
605
606 #[test]
607 fn test_multi_file_nested_includes() {
608 use rustledger_loader::{Loader, VirtualFileSystem};
609 use std::path::Path;
610
611 let mut vfs = VirtualFileSystem::new();
612 vfs.add_file("main.beancount", r#"include "accounts/index.beancount""#);
613 vfs.add_file(
614 "accounts/index.beancount",
615 r#"
616include "assets.beancount"
617include "expenses.beancount"
618"#,
619 );
620 vfs.add_file(
621 "accounts/assets.beancount",
622 "2024-01-01 open Assets:Bank USD",
623 );
624 vfs.add_file(
625 "accounts/expenses.beancount",
626 "2024-01-01 open Expenses:Food USD",
627 );
628
629 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
630 let result = loader.load(Path::new("main.beancount")).unwrap();
631
632 assert!(result.errors.is_empty(), "should have no errors");
633 assert_eq!(result.directives.len(), 2); }
635
636 #[test]
637 fn test_multi_file_validation() {
638 use rustledger_booking::BookingEngine;
639 use rustledger_core::Directive;
640 use rustledger_loader::{Loader, VirtualFileSystem};
641 use std::path::Path;
642
643 let mut vfs = VirtualFileSystem::new();
644 vfs.add_file(
645 "main.beancount",
646 r#"
647include "accounts.beancount"
648
6492024-01-15 * "Coffee"
650 Expenses:Food 5.00 USD
651 Assets:Bank
652"#,
653 );
654 vfs.add_file(
655 "accounts.beancount",
656 r"
6572024-01-01 open Assets:Bank USD
6582024-01-01 open Expenses:Food USD
659",
660 );
661
662 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
663 let result = loader.load(Path::new("main.beancount")).unwrap();
664
665 assert!(result.errors.is_empty());
666
667 let mut directives: Vec<_> = result.directives.into_iter().map(|s| s.value).collect();
669 let mut engine = BookingEngine::new();
670 engine.register_account_methods(directives.iter());
671 for directive in &mut directives {
672 if let Directive::Transaction(txn) = directive
673 && let Ok(result) = engine.book_and_interpolate(txn)
674 {
675 engine.apply(&result.transaction);
676 *txn = result.transaction;
677 }
678 }
679 directives.sort_by_key(rustledger_core::Directive::date);
681 let validation_errors = validate_ledger(&directives);
682 assert!(
683 validation_errors.is_empty(),
684 "ledger should be valid, but got: {validation_errors:?}"
685 );
686 }
687
688 #[test]
690 fn test_parsed_ledger_multi_file_via_process() {
691 use rustledger_core::Directive;
692 use rustledger_loader::{FileSystem, LoadOptions, Loader, VirtualFileSystem, process};
693 use std::path::Path;
694
695 let mut vfs = VirtualFileSystem::new();
696 vfs.add_file(
697 "main.beancount",
698 r#"
699include "accounts.beancount"
700
7012024-01-15 * "Coffee"
702 Expenses:Food 5.00 USD
703 Assets:Bank
704"#,
705 );
706 vfs.add_file(
707 "accounts.beancount",
708 r"
7092024-01-01 open Assets:Bank USD
7102024-01-01 open Expenses:Food USD
711",
712 );
713
714 assert!(vfs.exists(Path::new("main.beancount")));
715
716 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
717 let raw = loader.load(Path::new("main.beancount")).unwrap();
718
719 let options = LoadOptions {
720 validate: true,
721 ..Default::default()
722 };
723
724 let ledger = process(raw, &options).unwrap();
725 let directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
726
727 assert_eq!(directives.len(), 3);
729
730 let dates: Vec<_> = directives
732 .iter()
733 .map(rustledger_core::Directive::date)
734 .collect();
735 assert!(dates.windows(2).all(|w| w[0] <= w[1]));
736
737 let txn = directives
739 .iter()
740 .find_map(|d| match d {
741 Directive::Transaction(t) => Some(t),
742 _ => None,
743 })
744 .expect("should have transaction");
745
746 let bank = txn
747 .postings
748 .iter()
749 .find(|p| p.account.as_str().contains("Bank"))
750 .expect("should have bank posting");
751 assert!(
752 bank.units
753 .as_ref()
754 .and_then(rustledger_core::IncompleteAmount::number)
755 .is_some(),
756 "bank amount should be interpolated"
757 );
758
759 assert!(ledger.errors.is_empty(), "errors: {:?}", ledger.errors);
761 }
762
763 #[test]
765 fn test_total_cost_produces_per_unit_cost() {
766 use helpers::load_and_book;
767 use rustledger_core::Directive;
768
769 let source = r#"
7702020-01-01 open Assets:Investments:PROP PROP
7712020-01-01 open Assets:Bank AUD
772
7732020-01-16 * "Buy PROP"
774 Assets:Investments:PROP 273.2200 PROP {{150.00 AUD}}
775 Assets:Bank -150.00 AUD
776"#;
777 let load = load_and_book(source);
778 assert!(load.errors.is_empty(), "errors: {:?}", load.errors);
779
780 let txn = load
782 .directives
783 .iter()
784 .find_map(|d| match d {
785 Directive::Transaction(txn) => Some(txn),
786 _ => None,
787 })
788 .expect("should have at least one transaction");
789
790 let prop_posting = txn
791 .postings
792 .iter()
793 .find(|p| {
794 p.units
795 .as_ref()
796 .is_some_and(|u| u.currency() == Some("PROP"))
797 })
798 .expect("should have PROP posting");
799
800 let cost = prop_posting.cost.as_ref().expect("should have cost");
801 let per_unit = cost
802 .number_per
803 .expect("total cost {{}} should be converted to per-unit cost, but number_per is None");
804
805 use std::str::FromStr;
807 let expected = rustledger_core::Decimal::from_str("0.5490").unwrap();
808 let diff = (per_unit - expected).abs();
809 assert!(
810 diff < rustledger_core::Decimal::from_str("0.001").unwrap(),
811 "per-unit cost should be ~0.5490, got {per_unit}"
812 );
813 }
814
815 fn cli_process(source: &str) -> Vec<rustledger_core::Directive> {
821 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
822 use std::path::Path;
823
824 let mut vfs = VirtualFileSystem::new();
825 vfs.add_file("test.beancount", source);
826 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
827 let raw = loader.load(Path::new("test.beancount")).unwrap();
828 let options = LoadOptions {
829 validate: false,
830 ..Default::default()
831 };
832 let ledger = process(raw, &options).unwrap();
833 ledger.directives.into_iter().map(|s| s.value).collect()
834 }
835
836 fn wasm_process(source: &str) -> Vec<rustledger_core::Directive> {
838 let load = helpers::load_and_book(source);
839 assert!(load.errors.is_empty(), "WASM errors: {:?}", load.errors);
840 load.directives
841 }
842
843 #[test]
845 fn test_parity_sorting() {
846 use rustledger_core::Directive;
847
848 let source = r#"
8492024-01-01 open Assets:Bank USD
8502024-01-01 open Expenses:Food USD
851
8522024-03-01 * "March"
853 Expenses:Food 30 USD
854 Assets:Bank
855
8562024-01-15 * "January"
857 Expenses:Food 10 USD
858 Assets:Bank
859
8602024-02-15 * "February"
861 Expenses:Food 20 USD
862 Assets:Bank
863"#;
864 let cli = cli_process(source);
865 let wasm = wasm_process(source);
866
867 assert_eq!(cli.len(), wasm.len(), "directive count mismatch");
869
870 let cli_dates: Vec<_> = cli.iter().map(rustledger_core::Directive::date).collect();
872 let wasm_dates: Vec<_> = wasm.iter().map(rustledger_core::Directive::date).collect();
873 assert_eq!(cli_dates, wasm_dates, "date order mismatch");
874
875 let txn_dates: Vec<_> = wasm
877 .iter()
878 .filter_map(|d| match d {
879 Directive::Transaction(t) => Some(t.date),
880 _ => None,
881 })
882 .collect();
883 assert!(
884 txn_dates.windows(2).all(|w| w[0] <= w[1]),
885 "transactions not sorted: {txn_dates:?}"
886 );
887 }
888
889 #[test]
891 fn test_parity_total_cost() {
892 let source = r#"
8932020-01-01 open Assets:Investments PROP
8942020-01-01 open Assets:Bank AUD
895
8962020-01-16 * "Buy"
897 Assets:Investments 273.2200 PROP {{150.00 AUD}}
898 Assets:Bank -150.00 AUD
899"#;
900 let cli = cli_process(source);
901 let wasm = wasm_process(source);
902
903 fn get_cost_per_unit(
904 directives: &[rustledger_core::Directive],
905 ) -> rustledger_core::Decimal {
906 directives
907 .iter()
908 .find_map(|d| match d {
909 rustledger_core::Directive::Transaction(t) => t
910 .postings
911 .iter()
912 .find_map(|p| p.cost.as_ref().and_then(|c| c.number_per)),
913 _ => None,
914 })
915 .expect("should have a cost")
916 }
917
918 assert_eq!(
919 get_cost_per_unit(&cli),
920 get_cost_per_unit(&wasm),
921 "per-unit cost differs between CLI and WASM"
922 );
923 }
924
925 #[test]
927 fn test_parity_interpolation() {
928 let source = r#"
9292024-01-01 open Assets:Bank USD
9302024-01-01 open Expenses:Food USD
931
9322024-01-15 * "Coffee"
933 Expenses:Food 5.00 USD
934 Assets:Bank
935"#;
936 let cli = cli_process(source);
937 let wasm = wasm_process(source);
938
939 fn get_bank_amount(directives: &[rustledger_core::Directive]) -> rustledger_core::Decimal {
940 directives
941 .iter()
942 .find_map(|d| match d {
943 rustledger_core::Directive::Transaction(t) => t.postings.iter().find_map(|p| {
944 if p.account.as_str().contains("Bank") {
945 p.units
946 .as_ref()
947 .and_then(rustledger_core::IncompleteAmount::number)
948 } else {
949 None
950 }
951 }),
952 _ => None,
953 })
954 .expect("should have bank posting with amount")
955 }
956
957 assert_eq!(
958 get_bank_amount(&cli),
959 get_bank_amount(&wasm),
960 "interpolated amount differs"
961 );
962 }
963
964 #[test]
966 fn test_parity_pad_expansion() {
967 use rustledger_booking::expand_pads;
968
969 let source = r"
9702024-01-01 open Assets:Bank USD
9712024-01-01 open Equity:Opening USD
972
9732024-01-01 pad Assets:Bank Equity:Opening
9742024-01-15 balance Assets:Bank 1000 USD
975";
976 let cli = cli_process(source);
977 let wasm = wasm_process(source);
978
979 let cli_expanded = expand_pads(&cli);
980 let wasm_expanded = expand_pads(&wasm);
981
982 assert_eq!(
983 cli_expanded.len(),
984 wasm_expanded.len(),
985 "expanded directive count differs"
986 );
987 }
988
989 #[test]
994 fn test_parsed_ledger_serialize_roundtrip() {
995 use crate::cache;
996 use crate::helpers::load_and_book;
997
998 let source = r#"
999option "title" "Cache Test"
1000option "operating_currency" "USD"
1001
10022024-01-01 open Assets:Bank USD
10032024-01-01 open Expenses:Food USD
1004
10052024-01-15 * "Coffee" "Morning latte"
1006 Expenses:Food 5.00 USD
1007 Assets:Bank -5.00 USD
1008
10092024-01-20 * "Groceries"
1010 Expenses:Food 42.50 USD
1011 Assets:Bank -42.50 USD
1012"#;
1013
1014 let processed = load_and_book(source);
1016 let payload = cache::ParsedLedgerPayload {
1017 directives: processed.directives.clone(),
1018 options: processed.options.clone(),
1019 parse_errors: Vec::new(),
1020 validation_errors: Vec::new(),
1021 };
1022
1023 let bytes = cache::serialize_parsed(&payload).expect("serialize");
1024 let restored = cache::deserialize_parsed(&bytes).expect("deserialize");
1025
1026 assert_eq!(
1027 restored.directives.len(),
1028 processed.directives.len(),
1029 "directive count should match after roundtrip"
1030 );
1031 assert_eq!(
1032 restored.options.title.as_deref(),
1033 Some("Cache Test"),
1034 "title preserved"
1035 );
1036 assert_eq!(
1037 restored.options.operating_currencies,
1038 ["USD"],
1039 "operating currencies preserved"
1040 );
1041
1042 use crate::editor::EditorCache;
1044 let parse_result = rustledger_parser::parse(source);
1045 let editor_cache = EditorCache::new(source, &parse_result);
1046 assert!(
1047 !editor_cache.accounts.is_empty(),
1048 "editor cache should have accounts after re-parse"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_ledger_serialize_roundtrip() {
1054 use crate::cache;
1055 use crate::helpers::load_and_book;
1056
1057 let source = r#"
1058option "title" "Multi Cache"
1059option "operating_currency" "EUR"
1060
10612024-01-01 open Assets:Bank EUR
10622024-01-01 open Expenses:Rent EUR
1063
10642024-02-01 * "Rent"
1065 Expenses:Rent 800 EUR
1066 Assets:Bank -800 EUR
1067"#;
1068
1069 let processed = load_and_book(source);
1070 let payload = cache::LedgerPayload {
1071 directives: processed.directives.clone(),
1072 options: processed.options.clone(),
1073 errors: Vec::new(),
1074 };
1075
1076 let bytes = cache::serialize_ledger(&payload).expect("serialize");
1077 let restored = cache::deserialize_ledger(&bytes).expect("deserialize");
1078
1079 assert_eq!(
1080 restored.directives.len(),
1081 processed.directives.len(),
1082 "directive count should match after roundtrip"
1083 );
1084 assert_eq!(restored.options.title.as_deref(), Some("Multi Cache"));
1085
1086 let editor_cache = crate::editor::EditorCache::from_directives(&restored.directives);
1088 assert!(
1089 !editor_cache.accounts.is_empty(),
1090 "editor cache should have accounts from restored directives"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_serialize_rejects_corrupted_bytes() {
1096 use crate::cache;
1097
1098 assert!(cache::deserialize_ledger(b"GARBAGE_DATA_HERE").is_err());
1100 assert!(cache::deserialize_parsed(b"GARBAGE_DATA_HERE").is_err());
1101
1102 assert!(cache::deserialize_ledger(b"short").is_err());
1104 }
1105
1106 #[test]
1107 fn test_hash_sources_api() {
1108 let h1 = hash_sources(vec!["source v1".to_string()]);
1109 let h2 = hash_sources(vec!["source v1".to_string()]);
1110 let h3 = hash_sources(vec!["source v2".to_string()]);
1111
1112 assert_eq!(h1, h2, "same content → same hash");
1113 assert_ne!(h1, h3, "different content → different hash");
1114 assert_eq!(h1.len(), 64, "SHA-256 produces 64 hex chars");
1115 }
1116}