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}