Skip to main content

rustledger_wasm/
types.rs

1//! Data transfer objects for WASM serialization.
2//!
3//! These types provide a JavaScript-friendly representation of Beancount data,
4//! using string representations for dates and numbers.
5//!
6//! # Generated bindings (ADR-0004)
7//!
8//! The DTOs below have two generator-attribute layers, both inert in
9//! normal builds:
10//!
11//! - **`ts-export`** feature (Phase 1, #1218) — the ts-rs derive emits
12//!   per-type `.d.ts` files under `crates/rustledger-wasm/bindings/`.
13//!   The post-process script at `scripts/regen-bindings.sh`
14//!   concatenates them into the checked-in `bindings/index.d.ts`
15//!   (canonical TS API).
16//! - **`json-schema`** feature (Phase 3, #1232) — the schemars derive
17//!   lets the same script emit `bindings/index.schema.json`
18//!   (draft-2020-12). `datamodel-code-generator` then converts that
19//!   into `bindings/types.py` (Pydantic v2). Closes the
20//!   "hand-maintained Python stubs" gap left open by Phase 1/2.
21//!
22//! Adding a new field to any DTO below requires running
23//! `scripts/regen-bindings.sh` and committing the regenerated TS
24//! bundle, JSON Schema, and Python types — CI fails if any of them
25//! drift.
26
27use std::collections::HashMap;
28
29use serde::{Deserialize, Serialize};
30
31/// Result of parsing a Beancount file.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
34#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
35#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
36// `ledger` is `Option<Ledger>` (nullable on the wire) but has no
37// `skip_serializing_if`, so the field key is always present.
38// schemars 1.x's per-field `required` attribute would force the key
39// into the parent's `required` array but also un-null the type.
40// `extend("required" = ...)` overrides the entire required array, so
41// we must list every required field (including non-Option ones like
42// `errors`) -- if we listed only `ledger`, schemars would drop
43// `errors` from required.
44#[cfg_attr(
45    feature = "json-schema",
46    schemars(extend("required" = ["ledger", "errors"]))
47)]
48pub struct ParseResult {
49    /// The parsed ledger (if successful). Emitted as JSON `null` when
50    /// parsing failed entirely; no `skip_serializing_if`, so the field
51    /// is always present on the wire (TS: `Ledger | null`, not
52    /// `ledger?`). See the `#[schemars(extend(...))]` on the struct
53    /// itself for the "required-and-nullable" wire-contract enforcement.
54    pub ledger: Option<Ledger>,
55    /// Parse errors.
56    pub errors: Vec<Error>,
57}
58
59/// A parsed Beancount ledger.
60///
61/// **Renamed to `LedgerJson` on the TS side** to avoid colliding with
62/// the wasm-bindgen-exported `Ledger` class (the runtime wrapper that
63/// owns the parsed data). `LedgerJson` is the wire shape; `Ledger` is
64/// the class consumers instantiate via `Ledger.fromFiles(...)`. The
65/// Rust struct keeps the shorter name for internal use; the rename
66/// is applied via `#[ts(rename = ...)]`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
69#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
70#[cfg_attr(
71    feature = "ts-export",
72    ts(export, export_to = "bindings/", rename = "LedgerJson")
73)]
74#[cfg_attr(feature = "json-schema", schemars(rename = "LedgerJson"))]
75pub struct Ledger {
76    /// All directives in the ledger.
77    pub directives: Vec<DirectiveJson>,
78    /// Ledger options.
79    pub options: LedgerOptions,
80}
81
82/// Ledger options.
83#[derive(
84    Debug, Clone, Default, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
85)]
86#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
87#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
89// `title` is `Option<String>` (nullable) but always present on the
90// wire. `extend("required" = ...)` overrides the auto-detected list,
91// so we must include every required field (operating_currencies
92// included). See `ParseResult` for the full rationale.
93#[cfg_attr(
94    feature = "json-schema",
95    schemars(extend("required" = ["operating_currencies", "title"]))
96)]
97pub struct LedgerOptions {
98    /// Operating currencies.
99    pub operating_currencies: Vec<String>,
100    /// Ledger title. Emitted as JSON `null` when no title is set
101    /// (no `skip_serializing_if`; field is always present on the
102    /// wire). TS: `string | null`, not `title?`. The required-and-
103    /// nullable wire contract is enforced via the `schemars(extend)`
104    /// on the struct itself; see `ParseResult` for the rationale.
105    pub title: Option<String>,
106}
107
108/// Metadata-value wire format for WASM consumers.
109///
110/// **JSON output is byte-equivalent to FFI-WASI's
111/// `meta_value_to_json`** — JS clients writing portable code see
112/// identical metadata values from both bindings. The Rust-side
113/// types are independent though: FFI-WASI emits
114/// `serde_json::Value` (untyped), this crate emits a typed enum.
115/// Unifying the source-of-truth is tracked by issue #1200 item 2.
116///
117/// The host's [`rustledger_core::MetaValue`] is richer than the wire
118/// type — `Account`/`Currency`/`Tag`/`Link`/`Date`/`Number` all
119/// flatten to JSON strings here, matching FFI-WASI behavior. JS
120/// consumers that need the strong type info should query the host
121/// via a typed API; this enum is the lossy-but-portable view.
122///
123/// Untagged on the wire: `"hello"` serializes as a string,
124/// `true` as a boolean, `null` as null, and an [`AmountValue`]
125/// `{number,currency}` as a plain object. The TypeScript union is
126/// `Record<string, string | boolean | {number, currency} | null>` —
127/// no raw JSON number arm because `MetaValue::Number` (`Decimal`)
128/// stringifies to preserve precision. Issue #1168 proposed
129/// `string | number | boolean | null`; we substitute the
130/// `{number,currency}` shape for `number` so cost-bearing metadata
131/// round-trips cleanly and so JS numeric literals don't silently
132/// alias into the wire (see the `meta_value_json_rejects_raw_json_number`
133/// test).
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(untagged)]
136#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
137#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
138#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
139pub enum MetaValueJson {
140    /// String/Account/Currency/Tag/Link/Date/Number — anything the
141    /// host can represent as a string, including `rust_decimal::Decimal`
142    /// values stringified to preserve precision (JSON numbers can't
143    /// represent arbitrary-precision decimals losslessly).
144    String(String),
145    /// Boolean values.
146    Bool(bool),
147    /// Amount values (`{number, currency}`) — the only structured
148    /// shape that survives the round-trip. Same `{number, currency}`
149    /// envelope as [`AmountValue`] so JS consumers can branch on
150    /// shape without a discriminator tag.
151    ///
152    /// **Deserialize note**: serde's untagged-enum matcher accepts
153    /// extra fields in a JSON object (`#[serde(deny_unknown_fields)]`
154    /// can't be applied per-variant on an untagged enum without
155    /// breaking the wider match). A JS client sending
156    /// `{number: "100", currency: "USD", extra: "x"}` deserializes as
157    /// `Amount { number: "100", currency: "USD" }` with `extra`
158    /// silently dropped. Output-side consumers (the production path)
159    /// are unaffected; treat `Deserialize` here as best-effort and
160    /// validate at the host boundary if you need stricter checks.
161    Amount {
162        /// The decimal quantity, stringified for precision.
163        number: String,
164        /// The currency code.
165        currency: String,
166    },
167    /// Absent / null metadata value. Deserializes from JSON `null`;
168    /// serializes to JSON `null`. (Serde supports unit variants in
169    /// untagged enums for null values specifically — a less common
170    /// pattern than struct/tuple variants but well-defined.)
171    Null,
172}
173
174/// Tagged-union wire-format for a [`rustledger_core::MetaValue`] that
175/// preserves the host's variant tag.
176///
177/// Used **only** in `DirectiveJson::Custom`'s `values` field, where
178/// callers genuinely need to distinguish (for example) a `Date` from
179/// a `String` or an `Account` — all three of which collapse to a bare
180/// JSON string under the untagged [`MetaValueJson`] shape.
181///
182/// Wire shape: `{"type": "<variant>", "value": ...}` — mirrors
183/// `rustledger-ffi-wasi::TypedValue` (see
184/// `crates/rustledger-ffi-wasi/src/types/output.rs::TypedValue`) so
185/// portable JS consumers see identical envelopes across both bindings.
186///
187/// **Why `value: MetaValueJson` and not `serde_json::Value`** —
188/// `serde_json` is intentionally a host-only dev-dependency for this
189/// crate (the runtime build avoids it to keep the wasm32 dep chain
190/// small). [`MetaValueJson`] already covers every payload shape
191/// FFI-WASI's `TypedValue` emits: `String` for the string-flavored
192/// variants, `Bool` for `bool`, `Amount` for `amount`, `Null` for
193/// `null`. The serialized JSON is bit-identical to FFI-WASI's.
194///
195/// `MetaValueJson` (untagged) is retained for the `meta` map of every
196/// directive — there the lossy shape is intentional and matches what
197/// FFI-WASI's metadata side also emits.
198///
199/// **Breaking change from #1199** for the WASM binding: pre-#1207
200/// `Custom.values` emitted raw `MetaValueJson` values (lossy). Closes
201/// #1207.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
204#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
205#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
206pub struct TypedValueJson {
207    /// Variant tag — one of `"string"`, `"account"`, `"currency"`,
208    /// `"tag"`, `"link"`, `"date"`, `"number"`, `"bool"`, `"amount"`,
209    /// `"null"`. Matches FFI-WASI's tag strings exactly.
210    ///
211    /// Renamed via `#[ts(type = ...)]` so the discriminator is a
212    /// string-literal union on the TS side. The post-process script
213    /// further narrows the full struct shape into a discriminated
214    /// union (per-variant `{type, value}` rows) -- see ADR-0004 for
215    /// why the narrowing is hand-tuned rather than generator-driven.
216    #[serde(rename = "type")]
217    #[cfg_attr(
218        feature = "ts-export",
219        ts(
220            type = "\"string\" | \"account\" | \"currency\" | \"tag\" | \"link\" | \"date\" | \"number\" | \"bool\" | \"amount\" | \"null\""
221        )
222    )]
223    pub value_type: String,
224    /// Variant payload (see [`MetaValueJson`] for the four shapes).
225    pub value: MetaValueJson,
226}
227
228/// A directive in JSON-serializable form.
229///
230/// Each variant corresponds to a Beancount directive type, with fields
231/// representing the directive's data in a JavaScript-friendly format.
232///
233/// All variants carry a `meta` field with user-defined key/value
234/// metadata from the source (issue #1168). Empty metadata serializes
235/// as an absent field, so existing consumers continue to see the
236/// pre-#1168 shape on directives without explicit metadata.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(tag = "type")]
239#[allow(missing_docs)]
240#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
241#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
242#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
243pub enum DirectiveJson {
244    /// Transaction directive.
245    #[serde(rename = "transaction")]
246    Transaction {
247        date: String,
248        flag: String,
249        /// Optional payee. Mirrors FFI-WASI's shape: absent on the
250        /// wire when `None` (closes #1221).
251        #[serde(skip_serializing_if = "Option::is_none")]
252        #[cfg_attr(feature = "ts-export", ts(optional))]
253        payee: Option<String>,
254        /// Optional narration. Empty narrations are normalized to
255        /// `None` in `convert.rs` so the field is absent on the wire
256        /// in the empty case -- matches FFI-WASI's pattern (#1221).
257        #[serde(skip_serializing_if = "Option::is_none")]
258        #[cfg_attr(feature = "ts-export", ts(optional))]
259        narration: Option<String>,
260        tags: Vec<String>,
261        links: Vec<String>,
262        postings: Vec<PostingJson>,
263        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
264        meta: HashMap<String, MetaValueJson>,
265    },
266    /// Balance assertion.
267    #[serde(rename = "balance")]
268    Balance {
269        date: String,
270        account: String,
271        amount: AmountValue,
272        /// Explicit tolerance from the `~ 0.01` annotation, stringified.
273        /// Mirrors `rustledger_core::Balance::tolerance`.
274        #[serde(skip_serializing_if = "Option::is_none")]
275        #[cfg_attr(feature = "ts-export", ts(optional))]
276        tolerance: Option<String>,
277        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
278        meta: HashMap<String, MetaValueJson>,
279    },
280    /// Open account.
281    #[serde(rename = "open")]
282    Open {
283        date: String,
284        account: String,
285        currencies: Vec<String>,
286        #[serde(skip_serializing_if = "Option::is_none")]
287        #[cfg_attr(feature = "ts-export", ts(optional))]
288        booking: Option<String>,
289        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
290        meta: HashMap<String, MetaValueJson>,
291    },
292    /// Close account.
293    #[serde(rename = "close")]
294    Close {
295        date: String,
296        account: String,
297        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
298        meta: HashMap<String, MetaValueJson>,
299    },
300    /// Commodity declaration.
301    #[serde(rename = "commodity")]
302    Commodity {
303        date: String,
304        currency: String,
305        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
306        meta: HashMap<String, MetaValueJson>,
307    },
308    /// Pad directive.
309    #[serde(rename = "pad")]
310    Pad {
311        date: String,
312        account: String,
313        source_account: String,
314        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
315        meta: HashMap<String, MetaValueJson>,
316    },
317    /// Event directive.
318    #[serde(rename = "event")]
319    Event {
320        date: String,
321        event_type: String,
322        value: String,
323        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
324        meta: HashMap<String, MetaValueJson>,
325    },
326    /// Note directive.
327    #[serde(rename = "note")]
328    Note {
329        date: String,
330        account: String,
331        comment: String,
332        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
333        meta: HashMap<String, MetaValueJson>,
334    },
335    /// Document directive.
336    #[serde(rename = "document")]
337    Document {
338        date: String,
339        account: String,
340        path: String,
341        /// Tags attached to the document directive (issue #1144).
342        #[serde(skip_serializing_if = "Vec::is_empty", default)]
343        tags: Vec<String>,
344        /// Links attached to the document directive (issue #1144).
345        #[serde(skip_serializing_if = "Vec::is_empty", default)]
346        links: Vec<String>,
347        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
348        meta: HashMap<String, MetaValueJson>,
349    },
350    /// Price directive.
351    #[serde(rename = "price")]
352    Price {
353        date: String,
354        currency: String,
355        amount: AmountValue,
356        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
357        meta: HashMap<String, MetaValueJson>,
358    },
359    /// Query directive.
360    #[serde(rename = "query")]
361    Query {
362        date: String,
363        name: String,
364        query_string: String,
365        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
366        meta: HashMap<String, MetaValueJson>,
367    },
368    /// Custom directive.
369    ///
370    /// `values` carries the positional arguments after the type
371    /// keyword. Each value is a [`TypedValueJson`] tagged union
372    /// (`{type, value}`) that preserves the host `MetaValue`
373    /// variant tag, so JS consumers can distinguish (for example)
374    /// a `Date` from a `String` from an `Account` — all of which
375    /// would otherwise collapse to bare JSON strings under the
376    /// untagged `MetaValueJson` shape.
377    ///
378    /// Pre-#1168: `values` was dropped entirely from the JSON output.
379    /// Pre-#1207: present but emitted raw via `MetaValueJson` (lossy).
380    /// Post-#1207: emitted via `TypedValueJson` (this variant), mirroring
381    /// FFI-WASI's `Vec<TypedValue>`.
382    ///
383    /// Both `values` and `meta` use `skip_serializing_if` to omit
384    /// the field when empty (consistent shape: a Custom directive
385    /// with no positional args and no metadata serializes as
386    /// `{type, date, custom_type}`, matching what the TS shape
387    /// declares via `values?` / `meta?`).
388    #[serde(rename = "custom")]
389    Custom {
390        date: String,
391        custom_type: String,
392        /// Positional values after the `custom TYPE` keyword. Each
393        /// entry is a [`TypedValueJson`] (`{type, value}`) — the
394        /// tagged shape preserves the host `MetaValue` variant tag so
395        /// JS consumers can distinguish a `Date` from a `String` from
396        /// an `Account` (closes #1207). Mirrors FFI-WASI's
397        /// `Vec<TypedValue>` exactly.
398        #[serde(skip_serializing_if = "Vec::is_empty", default)]
399        values: Vec<TypedValueJson>,
400        #[serde(skip_serializing_if = "HashMap::is_empty", default)]
401        meta: HashMap<String, MetaValueJson>,
402    },
403}
404
405impl DirectiveJson {
406    /// Return the metadata map for this directive, regardless of
407    /// which variant it is.
408    ///
409    /// Every variant carries a `meta` field but the per-variant
410    /// destructure pattern means call sites that want to read meta
411    /// generically need a 12-arm match. This accessor centralizes
412    /// that match so callers don't reimplement it (and so adding a
413    /// future variant fails compilation here, not at every call
414    /// site).
415    ///
416    /// **Rust-only API**: not exposed to JavaScript via
417    /// `#[wasm_bindgen]`. JS consumers read `directive.meta`
418    /// directly off the serialized object — `meta()` only serves
419    /// Rust callers (tests in this crate; downstream Rust crates
420    /// that consume the WASM-crate types directly).
421    #[must_use]
422    pub fn meta(&self) -> &HashMap<String, MetaValueJson> {
423        match self {
424            Self::Transaction { meta, .. }
425            | Self::Balance { meta, .. }
426            | Self::Open { meta, .. }
427            | Self::Close { meta, .. }
428            | Self::Commodity { meta, .. }
429            | Self::Pad { meta, .. }
430            | Self::Event { meta, .. }
431            | Self::Note { meta, .. }
432            | Self::Document { meta, .. }
433            | Self::Price { meta, .. }
434            | Self::Query { meta, .. }
435            | Self::Custom { meta, .. } => meta,
436        }
437    }
438}
439
440/// A posting in JSON-serializable form.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
443#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
444#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
445pub struct PostingJson {
446    /// Account name.
447    pub account: String,
448    /// Units (amount).
449    #[serde(skip_serializing_if = "Option::is_none")]
450    #[cfg_attr(feature = "ts-export", ts(optional))]
451    pub units: Option<AmountValue>,
452    /// Cost specification.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    #[cfg_attr(feature = "ts-export", ts(optional))]
455    pub cost: Option<PostingCostJson>,
456    /// Price annotation.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    #[cfg_attr(feature = "ts-export", ts(optional))]
459    pub price: Option<AmountValue>,
460    /// Posting-level flag (e.g., `"!"` for pending). Mirrors
461    /// `rustledger_core::Posting::flag`.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    #[cfg_attr(feature = "ts-export", ts(optional))]
464    pub flag: Option<String>,
465    /// Posting-level metadata (issue #1168). Empty when the posting
466    /// has no explicit metadata.
467    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
468    pub meta: HashMap<String, MetaValueJson>,
469}
470
471/// Wire-format of the numeric component of a [`PostingCostJson`].
472///
473/// Mirrors `rustledger_core::CostNumber` on the wire so JS consumers
474/// see the same mutual exclusion the host enforces. Use the `kind`
475/// field as the discriminator.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(tag = "kind", rename_all = "snake_case")]
478#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
479#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
480#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
481pub enum CostNumberJson {
482    /// Per-unit cost (e.g., `{100 USD}`).
483    PerUnit {
484        /// Per-unit value.
485        value: String,
486    },
487    /// Total cost as written (e.g., `{{1000 USD}}`), pre-booking.
488    Total {
489        /// Total value.
490        value: String,
491    },
492    /// Compound cost as written (e.g. `{5.00 # 10.00 USD}`): per-unit
493    /// AND lump total; costs `N * per_unit + total` (pre-booking only —
494    /// booking rewrites to `PerUnitFromTotal`).
495    Compound {
496        /// Per-unit component (zero when omitted).
497        per_unit: String,
498        /// Lump-total component (zero when omitted).
499        total: String,
500    },
501    /// Post-booking derived per-unit with preserved source total.
502    PerUnitFromTotal {
503        /// Derived per-unit.
504        per_unit: String,
505        /// Source total.
506        total: String,
507    },
508}
509
510/// A posting cost in JSON-serializable form.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
513#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
514#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
515pub struct PostingCostJson {
516    /// Cost number (per-unit, total, or post-booking pair).
517    #[serde(skip_serializing_if = "Option::is_none")]
518    #[cfg_attr(feature = "ts-export", ts(optional))]
519    pub number: Option<CostNumberJson>,
520    /// Cost currency.
521    #[serde(skip_serializing_if = "Option::is_none")]
522    #[cfg_attr(feature = "ts-export", ts(optional))]
523    pub currency: Option<String>,
524    /// Acquisition date.
525    #[serde(skip_serializing_if = "Option::is_none")]
526    #[cfg_attr(feature = "ts-export", ts(optional))]
527    pub date: Option<String>,
528    /// Lot label.
529    #[serde(skip_serializing_if = "Option::is_none")]
530    #[cfg_attr(feature = "ts-export", ts(optional))]
531    pub label: Option<String>,
532}
533
534/// Error severity level.
535#[derive(
536    Debug,
537    Clone,
538    Copy,
539    PartialEq,
540    Eq,
541    Serialize,
542    Deserialize,
543    rkyv::Archive,
544    rkyv::Serialize,
545    rkyv::Deserialize,
546)]
547#[serde(rename_all = "lowercase")]
548#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
549#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
550#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
551pub enum Severity {
552    /// An error that prevents processing.
553    Error,
554    /// A warning that doesn't prevent processing.
555    Warning,
556}
557
558/// An error with source location.
559///
560/// **Renamed to `BeancountError` on the TS side** to avoid shadowing
561/// the JS-builtin `Error` type. The Rust struct keeps the shorter
562/// `Error` name for internal use; the rename is applied via
563/// `#[ts(rename = ...)]` so consumers see a non-shadowing name.
564#[derive(
565    Debug, Clone, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
566)]
567#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
568#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
569#[cfg_attr(
570    feature = "ts-export",
571    ts(export, export_to = "bindings/", rename = "BeancountError")
572)]
573#[cfg_attr(
574    feature = "json-schema",
575    schemars(
576        rename = "BeancountError",
577        extend("required" = ["message", "code", "phase", "hint", "file", "line", "column", "end_line", "end_column", "severity"])
578    )
579)]
580pub struct Error {
581    /// Error message.
582    pub message: String,
583    /// Stable error code (e.g. `"P0001"` for a parse error, `"E3001"` for a
584    /// validation error). `null` for errors without a code (generic processing
585    /// / query / plugin errors). Lets consumers branch on error type instead of
586    /// matching on message text.
587    pub code: Option<String>,
588    /// Processing phase that produced the error: typically `"parse"`,
589    /// `"validate"`, `"plugin"`, or `"lint"`. `null` when not
590    /// attributable to a phase. The set is open (the loader phase is a free
591    /// string), so the TS type is a union of the known values plus `string` —
592    /// consumers get autocomplete on the common phases without rejecting others.
593    #[cfg_attr(
594        feature = "ts-export",
595        ts(type = "\"parse\" | \"validate\" | \"plugin\" | \"lint\" | (string & {}) | null")
596    )]
597    pub phase: Option<String>,
598    /// Actionable hint for fixing the error, when one is available. `null`
599    /// otherwise.
600    pub hint: Option<String>,
601    /// Source file the error came from (multi-file ledgers). `null` for the
602    /// single-source WASM entry points (`parse`, `check`, …).
603    pub file: Option<String>,
604    /// Start line (1-based). `null` when the error has no source
605    /// location (e.g. validation errors not tied to a span). Field is
606    /// always present on the wire (no `skip_serializing_if`); see the
607    /// struct-level `schemars(extend)` for the required-and-nullable
608    /// rationale. `range(min = 1)` enforces the 1-based documented
609    /// contract on the JSON Schema side (schemars defaults to
610    /// `minimum: 0` for u32).
611    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
612    pub line: Option<u32>,
613    /// Start column (1-based). `null` when the error has no source
614    /// location. See `line` above for `range` rationale.
615    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
616    pub column: Option<u32>,
617    /// End line (1-based) of the error span. `null` when no span. See `line`.
618    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
619    pub end_line: Option<u32>,
620    /// End column (1-based) of the error span. `null` when no span. See `line`.
621    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
622    pub end_column: Option<u32>,
623    /// Error severity.
624    pub severity: Severity,
625}
626
627impl Error {
628    /// Bare error with the given message + severity; all location/code/phase/
629    /// hint/file fields `None`. Fill them via the builder methods below or the
630    /// `*_to_wasm` conversion helpers in `helpers`.
631    fn base(message: impl Into<String>, severity: Severity) -> Self {
632        Self {
633            message: message.into(),
634            code: None,
635            phase: None,
636            hint: None,
637            file: None,
638            line: None,
639            column: None,
640            end_line: None,
641            end_column: None,
642            severity,
643        }
644    }
645
646    /// Create a new error with a message.
647    pub fn new(message: impl Into<String>) -> Self {
648        Self::base(message, Severity::Error)
649    }
650
651    /// Create an error with a (start) line number.
652    pub fn with_line(message: impl Into<String>, line: u32) -> Self {
653        Self {
654            line: Some(line),
655            ..Self::base(message, Severity::Error)
656        }
657    }
658
659    /// Create a warning.
660    pub fn warning(message: impl Into<String>) -> Self {
661        Self::base(message, Severity::Warning)
662    }
663
664    /// Builder: set the stable error code (e.g. `"P0001"`, `"E3001"`).
665    #[must_use]
666    pub fn with_code(mut self, code: impl Into<String>) -> Self {
667        self.code = Some(code.into());
668        self
669    }
670
671    /// Builder: set the processing phase (`"parse"`, `"validate"`, …).
672    #[must_use]
673    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
674        self.phase = Some(phase.into());
675        self
676    }
677
678    /// Builder: set the actionable hint (no-op for `None`).
679    #[must_use]
680    pub fn with_hint(mut self, hint: Option<String>) -> Self {
681        self.hint = hint;
682        self
683    }
684
685    /// Builder: set the source file.
686    #[must_use]
687    pub fn with_file(mut self, file: Option<String>) -> Self {
688        self.file = file;
689        self
690    }
691
692    /// Builder: set the full 1-based span (start + end line/column).
693    #[must_use]
694    pub fn with_span(mut self, start: (u32, u32), end: (u32, u32)) -> Self {
695        self.line = Some(start.0);
696        self.column = Some(start.1);
697        self.end_line = Some(end.0);
698        self.end_column = Some(end.1);
699        self
700    }
701}
702
703impl From<rustledger_loader::LedgerError> for Error {
704    fn from(e: rustledger_loader::LedgerError) -> Self {
705        Self {
706            // `LedgerError` already carries code + phase + file location.
707            code: (!e.code.is_empty()).then(|| e.code.clone()),
708            phase: (!e.phase.is_empty()).then(|| e.phase.clone()),
709            hint: None,
710            file: e
711                .location
712                .as_ref()
713                .map(|loc| loc.file.display().to_string()),
714            line: e.location.as_ref().map(|loc| loc.line as u32),
715            column: e.location.as_ref().map(|loc| loc.column as u32),
716            // `ErrorLocation` is a single point (no end); leave end positions null.
717            end_line: None,
718            end_column: None,
719            severity: match e.severity {
720                rustledger_loader::ErrorSeverity::Error => Severity::Error,
721                rustledger_loader::ErrorSeverity::Warning => Severity::Warning,
722            },
723            message: e.message,
724        }
725    }
726}
727
728/// Result of validation.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
731#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
732#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
733pub struct ValidationResult {
734    /// Whether the ledger is valid.
735    pub valid: bool,
736    /// Validation errors.
737    pub errors: Vec<Error>,
738}
739
740/// Result of a BQL query.
741#[derive(Debug, Clone, Serialize, Deserialize)]
742#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
743#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
744#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
745pub struct QueryResult {
746    /// Column names.
747    pub columns: Vec<String>,
748    /// Result rows.
749    pub rows: Vec<Vec<CellValue>>,
750    /// Query errors.
751    pub errors: Vec<Error>,
752}
753
754/// A cell value that serializes properly to JavaScript.
755///
756/// Uses untagged serialization to produce clean JSON output.
757#[derive(Debug, Clone, Serialize, Deserialize)]
758#[serde(untagged)]
759#[allow(missing_docs)]
760#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
761#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
762#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
763pub enum CellValue {
764    /// Null value.
765    Null,
766    /// String value.
767    String(String),
768    /// Integer value. ts-rs defaults `i64` to `bigint`, but the JSON
769    /// wire emits it as a plain Number -- override to `number` so the
770    /// TS shape matches what JS consumers actually receive.
771    Integer(#[cfg_attr(feature = "ts-export", ts(type = "number"))] i64),
772    /// Boolean value.
773    Boolean(bool),
774    /// Amount with number and currency.
775    Amount { number: String, currency: String },
776    /// Position with units and optional cost.
777    Position {
778        units: AmountValue,
779        #[serde(skip_serializing_if = "Option::is_none")]
780        #[cfg_attr(feature = "ts-export", ts(optional))]
781        cost: Option<CostValue>,
782    },
783    /// Inventory with positions.
784    Inventory { positions: Vec<PositionValue> },
785    /// Set of strings.
786    StringSet(Vec<String>),
787    /// Generic set of values (for IN operator).
788    Set(Vec<Box<Self>>),
789    /// Object with key-value pairs (for `entry` and `meta` columns).
790    Object(std::collections::HashMap<String, Box<Self>>),
791}
792
793/// Amount value for serialization.
794#[derive(Debug, Clone, Serialize, Deserialize)]
795#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
796#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
797#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
798pub struct AmountValue {
799    /// The number as a string.
800    pub number: String,
801    /// The currency.
802    pub currency: String,
803}
804
805/// Position value for serialization.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
808#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
809#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
810pub struct PositionValue {
811    /// The units.
812    pub units: AmountValue,
813}
814
815/// Cost value for serialization.
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
818#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
819#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
820pub struct CostValue {
821    /// Cost per unit.
822    pub number: String,
823    /// Cost currency.
824    pub currency: String,
825    /// Acquisition date.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    #[cfg_attr(feature = "ts-export", ts(optional))]
828    pub date: Option<String>,
829    /// Lot label.
830    #[serde(skip_serializing_if = "Option::is_none")]
831    #[cfg_attr(feature = "ts-export", ts(optional))]
832    pub label: Option<String>,
833}
834
835/// Result of formatting.
836#[derive(Debug, Clone, Serialize, Deserialize)]
837#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
838#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
839#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
840// `formatted` is `Option<String>` (nullable) without
841// `skip_serializing_if` -- always present on the wire. See
842// `ParseResult` for the `extend("required" = ...)` rationale.
843#[cfg_attr(
844    feature = "json-schema",
845    schemars(extend("required" = ["formatted", "errors"]))
846)]
847pub struct FormatResult {
848    /// Formatted source (if successful). Emitted as JSON `null` on
849    /// failure; no `skip_serializing_if`, so the field is always
850    /// present on the wire.
851    pub formatted: Option<String>,
852    /// Format errors.
853    pub errors: Vec<Error>,
854}
855
856/// Result of pad expansion.
857#[derive(Debug, Clone, Serialize, Deserialize)]
858#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
859#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
860#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
861pub struct PadResult {
862    /// The original directives, verbatim. `Pad` directives are NOT
863    /// removed — consumers wanting a pads-removed view should
864    /// filter on directive type. The `padding_transactions` field
865    /// carries the synthesized P-flag transactions separately.
866    pub directives: Vec<DirectiveJson>,
867    /// Generated padding transactions (synthesized P-flag, one per
868    /// pad-balance pair, multi-currency pads produce one per
869    /// currency).
870    pub padding_transactions: Vec<DirectiveJson>,
871    /// Pad processing errors (e.g. unused pads with no matching
872    /// balance assertion).
873    pub errors: Vec<Error>,
874}
875
876/// Result of running a plugin.
877#[derive(Debug, Clone, Serialize, Deserialize)]
878#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
879#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
880#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
881pub struct PluginResult {
882    /// Modified directives.
883    pub directives: Vec<DirectiveJson>,
884    /// Plugin errors/warnings.
885    pub errors: Vec<Error>,
886}
887
888/// Plugin information.
889#[derive(Debug, Clone, Serialize, Deserialize)]
890#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
891#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
892#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
893pub struct PluginInfo {
894    /// Plugin name.
895    pub name: String,
896    /// Plugin description.
897    pub description: String,
898}
899
900/// BQL completion suggestion for WASM.
901#[derive(Debug, Clone, Serialize, Deserialize)]
902#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
903#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
904#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
905pub struct CompletionJson {
906    /// The completion text to insert.
907    pub text: String,
908    /// Category: keyword, function, column, operator, literal.
909    pub category: String,
910    /// Optional description/documentation.
911    #[serde(skip_serializing_if = "Option::is_none")]
912    #[cfg_attr(feature = "ts-export", ts(optional))]
913    pub description: Option<String>,
914}
915
916/// Result of BQL completion request.
917#[derive(Debug, Clone, Serialize, Deserialize)]
918#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
919#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
920#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
921pub struct CompletionResultJson {
922    /// List of completions.
923    pub completions: Vec<CompletionJson>,
924    /// Current context for debugging.
925    pub context: String,
926}
927
928// =============================================================================
929// LSP-like Types for Editor Integration
930// =============================================================================
931
932/// A completion item for Beancount source editing.
933#[derive(Debug, Clone, Serialize, Deserialize)]
934#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
935#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
936#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
937pub struct EditorCompletion {
938    /// The label to display in the completion list.
939    pub label: String,
940    /// The kind of completion item.
941    pub kind: CompletionKind,
942    /// A human-readable string with additional information.
943    #[serde(skip_serializing_if = "Option::is_none")]
944    #[cfg_attr(feature = "ts-export", ts(optional))]
945    pub detail: Option<String>,
946    /// The text to insert when this completion is selected.
947    #[serde(skip_serializing_if = "Option::is_none")]
948    #[cfg_attr(feature = "ts-export", ts(optional))]
949    pub insert_text: Option<String>,
950}
951
952/// The kind of a completion item.
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
954#[serde(rename_all = "lowercase")]
955#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
956#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
957#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
958pub enum CompletionKind {
959    /// A keyword (directive name).
960    Keyword,
961    /// An account name.
962    Account,
963    /// An account segment (partial account).
964    AccountSegment,
965    /// A currency/commodity.
966    Currency,
967    /// A payee name.
968    Payee,
969    /// A date value.
970    Date,
971    /// A text/string value.
972    Text,
973    /// A tag (after `#`).
974    Tag,
975    /// A link (after `^`).
976    Link,
977}
978
979/// Result of a completion request.
980#[derive(Debug, Clone, Serialize, Deserialize)]
981#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
982#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
983#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
984pub struct EditorCompletionResult {
985    /// The completions.
986    pub completions: Vec<EditorCompletion>,
987    /// The detected context.
988    pub context: String,
989}
990
991/// Hover information for a symbol.
992#[derive(Debug, Clone, Serialize, Deserialize)]
993#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
994#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
995#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
996pub struct EditorHoverInfo {
997    /// The hover content (Markdown formatted).
998    pub contents: String,
999    /// The range of the hovered symbol (optional).
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    #[cfg_attr(feature = "ts-export", ts(optional))]
1002    pub range: Option<EditorRange>,
1003}
1004
1005/// A range in the document.
1006#[derive(Debug, Clone, Serialize, Deserialize)]
1007#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1008#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1009#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1010pub struct EditorRange {
1011    /// Start line (0-based).
1012    pub start_line: u32,
1013    /// Start character (0-based).
1014    pub start_character: u32,
1015    /// End line (0-based).
1016    pub end_line: u32,
1017    /// End character (0-based).
1018    pub end_character: u32,
1019}
1020
1021/// A location in the document.
1022#[derive(Debug, Clone, Serialize, Deserialize)]
1023#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1024#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1025#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1026pub struct EditorLocation {
1027    /// Line number (0-based).
1028    pub line: u32,
1029    /// Character offset (0-based).
1030    pub character: u32,
1031}
1032
1033/// A document symbol for the outline view.
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1035#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1036#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1037#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1038pub struct EditorDocumentSymbol {
1039    /// The name of this symbol.
1040    pub name: String,
1041    /// More detail for this symbol.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    #[cfg_attr(feature = "ts-export", ts(optional))]
1044    pub detail: Option<String>,
1045    /// The kind of this symbol.
1046    pub kind: SymbolKind,
1047    /// The range enclosing this symbol.
1048    pub range: EditorRange,
1049    /// Children of this symbol (e.g., postings in a transaction).
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    #[cfg_attr(feature = "ts-export", ts(optional))]
1052    pub children: Option<Vec<Self>>,
1053    /// Whether this symbol is deprecated (e.g., closed account).
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    #[cfg_attr(feature = "ts-export", ts(optional))]
1056    pub deprecated: Option<bool>,
1057}
1058
1059/// The kind of a symbol.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1061#[serde(rename_all = "lowercase")]
1062#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1063#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1064#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1065pub enum SymbolKind {
1066    /// A transaction.
1067    Transaction,
1068    /// An account (open/close).
1069    Account,
1070    /// A balance assertion.
1071    Balance,
1072    /// A commodity/currency declaration.
1073    Commodity,
1074    /// A posting within a transaction.
1075    Posting,
1076    /// A pad directive.
1077    Pad,
1078    /// An event.
1079    Event,
1080    /// A note.
1081    Note,
1082    /// A document link.
1083    Document,
1084    /// A price.
1085    Price,
1086    /// A query definition.
1087    Query,
1088    /// A custom directive.
1089    Custom,
1090}
1091
1092// =============================================================================
1093// References Types
1094// =============================================================================
1095
1096/// The kind of symbol being referenced.
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1098#[serde(rename_all = "lowercase")]
1099#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1100#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1101#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1102pub enum ReferenceKind {
1103    /// An account reference.
1104    Account,
1105    /// A currency/commodity reference.
1106    Currency,
1107    /// A payee reference.
1108    Payee,
1109}
1110
1111/// A reference to a symbol in the document.
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1114#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1115#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1116pub struct EditorReference {
1117    /// The range of this reference.
1118    pub range: EditorRange,
1119    /// The kind of reference.
1120    pub kind: ReferenceKind,
1121    /// Whether this is the defining occurrence.
1122    pub is_definition: bool,
1123    /// Human-readable context (e.g., directive type).
1124    #[serde(skip_serializing_if = "Option::is_none")]
1125    #[cfg_attr(feature = "ts-export", ts(optional))]
1126    pub context: Option<String>,
1127}
1128
1129/// Result of a find-references request.
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1132#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1133#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1134pub struct EditorReferencesResult {
1135    /// The symbol being searched for.
1136    pub symbol: String,
1137    /// The kind of symbol.
1138    pub kind: ReferenceKind,
1139    /// All references found.
1140    pub references: Vec<EditorReference>,
1141}
1142
1143// Wire-format pins live in a host-only test module: they test
1144// `serde_json` round-trips which are target-independent, and pulling
1145// `serde_json` into the wasm32 test target activates a `getrandom`
1146// transitive that fails to compile on `wasm32-unknown-unknown`
1147// without the `wasm_js` backend flag. The shape we're pinning is the
1148// same on every target, so running these on the host is sufficient.
1149#[cfg(all(test, not(target_arch = "wasm32")))]
1150mod cost_number_wire_tests {
1151    //! Wire-format pins for #1164. Catches silent shape drift that
1152    //! would break TypeScript clients.
1153
1154    use super::*;
1155
1156    #[test]
1157    fn per_unit_serializes_with_kind_tag() {
1158        let cn = CostNumberJson::PerUnit {
1159            value: "100".into(),
1160        };
1161        let json = serde_json::to_value(&cn).unwrap();
1162        assert_eq!(
1163            json,
1164            serde_json::json!({"kind": "per_unit", "value": "100"})
1165        );
1166    }
1167
1168    #[test]
1169    fn total_serializes_with_kind_tag() {
1170        let cn = CostNumberJson::Total {
1171            value: "1500".into(),
1172        };
1173        let json = serde_json::to_value(&cn).unwrap();
1174        assert_eq!(json, serde_json::json!({"kind": "total", "value": "1500"}));
1175    }
1176
1177    #[test]
1178    fn per_unit_from_total_carries_both_values() {
1179        let cn = CostNumberJson::PerUnitFromTotal {
1180            per_unit: "150".into(),
1181            total: "300".into(),
1182        };
1183        let json = serde_json::to_value(&cn).unwrap();
1184        assert_eq!(
1185            json,
1186            serde_json::json!({
1187                "kind": "per_unit_from_total",
1188                "per_unit": "150",
1189                "total": "300",
1190            })
1191        );
1192    }
1193
1194    #[test]
1195    fn round_trip_all_variants() {
1196        for cn in [
1197            CostNumberJson::PerUnit { value: "1".into() },
1198            CostNumberJson::Total { value: "10".into() },
1199            CostNumberJson::PerUnitFromTotal {
1200                per_unit: "1".into(),
1201                total: "10".into(),
1202            },
1203        ] {
1204            let json = serde_json::to_string(&cn).unwrap();
1205            let back: CostNumberJson = serde_json::from_str(&json).unwrap();
1206            // Same JSON on round-trip means the wire shape is stable.
1207            assert_eq!(serde_json::to_string(&back).unwrap(), json);
1208        }
1209    }
1210
1211    #[test]
1212    fn posting_cost_with_total_pre_booking_distinguishes_from_bare_brace() {
1213        // Pre-PR, a `Total` cost serialized as `{number: null,
1214        // currency: ...}` — indistinguishable from a deliberate
1215        // `{USD}` lot match. The new shape preserves the variant.
1216        let with_total = PostingCostJson {
1217            number: Some(CostNumberJson::Total {
1218                value: "1500".into(),
1219            }),
1220            currency: Some("USD".into()),
1221            date: None,
1222            label: None,
1223        };
1224        let bare = PostingCostJson {
1225            number: None,
1226            currency: Some("USD".into()),
1227            date: None,
1228            label: None,
1229        };
1230        let with_total_json = serde_json::to_value(&with_total).unwrap();
1231        let bare_json = serde_json::to_value(&bare).unwrap();
1232        assert_ne!(
1233            with_total_json, bare_json,
1234            "pre-booking Total and bare {{}} must serialize distinctly"
1235        );
1236        assert!(with_total_json["number"].is_object());
1237        assert!(bare_json.get("number").is_none());
1238    }
1239}
1240
1241/// Codegen vehicle for the JSON Schema export (ADR-0004 Phase 3, #1232).
1242///
1243/// `schema_for!(ParseResult)` only walks types reachable from
1244/// `ParseResult`, which covers parse output but misses the return shapes
1245/// of `query`, `format`, `validate`, `runPlugin`, `listPlugins`, the BQL
1246/// completion API, and the editor LSP-like surfaces. Listing every
1247/// top-level public DTO here gives the generator a single root that
1248/// reaches the whole wire surface; the resulting schema has every
1249/// public type under `$defs`.
1250///
1251/// **Not a wire-format type.** No `Serialize`/`Deserialize` derive,
1252/// no `wasm_bindgen` export -- it exists only so that
1253/// `schema_for!(RustledgerBindings)` produces the union of
1254/// definitions. The export test then strips the wrapper's own
1255/// root-level keys (`type`, `title`, `properties`, `required`) before
1256/// writing the JSON Schema, so consumers see a definitions-only
1257/// document with no top-level `RustledgerBindings` object -- and
1258/// datamodel-code-generator does not emit a corresponding Pydantic
1259/// class. Field types that are reachable transitively (e.g.
1260/// `Severity` from `BeancountError`, `CompletionKind` from
1261/// `EditorCompletion`) don't need to be listed.
1262#[cfg(feature = "json-schema")]
1263#[derive(schemars::JsonSchema)]
1264#[allow(dead_code)]
1265struct RustledgerBindings {
1266    parse_result: ParseResult,
1267    validation_result: ValidationResult,
1268    query_result: QueryResult,
1269    format_result: FormatResult,
1270    pad_result: PadResult,
1271    plugin_result: PluginResult,
1272    plugin_info: PluginInfo,
1273    completion_result: CompletionResultJson,
1274    editor_completion_result: EditorCompletionResult,
1275    editor_hover_info: EditorHoverInfo,
1276    editor_document_symbol: EditorDocumentSymbol,
1277    editor_references_result: EditorReferencesResult,
1278    // `EditorLocation` is the return type of `getDefinition()` and is
1279    // not referenced by any of the other listed DTOs, so it needs an
1280    // explicit field here -- without it the schema/Python bindings
1281    // silently omit it while the TS bindings still export it.
1282    editor_location: EditorLocation,
1283}
1284
1285/// JSON Schema export entry point (ADR-0004 Phase 3, issue #1232).
1286///
1287/// Counterpart to ts-rs's auto-generated `export_bindings_*` tests.
1288/// Only compiled when the `json-schema` feature is on, which pulls
1289/// `schemars` into the dep graph. Driven by `scripts/regen-bindings.sh`:
1290/// the script sets `RUSTLEDGER_REGEN_SCHEMA=1` and runs `cargo test -p
1291/// rustledger-wasm --features json-schema --lib -- --include-ignored
1292/// --nocapture --exact types::export_json_schema::export_index_schema`,
1293/// which writes `bindings/index.schema.json` from the
1294/// `RustledgerBindings` wrapper above (covers all public DTOs).
1295///
1296/// Two opt-in gates protect the source tree:
1297///   1. `#[ignore]` -- plain `cargo test` skips this.
1298///   2. `RUSTLEDGER_REGEN_SCHEMA=1` -- a developer running
1299///      `cargo test --include-ignored` (a common debug command) does
1300///      NOT silently overwrite the checked-in schema; the test
1301///      `panic!`s with a guidance message so the failure is loud and
1302///      visible without needing `--nocapture`. Only the regen script
1303///      sets the env var.
1304///
1305/// The test also prints a unique sentinel on success
1306/// (`EXPORT_INDEX_SCHEMA_RAN_OK`) which the regen script greps for --
1307/// catches the case where `cargo test --exact` matches zero tests
1308/// (e.g. after a future rename) and silently exits 0 with no
1309/// regeneration.
1310#[cfg(all(test, feature = "json-schema", not(target_arch = "wasm32")))]
1311mod export_json_schema {
1312    use std::fs;
1313    use std::path::PathBuf;
1314
1315    use super::RustledgerBindings;
1316
1317    /// Sentinel string printed on a successful schema write. The regen
1318    /// script greps for this exact bytes; do not change without
1319    /// updating `scripts/regen-bindings.sh`.
1320    pub const SUCCESS_SENTINEL: &str = "EXPORT_INDEX_SCHEMA_RAN_OK";
1321
1322    #[test]
1323    #[ignore = "writes bindings/index.schema.json; driven by scripts/regen-bindings.sh"]
1324    fn export_index_schema() {
1325        // Belt-and-suspenders guard. `#[ignore]` already prevents an
1326        // unintentional run, but `--include-ignored` is common enough
1327        // in debug workflows that we panic (rather than silently
1328        // returning Ok) so the failure is visible without
1329        // `--nocapture`. A green-passing test with the env var unset
1330        // would otherwise mislead a developer into thinking the
1331        // schema was regenerated.
1332        assert!(
1333            std::env::var_os("RUSTLEDGER_REGEN_SCHEMA").is_some(),
1334            "export_index_schema mutates bindings/index.schema.json and \
1335             must be driven by scripts/regen-bindings.sh, not invoked \
1336             directly. Set RUSTLEDGER_REGEN_SCHEMA=1 to opt in."
1337        );
1338
1339        let schema = schemars::schema_for!(RustledgerBindings);
1340
1341        // Round-trip through `serde_json::Value` so we can strip the
1342        // wrapper's root-level keys. `RustledgerBindings` exists only
1343        // to seed `$defs` with every public DTO -- its own
1344        // `type: object, properties: {...}, required: [...]` shape is
1345        // an internal artifact, not a wire-format contract. Leaving
1346        // it in causes datamodel-code-generator to emit a public
1347        // `RustledgerBindings(BaseModel)` class consumers can import
1348        // (and worse, prefixed-mangled when we try to rename it with
1349        // a leading underscore). Stripping after generation gives a
1350        // definitions-only schema (`$schema` + `$defs` only) which
1351        // datamodel-code-generator handles cleanly: one Pydantic
1352        // class per `$def`, no wrapper.
1353        let mut schema_value = serde_json::to_value(&schema)
1354            .expect("schemars schema should round-trip through serde_json");
1355        if let Some(obj) = schema_value.as_object_mut() {
1356            obj.remove("type");
1357            obj.remove("title");
1358            obj.remove("properties");
1359            obj.remove("required");
1360            obj.remove("additionalProperties");
1361            // The wrapper's rustdoc gets emitted as `description`.
1362            // Drop it -- datamodel-code-generator otherwise treats the
1363            // root as a documented type and emits a placeholder
1364            // `Model(RootModel[Any])` class.
1365            obj.remove("description");
1366        }
1367
1368        // Pretty-print to stabilize the on-disk format for git diffs;
1369        // the regen script later runs prettier over it for a final
1370        // canonicalization pass alongside the TS bundle.
1371        let pretty = serde_json::to_string_pretty(&schema_value)
1372            .expect("stripped schema should serialize cleanly");
1373
1374        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1375        path.push("bindings");
1376        fs::create_dir_all(&path).expect("create bindings/ directory");
1377        path.push("index.schema.json");
1378        fs::write(&path, format!("{pretty}\n")).expect("write index.schema.json");
1379
1380        // Sentinel for `scripts/regen-bindings.sh` to grep for.
1381        // `println!` (stdout, not stderr) makes it survive `--quiet`
1382        // when the script pipes cargo output through `tee`.
1383        println!("{SUCCESS_SENTINEL}");
1384        eprintln!("Wrote: {}", path.display());
1385    }
1386}
1387
1388/// Guards the DTOs that hand-override schemars' auto-detected `required`
1389/// array via `schemars(extend("required" = [...]))`.
1390///
1391/// `extend("required" = ...)` *replaces* the auto-detected array rather
1392/// than merging into it (schemars 1.x has no merge form). So if a field
1393/// is added to one of these structs and the author forgets to update the
1394/// hand-written list, that field silently drops out of `required` even
1395/// though the wire always emits it -- with no compile error and no other
1396/// test catching it. (PR #1241's round-2 review found exactly this: the
1397/// round-1 `extend` sweep missed `FormatResult`.)
1398///
1399/// Every field on these four DTOs is a required wire field -- the
1400/// nullable ones (`ParseResult.ledger`, `LedgerOptions.title`,
1401/// `BeancountError.line/column`, `FormatResult.formatted`) are
1402/// always-present-but-nullable, never absent. So the invariant is exact:
1403/// the emitted `required` set must equal the full property set. If you
1404/// add a genuinely-optional field to one of these structs, this test is
1405/// the tripwire -- update it deliberately alongside the `extend` list.
1406#[cfg(all(test, feature = "json-schema", not(target_arch = "wasm32")))]
1407mod schema_required_invariants {
1408    use std::collections::BTreeSet;
1409
1410    use super::RustledgerBindings;
1411
1412    /// Assert the `$def` for `def_name` lists every one of its
1413    /// properties in `required`.
1414    fn assert_required_equals_all_properties(def_name: &str) {
1415        let schema = schemars::schema_for!(RustledgerBindings);
1416        let value = serde_json::to_value(&schema).expect("schema round-trips through serde_json");
1417
1418        let def = value
1419            .get("$defs")
1420            .and_then(|d| d.get(def_name))
1421            .unwrap_or_else(|| panic!("{def_name} missing from $defs"));
1422
1423        let properties: BTreeSet<&str> = def
1424            .get("properties")
1425            .and_then(serde_json::Value::as_object)
1426            .unwrap_or_else(|| panic!("{def_name} has no properties object"))
1427            .keys()
1428            .map(String::as_str)
1429            .collect();
1430
1431        let required: BTreeSet<&str> = def
1432            .get("required")
1433            .and_then(serde_json::Value::as_array)
1434            .unwrap_or_else(|| {
1435                panic!("{def_name}.required is missing -- did schemars(extend) get dropped?")
1436            })
1437            .iter()
1438            .map(|v| v.as_str().expect("required entry should be a string"))
1439            .collect();
1440
1441        assert_eq!(
1442            required, properties,
1443            "{def_name}: the schemars(extend(\"required\" = [...])) list is out of \
1444             sync with the struct's fields. Every field on this DTO is a required \
1445             wire field, so `required` must list all of them. Update the \
1446             extend(\"required\") attribute on the struct in types.rs (and this \
1447             test, if you intentionally introduced an optional field)."
1448        );
1449    }
1450
1451    #[test]
1452    fn parse_result_requires_all_fields() {
1453        assert_required_equals_all_properties("ParseResult");
1454    }
1455
1456    #[test]
1457    fn ledger_options_requires_all_fields() {
1458        assert_required_equals_all_properties("LedgerOptions");
1459    }
1460
1461    #[test]
1462    fn beancount_error_requires_all_fields() {
1463        assert_required_equals_all_properties("BeancountError");
1464    }
1465
1466    #[test]
1467    fn format_result_requires_all_fields() {
1468        assert_required_equals_all_properties("FormatResult");
1469    }
1470}