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