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