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    /// Post-booking derived per-unit with preserved source total.
493    PerUnitFromTotal {
494        /// Derived per-unit.
495        per_unit: String,
496        /// Source total.
497        total: String,
498    },
499}
500
501/// A posting cost in JSON-serializable form.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
504#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
505#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
506pub struct PostingCostJson {
507    /// Cost number (per-unit, total, or post-booking pair).
508    #[serde(skip_serializing_if = "Option::is_none")]
509    #[cfg_attr(feature = "ts-export", ts(optional))]
510    pub number: Option<CostNumberJson>,
511    /// Cost currency.
512    #[serde(skip_serializing_if = "Option::is_none")]
513    #[cfg_attr(feature = "ts-export", ts(optional))]
514    pub currency: Option<String>,
515    /// Acquisition date.
516    #[serde(skip_serializing_if = "Option::is_none")]
517    #[cfg_attr(feature = "ts-export", ts(optional))]
518    pub date: Option<String>,
519    /// Lot label.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    #[cfg_attr(feature = "ts-export", ts(optional))]
522    pub label: Option<String>,
523}
524
525/// Error severity level.
526#[derive(
527    Debug,
528    Clone,
529    Copy,
530    PartialEq,
531    Eq,
532    Serialize,
533    Deserialize,
534    rkyv::Archive,
535    rkyv::Serialize,
536    rkyv::Deserialize,
537)]
538#[serde(rename_all = "lowercase")]
539#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
540#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
541#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
542pub enum Severity {
543    /// An error that prevents processing.
544    Error,
545    /// A warning that doesn't prevent processing.
546    Warning,
547}
548
549/// An error with source location.
550///
551/// **Renamed to `BeancountError` on the TS side** to avoid shadowing
552/// the JS-builtin `Error` type. The Rust struct keeps the shorter
553/// `Error` name for internal use; the rename is applied via
554/// `#[ts(rename = ...)]` so consumers see a non-shadowing name.
555#[derive(
556    Debug, Clone, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
557)]
558#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
559#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
560#[cfg_attr(
561    feature = "ts-export",
562    ts(export, export_to = "bindings/", rename = "BeancountError")
563)]
564#[cfg_attr(
565    feature = "json-schema",
566    schemars(
567        rename = "BeancountError",
568        extend("required" = ["message", "line", "column", "severity"])
569    )
570)]
571pub struct Error {
572    /// Error message.
573    pub message: String,
574    /// Line number (1-based). `null` when the error has no source
575    /// location (e.g. validation errors not tied to a span). Field is
576    /// always present on the wire (no `skip_serializing_if`); see the
577    /// struct-level `schemars(extend)` for the required-and-nullable
578    /// rationale. `range(min = 1)` enforces the 1-based documented
579    /// contract on the JSON Schema side (schemars defaults to
580    /// `minimum: 0` for u32).
581    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
582    pub line: Option<u32>,
583    /// Column number (1-based). `null` when the error has no source
584    /// location. See `line` above for `range` rationale.
585    #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
586    pub column: Option<u32>,
587    /// Error severity.
588    pub severity: Severity,
589}
590
591impl Error {
592    /// Create a new error with a message.
593    pub fn new(message: impl Into<String>) -> Self {
594        Self {
595            message: message.into(),
596            line: None,
597            column: None,
598            severity: Severity::Error,
599        }
600    }
601
602    /// Create an error with a line number.
603    pub fn with_line(message: impl Into<String>, line: u32) -> Self {
604        Self {
605            message: message.into(),
606            line: Some(line),
607            column: None,
608            severity: Severity::Error,
609        }
610    }
611
612    /// Create a warning.
613    pub fn warning(message: impl Into<String>) -> Self {
614        Self {
615            message: message.into(),
616            line: None,
617            column: None,
618            severity: Severity::Warning,
619        }
620    }
621}
622
623impl From<rustledger_loader::LedgerError> for Error {
624    fn from(e: rustledger_loader::LedgerError) -> Self {
625        Self {
626            message: e.message,
627            line: e.location.as_ref().map(|loc| loc.line as u32),
628            column: e.location.as_ref().map(|loc| loc.column as u32),
629            severity: match e.severity {
630                rustledger_loader::ErrorSeverity::Error => Severity::Error,
631                rustledger_loader::ErrorSeverity::Warning => Severity::Warning,
632            },
633        }
634    }
635}
636
637/// Result of validation.
638#[derive(Debug, Clone, Serialize, Deserialize)]
639#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
640#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
641#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
642pub struct ValidationResult {
643    /// Whether the ledger is valid.
644    pub valid: bool,
645    /// Validation errors.
646    pub errors: Vec<Error>,
647}
648
649/// Result of a BQL query.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
652#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
653#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
654pub struct QueryResult {
655    /// Column names.
656    pub columns: Vec<String>,
657    /// Result rows.
658    pub rows: Vec<Vec<CellValue>>,
659    /// Query errors.
660    pub errors: Vec<Error>,
661}
662
663/// A cell value that serializes properly to JavaScript.
664///
665/// Uses untagged serialization to produce clean JSON output.
666#[derive(Debug, Clone, Serialize, Deserialize)]
667#[serde(untagged)]
668#[allow(missing_docs)]
669#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
670#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
671#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
672pub enum CellValue {
673    /// Null value.
674    Null,
675    /// String value.
676    String(String),
677    /// Integer value. ts-rs defaults `i64` to `bigint`, but the JSON
678    /// wire emits it as a plain Number -- override to `number` so the
679    /// TS shape matches what JS consumers actually receive.
680    Integer(#[cfg_attr(feature = "ts-export", ts(type = "number"))] i64),
681    /// Boolean value.
682    Boolean(bool),
683    /// Amount with number and currency.
684    Amount { number: String, currency: String },
685    /// Position with units and optional cost.
686    Position {
687        units: AmountValue,
688        #[serde(skip_serializing_if = "Option::is_none")]
689        #[cfg_attr(feature = "ts-export", ts(optional))]
690        cost: Option<CostValue>,
691    },
692    /// Inventory with positions.
693    Inventory { positions: Vec<PositionValue> },
694    /// Set of strings.
695    StringSet(Vec<String>),
696    /// Generic set of values (for IN operator).
697    Set(Vec<Box<Self>>),
698    /// Object with key-value pairs (for `entry` and `meta` columns).
699    Object(std::collections::HashMap<String, Box<Self>>),
700}
701
702/// Amount value for serialization.
703#[derive(Debug, Clone, Serialize, Deserialize)]
704#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
705#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
706#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
707pub struct AmountValue {
708    /// The number as a string.
709    pub number: String,
710    /// The currency.
711    pub currency: String,
712}
713
714/// Position value for serialization.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
717#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
718#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
719pub struct PositionValue {
720    /// The units.
721    pub units: AmountValue,
722}
723
724/// Cost value for serialization.
725#[derive(Debug, Clone, Serialize, Deserialize)]
726#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
727#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
728#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
729pub struct CostValue {
730    /// Cost per unit.
731    pub number: String,
732    /// Cost currency.
733    pub currency: String,
734    /// Acquisition date.
735    #[serde(skip_serializing_if = "Option::is_none")]
736    #[cfg_attr(feature = "ts-export", ts(optional))]
737    pub date: Option<String>,
738    /// Lot label.
739    #[serde(skip_serializing_if = "Option::is_none")]
740    #[cfg_attr(feature = "ts-export", ts(optional))]
741    pub label: Option<String>,
742}
743
744/// Result of formatting.
745#[derive(Debug, Clone, Serialize, Deserialize)]
746#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
747#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
748#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
749// `formatted` is `Option<String>` (nullable) without
750// `skip_serializing_if` -- always present on the wire. See
751// `ParseResult` for the `extend("required" = ...)` rationale.
752#[cfg_attr(
753    feature = "json-schema",
754    schemars(extend("required" = ["formatted", "errors"]))
755)]
756pub struct FormatResult {
757    /// Formatted source (if successful). Emitted as JSON `null` on
758    /// failure; no `skip_serializing_if`, so the field is always
759    /// present on the wire.
760    pub formatted: Option<String>,
761    /// Format errors.
762    pub errors: Vec<Error>,
763}
764
765/// Result of pad expansion.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
768#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
769#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
770pub struct PadResult {
771    /// The original directives, verbatim. `Pad` directives are NOT
772    /// removed — consumers wanting a pads-removed view should
773    /// filter on directive type. The `padding_transactions` field
774    /// carries the synthesized P-flag transactions separately.
775    pub directives: Vec<DirectiveJson>,
776    /// Generated padding transactions (synthesized P-flag, one per
777    /// pad-balance pair, multi-currency pads produce one per
778    /// currency).
779    pub padding_transactions: Vec<DirectiveJson>,
780    /// Pad processing errors (e.g. unused pads with no matching
781    /// balance assertion).
782    pub errors: Vec<Error>,
783}
784
785/// Result of running a plugin.
786#[derive(Debug, Clone, Serialize, Deserialize)]
787#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
788#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
789#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
790pub struct PluginResult {
791    /// Modified directives.
792    pub directives: Vec<DirectiveJson>,
793    /// Plugin errors/warnings.
794    pub errors: Vec<Error>,
795}
796
797/// Plugin information.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
800#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
801#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
802pub struct PluginInfo {
803    /// Plugin name.
804    pub name: String,
805    /// Plugin description.
806    pub description: String,
807}
808
809/// BQL completion suggestion for WASM.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
812#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
813#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
814pub struct CompletionJson {
815    /// The completion text to insert.
816    pub text: String,
817    /// Category: keyword, function, column, operator, literal.
818    pub category: String,
819    /// Optional description/documentation.
820    #[serde(skip_serializing_if = "Option::is_none")]
821    #[cfg_attr(feature = "ts-export", ts(optional))]
822    pub description: Option<String>,
823}
824
825/// Result of BQL completion request.
826#[derive(Debug, Clone, Serialize, Deserialize)]
827#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
828#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
829#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
830pub struct CompletionResultJson {
831    /// List of completions.
832    pub completions: Vec<CompletionJson>,
833    /// Current context for debugging.
834    pub context: String,
835}
836
837// =============================================================================
838// LSP-like Types for Editor Integration
839// =============================================================================
840
841/// A completion item for Beancount source editing.
842#[derive(Debug, Clone, Serialize, Deserialize)]
843#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
844#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
845#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
846pub struct EditorCompletion {
847    /// The label to display in the completion list.
848    pub label: String,
849    /// The kind of completion item.
850    pub kind: CompletionKind,
851    /// A human-readable string with additional information.
852    #[serde(skip_serializing_if = "Option::is_none")]
853    #[cfg_attr(feature = "ts-export", ts(optional))]
854    pub detail: Option<String>,
855    /// The text to insert when this completion is selected.
856    #[serde(skip_serializing_if = "Option::is_none")]
857    #[cfg_attr(feature = "ts-export", ts(optional))]
858    pub insert_text: Option<String>,
859}
860
861/// The kind of a completion item.
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(rename_all = "lowercase")]
864#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
865#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
866#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
867pub enum CompletionKind {
868    /// A keyword (directive name).
869    Keyword,
870    /// An account name.
871    Account,
872    /// An account segment (partial account).
873    AccountSegment,
874    /// A currency/commodity.
875    Currency,
876    /// A payee name.
877    Payee,
878    /// A date value.
879    Date,
880    /// A text/string value.
881    Text,
882    /// A tag (after `#`).
883    Tag,
884    /// A link (after `^`).
885    Link,
886}
887
888/// Result of a completion request.
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 EditorCompletionResult {
894    /// The completions.
895    pub completions: Vec<EditorCompletion>,
896    /// The detected context.
897    pub context: String,
898}
899
900/// Hover information for a symbol.
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 EditorHoverInfo {
906    /// The hover content (Markdown formatted).
907    pub contents: String,
908    /// The range of the hovered symbol (optional).
909    #[serde(skip_serializing_if = "Option::is_none")]
910    #[cfg_attr(feature = "ts-export", ts(optional))]
911    pub range: Option<EditorRange>,
912}
913
914/// A range in the document.
915#[derive(Debug, Clone, Serialize, Deserialize)]
916#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
917#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
918#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
919pub struct EditorRange {
920    /// Start line (0-based).
921    pub start_line: u32,
922    /// Start character (0-based).
923    pub start_character: u32,
924    /// End line (0-based).
925    pub end_line: u32,
926    /// End character (0-based).
927    pub end_character: u32,
928}
929
930/// A location in the document.
931#[derive(Debug, Clone, Serialize, Deserialize)]
932#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
933#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
934#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
935pub struct EditorLocation {
936    /// Line number (0-based).
937    pub line: u32,
938    /// Character offset (0-based).
939    pub character: u32,
940}
941
942/// A document symbol for the outline view.
943#[derive(Debug, Clone, Serialize, Deserialize)]
944#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
945#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
946#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
947pub struct EditorDocumentSymbol {
948    /// The name of this symbol.
949    pub name: String,
950    /// More detail for this symbol.
951    #[serde(skip_serializing_if = "Option::is_none")]
952    #[cfg_attr(feature = "ts-export", ts(optional))]
953    pub detail: Option<String>,
954    /// The kind of this symbol.
955    pub kind: SymbolKind,
956    /// The range enclosing this symbol.
957    pub range: EditorRange,
958    /// Children of this symbol (e.g., postings in a transaction).
959    #[serde(skip_serializing_if = "Option::is_none")]
960    #[cfg_attr(feature = "ts-export", ts(optional))]
961    pub children: Option<Vec<Self>>,
962    /// Whether this symbol is deprecated (e.g., closed account).
963    #[serde(skip_serializing_if = "Option::is_none")]
964    #[cfg_attr(feature = "ts-export", ts(optional))]
965    pub deprecated: Option<bool>,
966}
967
968/// The kind of a symbol.
969#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
970#[serde(rename_all = "lowercase")]
971#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
972#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
973#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
974pub enum SymbolKind {
975    /// A transaction.
976    Transaction,
977    /// An account (open/close).
978    Account,
979    /// A balance assertion.
980    Balance,
981    /// A commodity/currency declaration.
982    Commodity,
983    /// A posting within a transaction.
984    Posting,
985    /// A pad directive.
986    Pad,
987    /// An event.
988    Event,
989    /// A note.
990    Note,
991    /// A document link.
992    Document,
993    /// A price.
994    Price,
995    /// A query definition.
996    Query,
997    /// A custom directive.
998    Custom,
999}
1000
1001// =============================================================================
1002// References Types
1003// =============================================================================
1004
1005/// The kind of symbol being referenced.
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1007#[serde(rename_all = "lowercase")]
1008#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1009#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1010#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1011pub enum ReferenceKind {
1012    /// An account reference.
1013    Account,
1014    /// A currency/commodity reference.
1015    Currency,
1016    /// A payee reference.
1017    Payee,
1018}
1019
1020/// A reference to a symbol in the document.
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1023#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1024#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1025pub struct EditorReference {
1026    /// The range of this reference.
1027    pub range: EditorRange,
1028    /// The kind of reference.
1029    pub kind: ReferenceKind,
1030    /// Whether this is the defining occurrence.
1031    pub is_definition: bool,
1032    /// Human-readable context (e.g., directive type).
1033    #[serde(skip_serializing_if = "Option::is_none")]
1034    #[cfg_attr(feature = "ts-export", ts(optional))]
1035    pub context: Option<String>,
1036}
1037
1038/// Result of a find-references request.
1039#[derive(Debug, Clone, Serialize, Deserialize)]
1040#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]
1041#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1042#[cfg_attr(feature = "ts-export", ts(export, export_to = "bindings/"))]
1043pub struct EditorReferencesResult {
1044    /// The symbol being searched for.
1045    pub symbol: String,
1046    /// The kind of symbol.
1047    pub kind: ReferenceKind,
1048    /// All references found.
1049    pub references: Vec<EditorReference>,
1050}
1051
1052// Wire-format pins live in a host-only test module: they test
1053// `serde_json` round-trips which are target-independent, and pulling
1054// `serde_json` into the wasm32 test target activates a `getrandom`
1055// transitive that fails to compile on `wasm32-unknown-unknown`
1056// without the `wasm_js` backend flag. The shape we're pinning is the
1057// same on every target, so running these on the host is sufficient.
1058#[cfg(all(test, not(target_arch = "wasm32")))]
1059mod cost_number_wire_tests {
1060    //! Wire-format pins for #1164. Catches silent shape drift that
1061    //! would break TypeScript clients.
1062
1063    use super::*;
1064
1065    #[test]
1066    fn per_unit_serializes_with_kind_tag() {
1067        let cn = CostNumberJson::PerUnit {
1068            value: "100".into(),
1069        };
1070        let json = serde_json::to_value(&cn).unwrap();
1071        assert_eq!(
1072            json,
1073            serde_json::json!({"kind": "per_unit", "value": "100"})
1074        );
1075    }
1076
1077    #[test]
1078    fn total_serializes_with_kind_tag() {
1079        let cn = CostNumberJson::Total {
1080            value: "1500".into(),
1081        };
1082        let json = serde_json::to_value(&cn).unwrap();
1083        assert_eq!(json, serde_json::json!({"kind": "total", "value": "1500"}));
1084    }
1085
1086    #[test]
1087    fn per_unit_from_total_carries_both_values() {
1088        let cn = CostNumberJson::PerUnitFromTotal {
1089            per_unit: "150".into(),
1090            total: "300".into(),
1091        };
1092        let json = serde_json::to_value(&cn).unwrap();
1093        assert_eq!(
1094            json,
1095            serde_json::json!({
1096                "kind": "per_unit_from_total",
1097                "per_unit": "150",
1098                "total": "300",
1099            })
1100        );
1101    }
1102
1103    #[test]
1104    fn round_trip_all_variants() {
1105        for cn in [
1106            CostNumberJson::PerUnit { value: "1".into() },
1107            CostNumberJson::Total { value: "10".into() },
1108            CostNumberJson::PerUnitFromTotal {
1109                per_unit: "1".into(),
1110                total: "10".into(),
1111            },
1112        ] {
1113            let json = serde_json::to_string(&cn).unwrap();
1114            let back: CostNumberJson = serde_json::from_str(&json).unwrap();
1115            // Same JSON on round-trip means the wire shape is stable.
1116            assert_eq!(serde_json::to_string(&back).unwrap(), json);
1117        }
1118    }
1119
1120    #[test]
1121    fn posting_cost_with_total_pre_booking_distinguishes_from_bare_brace() {
1122        // Pre-PR, a `Total` cost serialized as `{number: null,
1123        // currency: ...}` — indistinguishable from a deliberate
1124        // `{USD}` lot match. The new shape preserves the variant.
1125        let with_total = PostingCostJson {
1126            number: Some(CostNumberJson::Total {
1127                value: "1500".into(),
1128            }),
1129            currency: Some("USD".into()),
1130            date: None,
1131            label: None,
1132        };
1133        let bare = PostingCostJson {
1134            number: None,
1135            currency: Some("USD".into()),
1136            date: None,
1137            label: None,
1138        };
1139        let with_total_json = serde_json::to_value(&with_total).unwrap();
1140        let bare_json = serde_json::to_value(&bare).unwrap();
1141        assert_ne!(
1142            with_total_json, bare_json,
1143            "pre-booking Total and bare {{}} must serialize distinctly"
1144        );
1145        assert!(with_total_json["number"].is_object());
1146        assert!(bare_json.get("number").is_none());
1147    }
1148}
1149
1150/// Codegen vehicle for the JSON Schema export (ADR-0004 Phase 3, #1232).
1151///
1152/// `schema_for!(ParseResult)` only walks types reachable from
1153/// `ParseResult`, which covers parse output but misses the return shapes
1154/// of `query`, `format`, `validate`, `runPlugin`, `listPlugins`, the BQL
1155/// completion API, and the editor LSP-like surfaces. Listing every
1156/// top-level public DTO here gives the generator a single root that
1157/// reaches the whole wire surface; the resulting schema has every
1158/// public type under `$defs`.
1159///
1160/// **Not a wire-format type.** No `Serialize`/`Deserialize` derive,
1161/// no `wasm_bindgen` export -- it exists only so that
1162/// `schema_for!(RustledgerBindings)` produces the union of
1163/// definitions. The export test then strips the wrapper's own
1164/// root-level keys (`type`, `title`, `properties`, `required`) before
1165/// writing the JSON Schema, so consumers see a definitions-only
1166/// document with no top-level `RustledgerBindings` object -- and
1167/// datamodel-code-generator does not emit a corresponding Pydantic
1168/// class. Field types that are reachable transitively (e.g.
1169/// `Severity` from `BeancountError`, `CompletionKind` from
1170/// `EditorCompletion`) don't need to be listed.
1171#[cfg(feature = "json-schema")]
1172#[derive(schemars::JsonSchema)]
1173#[allow(dead_code)]
1174struct RustledgerBindings {
1175    parse_result: ParseResult,
1176    validation_result: ValidationResult,
1177    query_result: QueryResult,
1178    format_result: FormatResult,
1179    pad_result: PadResult,
1180    plugin_result: PluginResult,
1181    plugin_info: PluginInfo,
1182    completion_result: CompletionResultJson,
1183    editor_completion_result: EditorCompletionResult,
1184    editor_hover_info: EditorHoverInfo,
1185    editor_document_symbol: EditorDocumentSymbol,
1186    editor_references_result: EditorReferencesResult,
1187    // `EditorLocation` is the return type of `getDefinition()` and is
1188    // not referenced by any of the other listed DTOs, so it needs an
1189    // explicit field here -- without it the schema/Python bindings
1190    // silently omit it while the TS bindings still export it.
1191    editor_location: EditorLocation,
1192}
1193
1194/// JSON Schema export entry point (ADR-0004 Phase 3, issue #1232).
1195///
1196/// Counterpart to ts-rs's auto-generated `export_bindings_*` tests.
1197/// Only compiled when the `json-schema` feature is on, which pulls
1198/// `schemars` into the dep graph. Driven by `scripts/regen-bindings.sh`:
1199/// the script sets `RUSTLEDGER_REGEN_SCHEMA=1` and runs `cargo test -p
1200/// rustledger-wasm --features json-schema --lib -- --include-ignored
1201/// --nocapture --exact types::export_json_schema::export_index_schema`,
1202/// which writes `bindings/index.schema.json` from the
1203/// `RustledgerBindings` wrapper above (covers all public DTOs).
1204///
1205/// Two opt-in gates protect the source tree:
1206///   1. `#[ignore]` -- plain `cargo test` skips this.
1207///   2. `RUSTLEDGER_REGEN_SCHEMA=1` -- a developer running
1208///      `cargo test --include-ignored` (a common debug command) does
1209///      NOT silently overwrite the checked-in schema; the test
1210///      `panic!`s with a guidance message so the failure is loud and
1211///      visible without needing `--nocapture`. Only the regen script
1212///      sets the env var.
1213///
1214/// The test also prints a unique sentinel on success
1215/// (`EXPORT_INDEX_SCHEMA_RAN_OK`) which the regen script greps for --
1216/// catches the case where `cargo test --exact` matches zero tests
1217/// (e.g. after a future rename) and silently exits 0 with no
1218/// regeneration.
1219#[cfg(all(test, feature = "json-schema", not(target_arch = "wasm32")))]
1220mod export_json_schema {
1221    use std::fs;
1222    use std::path::PathBuf;
1223
1224    use super::RustledgerBindings;
1225
1226    /// Sentinel string printed on a successful schema write. The regen
1227    /// script greps for this exact bytes; do not change without
1228    /// updating `scripts/regen-bindings.sh`.
1229    pub const SUCCESS_SENTINEL: &str = "EXPORT_INDEX_SCHEMA_RAN_OK";
1230
1231    #[test]
1232    #[ignore = "writes bindings/index.schema.json; driven by scripts/regen-bindings.sh"]
1233    fn export_index_schema() {
1234        // Belt-and-suspenders guard. `#[ignore]` already prevents an
1235        // unintentional run, but `--include-ignored` is common enough
1236        // in debug workflows that we panic (rather than silently
1237        // returning Ok) so the failure is visible without
1238        // `--nocapture`. A green-passing test with the env var unset
1239        // would otherwise mislead a developer into thinking the
1240        // schema was regenerated.
1241        assert!(
1242            std::env::var_os("RUSTLEDGER_REGEN_SCHEMA").is_some(),
1243            "export_index_schema mutates bindings/index.schema.json and \
1244             must be driven by scripts/regen-bindings.sh, not invoked \
1245             directly. Set RUSTLEDGER_REGEN_SCHEMA=1 to opt in."
1246        );
1247
1248        let schema = schemars::schema_for!(RustledgerBindings);
1249
1250        // Round-trip through `serde_json::Value` so we can strip the
1251        // wrapper's root-level keys. `RustledgerBindings` exists only
1252        // to seed `$defs` with every public DTO -- its own
1253        // `type: object, properties: {...}, required: [...]` shape is
1254        // an internal artifact, not a wire-format contract. Leaving
1255        // it in causes datamodel-code-generator to emit a public
1256        // `RustledgerBindings(BaseModel)` class consumers can import
1257        // (and worse, prefixed-mangled when we try to rename it with
1258        // a leading underscore). Stripping after generation gives a
1259        // definitions-only schema (`$schema` + `$defs` only) which
1260        // datamodel-code-generator handles cleanly: one Pydantic
1261        // class per `$def`, no wrapper.
1262        let mut schema_value = serde_json::to_value(&schema)
1263            .expect("schemars schema should round-trip through serde_json");
1264        if let Some(obj) = schema_value.as_object_mut() {
1265            obj.remove("type");
1266            obj.remove("title");
1267            obj.remove("properties");
1268            obj.remove("required");
1269            obj.remove("additionalProperties");
1270            // The wrapper's rustdoc gets emitted as `description`.
1271            // Drop it -- datamodel-code-generator otherwise treats the
1272            // root as a documented type and emits a placeholder
1273            // `Model(RootModel[Any])` class.
1274            obj.remove("description");
1275        }
1276
1277        // Pretty-print to stabilize the on-disk format for git diffs;
1278        // the regen script later runs prettier over it for a final
1279        // canonicalization pass alongside the TS bundle.
1280        let pretty = serde_json::to_string_pretty(&schema_value)
1281            .expect("stripped schema should serialize cleanly");
1282
1283        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1284        path.push("bindings");
1285        fs::create_dir_all(&path).expect("create bindings/ directory");
1286        path.push("index.schema.json");
1287        fs::write(&path, format!("{pretty}\n")).expect("write index.schema.json");
1288
1289        // Sentinel for `scripts/regen-bindings.sh` to grep for.
1290        // `println!` (stdout, not stderr) makes it survive `--quiet`
1291        // when the script pipes cargo output through `tee`.
1292        println!("{SUCCESS_SENTINEL}");
1293        eprintln!("Wrote: {}", path.display());
1294    }
1295}
1296
1297/// Guards the DTOs that hand-override schemars' auto-detected `required`
1298/// array via `schemars(extend("required" = [...]))`.
1299///
1300/// `extend("required" = ...)` *replaces* the auto-detected array rather
1301/// than merging into it (schemars 1.x has no merge form). So if a field
1302/// is added to one of these structs and the author forgets to update the
1303/// hand-written list, that field silently drops out of `required` even
1304/// though the wire always emits it -- with no compile error and no other
1305/// test catching it. (PR #1241's round-2 review found exactly this: the
1306/// round-1 `extend` sweep missed `FormatResult`.)
1307///
1308/// Every field on these four DTOs is a required wire field -- the
1309/// nullable ones (`ParseResult.ledger`, `LedgerOptions.title`,
1310/// `BeancountError.line/column`, `FormatResult.formatted`) are
1311/// always-present-but-nullable, never absent. So the invariant is exact:
1312/// the emitted `required` set must equal the full property set. If you
1313/// add a genuinely-optional field to one of these structs, this test is
1314/// the tripwire -- update it deliberately alongside the `extend` list.
1315#[cfg(all(test, feature = "json-schema", not(target_arch = "wasm32")))]
1316mod schema_required_invariants {
1317    use std::collections::BTreeSet;
1318
1319    use super::RustledgerBindings;
1320
1321    /// Assert the `$def` for `def_name` lists every one of its
1322    /// properties in `required`.
1323    fn assert_required_equals_all_properties(def_name: &str) {
1324        let schema = schemars::schema_for!(RustledgerBindings);
1325        let value = serde_json::to_value(&schema).expect("schema round-trips through serde_json");
1326
1327        let def = value
1328            .get("$defs")
1329            .and_then(|d| d.get(def_name))
1330            .unwrap_or_else(|| panic!("{def_name} missing from $defs"));
1331
1332        let properties: BTreeSet<&str> = def
1333            .get("properties")
1334            .and_then(serde_json::Value::as_object)
1335            .unwrap_or_else(|| panic!("{def_name} has no properties object"))
1336            .keys()
1337            .map(String::as_str)
1338            .collect();
1339
1340        let required: BTreeSet<&str> = def
1341            .get("required")
1342            .and_then(serde_json::Value::as_array)
1343            .unwrap_or_else(|| {
1344                panic!("{def_name}.required is missing -- did schemars(extend) get dropped?")
1345            })
1346            .iter()
1347            .map(|v| v.as_str().expect("required entry should be a string"))
1348            .collect();
1349
1350        assert_eq!(
1351            required, properties,
1352            "{def_name}: the schemars(extend(\"required\" = [...])) list is out of \
1353             sync with the struct's fields. Every field on this DTO is a required \
1354             wire field, so `required` must list all of them. Update the \
1355             extend(\"required\") attribute on the struct in types.rs (and this \
1356             test, if you intentionally introduced an optional field)."
1357        );
1358    }
1359
1360    #[test]
1361    fn parse_result_requires_all_fields() {
1362        assert_required_equals_all_properties("ParseResult");
1363    }
1364
1365    #[test]
1366    fn ledger_options_requires_all_fields() {
1367        assert_required_equals_all_properties("LedgerOptions");
1368    }
1369
1370    #[test]
1371    fn beancount_error_requires_all_fields() {
1372        assert_required_equals_all_properties("BeancountError");
1373    }
1374
1375    #[test]
1376    fn format_result_requires_all_fields() {
1377        assert_required_equals_all_properties("FormatResult");
1378    }
1379}