Skip to main content

rustledger_wasm/
parsed_ledger.rs

1//! Stateful ledger classes for WASM.
2//!
3//! Provides two classes:
4//! - [`ParsedLedger`]: Single-file ledger with full editor features (completions, hover, etc.)
5//! - [`Ledger`]: Multi-file ledger for queries and validation (no position-based editor features)
6
7use std::collections::HashMap;
8use std::path::Path;
9use wasm_bindgen::prelude::*;
10
11use rustledger_core::Directive;
12use rustledger_parser::ParseResult as ParserResult;
13
14use crate::cache;
15use crate::convert::directive_to_json;
16use crate::editor;
17use crate::helpers::{load_and_book, run_validation, to_js};
18#[cfg(feature = "plugins")]
19use crate::types::PluginResult;
20use crate::types::{Error, FormatResult, LedgerOptions, PadResult, QueryResult};
21
22// =============================================================================
23// Shared query/directive logic (used by both ParsedLedger and Ledger)
24// =============================================================================
25
26fn execute_query(
27    directives: &[Directive],
28    query_str: &str,
29    account_types: rustledger_core::AccountTypes,
30) -> Result<JsValue, JsError> {
31    use crate::convert::value_to_cell;
32    use rustledger_query::{Executor, parse as parse_query};
33
34    let query = match parse_query(query_str) {
35        Ok(q) => q,
36        Err(e) => {
37            let result = QueryResult {
38                columns: Vec::new(),
39                rows: Vec::new(),
40                errors: vec![Error::new(e.to_string())],
41            };
42            return to_js(&result);
43        }
44    };
45
46    // BQL is a balance-computing consumer; expand pads explicitly
47    // before handing the directive list to the executor (#1288).
48    // `ParsedLedger.directives` / `Ledger.directives` are
49    // source-faithful per the architectural rule documented on
50    // `rustledger_loader::Ledger.directives`; the executor needs
51    // the expanded view to compute `sum(position)` with pad effects
52    // included.
53    let expanded = rustledger_booking::merge_with_padding(directives);
54    let mut executor = Executor::new(&expanded);
55    // Config-aware classification (POSSIGN/ACCOUNT_SORTKEY honor name_*
56    // renames) — the wasm wire LedgerOptions deliberately lacks name_*,
57    // so callers supply core AccountTypes from their construction source.
58    executor.set_account_types(account_types);
59    match executor.execute(&query) {
60        Ok(result) => {
61            let rows: Vec<Vec<_>> = result
62                .rows
63                .iter()
64                .map(|row| row.iter().map(value_to_cell).collect())
65                .collect();
66
67            let query_result = QueryResult {
68                columns: result.columns,
69                rows,
70                errors: Vec::new(),
71            };
72            to_js(&query_result)
73        }
74        Err(e) => {
75            let result = QueryResult {
76                columns: Vec::new(),
77                rows: Vec::new(),
78                errors: vec![Error::new(format!("Query execution error: {e}"))],
79            };
80            to_js(&result)
81        }
82    }
83}
84
85fn execute_expand_pads(directives: &[Directive]) -> Result<JsValue, JsError> {
86    use rustledger_booking::process_pads;
87
88    let pad_result = process_pads(directives);
89
90    let result = PadResult {
91        // The source stream, verbatim — `process_pads` no longer
92        // echoes its input back, so read it from the directives we
93        // were handed instead of from the result.
94        directives: directives.iter().map(directive_to_json).collect(),
95        padding_transactions: pad_result
96            .padding_transactions
97            .iter()
98            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
99            .collect(),
100        errors: pad_result
101            .errors
102            .iter()
103            .map(|e| Error::new(e.message.clone()))
104            .collect(),
105    };
106    to_js(&result)
107}
108
109#[cfg(feature = "plugins")]
110fn execute_plugin(directives: &[Directive], plugin_name: &str) -> Result<JsValue, JsError> {
111    use rustledger_plugin::{
112        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
113        wrappers_to_directives,
114    };
115
116    let registry = NativePluginRegistry::global();
117    // External API runs plugins on already-booked input — synth
118    // plugins are a loader-internal concern and would re-emit Opens
119    // for accounts the booking pass already opened.
120    let Some(plugin) = registry.find_regular(plugin_name) else {
121        let result = PluginResult {
122            directives: Vec::new(),
123            errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
124        };
125        return to_js(&result);
126    };
127
128    let wrappers = directives_to_wrappers(directives);
129    let input = PluginInput {
130        directives: wrappers,
131        options: PluginOptions::default(),
132        config: None,
133    };
134
135    let input_dirs = input.directives.clone();
136    let output = plugin.process(input);
137    let materialized = crate::api::materialize_plugin_ops(&input_dirs, &output);
138
139    let output_directives = match wrappers_to_directives(&materialized) {
140        Ok(dirs) => dirs,
141        Err(e) => {
142            let result = PluginResult {
143                directives: Vec::new(),
144                errors: vec![Error::new(format!("Conversion error: {e}"))],
145            };
146            return to_js(&result);
147        }
148    };
149
150    let result = PluginResult {
151        directives: output_directives.iter().map(directive_to_json).collect(),
152        errors: output
153            .errors
154            .iter()
155            .map(|e| match e.severity {
156                rustledger_plugin::PluginErrorSeverity::Warning => {
157                    Error::warning(e.message.clone())
158                }
159                rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
160            })
161            .collect(),
162    };
163    to_js(&result)
164}
165
166// =============================================================================
167// ParsedLedger: Single-file with full editor features
168// =============================================================================
169
170/// A parsed and validated single-file ledger with editor features.
171///
172/// Use this class for single-file ledgers where you need completions, hover,
173/// go-to-definition, and other editor integration features.
174///
175/// For multi-file ledgers, use [`Ledger`] instead.
176///
177/// # Example (JavaScript)
178///
179/// ```javascript
180/// const ledger = new ParsedLedger(source);
181/// if (ledger.isValid()) {
182///     const balances = ledger.query("BALANCES");
183///     const completions = ledger.getCompletions(line, char);
184/// }
185/// ```
186#[wasm_bindgen(skip_typescript)]
187pub struct ParsedLedger {
188    /// The original source text.
189    source: String,
190    /// The raw parse result (for editor features).
191    parse_result: ParserResult,
192    /// The booked directives.
193    directives: Vec<Directive>,
194    /// Ledger options.
195    options: LedgerOptions,
196    /// Parse errors.
197    parse_errors: Vec<Error>,
198    /// Validation errors.
199    validation_errors: Vec<Error>,
200    /// Cached editor data (accounts, currencies, payees, line index).
201    editor_cache: editor::EditorCache,
202}
203
204#[wasm_bindgen]
205impl ParsedLedger {
206    /// Create a new `ParsedLedger` from a single source string.
207    ///
208    /// Parses, books, and validates the source. Call `isValid()` to check for errors.
209    #[wasm_bindgen(constructor)]
210    pub fn new(source: &str) -> Self {
211        let load = load_and_book(source);
212        let validation_errors = run_validation(&load);
213        let editor_cache = editor::EditorCache::new(source, &load.parse_result);
214
215        Self {
216            source: source.to_string(),
217            parse_result: load.parse_result,
218            directives: load.directives,
219            options: load.options,
220            parse_errors: load.errors,
221            validation_errors,
222            editor_cache,
223        }
224    }
225
226    /// Check if the ledger is valid (no parse or validation errors).
227    #[wasm_bindgen(js_name = "isValid")]
228    pub fn is_valid(&self) -> bool {
229        self.parse_errors.is_empty() && self.validation_errors.is_empty()
230    }
231
232    /// Get all errors (parse + validation).
233    #[wasm_bindgen(js_name = "getErrors")]
234    pub fn get_errors(&self) -> Result<JsValue, JsError> {
235        let mut all_errors = self.parse_errors.clone();
236        all_errors.extend(self.validation_errors.clone());
237        to_js(&all_errors)
238    }
239
240    /// Get parse errors only.
241    #[wasm_bindgen(js_name = "getParseErrors")]
242    pub fn get_parse_errors(&self) -> Result<JsValue, JsError> {
243        to_js(&self.parse_errors)
244    }
245
246    /// Get validation errors only.
247    #[wasm_bindgen(js_name = "getValidationErrors")]
248    pub fn get_validation_errors(&self) -> Result<JsValue, JsError> {
249        to_js(&self.validation_errors)
250    }
251
252    /// Get the parsed directives.
253    #[wasm_bindgen(js_name = "getDirectives")]
254    pub fn get_directives(&self) -> Result<JsValue, JsError> {
255        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
256        to_js(&directives)
257    }
258
259    /// Get the ledger options.
260    #[wasm_bindgen(js_name = "getOptions")]
261    pub fn get_options(&self) -> Result<JsValue, JsError> {
262        to_js(&self.options)
263    }
264
265    /// Get the number of directives.
266    #[wasm_bindgen(js_name = "directiveCount")]
267    pub fn directive_count(&self) -> usize {
268        self.directives.len()
269    }
270
271    /// Run a BQL query on this ledger.
272    #[wasm_bindgen]
273    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
274        if !self.parse_errors.is_empty() {
275            let result = QueryResult {
276                columns: Vec::new(),
277                rows: Vec::new(),
278                errors: self.parse_errors.clone(),
279            };
280            return to_js(&result);
281        }
282        execute_query(
283            &self.directives,
284            query_str,
285            crate::helpers::account_types_from_raw(&self.parse_result.options),
286        )
287    }
288
289    /// Get account balances (shorthand for query("BALANCES")).
290    #[wasm_bindgen]
291    pub fn balances(&self) -> Result<JsValue, JsError> {
292        self.query("BALANCES")
293    }
294
295    /// Format the ledger source.
296    ///
297    /// Reformats the original source preserving comments, blank lines, and
298    /// non-directive content with file-wide aligned columns.
299    #[wasm_bindgen]
300    pub fn format(&self) -> Result<JsValue, JsError> {
301        use rustledger_parser::format::format_source_with_parsed;
302
303        if !self.parse_errors.is_empty() {
304            let result = FormatResult {
305                formatted: None,
306                errors: self.parse_errors.clone(),
307            };
308            return to_js(&result);
309        }
310
311        // Reuse the cached `ParseResult` we already own instead of
312        // re-parsing `self.source`. Byte-identical output to
313        // `format_source(&self.source)` per the parser-side
314        // `format_source_with_parsed_matches_format_source` test.
315        // On large ledgers loaded into a long-lived WASM session,
316        // this cuts the per-format cost roughly in half.
317        let formatted = format_source_with_parsed(&self.parse_result, &self.source);
318
319        let result = FormatResult {
320            formatted: Some(formatted),
321            errors: Vec::new(),
322        };
323        to_js(&result)
324    }
325
326    /// Expand pad directives.
327    #[wasm_bindgen(js_name = "expandPads")]
328    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
329        if !self.parse_errors.is_empty() {
330            let result = PadResult {
331                directives: Vec::new(),
332                padding_transactions: Vec::new(),
333                errors: self.parse_errors.clone(),
334            };
335            return to_js(&result);
336        }
337        execute_expand_pads(&self.directives)
338    }
339
340    /// Run a native plugin on this ledger.
341    #[cfg(feature = "plugins")]
342    #[wasm_bindgen(js_name = "runPlugin")]
343    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
344        if !self.parse_errors.is_empty() {
345            let result = PluginResult {
346                directives: Vec::new(),
347                errors: self.parse_errors.clone(),
348            };
349            return to_js(&result);
350        }
351        execute_plugin(&self.directives, plugin_name)
352    }
353
354    // =========================================================================
355    // Editor Integration (LSP-like features)
356    // =========================================================================
357
358    /// Get completions at the given position.
359    #[wasm_bindgen(js_name = "getCompletions")]
360    pub fn get_completions(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
361        let result =
362            editor::get_completions_cached(&self.source, line, character, &self.editor_cache);
363        to_js(&result)
364    }
365
366    /// Get hover information at the given position.
367    #[wasm_bindgen(js_name = "getHoverInfo")]
368    pub fn get_hover_info(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
369        let result = editor::get_hover_info_cached(
370            &self.source,
371            line,
372            character,
373            &self.parse_result,
374            &self.editor_cache,
375        );
376        to_js(&result)
377    }
378
379    /// Get the definition location for the symbol at the given position.
380    #[wasm_bindgen(js_name = "getDefinition")]
381    pub fn get_definition(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
382        let result = editor::get_definition_cached(
383            &self.source,
384            line,
385            character,
386            &self.parse_result,
387            &self.editor_cache,
388        );
389        to_js(&result)
390    }
391
392    /// Get all document symbols for the outline view.
393    #[wasm_bindgen(js_name = "getDocumentSymbols")]
394    pub fn get_document_symbols(&self) -> Result<JsValue, JsError> {
395        let result = editor::get_document_symbols_cached(&self.parse_result, &self.editor_cache);
396        to_js(&result)
397    }
398
399    /// Find all references to the symbol at the given position.
400    #[wasm_bindgen(js_name = "getReferences")]
401    pub fn get_references(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
402        let result = editor::get_references_cached(
403            &self.source,
404            line,
405            character,
406            &self.parse_result,
407            &self.editor_cache,
408        );
409        to_js(&result)
410    }
411
412    // =========================================================================
413    // Serialization / Caching
414    // =========================================================================
415
416    /// Serialize this ledger to a compact binary blob (rkyv).
417    ///
418    /// Store the bytes in OPFS or `IndexedDB` alongside a source fingerprint
419    /// (see [`crate::hash_sources`]) and restore later with [`ParsedLedger::from_cache`].
420    #[wasm_bindgen]
421    pub fn serialize(&self) -> Result<Vec<u8>, JsError> {
422        // Clone fields into the payload. rkyv's Serialize derive requires owned
423        // types; a zero-copy borrowed serializer would add significant complexity
424        // for minimal gain since serialize() is called once per cache write.
425        let payload = cache::ParsedLedgerPayload {
426            directives: self.directives.clone(),
427            options: self.options.clone(),
428            parse_errors: self.parse_errors.clone(),
429            validation_errors: self.validation_errors.clone(),
430        };
431        cache::serialize_parsed(&payload).map_err(|e| JsError::new(&e))
432    }
433
434    /// Restore a `ParsedLedger` from bytes produced by [`ParsedLedger::serialize`].
435    ///
436    /// The `source` parameter must be the same source text used when the cache
437    /// was created; it is re-parsed (but not re-booked or re-validated) so that
438    /// editor features continue to work.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if the bytes are invalid or were produced by a different
443    /// library version.
444    #[wasm_bindgen(js_name = "fromCache")]
445    pub fn from_cache(bytes: &[u8], source: &str) -> Result<Self, JsError> {
446        let mut payload = cache::deserialize_parsed(bytes).map_err(|e| JsError::new(&e))?;
447
448        // Re-intern strings to deduplicate identical Arc<str> allocations.
449        rustledger_loader::reintern_plain_directives(&mut payload.directives);
450
451        // Re-parse source for editor spans (cheap; booking is the expensive part).
452        let parse_result = rustledger_parser::parse(source);
453        let editor_cache = editor::EditorCache::new(source, &parse_result);
454
455        Ok(Self {
456            source: source.to_string(),
457            parse_result,
458            directives: payload.directives,
459            options: payload.options,
460            parse_errors: payload.parse_errors,
461            validation_errors: payload.validation_errors,
462            editor_cache,
463        })
464    }
465}
466
467// =============================================================================
468// Ledger: Multi-file with queries and cross-file completions
469// =============================================================================
470
471/// A fully processed multi-file ledger for queries and validation.
472///
473/// Use this class for ledgers that span multiple files with `include` directives.
474/// Caches the processed result for efficient repeated queries.
475///
476/// For single-file ledgers with editor features, use [`ParsedLedger`] instead.
477///
478/// # Example (JavaScript)
479///
480/// ```javascript
481/// const ledger = Ledger.fromFiles({
482///     "main.beancount": 'include "accounts.beancount"\n...',
483///     "accounts.beancount": "2024-01-01 open Assets:Bank USD\n..."
484/// }, "main.beancount");
485///
486/// if (ledger.isValid()) {
487///     const balances = ledger.query("BALANCES");
488///     const completions = ledger.getCompletions(currentSource, line, char);
489/// }
490/// ```
491#[wasm_bindgen(skip_typescript)]
492pub struct Ledger {
493    /// The booked directives from all files.
494    directives: Vec<Directive>,
495    /// Ledger options.
496    options: LedgerOptions,
497    /// Configured account-type roots (`name_*` renames) for query
498    /// classification. Not part of the wire `LedgerOptions` (deliberate);
499    /// persisted in the cache payload so `fromCache` ledgers classify
500    /// identically.
501    account_types: rustledger_core::AccountTypes,
502    /// Processing errors (load, booking, validation).
503    errors: Vec<Error>,
504    /// Editor cache for cross-file completions.
505    editor_cache: editor::EditorCache,
506}
507
508#[wasm_bindgen]
509impl Ledger {
510    /// Create a `Ledger` from multiple files with include resolution.
511    ///
512    /// Loads and runs the same processing pipeline as the CLI:
513    /// sort → synth-plugins → Early validation → book → regular-plugins → Late validation → finalize.
514    ///
515    /// # Arguments
516    ///
517    /// * `files` - A JavaScript object mapping file paths to their contents.
518    /// * `entry_point` - The main file to start loading from (must exist in `files`).
519    #[wasm_bindgen(js_name = "fromFiles")]
520    pub fn from_files(files: JsValue, entry_point: &str) -> Result<Self, JsError> {
521        use rustledger_loader::{FileSystem, LoadOptions, Loader, VirtualFileSystem, process};
522
523        let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
524            .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
525
526        if file_map.is_empty() {
527            return Err(JsError::new("Files map cannot be empty"));
528        }
529
530        let vfs = VirtualFileSystem::from_files(file_map);
531
532        if !vfs.exists(Path::new(entry_point)) {
533            return Err(JsError::new(&format!(
534                "Entry point '{entry_point}' not found in files map"
535            )));
536        }
537
538        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
539
540        let load_result = match loader.load(Path::new(entry_point)) {
541            Ok(result) => result,
542            Err(e) => {
543                return Ok(Self {
544                    directives: Vec::new(),
545                    options: LedgerOptions::default(),
546                    account_types: rustledger_core::AccountTypes::default(),
547                    errors: vec![Error::new(format!("Load error: {e}"))],
548                    editor_cache: editor::EditorCache::from_directives(&[]),
549                });
550            }
551        };
552
553        let options = LedgerOptions {
554            title: load_result.options.title.clone(),
555            operating_currencies: load_result.options.operating_currency.clone(),
556        };
557        let account_types = load_result.options.to_account_types();
558
559        let load_options = LoadOptions {
560            validate: true,
561            ..Default::default()
562        };
563
564        match process(load_result, &load_options) {
565            Ok(ledger) => {
566                let directives: Vec<Directive> =
567                    ledger.directives.into_iter().map(|s| s.value).collect();
568                let mut errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
569                // Include option warnings (E7001–E7006) so WASM consumers
570                // see the same diagnostics as `rledger check` and the LSP.
571                for w in &ledger.options.warnings {
572                    errors.push(Error::new(format!("[{}] {}", w.code, w.message)));
573                }
574                let editor_cache = editor::EditorCache::from_directives(&directives);
575
576                Ok(Self {
577                    directives,
578                    options,
579                    account_types,
580                    errors,
581                    editor_cache,
582                })
583            }
584            Err(e) => Ok(Self {
585                directives: Vec::new(),
586                options,
587                account_types,
588                errors: vec![Error::new(format!("Processing error: {e}"))],
589                editor_cache: editor::EditorCache::from_directives(&[]),
590            }),
591        }
592    }
593
594    /// Check if the ledger is valid (no errors).
595    #[wasm_bindgen(js_name = "isValid")]
596    pub fn is_valid(&self) -> bool {
597        self.errors.is_empty()
598    }
599
600    /// Get all errors.
601    #[wasm_bindgen(js_name = "getErrors")]
602    pub fn get_errors(&self) -> Result<JsValue, JsError> {
603        to_js(&self.errors)
604    }
605
606    /// Get the parsed directives.
607    #[wasm_bindgen(js_name = "getDirectives")]
608    pub fn get_directives(&self) -> Result<JsValue, JsError> {
609        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
610        to_js(&directives)
611    }
612
613    /// Get the ledger options.
614    #[wasm_bindgen(js_name = "getOptions")]
615    pub fn get_options(&self) -> Result<JsValue, JsError> {
616        to_js(&self.options)
617    }
618
619    /// Get the number of directives.
620    #[wasm_bindgen(js_name = "directiveCount")]
621    pub fn directive_count(&self) -> usize {
622        self.directives.len()
623    }
624
625    /// Run a BQL query on this ledger.
626    #[wasm_bindgen]
627    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
628        execute_query(&self.directives, query_str, self.account_types.clone())
629    }
630
631    /// Get account balances (shorthand for query("BALANCES")).
632    #[wasm_bindgen]
633    pub fn balances(&self) -> Result<JsValue, JsError> {
634        self.query("BALANCES")
635    }
636
637    /// Expand pad directives.
638    #[wasm_bindgen(js_name = "expandPads")]
639    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
640        execute_expand_pads(&self.directives)
641    }
642
643    /// Run a native plugin on this ledger.
644    #[cfg(feature = "plugins")]
645    #[wasm_bindgen(js_name = "runPlugin")]
646    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
647        execute_plugin(&self.directives, plugin_name)
648    }
649
650    /// Get completions for a source string using cross-file data.
651    ///
652    /// Pass the source text of the file currently being edited.
653    /// Completions use accounts, currencies, and payees from all loaded files.
654    #[wasm_bindgen(js_name = "getCompletions")]
655    pub fn get_completions(
656        &self,
657        source: &str,
658        line: u32,
659        character: u32,
660    ) -> Result<JsValue, JsError> {
661        let result = editor::get_completions_cached(source, line, character, &self.editor_cache);
662        to_js(&result)
663    }
664
665    // =========================================================================
666    // Serialization / Caching
667    // =========================================================================
668
669    /// Serialize this ledger to a compact binary blob (rkyv).
670    ///
671    /// Store the bytes in OPFS or `IndexedDB` alongside a source fingerprint
672    /// (see [`crate::hash_sources`]) and restore later with [`Ledger::from_cache`].
673    #[wasm_bindgen]
674    pub fn serialize(&self) -> Result<Vec<u8>, JsError> {
675        let payload = cache::LedgerPayload {
676            directives: self.directives.clone(),
677            options: self.options.clone(),
678            account_type_names: vec![
679                self.account_types.assets.clone(),
680                self.account_types.liabilities.clone(),
681                self.account_types.equity.clone(),
682                self.account_types.income.clone(),
683                self.account_types.expenses.clone(),
684            ],
685            errors: self.errors.clone(),
686        };
687        cache::serialize_ledger(&payload).map_err(|e| JsError::new(&e))
688    }
689
690    /// Restore a `Ledger` from bytes produced by [`Ledger::serialize`].
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if the bytes are invalid or were produced by a different
695    /// library version.
696    #[wasm_bindgen(js_name = "fromCache")]
697    pub fn from_cache(bytes: &[u8]) -> Result<Self, JsError> {
698        let mut payload = cache::deserialize_ledger(bytes).map_err(|e| JsError::new(&e))?;
699
700        // Re-intern strings to deduplicate identical Arc<str> allocations.
701        rustledger_loader::reintern_plain_directives(&mut payload.directives);
702
703        let editor_cache = editor::EditorCache::from_directives(&payload.directives);
704
705        let account_types = match <[String; 5]>::try_from(payload.account_type_names) {
706            Ok([assets, liabilities, equity, income, expenses]) => rustledger_core::AccountTypes {
707                assets,
708                liabilities,
709                equity,
710                income,
711                expenses,
712            },
713            // Wrong arity can only come from a hand-built blob (the version
714            // header already gates format changes); fall back to defaults
715            // rather than erroring on an otherwise-valid payload.
716            Err(_) => rustledger_core::AccountTypes::default(),
717        };
718
719        Ok(Self {
720            directives: payload.directives,
721            options: payload.options,
722            account_types,
723            errors: payload.errors,
724            editor_cache,
725        })
726    }
727}