Skip to main content

rustledger_plugin_types/
lib.rs

1//! WASM Plugin Interface Types for rustledger
2//!
3//! This crate provides the type definitions for rustledger's WASM plugin interface.
4//! Use it as a dependency in your plugin crate to ensure type compatibility with
5//! the rustledger host.
6//!
7//! # Two subsystems
8//!
9//! Rustledger has two distinct WASM plugin subsystems, and this crate hosts
10//! the shared types for both:
11//!
12//! - **Directive plugins** transform the directive stream *after* parsing
13//!   (tagging, dedup, categorization). Required export: `process`. Host
14//!   loader: `rustledger-plugin`. The Quick Start below covers this case.
15//! - **WASM importers** turn bank-statement files *into* directives.
16//!   Required exports: `metadata`, `identify`, `extract`, `extract_enriched`.
17//!   Host loader: `rustledger-importer::WasmImporter`. Use the
18//!   `wasm_importer_main!` macro (behind the `guest` feature) to generate
19//!   the boilerplate. See the `guest` module for details.
20//!
21//! # Directive-Plugin Quick Start
22//!
23//! Use the `wasm_plugin_main!` macro (behind the `guest` feature) to
24//! generate the required `alloc` + `process` exports from a single
25//! user fn. Add this to your plugin's `Cargo.toml`:
26//!
27//! ```toml
28//! [dependencies]
29//! rustledger-plugin-types = { version = "0.15", features = ["guest"] }
30//! ```
31//!
32//! Then in your plugin:
33//!
34//! ```rust,ignore
35//! use rustledger_plugin_types::{
36//!     PluginInput, PluginOutput, wasm_plugin_main,
37//! };
38//!
39//! fn process(input: PluginInput) -> PluginOutput {
40//!     // Simplest case: keep every input unchanged.
41//!     PluginOutput::passthrough(input.directives.len())
42//! }
43//!
44//! wasm_plugin_main! {
45//!     process: process,
46//! }
47//! ```
48//!
49//! See the `guest` module for the full macro reference (including
50//! the once-per-crate constraint on the `wasm32` target). If you need
51//! to write the `extern "C"` exports manually — for finer control or
52//! to avoid the `guest` feature — see the "Without the macro" section
53//! in the crate README.
54//!
55//! # Serialization Format
56//!
57//! Plugins communicate with the host via `MessagePack` serialization. The host
58//! calls `process(ptr, len)` with a pointer to MessagePack-encoded [`PluginInput`].
59//! The plugin returns a packed u64 containing a pointer and length to
60//! MessagePack-encoded [`PluginOutput`].
61//!
62//! # Memory Management
63//!
64//! Plugins must export an `alloc(size: u32) -> *mut u8` function. The host uses
65//! this to allocate memory in the WASM linear memory for passing input data.
66//! The plugin uses it to allocate memory for output data.
67//!
68//! Optionally, plugins can export a `dealloc(ptr: *mut u8, size: u32)` function
69//! to free memory. This is not required by the host but can be useful for
70//! memory management within longer-running plugin operations.
71//!
72//! # Version Compatibility
73//!
74//! Plugin types are versioned with rustledger. For best compatibility, use the
75//! same minor version of `rustledger-plugin-types` as the rustledger host you're
76//! targeting (e.g., `0.15.x` for rustledger `0.15.x`).
77//!
78//! # Building
79//!
80//! Build your plugin for the WASM target:
81//!
82//! ```sh
83//! rustup target add wasm32-unknown-unknown
84//! cargo build --target wasm32-unknown-unknown --release
85//! ```
86//!
87//! The output will be in `target/wasm32-unknown-unknown/release/your_plugin.wasm`
88//!
89//! # WASM-Importer Quick Start
90//!
91//! Importers read source files (CSV, OFX, …) and emit directives. The host
92//! loader lives in `rustledger-importer`; the wire format and a
93//! boilerplate-eliminating macro live here.
94//!
95//! Enable the `guest` feature, then use `wasm_importer_main!`:
96//!
97//! ```toml
98//! [dependencies]
99//! rustledger-plugin-types = { version = "0.15", features = ["guest"] }
100//! ```
101//!
102//! ```rust,ignore
103//! use rustledger_plugin_types::{
104//!     DirectiveData, DirectiveWrapper, ImporterInput, ImporterOutput,
105//!     OpenData, wasm_importer_main,
106//! };
107//!
108//! fn identify(path: &str) -> bool {
109//!     path.ends_with(".mybank")
110//! }
111//!
112//! fn extract(input: ImporterInput) -> ImporterOutput {
113//!     // Parse input.content; emit DirectiveWrapper values.
114//!     ImporterOutput::new(vec![/* … */])
115//! }
116//!
117//! wasm_importer_main! {
118//!     name: "my-bank",
119//!     description: "MyBank CSV statements",
120//!     identify: identify,
121//!     extract: extract,
122//!     // `extract_enriched` is auto-generated as a Default-categorization
123//!     // passthrough. Add `extract_enriched: my_fn` to override.
124//! }
125//! ```
126//!
127//! Importer ABI types defined in this crate: [`ImporterInput`],
128//! [`IdentifyInput`], [`IdentifyOutput`], [`ImporterOutput`],
129//! [`EnrichedImporterOutput`], [`MetadataOutput`], [`EnrichmentWrapper`],
130//! [`AlternativeWrapper`].
131//!
132//! Wire-format method strings for `EnrichmentWrapper::method`: `"rule"`,
133//! `"merchant-dict"` (hyphen, not underscore), `"ml"`, `"llm"`, `"manual"`,
134//! `"default"`. Unknown values trigger a host warning and fall back to
135//! `Default`.
136
137#![warn(missing_docs)]
138
139#[cfg(feature = "guest")]
140pub mod guest;
141
142use serde::{Deserialize, Serialize};
143
144/// Version of the host/guest WASM ABI defined by this crate.
145///
146/// A WASM plugin or importer built with the `wasm_plugin_main!` /
147/// `wasm_importer_main!` macros exports this value as
148/// `__rustledger_abi_version() -> u32`. The host reads that export right
149/// after instantiating the module and refuses to run a guest whose
150/// version differs from its own — turning what used to be an opaque
151/// trap deep inside a later call (a guest built against an
152/// incompatible `plugin-types`) into a clear, actionable load-time
153/// error (issue #1234).
154///
155/// Bump this whenever a *breaking* change is made to the wire format or
156/// the export/call convention shared between host and guest (a changed
157/// `PluginInput`/`ImporterInput` shape, a renamed required export, a
158/// different packing scheme, …). It is intentionally a small standalone
159/// counter rather than the crate's `SemVer`: most `plugin-types` releases
160/// do not touch the ABI, and a guest only needs to agree with the host
161/// on the ABI, not on the exact crate version.
162pub const ABI_VERSION: u32 = 1;
163
164/// The WASM export symbol a guest uses to advertise [`ABI_VERSION`].
165/// The `wasm_*_main!` macros emit it; the host looks it up by this
166/// name. Kept here as the single source of truth shared by both sides.
167pub const ABI_VERSION_EXPORT: &str = "__rustledger_abi_version";
168
169// ============================================================================
170// Top-Level Plugin Interface
171// ============================================================================
172
173/// Input passed to a plugin.
174///
175/// The host serializes this struct via `MessagePack` and passes it to the
176/// plugin's `process` function.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PluginInput {
179    /// All directives to process.
180    pub directives: Vec<DirectiveWrapper>,
181    /// Ledger options.
182    pub options: PluginOptions,
183    /// Plugin-specific configuration string (from the plugin directive).
184    ///
185    /// For example, `plugin "myplugin.wasm" "threshold=100"` would set
186    /// `config` to `Some("threshold=100")`.
187    pub config: Option<String>,
188}
189
190/// Output returned from a plugin.
191///
192/// The plugin serializes this struct via `MessagePack` and returns a pointer
193/// to it from the `process` function.
194///
195/// Output is an **ordered sequence of operations** ([`PluginOp`]) — not a
196/// replacement list of directives. The host materializes the resulting
197/// directive list by walking the ops in order, preserving the original
198/// source span / `file_id` for `Keep` and `Modify` ops so plugin-transformed
199/// directives retain byte-precise source locations for error reporting.
200///
201/// Every input directive index must appear in EXACTLY ONE op across
202/// `Keep` / `Modify` / `Delete`; the host validates this and emits a
203/// plugin error if the invariant is violated.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct PluginOutput {
206    /// Ordered operations that describe the resulting directive list.
207    pub ops: Vec<PluginOp>,
208    /// Errors generated by the plugin.
209    pub errors: Vec<PluginError>,
210}
211
212impl PluginOutput {
213    /// Create an output that passes through every input directive unchanged.
214    /// `len` is the number of input directives.
215    #[must_use]
216    pub fn passthrough(len: usize) -> Self {
217        Self {
218            ops: (0..len).map(PluginOp::Keep).collect(),
219            errors: Vec::new(),
220        }
221    }
222}
223
224/// One operation in a [`PluginOutput`]'s ordered op list.
225///
226/// Ops describe how each output directive relates to the input:
227/// - [`PluginOp::Keep`] — reuse `input[i]` unchanged. Span and
228///   `file_id` preserved.
229/// - [`PluginOp::Modify`] — output a new wrapper, but inherit `input[i]`'s
230///   source identity (span / `file_id`). Plugins use this when transforming
231///   an existing directive's content (e.g., adding tags) so error
232///   reporting still points at the original source location.
233/// - [`PluginOp::Insert`] — emit a fresh directive with synthesized
234///   source location (`SYNTHESIZED_FILE_ID`, zero span). Use for
235///   directives the plugin invents from scratch.
236/// - [`PluginOp::Delete`] — drop `input[i]`. Must be explicit; omitting
237///   an index without `Delete` is a protocol violation that the host
238///   reports as a plugin error.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub enum PluginOp {
241    /// Reuse `input[i]` unchanged (preserves original span + `file_id`).
242    Keep(usize),
243    /// Replace `input[i]`'s content with `wrapper`, but inherit
244    /// `input[i]`'s source identity (span + `file_id`).
245    Modify(usize, DirectiveWrapper),
246    /// Insert a fresh directive with synthesized source location.
247    Insert(DirectiveWrapper),
248    /// Drop `input[i]`. Must be explicit — see type-level docs.
249    Delete(usize),
250}
251
252/// Ledger options passed to plugins.
253#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct PluginOptions {
255    /// Operating currencies (from `option "operating_currency" "USD"`).
256    pub operating_currencies: Vec<String>,
257    /// Ledger title (from `option "title" "My Ledger"`).
258    pub title: Option<String>,
259}
260
261// ============================================================================
262// Plugin Errors
263// ============================================================================
264
265/// Error generated by a plugin.
266///
267/// Use [`PluginError::error`] or [`PluginError::warning`] to create errors,
268/// and optionally chain [`PluginError::at`] to set the source location.
269///
270/// # Example
271///
272/// ```
273/// use rustledger_plugin_types::{PluginError, PluginErrorSeverity};
274///
275/// let error = PluginError::error("Invalid transaction")
276///     .at("ledger.beancount", 42);
277///
278/// let warning = PluginError::warning("Duplicate entry detected");
279/// ```
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct PluginError {
282    /// Error message.
283    pub message: String,
284    /// Source file (if known).
285    pub source_file: Option<String>,
286    /// Line number (if known).
287    pub line_number: Option<u32>,
288    /// Error severity.
289    pub severity: PluginErrorSeverity,
290}
291
292/// Severity of a plugin error.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum PluginErrorSeverity {
295    /// Warning - processing continues.
296    #[serde(rename = "warning")]
297    Warning,
298    /// Error - ledger is marked invalid.
299    #[serde(rename = "error")]
300    Error,
301}
302
303impl PluginError {
304    /// Create a new error.
305    #[must_use]
306    pub fn error(message: impl Into<String>) -> Self {
307        Self {
308            message: message.into(),
309            source_file: None,
310            line_number: None,
311            severity: PluginErrorSeverity::Error,
312        }
313    }
314
315    /// Create a new warning.
316    #[must_use]
317    pub fn warning(message: impl Into<String>) -> Self {
318        Self {
319            message: message.into(),
320            source_file: None,
321            line_number: None,
322            severity: PluginErrorSeverity::Warning,
323        }
324    }
325
326    /// Set the source location.
327    #[must_use]
328    pub fn at(mut self, file: impl Into<String>, line: u32) -> Self {
329        self.source_file = Some(file.into());
330        self.line_number = Some(line);
331        self
332    }
333}
334
335// ============================================================================
336// Directive Types
337// ============================================================================
338
339/// A wrapper around directives for serialization.
340///
341/// This wrapper provides a uniform interface for all directive types,
342/// with source location tracking for error reporting.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct DirectiveWrapper {
345    /// The type of directive (derived from data, not serialized to avoid duplicate keys).
346    #[serde(skip_serializing, default)]
347    pub directive_type: String,
348    /// The directive date (YYYY-MM-DD format).
349    pub date: String,
350    /// Source filename (for tracking through plugin processing).
351    /// If None, the directive was created by a plugin.
352    #[serde(skip_serializing_if = "Option::is_none", default)]
353    pub filename: Option<String>,
354    /// Source line number (1-based).
355    /// If None, the directive was created by a plugin.
356    #[serde(skip_serializing_if = "Option::is_none", default)]
357    pub lineno: Option<u32>,
358    /// Directive-specific data as a nested structure.
359    #[serde(flatten)]
360    pub data: DirectiveData,
361}
362
363impl DirectiveWrapper {
364    /// Returns the sort order for directive types, matching Python beancount's `SORT_ORDER`.
365    ///
366    /// Order ensures logical processing:
367    /// - Open (-2): Accounts must be opened first
368    /// - Balance (-1): Balance assertions checked before transactions
369    /// - Default (0): Transactions, Commodity, Pad, Event, Note, Price, Query, Custom
370    /// - Document (1): Documents recorded after transactions
371    /// - Close (2): Accounts closed last
372    #[must_use]
373    pub const fn type_sort_order(&self) -> i8 {
374        match &self.data {
375            DirectiveData::Open(_) => -2,
376            DirectiveData::Balance(_) => -1,
377            DirectiveData::Document(_) => 1,
378            DirectiveData::Close(_) => 2,
379            _ => 0,
380        }
381    }
382
383    /// Returns a sort key tuple matching Python beancount's `entry_sortkey()`.
384    ///
385    /// Sorts by: (date, `type_order`, lineno)
386    #[must_use]
387    pub fn sort_key(&self) -> (&str, i8, u32) {
388        (
389            &self.date,
390            self.type_sort_order(),
391            self.lineno.unwrap_or(u32::MAX),
392        )
393    }
394}
395
396/// Directive-specific data.
397///
398/// Each variant corresponds to a Beancount directive type.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(tag = "type")]
401pub enum DirectiveData {
402    /// Transaction data.
403    #[serde(rename = "transaction")]
404    Transaction(TransactionData),
405    /// Balance assertion data.
406    #[serde(rename = "balance")]
407    Balance(BalanceData),
408    /// Open account data.
409    #[serde(rename = "open")]
410    Open(OpenData),
411    /// Close account data.
412    #[serde(rename = "close")]
413    Close(CloseData),
414    /// Commodity declaration data.
415    #[serde(rename = "commodity")]
416    Commodity(CommodityData),
417    /// Pad directive data.
418    #[serde(rename = "pad")]
419    Pad(PadData),
420    /// Event data.
421    #[serde(rename = "event")]
422    Event(EventData),
423    /// Note data.
424    #[serde(rename = "note")]
425    Note(NoteData),
426    /// Document data.
427    #[serde(rename = "document")]
428    Document(DocumentData),
429    /// Price data.
430    #[serde(rename = "price")]
431    Price(PriceData),
432    /// Query data.
433    #[serde(rename = "query")]
434    Query(QueryData),
435    /// Custom directive data.
436    #[serde(rename = "custom")]
437    Custom(CustomData),
438}
439
440// ============================================================================
441// Transaction Types
442// ============================================================================
443
444/// Transaction data for serialization.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct TransactionData {
447    /// Transaction flag (`*` for complete, `!` for incomplete/pending).
448    pub flag: String,
449    /// Optional payee.
450    pub payee: Option<String>,
451    /// Narration/description.
452    pub narration: String,
453    /// Tags without the `#` prefix.
454    pub tags: Vec<String>,
455    /// Links without the `^` prefix.
456    pub links: Vec<String>,
457    /// Metadata key-value pairs.
458    pub metadata: Vec<(String, MetaValueData)>,
459    /// Postings.
460    pub postings: Vec<PostingData>,
461}
462
463/// Source-location metadata for a posting that the host parsed from a
464/// beancount file.
465///
466/// Plugins receive this on every parser-derived posting and **must**
467/// preserve it unchanged when modifying an existing posting (the default
468/// for a typical "edit one field" plugin). When a plugin synthesizes a
469/// brand-new posting, leave [`PostingData::span`] as `None` and the host
470/// will mark it `SYNTHESIZED_FILE_ID`.
471///
472/// Byte offsets are stored as `u64` so the wire format is stable
473/// across 32-bit (WASM) and 64-bit (host) targets, and so very large
474/// concatenated source trees (includes-of-includes) cannot silently
475/// overflow. The contents are otherwise opaque to plugin code: do
476/// not synthesize spans by guessing offsets.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478pub struct SourceSpan {
479    /// Start byte offset within the file (inclusive).
480    pub start: u64,
481    /// End byte offset within the file (exclusive).
482    pub end: u64,
483    /// Source file index in the host's source map.
484    pub file_id: u16,
485}
486
487/// Posting data for serialization.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct PostingData {
490    /// Account name (e.g., `Assets:Bank:Checking`).
491    pub account: String,
492    /// Units (amount + currency). None for auto-balanced postings.
493    pub units: Option<AmountData>,
494    /// Cost specification (for lot tracking).
495    pub cost: Option<CostData>,
496    /// Price annotation (@ or @@).
497    pub price: Option<PriceAnnotationData>,
498    /// Optional posting flag.
499    pub flag: Option<String>,
500    /// Posting metadata.
501    pub metadata: Vec<(String, MetaValueData)>,
502    /// Source location of the posting line in the file the host parsed
503    /// from, if any. Plugins **must preserve** this unchanged when
504    /// modifying an existing posting; set to `None` only for postings
505    /// the plugin itself synthesizes. See [`SourceSpan`] for details.
506    #[serde(default)]
507    pub span: Option<SourceSpan>,
508}
509
510/// Amount data for serialization.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct AmountData {
513    /// Number as string (preserves precision).
514    pub number: String,
515    /// Currency code.
516    pub currency: String,
517}
518
519/// The numeric component of a [`CostData`].
520///
521/// Mirrors the host's `rustledger_core::CostNumber` on the wire. The
522/// per-unit vs total axes are mutually exclusive by construction —
523/// pre-#1164 they were split into independent `number_per` /
524/// `number_total` Option fields on `CostData`, which allowed the
525/// invalid both-set state on the wire and forced every plugin to write
526/// "what if both?" defensive branches. Numbers are stringly-typed for
527/// arbitrary precision across the WASM boundary.
528///
529/// `PerUnitFromTotal` is the post-booking shape that plugins see after
530/// the booker has derived a per-unit value from a `{{ total }}` spec.
531/// It carries BOTH the derived per-unit AND the original total so
532/// plugins that care about precision (e.g. `currency_accounts`, which
533/// matches Python's `beancount.core.convert.get_cost`) can use the
534/// original total rather than redividing.
535///
536/// Serializes as `{"kind": "per_unit", "value": "100"}` /
537/// `{"kind": "total", "value": "1500"}` / `{"kind":
538/// "per_unit_from_total", "per_unit": "150", "total": "300"}` — the
539/// `kind`-tagged shape is shared with FFI-WASI, WASM, and Python so
540/// every client language sees one wire contract.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[serde(tag = "kind", rename_all = "snake_case")]
543pub enum CostNumberData {
544    /// Per-unit cost: `{150.00 USD}`.
545    PerUnit {
546        /// Per-unit value.
547        value: String,
548    },
549    /// Total cost for the posting's units: `{{ 1500.00 USD }}`.
550    Total {
551        /// Total value.
552        value: String,
553    },
554    /// Post-booking derived per-unit with the original total preserved.
555    /// `per_unit == total / |units|` by host construction; preferring
556    /// `total` for cost-basis-style reads avoids the
557    /// division-then-multiplication precision loss that hits the
558    /// `rust_decimal` 28-digit ceiling on long ledgers.
559    PerUnitFromTotal {
560        /// Derived per-unit value.
561        per_unit: String,
562        /// Original `{{ total }}` as written.
563        total: String,
564    },
565}
566
567impl CostNumberData {
568    /// Per-unit value if the variant carries one ([`Self::PerUnit`] or
569    /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::Total`].
570    #[must_use]
571    pub fn per_unit(&self) -> Option<&str> {
572        match self {
573            Self::PerUnit { value }
574            | Self::PerUnitFromTotal {
575                per_unit: value, ..
576            } => Some(value),
577            Self::Total { .. } => None,
578        }
579    }
580
581    /// Total value if the variant carries one ([`Self::Total`] or
582    /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::PerUnit`].
583    #[must_use]
584    pub fn total(&self) -> Option<&str> {
585        match self {
586            Self::Total { value } | Self::PerUnitFromTotal { total: value, .. } => Some(value),
587            Self::PerUnit { .. } => None,
588        }
589    }
590}
591
592/// Cost data for serialization.
593///
594/// Represents cost specifications like `{100 USD}` or `{100 USD, 2024-01-01, "lot1"}`.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct CostData {
597    /// The numeric component: per-unit, total, or absent (e.g. `{}`).
598    ///
599    /// Pre-#1164 this was a pair of `Option<String>` fields
600    /// (`number_per` and `number_total`); see [`CostNumberData`] for
601    /// the rationale behind the consolidation.
602    pub number: Option<CostNumberData>,
603    /// Cost currency.
604    pub currency: Option<String>,
605    /// Acquisition date.
606    pub date: Option<String>,
607    /// Lot label.
608    pub label: Option<String>,
609    /// Merge lots flag.
610    pub merge: bool,
611}
612
613/// Price annotation data.
614///
615/// Represents price annotations like `@ 100 USD` or `@@ 1000 USD`
616/// (total price).
617///
618/// # Type-safe consumption (recommended)
619///
620/// Use [`PriceAnnotationData::view`] to get a [`PriceAnnotationView`]
621/// — a typed enum that forces consumers to handle `Unit` and `Total`
622/// arms exhaustively at compile time. **All new code that needs to
623/// distinguish per-unit from total prices MUST use `view()`** rather
624/// than reading `is_total` directly.
625///
626/// This struct is the wire format (kept for serialization stability
627/// across the WASM plugin boundary). The `view()` enum is a shaped
628/// accessor on top.
629///
630/// Pre-refactor (issue #992), the `implicit_prices` plugin read
631/// `posting.price.amount` directly and silently ignored `is_total`,
632/// emitting `@@` total amounts as per-unit prices. The fix in #997
633/// added explicit handling, but the type system didn't catch the bug
634/// originally because nothing forced consumers to read the bool. The
635/// `view()` enum closes that loop: a missing match arm is a compile
636/// error.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct PriceAnnotationData {
639    /// Whether this is a total price (`@@`) vs per-unit (`@`).
640    ///
641    /// **Prefer [`PriceAnnotationData::view`] for new code** — reading
642    /// this field directly is the bug shape that produced #992
643    /// (consumer ignores the field and treats every annotation as
644    /// per-unit). The `view()` enum forces exhaustive handling at
645    /// compile time.
646    pub is_total: bool,
647    /// The price amount (optional for incomplete/empty prices).
648    pub amount: Option<AmountData>,
649    /// The number only (for incomplete prices).
650    pub number: Option<String>,
651    /// The currency only (for incomplete prices).
652    pub currency: Option<String>,
653}
654
655/// Typed view of a [`PriceAnnotationData`].
656///
657/// Each arm distinguishes per-unit (`@`) from total (`@@`) at the
658/// **type level**, so a `match` on the view forces consumers to
659/// handle both cases. This is the recommended way to consume price
660/// annotations — see the docstring on [`PriceAnnotationData`] for the
661/// motivating bug.
662#[derive(Debug, Clone, Copy)]
663pub enum PriceAnnotationView<'a> {
664    /// `@ AMOUNT` — per-unit price with a complete amount.
665    Unit(&'a AmountData),
666    /// `@@ AMOUNT` — total price with a complete amount.
667    ///
668    /// Consumers that compute prices MUST divide by the posting's
669    /// `units.number.abs()` to recover the per-unit price. See
670    /// `rustledger_core::extract_per_unit_price` (in the
671    /// `rustledger-core` crate; not linked because that crate is not a
672    /// dependency of `rustledger-plugin-types`).
673    Total(&'a AmountData),
674    /// `@ NUMBER` / `@ CURRENCY` — per-unit annotation missing one
675    /// or both of (number, currency).
676    UnitIncomplete {
677        /// The number, if present.
678        number: Option<&'a str>,
679        /// The currency, if present.
680        currency: Option<&'a str>,
681    },
682    /// `@@ NUMBER` / `@@ CURRENCY` — incomplete total annotation.
683    TotalIncomplete {
684        /// The number, if present.
685        number: Option<&'a str>,
686        /// The currency, if present.
687        currency: Option<&'a str>,
688    },
689}
690
691impl PriceAnnotationData {
692    /// Get a typed view that distinguishes per-unit from total at
693    /// the type level. **Use this for new code that needs to handle
694    /// the price differently based on `@` vs `@@`.**
695    ///
696    /// Returns one of four variants — a missing match arm at the
697    /// consumer becomes a compile error, eliminating the class of
698    /// bug that produced issue #992.
699    #[must_use]
700    pub fn view(&self) -> PriceAnnotationView<'_> {
701        match (self.is_total, &self.amount) {
702            (false, Some(a)) => PriceAnnotationView::Unit(a),
703            (true, Some(a)) => PriceAnnotationView::Total(a),
704            (false, None) => PriceAnnotationView::UnitIncomplete {
705                number: self.number.as_deref(),
706                currency: self.currency.as_deref(),
707            },
708            (true, None) => PriceAnnotationView::TotalIncomplete {
709                number: self.number.as_deref(),
710                currency: self.currency.as_deref(),
711            },
712        }
713    }
714}
715
716// ============================================================================
717// Metadata Types
718// ============================================================================
719
720/// Metadata value for serialization.
721///
722/// Metadata can hold various types of values, preserving type information
723/// for accurate round-tripping.
724#[derive(Debug, Clone, Serialize, Deserialize)]
725#[serde(tag = "type", content = "value")]
726pub enum MetaValueData {
727    /// String value.
728    #[serde(rename = "string")]
729    String(String),
730    /// Number value (as string to preserve precision).
731    #[serde(rename = "number")]
732    Number(String),
733    /// Date value (YYYY-MM-DD).
734    #[serde(rename = "date")]
735    Date(String),
736    /// Account reference.
737    #[serde(rename = "account")]
738    Account(String),
739    /// Currency reference.
740    #[serde(rename = "currency")]
741    Currency(String),
742    /// Tag reference.
743    #[serde(rename = "tag")]
744    Tag(String),
745    /// Link reference.
746    #[serde(rename = "link")]
747    Link(String),
748    /// Amount value.
749    #[serde(rename = "amount")]
750    Amount(AmountData),
751    /// Boolean value.
752    #[serde(rename = "bool")]
753    Bool(bool),
754}
755
756// ============================================================================
757// Other Directive Types
758// ============================================================================
759
760/// Balance assertion data.
761#[derive(Debug, Clone, Serialize, Deserialize)]
762pub struct BalanceData {
763    /// Account name.
764    pub account: String,
765    /// Expected balance.
766    pub amount: AmountData,
767    /// Tolerance for balance check.
768    pub tolerance: Option<String>,
769    /// Metadata key-value pairs.
770    #[serde(default)]
771    pub metadata: Vec<(String, MetaValueData)>,
772}
773
774/// Open account data.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776pub struct OpenData {
777    /// Account name.
778    pub account: String,
779    /// Allowed currencies (empty means any currency).
780    pub currencies: Vec<String>,
781    /// Booking method (FIFO, LIFO, etc.).
782    pub booking: Option<String>,
783    /// Metadata key-value pairs.
784    #[serde(default)]
785    pub metadata: Vec<(String, MetaValueData)>,
786}
787
788/// Close account data.
789#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct CloseData {
791    /// Account name.
792    pub account: String,
793    /// Metadata key-value pairs.
794    #[serde(default)]
795    pub metadata: Vec<(String, MetaValueData)>,
796}
797
798/// Commodity declaration data.
799#[derive(Debug, Clone, Serialize, Deserialize)]
800pub struct CommodityData {
801    /// Currency code.
802    pub currency: String,
803    /// Metadata key-value pairs.
804    #[serde(default)]
805    pub metadata: Vec<(String, MetaValueData)>,
806}
807
808/// Pad directive data.
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct PadData {
811    /// Account to pad.
812    pub account: String,
813    /// Source account for padding.
814    pub source_account: String,
815    /// Metadata key-value pairs.
816    #[serde(default)]
817    pub metadata: Vec<(String, MetaValueData)>,
818}
819
820/// Event data.
821#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct EventData {
823    /// Event type.
824    pub event_type: String,
825    /// Event value.
826    pub value: String,
827    /// Metadata key-value pairs.
828    #[serde(default)]
829    pub metadata: Vec<(String, MetaValueData)>,
830}
831
832/// Note data.
833#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct NoteData {
835    /// Account name.
836    pub account: String,
837    /// Note comment.
838    pub comment: String,
839    /// Metadata key-value pairs.
840    #[serde(default)]
841    pub metadata: Vec<(String, MetaValueData)>,
842}
843
844/// Document data.
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct DocumentData {
847    /// Account name.
848    pub account: String,
849    /// Document path.
850    pub path: String,
851    /// Tags attached to the document directive. Added to core
852    /// `Document` in #1144; plumbed through the plugin layer in
853    /// #1214 (was previously dropped on both legs of the round-trip).
854    #[serde(default)]
855    pub tags: Vec<String>,
856    /// Links attached to the document directive (issue #1144).
857    #[serde(default)]
858    pub links: Vec<String>,
859    /// Metadata key-value pairs.
860    #[serde(default)]
861    pub metadata: Vec<(String, MetaValueData)>,
862}
863
864/// Price directive data.
865#[derive(Debug, Clone, Serialize, Deserialize)]
866pub struct PriceData {
867    /// Currency being priced.
868    pub currency: String,
869    /// Price amount.
870    pub amount: AmountData,
871    /// Metadata key-value pairs.
872    #[serde(default)]
873    pub metadata: Vec<(String, MetaValueData)>,
874}
875
876/// Query directive data.
877#[derive(Debug, Clone, Serialize, Deserialize)]
878pub struct QueryData {
879    /// Query name.
880    pub name: String,
881    /// Query string (BQL).
882    pub query: String,
883    /// Metadata key-value pairs.
884    #[serde(default)]
885    pub metadata: Vec<(String, MetaValueData)>,
886}
887
888/// Custom directive data.
889#[derive(Debug, Clone, Serialize, Deserialize)]
890pub struct CustomData {
891    /// Custom type (first value after `custom` keyword).
892    pub custom_type: String,
893    /// Values preserving their types.
894    pub values: Vec<MetaValueData>,
895    /// Metadata key-value pairs.
896    #[serde(default)]
897    pub metadata: Vec<(String, MetaValueData)>,
898}
899
900// ============================================================================
901// Importer ABI (wave 2.3: WASM-loaded importers)
902// ============================================================================
903//
904// These types are the wire format spoken between the rustledger host and
905// a WASM-loaded importer plugin (e.g. `rustledger-importer-mt940.wasm`).
906//
907// # Sandbox model
908//
909// WASM importers run in the same locked-down sandbox as directive plugins
910// (no filesystem, no network, no environment, no syscalls). The host reads
911// the source file and passes its bytes via [`ImporterInput::content`] —
912// the WASM importer does NOT open the file itself.
913//
914// # MessagePack contract
915//
916// All ABI types travel between host and guest as MessagePack-encoded byte
917// slices via `rmp_serde`. We use rmp-serde's **default positional struct
918// encoding** (compact arrays of values, no field names on the wire). This
919// is faster and smaller than map encoding at the cost of being strict
920// about field order.
921//
922// # Versioning
923//
924// We do not maintain wire-format backward compatibility. Any field
925// addition, removal, reorder, or type change is a major-version break
926// for the WASM ABI. Users of WASM importer modules are expected to
927// rebuild their importer against the host version they're targeting —
928// the host's ABI version (exposed via `wave-2.3 release notes`) is the
929// authoritative reference.
930//
931// Rationale: pre-v1.0 we ship structural changes freely; locking serde
932// `default`-tolerance into v1.0 would force every future ABI evolution
933// to be additive and live with a growing tail of compat shims. We'd
934// rather bump majors.
935
936/// Wire-format input passed from the host to a WASM importer's
937/// `extract` / `extract_enriched` entry point.
938///
939/// # `options` design note
940///
941/// The `options` map is `String -> String`. Values that are
942/// semantically numbers, booleans, or other types (e.g.
943/// `skip_rows = 5`, `has_header = true`, `delimiter = ","`) are
944/// string-encoded on the host side and parsed by the WASM importer.
945/// This keeps the WASM ABI minimal (no `serde_json::Value` or `rmpv`
946/// dep in the guest crate) at the cost of pushing string parsing into
947/// every importer. A future additive field (`options_typed`) could
948/// carry typed values if needed; not in v1.0 scope.
949#[derive(Debug, Clone, Serialize, Deserialize)]
950pub struct ImporterInput {
951    /// Source file path. Informational only — the WASM sandbox cannot
952    /// open this. Used for diagnostics and fingerprint generation.
953    pub path: String,
954    /// File content bytes. The host reads the file and forwards the
955    /// bytes here so the WASM importer doesn't need filesystem access.
956    pub content: Vec<u8>,
957    /// Target account for imported transactions
958    /// (from `ImporterConfig.account`).
959    pub account: String,
960    /// Currency for amounts (from `ImporterConfig.currency`).
961    pub currency: Option<String>,
962    /// Free-form importer-specific options. The host serializes
963    /// `importers.toml` entries' arbitrary fields into this map; the
964    /// WASM importer reads the keys it knows about. Keeps the
965    /// wire format independent of any host-side config struct shape.
966    /// See the type-level doc for the string-encoding trade-off.
967    pub options: std::collections::HashMap<String, String>,
968}
969
970/// Wire-format input to a WASM importer's `identify` entry point.
971///
972/// The WASM importer answers "do I handle this file?" based on the
973/// path (typically extension) alone — `extract` is the path that
974/// gets file content.
975#[derive(Debug, Clone, Serialize, Deserialize)]
976pub struct IdentifyInput {
977    /// Source file path. Informational only, same as
978    /// [`ImporterInput::path`].
979    pub path: String,
980}
981
982/// Wire-format output from a WASM importer's `identify`.
983#[derive(Debug, Clone, Serialize, Deserialize)]
984pub struct IdentifyOutput {
985    /// True if this importer handles the file at `IdentifyInput.path`.
986    pub matches: bool,
987}
988
989/// Wire-format output from a WASM importer's `metadata` entry point.
990/// Returned once at load time and cached by the host registry — used
991/// for `Importer::name()` and `Importer::description()` on the wrapper.
992#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct MetadataOutput {
994    /// Importer name (e.g. `"MT940"`, `"FinTS"`). Used by the registry
995    /// for `find_by_name` lookups.
996    pub name: String,
997    /// Human-readable description for `--list-importers` and similar.
998    pub description: String,
999}
1000
1001/// Wire-format output returned from a WASM importer's `extract`.
1002#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub struct ImporterOutput {
1004    /// Extracted directives.
1005    pub directives: Vec<DirectiveWrapper>,
1006    /// Warnings encountered during extraction (non-fatal).
1007    pub warnings: Vec<String>,
1008    /// Fatal-but-recoverable errors (e.g. malformed individual rows
1009    /// the importer chose to skip rather than abort on). Distinct from
1010    /// `warnings` (informational) and from a WASM trap (which the host
1011    /// surfaces as an `anyhow::Error`). Reuses the existing
1012    /// [`PluginError`] shape so importer errors flow into the same
1013    /// `LedgerError::location` path as plugin errors.
1014    pub errors: Vec<PluginError>,
1015}
1016
1017impl ImporterOutput {
1018    /// Create an output with no warnings or errors.
1019    #[must_use]
1020    pub const fn new(directives: Vec<DirectiveWrapper>) -> Self {
1021        Self {
1022            directives,
1023            warnings: Vec::new(),
1024            errors: Vec::new(),
1025        }
1026    }
1027
1028    /// Empty result with no directives, no warnings, no errors.
1029    #[must_use]
1030    pub const fn empty() -> Self {
1031        Self {
1032            directives: Vec::new(),
1033            warnings: Vec::new(),
1034            errors: Vec::new(),
1035        }
1036    }
1037}
1038
1039/// Wire-format output returned from a WASM importer's
1040/// `extract_enriched`. Each directive is paired with per-directive
1041/// categorization metadata.
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1043pub struct EnrichedImporterOutput {
1044    /// Directive–enrichment pairs, parallel to `ImporterOutput.directives`.
1045    pub entries: Vec<(DirectiveWrapper, EnrichmentWrapper)>,
1046    /// Warnings encountered during extraction (non-fatal).
1047    pub warnings: Vec<String>,
1048    /// Fatal-but-recoverable errors. Same semantics as
1049    /// [`ImporterOutput::errors`].
1050    pub errors: Vec<PluginError>,
1051}
1052
1053/// Wire-format counterpart to `rustledger_ops::enrichment::Enrichment`.
1054///
1055/// Kept here (rather than in `rustledger-ops`) so the importer ABI is
1056/// self-contained — WASM importers depend on `rustledger-plugin-types`
1057/// and shouldn't pull in the larger `rustledger-ops` graph just for an
1058/// enrichment definition. The host converts between the two shapes at
1059/// the trait boundary.
1060#[derive(Debug, Clone, Serialize, Deserialize)]
1061pub struct EnrichmentWrapper {
1062    /// Index of the directive this enrichment applies to (parallel to
1063    /// `EnrichedImporterOutput.entries`).
1064    pub directive_index: usize,
1065    /// Confidence score for the primary categorization (0.0 to 1.0).
1066    pub confidence: f64,
1067    /// How the primary categorization was determined. String-encoded
1068    /// to avoid pinning the `CategorizationMethod` enum's exact variant
1069    /// set into the wire format. Must match
1070    /// `CategorizationMethod::as_meta_value()` in `rustledger-ops`:
1071    /// `"rule"`, `"merchant-dict"`, `"ml"`, `"llm"`, `"default"`,
1072    /// `"manual"`. (Note: `merchant-dict` uses a hyphen, not an
1073    /// underscore — the host string-matches against
1074    /// `as_meta_value()`'s output, so the wire format must agree.)
1075    pub method: String,
1076    /// Other possible categorizations, sorted by confidence descending.
1077    pub alternatives: Vec<AlternativeWrapper>,
1078    /// Stable fingerprint for deduplication, serialized as a hex string.
1079    pub fingerprint: Option<String>,
1080}
1081
1082/// Wire-format counterpart to `rustledger_ops::enrichment::Alternative`.
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1084pub struct AlternativeWrapper {
1085    /// Account this alternative would assign.
1086    pub account: String,
1087    /// Confidence score for this alternative (0.0 to 1.0).
1088    pub confidence: f64,
1089    /// How this alternative was determined. Same encoding rules as
1090    /// [`EnrichmentWrapper::method`].
1091    pub method: String,
1092}
1093
1094// ============================================================================
1095// Utility Functions
1096// ============================================================================
1097
1098/// Sort directives using beancount's standard ordering.
1099///
1100/// This matches Python beancount's `entry_sortkey()`:
1101/// 1. Primary: date
1102/// 2. Secondary: directive type (Open, Balance, default, Document, Close)
1103/// 3. Tertiary: line number (preserves file order for same-date, same-type entries)
1104pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
1105    directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    #[test]
1113    fn test_plugin_error_builder() {
1114        let error = PluginError::error("test error").at("file.beancount", 10);
1115        assert_eq!(error.message, "test error");
1116        assert_eq!(error.source_file, Some("file.beancount".to_string()));
1117        assert_eq!(error.line_number, Some(10));
1118        assert_eq!(error.severity, PluginErrorSeverity::Error);
1119    }
1120
1121    #[test]
1122    fn test_plugin_warning() {
1123        let warning = PluginError::warning("test warning");
1124        assert_eq!(warning.severity, PluginErrorSeverity::Warning);
1125    }
1126
1127    #[test]
1128    fn test_directive_sort_order() {
1129        let open = DirectiveWrapper {
1130            directive_type: String::new(),
1131            date: "2024-01-01".to_string(),
1132            filename: None,
1133            lineno: Some(1),
1134            data: DirectiveData::Open(OpenData {
1135                account: "Assets:Bank".to_string(),
1136                currencies: vec![],
1137                booking: None,
1138                metadata: vec![],
1139            }),
1140        };
1141        assert_eq!(open.type_sort_order(), -2);
1142
1143        let close = DirectiveWrapper {
1144            directive_type: String::new(),
1145            date: "2024-01-01".to_string(),
1146            filename: None,
1147            lineno: Some(2),
1148            data: DirectiveData::Close(CloseData {
1149                account: "Assets:Bank".to_string(),
1150                metadata: vec![],
1151            }),
1152        };
1153        assert_eq!(close.type_sort_order(), 2);
1154    }
1155
1156    #[test]
1157    fn test_serde_roundtrip() {
1158        let input = PluginInput {
1159            directives: vec![DirectiveWrapper {
1160                directive_type: String::new(),
1161                date: "2024-01-15".to_string(),
1162                filename: Some("test.beancount".to_string()),
1163                lineno: Some(42),
1164                data: DirectiveData::Transaction(TransactionData {
1165                    flag: "*".to_string(),
1166                    payee: Some("Coffee Shop".to_string()),
1167                    narration: "Morning coffee".to_string(),
1168                    tags: vec!["food".to_string()],
1169                    links: vec![],
1170                    metadata: vec![],
1171                    postings: vec![PostingData {
1172                        account: "Expenses:Food".to_string(),
1173                        units: Some(AmountData {
1174                            number: "5.00".to_string(),
1175                            currency: "USD".to_string(),
1176                        }),
1177                        cost: None,
1178                        price: None,
1179                        flag: None,
1180                        metadata: vec![],
1181                        span: None,
1182                    }],
1183                }),
1184            }],
1185            options: PluginOptions {
1186                operating_currencies: vec!["USD".to_string()],
1187                title: Some("Test Ledger".to_string()),
1188            },
1189            config: Some("threshold=100".to_string()),
1190        };
1191
1192        // Test JSON roundtrip
1193        let json = serde_json::to_string(&input).unwrap();
1194        let decoded: PluginInput = serde_json::from_str(&json).unwrap();
1195        assert_eq!(decoded.directives.len(), 1);
1196        assert_eq!(decoded.config, Some("threshold=100".to_string()));
1197
1198        // Test MessagePack roundtrip
1199        let msgpack = rmp_serde::to_vec(&input).unwrap();
1200        let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
1201        assert_eq!(decoded.directives.len(), 1);
1202    }
1203
1204    // ===== PriceAnnotationData::view() — all four arms =====
1205    //
1206    // The view() enum is the type-safe interface that prevents the
1207    // #992 bug shape (consumer ignoring the is_total discriminator).
1208    // These tests pin the mapping from (is_total, amount) to each
1209    // PriceAnnotationView variant so a refactor of the underlying
1210    // struct can't silently change the dispatch.
1211
1212    fn amount(number: &str, currency: &str) -> AmountData {
1213        AmountData {
1214            number: number.to_string(),
1215            currency: currency.to_string(),
1216        }
1217    }
1218
1219    #[test]
1220    fn view_unit_complete() {
1221        // `@ 1.40 EUR`
1222        let pad = PriceAnnotationData {
1223            is_total: false,
1224            amount: Some(amount("1.40", "EUR")),
1225            number: None,
1226            currency: None,
1227        };
1228        match pad.view() {
1229            PriceAnnotationView::Unit(a) => {
1230                assert_eq!(a.number, "1.40");
1231                assert_eq!(a.currency, "EUR");
1232            }
1233            other => panic!("expected Unit, got {other:?}"),
1234        }
1235    }
1236
1237    #[test]
1238    fn view_total_complete() {
1239        // `@@ 1500 USD`
1240        let pad = PriceAnnotationData {
1241            is_total: true,
1242            amount: Some(amount("1500", "USD")),
1243            number: None,
1244            currency: None,
1245        };
1246        match pad.view() {
1247            PriceAnnotationView::Total(a) => {
1248                assert_eq!(a.number, "1500");
1249                assert_eq!(a.currency, "USD");
1250            }
1251            other => panic!("expected Total, got {other:?}"),
1252        }
1253    }
1254
1255    #[test]
1256    fn view_unit_incomplete_number_only() {
1257        // `@ 1.40` — number but no currency
1258        let pad = PriceAnnotationData {
1259            is_total: false,
1260            amount: None,
1261            number: Some("1.40".to_string()),
1262            currency: None,
1263        };
1264        match pad.view() {
1265            PriceAnnotationView::UnitIncomplete { number, currency } => {
1266                assert_eq!(number, Some("1.40"));
1267                assert_eq!(currency, None);
1268            }
1269            other => panic!("expected UnitIncomplete, got {other:?}"),
1270        }
1271    }
1272
1273    #[test]
1274    fn view_unit_incomplete_currency_only() {
1275        // `@ EUR` — currency but no number
1276        let pad = PriceAnnotationData {
1277            is_total: false,
1278            amount: None,
1279            number: None,
1280            currency: Some("EUR".to_string()),
1281        };
1282        match pad.view() {
1283            PriceAnnotationView::UnitIncomplete { number, currency } => {
1284                assert_eq!(number, None);
1285                assert_eq!(currency, Some("EUR"));
1286            }
1287            other => panic!("expected UnitIncomplete, got {other:?}"),
1288        }
1289    }
1290
1291    #[test]
1292    fn view_unit_incomplete_neither() {
1293        // `@` — bare annotation, neither number nor currency
1294        let pad = PriceAnnotationData {
1295            is_total: false,
1296            amount: None,
1297            number: None,
1298            currency: None,
1299        };
1300        match pad.view() {
1301            PriceAnnotationView::UnitIncomplete { number, currency } => {
1302                assert_eq!(number, None);
1303                assert_eq!(currency, None);
1304            }
1305            other => panic!("expected UnitIncomplete, got {other:?}"),
1306        }
1307    }
1308
1309    #[test]
1310    fn view_total_incomplete_number_only() {
1311        // `@@ 1500`
1312        let pad = PriceAnnotationData {
1313            is_total: true,
1314            amount: None,
1315            number: Some("1500".to_string()),
1316            currency: None,
1317        };
1318        match pad.view() {
1319            PriceAnnotationView::TotalIncomplete { number, currency } => {
1320                assert_eq!(number, Some("1500"));
1321                assert_eq!(currency, None);
1322            }
1323            other => panic!("expected TotalIncomplete, got {other:?}"),
1324        }
1325    }
1326
1327    #[test]
1328    fn view_total_incomplete_currency_only() {
1329        // `@@ USD`
1330        let pad = PriceAnnotationData {
1331            is_total: true,
1332            amount: None,
1333            number: None,
1334            currency: Some("USD".to_string()),
1335        };
1336        match pad.view() {
1337            PriceAnnotationView::TotalIncomplete { number, currency } => {
1338                assert_eq!(number, None);
1339                assert_eq!(currency, Some("USD"));
1340            }
1341            other => panic!("expected TotalIncomplete, got {other:?}"),
1342        }
1343    }
1344
1345    #[test]
1346    fn view_total_incomplete_neither() {
1347        // `@@` — bare total annotation
1348        let pad = PriceAnnotationData {
1349            is_total: true,
1350            amount: None,
1351            number: None,
1352            currency: None,
1353        };
1354        match pad.view() {
1355            PriceAnnotationView::TotalIncomplete { number, currency } => {
1356                assert_eq!(number, None);
1357                assert_eq!(currency, None);
1358            }
1359            other => panic!("expected TotalIncomplete, got {other:?}"),
1360        }
1361    }
1362
1363    #[test]
1364    fn view_amount_present_takes_priority_over_number_currency_fields() {
1365        // If both `amount` AND the loose `number`/`currency` fields
1366        // are set, `amount` wins — view() returns Unit/Total, never
1367        // an Incomplete variant. This pins the precedence so a
1368        // future field-juggling refactor can't accidentally invert
1369        // it.
1370        let pad = PriceAnnotationData {
1371            is_total: false,
1372            amount: Some(amount("1.40", "EUR")),
1373            number: Some("99".to_string()),    // ignored
1374            currency: Some("XYZ".to_string()), // ignored
1375        };
1376        match pad.view() {
1377            PriceAnnotationView::Unit(a) => {
1378                assert_eq!(a.number, "1.40");
1379                assert_eq!(a.currency, "EUR");
1380            }
1381            other => panic!("expected Unit, got {other:?}"),
1382        }
1383    }
1384
1385    // ===== Importer ABI round-trip tests =====
1386    //
1387    // Pin the MessagePack-roundtrip shape of the WASM importer wire
1388    // format. If any field is renamed, removed, or its type changes,
1389    // these tests catch it — that's a v1.0 ABI breakage we want to
1390    // notice at code-change time.
1391
1392    #[test]
1393    fn importer_input_msgpack_roundtrip() {
1394        let mut options = std::collections::HashMap::new();
1395        options.insert("date_column".to_string(), "Date".to_string());
1396        options.insert("delimiter".to_string(), ",".to_string());
1397
1398        let original = ImporterInput {
1399            path: "/path/to/foo.csv".to_string(),
1400            content: vec![0xDE, 0xAD, 0xBE, 0xEF],
1401            account: "Assets:Bank".to_string(),
1402            currency: Some("USD".to_string()),
1403            options,
1404        };
1405        let bytes = rmp_serde::to_vec(&original).unwrap();
1406        let decoded: ImporterInput = rmp_serde::from_slice(&bytes).unwrap();
1407        assert_eq!(decoded.path, original.path);
1408        assert_eq!(decoded.content, original.content);
1409        assert_eq!(decoded.account, original.account);
1410        assert_eq!(decoded.currency, original.currency);
1411        assert_eq!(decoded.options, original.options);
1412    }
1413
1414    #[test]
1415    fn importer_output_msgpack_roundtrip_empty() {
1416        let original = ImporterOutput::empty();
1417        let bytes = rmp_serde::to_vec(&original).unwrap();
1418        let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1419        assert!(decoded.directives.is_empty());
1420        assert!(decoded.warnings.is_empty());
1421    }
1422
1423    #[test]
1424    fn importer_output_msgpack_roundtrip_with_warning() {
1425        let mut out = ImporterOutput::new(vec![]);
1426        out.warnings.push("Skipped row 3: bad date".to_string());
1427        let bytes = rmp_serde::to_vec(&out).unwrap();
1428        let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1429        assert_eq!(decoded.warnings.len(), 1);
1430        assert!(decoded.warnings[0].contains("bad date"));
1431    }
1432
1433    #[test]
1434    fn enrichment_wrapper_msgpack_roundtrip() {
1435        let original = EnrichmentWrapper {
1436            directive_index: 7,
1437            confidence: 0.85,
1438            method: "rule".to_string(),
1439            alternatives: vec![AlternativeWrapper {
1440                account: "Expenses:Groceries".to_string(),
1441                confidence: 0.75,
1442                method: "merchant-dict".to_string(),
1443            }],
1444            fingerprint: Some("abc123def456".to_string()),
1445        };
1446        let bytes = rmp_serde::to_vec(&original).unwrap();
1447        let decoded: EnrichmentWrapper = rmp_serde::from_slice(&bytes).unwrap();
1448        assert_eq!(decoded.directive_index, original.directive_index);
1449        assert!((decoded.confidence - original.confidence).abs() < f64::EPSILON);
1450        assert_eq!(decoded.method, original.method);
1451        assert_eq!(decoded.alternatives.len(), 1);
1452        assert_eq!(decoded.alternatives[0].account, "Expenses:Groceries");
1453        // Every field on AlternativeWrapper must round-trip — if any drift
1454        // silently (renamed / dropped / type-changed) we want to catch it
1455        // here, not at the WASM boundary where it'd corrupt enriched results.
1456        assert!(
1457            (decoded.alternatives[0].confidence - 0.75).abs() < f64::EPSILON,
1458            "alternative confidence must round-trip exactly"
1459        );
1460        assert_eq!(decoded.alternatives[0].method, "merchant-dict");
1461        assert_eq!(decoded.fingerprint, original.fingerprint);
1462    }
1463
1464    #[test]
1465    fn enriched_importer_output_msgpack_roundtrip() {
1466        // Cover the more complex enriched variant — pair of
1467        // (DirectiveWrapper, EnrichmentWrapper) with metadata,
1468        // plus warnings + errors. Asserts every field individually.
1469        let dir = DirectiveWrapper {
1470            directive_type: "transaction".to_string(),
1471            date: "2024-01-15".to_string(),
1472            filename: Some("/tmp/foo.csv".to_string()),
1473            lineno: Some(7),
1474            data: DirectiveData::Transaction(TransactionData {
1475                flag: "*".to_string(),
1476                payee: Some("Whole Foods".to_string()),
1477                narration: "Groceries".to_string(),
1478                tags: vec![],
1479                links: vec![],
1480                metadata: vec![],
1481                postings: vec![],
1482            }),
1483        };
1484        let enr = EnrichmentWrapper {
1485            directive_index: 0,
1486            confidence: 0.92,
1487            method: "rule".to_string(),
1488            alternatives: vec![AlternativeWrapper {
1489                account: "Expenses:Other".to_string(),
1490                confidence: 0.10,
1491                method: "default".to_string(),
1492            }],
1493            fingerprint: Some("dead-beef".to_string()),
1494        };
1495        let original = EnrichedImporterOutput {
1496            entries: vec![(dir, enr)],
1497            warnings: vec!["row 3 skipped".to_string()],
1498            errors: vec![PluginError::error("row 4 unparsable").at("/tmp/foo.csv", 4)],
1499        };
1500        let bytes = rmp_serde::to_vec(&original).unwrap();
1501        let decoded: EnrichedImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1502        assert_eq!(decoded.entries.len(), 1);
1503        let (dir, enr) = &decoded.entries[0];
1504        // `directive_type` is intentionally `#[serde(skip_serializing, default)]`
1505        // on `DirectiveWrapper` — derived from the `data` variant, not on the
1506        // wire. Don't assert it here.
1507        assert_eq!(dir.date, "2024-01-15");
1508        match &dir.data {
1509            DirectiveData::Transaction(t) => {
1510                assert_eq!(t.payee.as_deref(), Some("Whole Foods"));
1511                assert_eq!(t.narration, "Groceries");
1512            }
1513            other => panic!("expected Transaction, got {other:?}"),
1514        }
1515        assert_eq!(enr.directive_index, 0);
1516        assert!((enr.confidence - 0.92).abs() < f64::EPSILON);
1517        assert_eq!(enr.method, "rule");
1518        assert_eq!(enr.alternatives.len(), 1);
1519        assert_eq!(enr.alternatives[0].method, "default");
1520        assert_eq!(enr.fingerprint, Some("dead-beef".to_string()));
1521        assert_eq!(decoded.warnings, vec!["row 3 skipped".to_string()]);
1522        assert_eq!(decoded.errors.len(), 1);
1523        assert_eq!(decoded.errors[0].message, "row 4 unparsable");
1524        assert_eq!(
1525            decoded.errors[0].source_file,
1526            Some("/tmp/foo.csv".to_string())
1527        );
1528        assert_eq!(decoded.errors[0].line_number, Some(4));
1529    }
1530
1531    #[test]
1532    fn identify_input_output_msgpack_roundtrip() {
1533        let input = IdentifyInput {
1534            path: "/tmp/statement.mt940".to_string(),
1535        };
1536        let input_bytes = rmp_serde::to_vec(&input).unwrap();
1537        let decoded_input: IdentifyInput = rmp_serde::from_slice(&input_bytes).unwrap();
1538        assert_eq!(decoded_input.path, input.path);
1539
1540        let output = IdentifyOutput { matches: true };
1541        let output_bytes = rmp_serde::to_vec(&output).unwrap();
1542        let decoded_output: IdentifyOutput = rmp_serde::from_slice(&output_bytes).unwrap();
1543        assert!(decoded_output.matches);
1544    }
1545
1546    #[test]
1547    fn metadata_output_msgpack_roundtrip() {
1548        let original = MetadataOutput {
1549            name: "MT940".to_string(),
1550            description: "SWIFT MT940 bank statement importer".to_string(),
1551        };
1552        let bytes = rmp_serde::to_vec(&original).unwrap();
1553        let decoded: MetadataOutput = rmp_serde::from_slice(&bytes).unwrap();
1554        assert_eq!(decoded.name, original.name);
1555        assert_eq!(decoded.description, original.description);
1556    }
1557}