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;
42mod convert;
43mod editor;
44mod helpers;
45mod utils;
46
47// Public modules
48pub mod types;
49
50// Public API modules
51mod api;
52mod parsed_ledger;
53
54// Re-export public API
55pub 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// =============================================================================
70// TypeScript Type Definitions
71// =============================================================================
72
73#[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// =============================================================================
492// Initialization
493// =============================================================================
494
495/// Initialize the WASM module.
496///
497/// This sets up panic hooks for better error messages in the browser console.
498/// Call this once before using any other functions.
499#[wasm_bindgen(start)]
500pub fn init() {
501    // Set up panic hook for better error messages
502    console_error_panic_hook::set_once();
503}
504
505// =============================================================================
506// Tests
507// =============================================================================
508
509#[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        // Valid ledger
541        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        // Invalid ledger (unopened account)
554        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()); // Parse succeeds
563        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    // =========================================================================
571    // Multi-file API tests
572    // =========================================================================
573
574    #[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        // 2 opens + 1 transaction = 3 directives
603        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); // 2 open directives
634    }
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        // Extract directives and book transactions
668        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        // Sort by date for proper validation
680        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 `ParsedLedger` multi-file construction via `process()` pipeline.
689    #[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        // Should have 2 opens + 1 transaction = 3 directives
728        assert_eq!(directives.len(), 3);
729
730        // Should be sorted by date
731        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        // Transaction should have interpolated bank amount
738        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        // No errors
760        assert!(ledger.errors.is_empty(), "errors: {:?}", ledger.errors);
761    }
762
763    /// Regression test for #659: total cost `{{ }}` syntax must produce per-unit cost.
764    #[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        // Find the transaction and check that cost has number_per set
781        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        // 150.00 / 273.2200 ≈ 0.5490
806        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    // =========================================================================
816    // Pipeline parity tests: verify WASM produces same results as CLI
817    // =========================================================================
818
819    /// Helper: process source through CLI pipeline and return directives.
820    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    /// Helper: process source through WASM pipeline and return directives.
837    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    /// Parity: out-of-order transactions should be sorted by date.
844    #[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        // Both should have same directive count
868        assert_eq!(cli.len(), wasm.len(), "directive count mismatch");
869
870        // Both should be sorted by date
871        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        // Verify transactions are in chronological order
876        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    /// Parity: total cost `{{ }}` produces identical per-unit costs.
890    #[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    /// Parity: interpolation fills in missing amounts identically.
926    #[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    /// Parity: pad directives produce correct padding transactions.
965    #[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    // =========================================================================
990    // Serialization / Caching roundtrip tests
991    // =========================================================================
992
993    #[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        // Build the payload the same way ParsedLedger.serialize() does
1015        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        // Verify the from_cache path works (re-parses source for editor features)
1043        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        // Verify EditorCache can be rebuilt from restored directives
1087        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        // Bad magic
1099        assert!(cache::deserialize_ledger(b"GARBAGE_DATA_HERE").is_err());
1100        assert!(cache::deserialize_parsed(b"GARBAGE_DATA_HERE").is_err());
1101
1102        // Too short
1103        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}