Skip to main content

rustledger_wasm/
lib.rs

1//! Beancount WASM Bindings.
2//!
3//! This crate provides WebAssembly bindings for using Beancount from JavaScript/TypeScript.
4//!
5//! # Features
6//!
7//! - Parse Beancount files (single and multi-file with includes)
8//! - Validate ledgers
9//! - Run BQL queries
10//! - Format directives
11//! - [`ParsedLedger`] — cached single-file with editor features (completions, hover, etc.)
12//! - [`Ledger`] — cached multi-file with queries and cross-file completions
13//!
14//! # Example (JavaScript)
15//!
16//! ```javascript
17//! import init, { parse, validateSource, query } from '@rustledger/wasm';
18//!
19//! await init();
20//!
21//! const source = `
22//! 2024-01-01 open Assets:Bank USD
23//! 2024-01-15 * "Coffee"
24//!   Expenses:Food  5.00 USD
25//!   Assets:Bank   -5.00 USD
26//! `;
27//!
28//! const result = parse(source);
29//! if (result.errors.length === 0) {
30//!     const validation = validateSource(source);
31//!     console.log('Validation errors:', validation.errors);
32//! }
33//! ```
34
35#![forbid(unsafe_code)]
36#![warn(missing_docs)]
37// wasm_bindgen doesn't support const fn on exported methods
38#![allow(clippy::missing_const_for_fn)]
39
40// Internal modules
41mod cache;
42// `convert` is `pub` so the cross-binding equivalence test crate
43// (`rustledger-wire-format-tests`, issue #1200) can call
44// `directive_to_json` directly. JS consumers use the bindings in
45// `api`/`parsed_ledger` instead.
46pub mod convert;
47mod editor;
48mod helpers;
49mod utils;
50
51// Public modules
52pub mod types;
53
54// Public API modules
55mod api;
56mod parsed_ledger;
57
58// Re-export public API
59pub use api::{balances, format, parse, query, validate_source, version};
60pub use api::{hash_sources, parse_multi_file, query_multi_file, validate_multi_file};
61
62#[cfg(feature = "completions")]
63pub use api::bql_completions;
64
65#[cfg(feature = "plugins")]
66pub use api::{list_plugins, run_plugin};
67
68pub use api::expand_pads;
69pub use parsed_ledger::{Ledger, ParsedLedger};
70
71use wasm_bindgen::prelude::*;
72
73// =============================================================================
74// TypeScript Type Definitions
75// =============================================================================
76//
77// **DTO types** come from the ts-rs-generated bundle at
78// `crates/rustledger-wasm/bindings/index.d.ts` (ADR-0004 Phase 2 / #1224).
79// We embed the bundle via `include_str!` so wasm-bindgen's
80// `pkg/*.d.ts` AND the hand-importable `bindings/index.d.ts` are the
81// same types -- no duplication, no drift.
82//
83// **Runtime classes and standalone function signatures** live in the
84// second `typescript_custom_section` below. These can't go in the
85// bundle (they're wasm-bindgen-managed, not serde DTOs). They
86// reference the bundle types by their generated names (`DirectiveJson`,
87// `LedgerJson`, etc.). If you rename a DTO via `#[ts(rename = ...)]`
88// in `src/types.rs`, update the references here too.
89//
90// Run `scripts/regen-bindings.sh` after touching any DTO; the
91// `bindings-fresh` CI job fails if the bundle, JSON Schema, or
92// Python types drift.
93
94#[wasm_bindgen(typescript_custom_section)]
95const TS_TYPES_DTOS: &'static str = include_str!("../bindings/index.d.ts");
96
97#[wasm_bindgen(typescript_custom_section)]
98const TS_TYPES: &'static str = r#"
99/**
100 * A parsed and validated ledger that caches the parse result.
101 * Use this class when you need to perform multiple operations on the same
102 * source without re-parsing each time.
103 */
104export class ParsedLedger {
105    constructor(source: string);
106    free(): void;
107
108    /** Check if the ledger is valid (no parse or validation errors). */
109    isValid(): boolean;
110
111    /** Get all errors (parse + validation). */
112    getErrors(): BeancountError[];
113
114    /** Get parse errors only. */
115    getParseErrors(): BeancountError[];
116
117    /** Get validation errors only. */
118    getValidationErrors(): BeancountError[];
119
120    /** Get the parsed directives. */
121    getDirectives(): DirectiveJson[];
122
123    /** Get the ledger options. */
124    getOptions(): LedgerOptions;
125
126    /** Get the number of directives. */
127    directiveCount(): number;
128
129    /** Run a BQL query on this ledger. */
130    query(queryStr: string): QueryResult;
131
132    /** Get account balances (shorthand for query("BALANCES")). */
133    balances(): QueryResult;
134
135    /** Format the ledger source. */
136    format(): FormatResult;
137
138    /** Expand pad directives. */
139    expandPads(): PadResult;
140
141    /** Run a native plugin on this ledger. */
142    runPlugin(pluginName: string): PluginResult;
143
144    // =========================================================================
145    // Editor Integration (LSP-like features)
146    // =========================================================================
147
148    /** Get completions at the given position. */
149    getCompletions(line: number, character: number): EditorCompletionResult;
150
151    /** Get hover information at the given position. */
152    getHoverInfo(line: number, character: number): EditorHoverInfo | null;
153
154    /** Get the definition location for the symbol at the given position. */
155    getDefinition(line: number, character: number): EditorLocation | null;
156
157    /** Get all document symbols for the outline view. */
158    getDocumentSymbols(): EditorDocumentSymbol[];
159
160    /** Find all references to the symbol at the given position. */
161    getReferences(line: number, character: number): EditorReferencesResult | null;
162
163    /** Serialize this ledger to a compact binary blob for caching. */
164    serialize(): Uint8Array;
165
166    /**
167     * Restore a ParsedLedger from cached bytes.
168     * The source must be the same text used when the cache was created.
169     * Throws if the bytes are invalid or from a different library version.
170     */
171    static fromCache(bytes: Uint8Array, source: string): ParsedLedger;
172}
173
174/**
175 * A fully processed multi-file ledger for queries and validation.
176 * Use this class for ledgers spanning multiple files with include directives.
177 * For single-file ledgers with editor features, use ParsedLedger instead.
178 */
179export class Ledger {
180    free(): void;
181
182    /** Create from multiple files with include resolution. */
183    static fromFiles(files: FileMap, entryPoint: string): Ledger;
184
185    /** Check if the ledger is valid (no errors). */
186    isValid(): boolean;
187
188    /** Get all errors. */
189    getErrors(): BeancountError[];
190
191    /** Get the parsed directives. */
192    getDirectives(): DirectiveJson[];
193
194    /** Get the ledger options. */
195    getOptions(): LedgerOptions;
196
197    /** Get the number of directives. */
198    directiveCount(): number;
199
200    /** Run a BQL query on this ledger. */
201    query(queryStr: string): QueryResult;
202
203    /** Get account balances (shorthand for query("BALANCES")). */
204    balances(): QueryResult;
205
206    /** Expand pad directives. */
207    expandPads(): PadResult;
208
209    /** Run a native plugin on this ledger. */
210    runPlugin(pluginName: string): PluginResult;
211
212    /** Get completions using cross-file data. Pass the source of the file being edited. */
213    getCompletions(source: string, line: number, character: number): EditorCompletionResult;
214
215    /** Serialize this ledger to a compact binary blob for caching. */
216    serialize(): Uint8Array;
217
218    /**
219     * Restore a Ledger from cached bytes.
220     * Throws if the bytes are invalid or from a different library version.
221     */
222    static fromCache(bytes: Uint8Array): Ledger;
223}
224
225// =============================================================================
226// Multi-File API (for WASM environments without filesystem access)
227// =============================================================================
228
229/** Map of file paths to their contents. */
230export type FileMap = Record<string, string>;
231
232/**
233 * Parse multiple Beancount files with include resolution.
234 *
235 * @param files - Object mapping file paths to their contents
236 * @param entryPoint - The main file to start loading from (must exist in files)
237 * @returns ParseResult with the combined ledger from all files
238 *
239 * @example
240 * const result = parseMultiFile({
241 *   "main.beancount": 'include "accounts.beancount"',
242 *   "accounts.beancount": "2024-01-01 open Assets:Bank USD"
243 * }, "main.beancount");
244 */
245export function parseMultiFile(files: FileMap, entryPoint: string): ParseResult;
246
247/**
248 * Validate multiple Beancount files with include resolution.
249 *
250 * @param files - Object mapping file paths to their contents
251 * @param entryPoint - The main file to start loading from (must exist in files)
252 * @returns ValidationResult indicating whether the combined ledger is valid
253 */
254export function validateMultiFile(files: FileMap, entryPoint: string): ValidationResult;
255
256/**
257 * Run a BQL query on multiple Beancount files.
258 *
259 * @param files - Object mapping file paths to their contents
260 * @param entryPoint - The main file to start loading from (must exist in files)
261 * @param query - The BQL query string to execute
262 * @returns QueryResult with columns, rows, and any errors
263 */
264export function queryMultiFile(files: FileMap, entryPoint: string, query: string): QueryResult;
265
266/**
267 * Compute a SHA-256 fingerprint of one or more source strings.
268 *
269 * Returns a lowercase hex string. Store alongside serialized ledger bytes
270 * and compare on next load; if the fingerprint changed, discard the cache.
271 *
272 * @param sources - Array of source strings
273 * @returns Lowercase hex SHA-256 hash
274 */
275export function hashSources(sources: string[]): string;
276"#;
277
278// =============================================================================
279// Initialization
280// =============================================================================
281
282/// Initialize the WASM module.
283///
284/// This sets up panic hooks for better error messages in the browser console.
285/// Call this once before using any other functions.
286#[wasm_bindgen(start)]
287pub fn init() {
288    // Set up panic hook for better error messages
289    console_error_panic_hook::set_once();
290}
291
292// =============================================================================
293// Tests
294// =============================================================================
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use rustledger_parser::parse as parse_beancount;
300    use rustledger_validate::{ValidationOptions, ValidationSession};
301
302    /// Test helper mirroring the deleted public `validate()`. Chains
303    /// Early + Late + finalize through a single session against the
304    /// same input.
305    fn validate_ledger(
306        directives: &[rustledger_core::Directive],
307    ) -> Vec<rustledger_validate::ValidationError> {
308        let today = rustledger_core::naive_date(2999, 12, 31).unwrap();
309        let session = ValidationSession::new(ValidationOptions::default());
310        let (session, mut errors) = session.run_early(directives, today);
311        let (session, late_errs) = session.run_late(directives, today);
312        errors.extend(late_errs);
313        errors.extend(session.finalize());
314        errors
315    }
316
317    #[test]
318    fn test_parse_simple() {
319        let source = r#"
3202024-01-01 open Assets:Bank USD
321
3222024-01-15 * "Coffee Shop" "Morning coffee"
323  Expenses:Food:Coffee  5.00 USD
324  Assets:Bank          -5.00 USD
325"#;
326
327        let result = parse_beancount(source);
328        assert!(result.errors.is_empty());
329        assert_eq!(result.directives.len(), 2);
330    }
331
332    #[test]
333    fn test_version() {
334        let v = version();
335        assert!(!v.is_empty());
336    }
337
338    #[test]
339    fn test_load_and_book() {
340        use helpers::load_and_book;
341
342        // Valid ledger
343        let source = r#"
3442024-01-01 open Assets:Bank USD
3452024-01-01 open Expenses:Food USD
346
3472024-01-15 * "Coffee"
348  Expenses:Food  5.00 USD
349  Assets:Bank   -5.00 USD
350"#;
351        let load = load_and_book(source);
352        assert!(load.errors.is_empty());
353        assert_eq!(load.directives.len(), 3);
354
355        // Invalid ledger (unopened account)
356        let source = r#"
3572024-01-01 open Assets:Bank USD
358
3592024-01-15 * "Coffee"
360  Expenses:Food  5.00 USD
361  Assets:Bank   -5.00 USD
362"#;
363        let load = load_and_book(source);
364        assert!(load.errors.is_empty()); // Parse succeeds
365        let validation_errors = validate_ledger(&load.directives);
366        assert!(
367            !validation_errors.is_empty(),
368            "should detect Expenses:Food not opened"
369        );
370    }
371
372    // =========================================================================
373    // Multi-file API tests
374    // =========================================================================
375
376    #[test]
377    fn test_multi_file_include_resolution() {
378        use rustledger_loader::{Loader, VirtualFileSystem};
379        use std::path::Path;
380
381        let mut vfs = VirtualFileSystem::new();
382        vfs.add_file(
383            "main.beancount",
384            r#"
385include "accounts.beancount"
386
3872024-01-15 * "Coffee"
388  Expenses:Food  5.00 USD
389  Assets:Bank   -5.00 USD
390"#,
391        );
392        vfs.add_file(
393            "accounts.beancount",
394            r"
3952024-01-01 open Assets:Bank USD
3962024-01-01 open Expenses:Food USD
397",
398        );
399
400        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
401        let result = loader.load(Path::new("main.beancount")).unwrap();
402
403        assert!(result.errors.is_empty(), "should have no errors");
404        // 2 opens + 1 transaction = 3 directives
405        assert_eq!(result.directives.len(), 3);
406    }
407
408    #[test]
409    fn test_multi_file_nested_includes() {
410        use rustledger_loader::{Loader, VirtualFileSystem};
411        use std::path::Path;
412
413        let mut vfs = VirtualFileSystem::new();
414        vfs.add_file("main.beancount", r#"include "accounts/index.beancount""#);
415        vfs.add_file(
416            "accounts/index.beancount",
417            r#"
418include "assets.beancount"
419include "expenses.beancount"
420"#,
421        );
422        vfs.add_file(
423            "accounts/assets.beancount",
424            "2024-01-01 open Assets:Bank USD",
425        );
426        vfs.add_file(
427            "accounts/expenses.beancount",
428            "2024-01-01 open Expenses:Food USD",
429        );
430
431        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
432        let result = loader.load(Path::new("main.beancount")).unwrap();
433
434        assert!(result.errors.is_empty(), "should have no errors");
435        assert_eq!(result.directives.len(), 2); // 2 open directives
436    }
437
438    #[test]
439    fn test_multi_file_validation() {
440        use rustledger_booking::BookingEngine;
441        use rustledger_core::Directive;
442        use rustledger_loader::{Loader, VirtualFileSystem};
443        use std::path::Path;
444
445        let mut vfs = VirtualFileSystem::new();
446        vfs.add_file(
447            "main.beancount",
448            r#"
449include "accounts.beancount"
450
4512024-01-15 * "Coffee"
452  Expenses:Food  5.00 USD
453  Assets:Bank
454"#,
455        );
456        vfs.add_file(
457            "accounts.beancount",
458            r"
4592024-01-01 open Assets:Bank USD
4602024-01-01 open Expenses:Food USD
461",
462        );
463
464        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
465        let result = loader.load(Path::new("main.beancount")).unwrap();
466
467        assert!(result.errors.is_empty());
468
469        // Extract directives and book transactions
470        let mut directives: Vec<_> = result.directives.into_iter().map(|s| s.value).collect();
471        let mut engine = BookingEngine::new();
472        engine.register_account_methods(directives.iter());
473        for directive in &mut directives {
474            if let Directive::Transaction(txn) = directive
475                && let Ok(result) = engine.book_and_interpolate(txn)
476            {
477                engine.apply(&result.transaction);
478                *txn = result.transaction;
479            }
480        }
481        // Sort by date for proper validation
482        directives.sort_by_key(rustledger_core::Directive::date);
483        let validation_errors = validate_ledger(&directives);
484        assert!(
485            validation_errors.is_empty(),
486            "ledger should be valid, but got: {validation_errors:?}"
487        );
488    }
489
490    /// Test `ParsedLedger` multi-file construction via `process()` pipeline.
491    #[test]
492    fn test_parsed_ledger_multi_file_via_process() {
493        use rustledger_core::Directive;
494        use rustledger_loader::{FileSystem, LoadOptions, Loader, VirtualFileSystem, process};
495        use std::path::Path;
496
497        let mut vfs = VirtualFileSystem::new();
498        vfs.add_file(
499            "main.beancount",
500            r#"
501include "accounts.beancount"
502
5032024-01-15 * "Coffee"
504  Expenses:Food  5.00 USD
505  Assets:Bank
506"#,
507        );
508        vfs.add_file(
509            "accounts.beancount",
510            r"
5112024-01-01 open Assets:Bank USD
5122024-01-01 open Expenses:Food USD
513",
514        );
515
516        assert!(vfs.exists(Path::new("main.beancount")));
517
518        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
519        let raw = loader.load(Path::new("main.beancount")).unwrap();
520
521        let options = LoadOptions {
522            validate: true,
523            ..Default::default()
524        };
525
526        let ledger = process(raw, &options).unwrap();
527        let directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
528
529        // Should have 2 opens + 1 transaction = 3 directives
530        assert_eq!(directives.len(), 3);
531
532        // Should be sorted by date
533        let dates: Vec<_> = directives
534            .iter()
535            .map(rustledger_core::Directive::date)
536            .collect();
537        assert!(dates.windows(2).all(|w| w[0] <= w[1]));
538
539        // Transaction should have interpolated bank amount
540        let txn = directives
541            .iter()
542            .find_map(|d| match d {
543                Directive::Transaction(t) => Some(t),
544                _ => None,
545            })
546            .expect("should have transaction");
547
548        let bank = txn
549            .postings
550            .iter()
551            .find(|p| p.account.as_str().contains("Bank"))
552            .expect("should have bank posting");
553        assert!(
554            bank.units
555                .as_ref()
556                .and_then(rustledger_core::IncompleteAmount::number)
557                .is_some(),
558            "bank amount should be interpolated"
559        );
560
561        // No errors
562        assert!(ledger.errors.is_empty(), "errors: {:?}", ledger.errors);
563    }
564
565    /// Regression test for #659: total cost `{{ }}` syntax must produce per-unit cost.
566    #[test]
567    fn test_total_cost_produces_per_unit_cost() {
568        use helpers::load_and_book;
569        use rustledger_core::Directive;
570        use std::str::FromStr;
571
572        let source = r#"
5732020-01-01 open Assets:Investments:PROP PROP
5742020-01-01 open Assets:Bank AUD
575
5762020-01-16 * "Buy PROP"
577  Assets:Investments:PROP  273.2200 PROP {{150.00 AUD}}
578  Assets:Bank              -150.00 AUD
579"#;
580        let load = load_and_book(source);
581        assert!(load.errors.is_empty(), "errors: {:?}", load.errors);
582
583        // Find the transaction and check that the booked cost carries
584        // a per-unit value derived from the source `{{...}}` total.
585        let txn = load
586            .directives
587            .iter()
588            .find_map(|d| match d {
589                Directive::Transaction(txn) => Some(txn),
590                _ => None,
591            })
592            .expect("should have at least one transaction");
593
594        let prop_posting = txn
595            .postings
596            .iter()
597            .find(|p| {
598                p.units
599                    .as_ref()
600                    .is_some_and(|u| u.currency() == Some("PROP"))
601            })
602            .expect("should have PROP posting");
603
604        let cost = prop_posting.cost.as_ref().expect("should have cost");
605        let per_unit = cost
606            .number
607            .as_ref()
608            .and_then(rustledger_core::CostNumber::per_unit)
609            .expect("total cost {{}} should be booked into a CostNumber that exposes per_unit()");
610
611        // 150.00 / 273.2200 ≈ 0.5490
612        let expected = rustledger_core::Decimal::from_str("0.5490").unwrap();
613        let diff = (per_unit - expected).abs();
614        assert!(
615            diff < rustledger_core::Decimal::from_str("0.001").unwrap(),
616            "per-unit cost should be ~0.5490, got {per_unit}"
617        );
618    }
619
620    // =========================================================================
621    // Pipeline parity tests: verify WASM produces same results as CLI
622    // =========================================================================
623
624    /// Helper: process source through CLI pipeline and return directives.
625    fn cli_process(source: &str) -> Vec<rustledger_core::Directive> {
626        use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
627        use std::path::Path;
628
629        let mut vfs = VirtualFileSystem::new();
630        vfs.add_file("test.beancount", source);
631        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
632        let raw = loader.load(Path::new("test.beancount")).unwrap();
633        let options = LoadOptions {
634            validate: false,
635            ..Default::default()
636        };
637        let ledger = process(raw, &options).unwrap();
638        ledger.directives.into_iter().map(|s| s.value).collect()
639    }
640
641    /// Helper: process source through WASM pipeline and return directives.
642    fn wasm_process(source: &str) -> Vec<rustledger_core::Directive> {
643        let load = helpers::load_and_book(source);
644        assert!(load.errors.is_empty(), "WASM errors: {:?}", load.errors);
645        load.directives
646    }
647
648    /// Parity: out-of-order transactions should be sorted by date.
649    #[test]
650    fn test_parity_sorting() {
651        use rustledger_core::Directive;
652
653        let source = r#"
6542024-01-01 open Assets:Bank USD
6552024-01-01 open Expenses:Food USD
656
6572024-03-01 * "March"
658  Expenses:Food  30 USD
659  Assets:Bank
660
6612024-01-15 * "January"
662  Expenses:Food  10 USD
663  Assets:Bank
664
6652024-02-15 * "February"
666  Expenses:Food  20 USD
667  Assets:Bank
668"#;
669        let cli = cli_process(source);
670        let wasm = wasm_process(source);
671
672        // Both should have same directive count
673        assert_eq!(cli.len(), wasm.len(), "directive count mismatch");
674
675        // Both should be sorted by date
676        let cli_dates: Vec<_> = cli.iter().map(rustledger_core::Directive::date).collect();
677        let wasm_dates: Vec<_> = wasm.iter().map(rustledger_core::Directive::date).collect();
678        assert_eq!(cli_dates, wasm_dates, "date order mismatch");
679
680        // Verify transactions are in chronological order
681        let txn_dates: Vec<_> = wasm
682            .iter()
683            .filter_map(|d| match d {
684                Directive::Transaction(t) => Some(t.date),
685                _ => None,
686            })
687            .collect();
688        assert!(
689            txn_dates.windows(2).all(|w| w[0] <= w[1]),
690            "transactions not sorted: {txn_dates:?}"
691        );
692    }
693
694    /// Parity: total cost `{{ }}` produces identical per-unit costs.
695    #[test]
696    fn test_parity_total_cost() {
697        fn get_cost_per_unit(
698            directives: &[rustledger_core::Directive],
699        ) -> rustledger_core::Decimal {
700            directives
701                .iter()
702                .find_map(|d| match d {
703                    rustledger_core::Directive::Transaction(t) => t.postings.iter().find_map(|p| {
704                        p.cost.as_ref().and_then(|c| {
705                            c.number
706                                .as_ref()
707                                .and_then(rustledger_core::CostNumber::per_unit)
708                        })
709                    }),
710                    _ => None,
711                })
712                .expect("should have a cost")
713        }
714
715        let source = r#"
7162020-01-01 open Assets:Investments PROP
7172020-01-01 open Assets:Bank AUD
718
7192020-01-16 * "Buy"
720  Assets:Investments  273.2200 PROP {{150.00 AUD}}
721  Assets:Bank         -150.00 AUD
722"#;
723        let cli = cli_process(source);
724        let wasm = wasm_process(source);
725
726        assert_eq!(
727            get_cost_per_unit(&cli),
728            get_cost_per_unit(&wasm),
729            "per-unit cost differs between CLI and WASM"
730        );
731    }
732
733    /// Parity: interpolation fills in missing amounts identically.
734    #[test]
735    fn test_parity_interpolation() {
736        fn get_bank_amount(directives: &[rustledger_core::Directive]) -> rustledger_core::Decimal {
737            directives
738                .iter()
739                .find_map(|d| match d {
740                    rustledger_core::Directive::Transaction(t) => t.postings.iter().find_map(|p| {
741                        if p.account.as_str().contains("Bank") {
742                            p.units
743                                .as_ref()
744                                .and_then(rustledger_core::IncompleteAmount::number)
745                        } else {
746                            None
747                        }
748                    }),
749                    _ => None,
750                })
751                .expect("should have bank posting with amount")
752        }
753
754        let source = r#"
7552024-01-01 open Assets:Bank USD
7562024-01-01 open Expenses:Food USD
757
7582024-01-15 * "Coffee"
759  Expenses:Food  5.00 USD
760  Assets:Bank
761"#;
762        let cli = cli_process(source);
763        let wasm = wasm_process(source);
764
765        assert_eq!(
766            get_bank_amount(&cli),
767            get_bank_amount(&wasm),
768            "interpolated amount differs"
769        );
770    }
771
772    /// Parity: CLI and WASM processing paths produce identical
773    /// merged views for a single pad+balance source. Asserts the
774    /// full directive vectors match, not just length — a length-
775    /// only check would silently accept a divergence that swapped
776    /// directive shapes (e.g. WASM emitting a Pad where CLI emits
777    /// a Transaction) for the same total count.
778    #[test]
779    fn test_parity_pad_expansion() {
780        use rustledger_booking::merge_with_padding;
781
782        let source = r"
7832024-01-01 open Assets:Bank USD
7842024-01-01 open Equity:Opening USD
785
7862024-01-01 pad Assets:Bank Equity:Opening
7872024-01-15 balance Assets:Bank 1000 USD
788";
789        let cli = cli_process(source);
790        let wasm = wasm_process(source);
791
792        let cli_merged = merge_with_padding(&cli);
793        let wasm_merged = merge_with_padding(&wasm);
794
795        assert_eq!(
796            cli_merged, wasm_merged,
797            "merged directive vectors differ between CLI and WASM paths",
798        );
799    }
800
801    // =========================================================================
802    // Serialization / Caching roundtrip tests
803    // =========================================================================
804
805    #[test]
806    fn test_parsed_ledger_serialize_roundtrip() {
807        use crate::cache;
808        use crate::editor::EditorCache;
809        use crate::helpers::load_and_book;
810
811        let source = r#"
812option "title" "Cache Test"
813option "operating_currency" "USD"
814
8152024-01-01 open Assets:Bank USD
8162024-01-01 open Expenses:Food USD
817
8182024-01-15 * "Coffee" "Morning latte"
819  Expenses:Food  5.00 USD
820  Assets:Bank   -5.00 USD
821
8222024-01-20 * "Groceries"
823  Expenses:Food  42.50 USD
824  Assets:Bank   -42.50 USD
825"#;
826
827        // Build the payload the same way ParsedLedger.serialize() does
828        let processed = load_and_book(source);
829        let payload = cache::ParsedLedgerPayload {
830            directives: processed.directives.clone(),
831            options: processed.options.clone(),
832            parse_errors: Vec::new(),
833            validation_errors: Vec::new(),
834        };
835
836        let bytes = cache::serialize_parsed(&payload).expect("serialize");
837        let restored = cache::deserialize_parsed(&bytes).expect("deserialize");
838
839        assert_eq!(
840            restored.directives.len(),
841            processed.directives.len(),
842            "directive count should match after roundtrip"
843        );
844        assert_eq!(
845            restored.options.title.as_deref(),
846            Some("Cache Test"),
847            "title preserved"
848        );
849        assert_eq!(
850            restored.options.operating_currencies,
851            ["USD"],
852            "operating currencies preserved"
853        );
854
855        // Verify the from_cache path works (re-parses source for editor features)
856        let parse_result = rustledger_parser::parse(source);
857        let editor_cache = EditorCache::new(source, &parse_result);
858        assert!(
859            !editor_cache.accounts.is_empty(),
860            "editor cache should have accounts after re-parse"
861        );
862    }
863
864    #[test]
865    fn test_ledger_serialize_roundtrip() {
866        use crate::cache;
867        use crate::helpers::load_and_book;
868
869        let source = r#"
870option "title" "Multi Cache"
871option "operating_currency" "EUR"
872
8732024-01-01 open Assets:Bank EUR
8742024-01-01 open Expenses:Rent EUR
875
8762024-02-01 * "Rent"
877  Expenses:Rent  800 EUR
878  Assets:Bank   -800 EUR
879"#;
880
881        let processed = load_and_book(source);
882        let payload = cache::LedgerPayload {
883            directives: processed.directives.clone(),
884            options: processed.options.clone(),
885            errors: Vec::new(),
886        };
887
888        let bytes = cache::serialize_ledger(&payload).expect("serialize");
889        let restored = cache::deserialize_ledger(&bytes).expect("deserialize");
890
891        assert_eq!(
892            restored.directives.len(),
893            processed.directives.len(),
894            "directive count should match after roundtrip"
895        );
896        assert_eq!(restored.options.title.as_deref(), Some("Multi Cache"));
897
898        // Verify EditorCache can be rebuilt from restored directives
899        let editor_cache = crate::editor::EditorCache::from_directives(&restored.directives);
900        assert!(
901            !editor_cache.accounts.is_empty(),
902            "editor cache should have accounts from restored directives"
903        );
904    }
905
906    #[test]
907    fn test_serialize_rejects_corrupted_bytes() {
908        use crate::cache;
909
910        // Bad magic
911        assert!(cache::deserialize_ledger(b"GARBAGE_DATA_HERE").is_err());
912        assert!(cache::deserialize_parsed(b"GARBAGE_DATA_HERE").is_err());
913
914        // Too short
915        assert!(cache::deserialize_ledger(b"short").is_err());
916    }
917
918    #[test]
919    fn test_hash_sources_api() {
920        let h1 = hash_sources(vec!["source v1".to_string()]);
921        let h2 = hash_sources(vec!["source v1".to_string()]);
922        let h3 = hash_sources(vec!["source v2".to_string()]);
923
924        assert_eq!(h1, h2, "same content → same hash");
925        assert_ne!(h1, h3, "different content → different hash");
926        assert_eq!(h1.len(), 64, "SHA-256 produces 64 hex chars");
927    }
928}