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/// Validate that `ops` form a complete, non-overlapping cover of the input.
253///
254/// Every one of the `n` input directives must appear in exactly one of
255/// `Keep`/`Modify`/`Delete`, with no out-of-bounds or duplicate references.
256/// [`PluginOp::Insert`] adds new directives and references no input index.
257///
258/// This is the single source of truth for the plugin-op contract, shared by the
259/// loader's in-pipeline pass (`rustledger_loader::process::apply_plugin_ops`)
260/// and the FFI's requested-plugin pass (`rustledger_ffi_wasi::helpers`), so the
261/// two surfaces cannot drift on what a well-formed op set is. The
262/// representation-specific materialization (span preservation, posting-span
263/// sanitization) stays with each caller.
264///
265/// # Errors
266/// Returns a human-readable message describing the first violation found
267/// (out-of-bounds index, an index referenced more than once, or an input
268/// directive omitted from every `Keep`/`Modify`/`Delete`).
269pub fn validate_op_coverage(n: usize, ops: &[PluginOp]) -> Result<(), String> {
270    let mut seen = vec![false; n];
271    for op in ops {
272        let idx = match op {
273            PluginOp::Keep(i) | PluginOp::Modify(i, _) | PluginOp::Delete(i) => Some(*i),
274            PluginOp::Insert(_) => None,
275        };
276        if let Some(i) = idx {
277            if i >= n {
278                return Err(format!(
279                    "plugin op references out-of-bounds input index {i} (input has {n} directives)"
280                ));
281            }
282            if seen[i] {
283                return Err(format!(
284                    "plugin op references input index {i} more than once"
285                ));
286            }
287            seen[i] = true;
288        }
289    }
290    for (i, was_seen) in seen.iter().enumerate() {
291        if !was_seen {
292            return Err(format!(
293                "plugin omitted input directive {i} (must appear in exactly one of Keep/Modify/Delete)"
294            ));
295        }
296    }
297    Ok(())
298}
299
300/// Ledger options passed to plugins.
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct PluginOptions {
303    /// Operating currencies (from `option "operating_currency" "USD"`).
304    pub operating_currencies: Vec<String>,
305    /// Ledger title (from `option "title" "My Ledger"`).
306    pub title: Option<String>,
307}
308
309// ============================================================================
310// Plugin Errors
311// ============================================================================
312
313/// Error generated by a plugin.
314///
315/// Use [`PluginError::error`] or [`PluginError::warning`] to create errors,
316/// and optionally chain [`PluginError::at`] to set the source location.
317///
318/// # Example
319///
320/// ```
321/// use rustledger_plugin_types::{PluginError, PluginErrorSeverity};
322///
323/// let error = PluginError::error("Invalid transaction")
324///     .at("ledger.beancount", 42);
325///
326/// let warning = PluginError::warning("Duplicate entry detected");
327/// ```
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct PluginError {
330    /// Error message.
331    pub message: String,
332    /// Source file (if known).
333    pub source_file: Option<String>,
334    /// Line number (if known).
335    pub line_number: Option<u32>,
336    /// Error severity.
337    pub severity: PluginErrorSeverity,
338}
339
340/// Severity of a plugin error.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
342pub enum PluginErrorSeverity {
343    /// Warning - processing continues.
344    #[serde(rename = "warning")]
345    Warning,
346    /// Error - ledger is marked invalid.
347    #[serde(rename = "error")]
348    Error,
349}
350
351impl PluginError {
352    /// Create a new error.
353    #[must_use]
354    pub fn error(message: impl Into<String>) -> Self {
355        Self {
356            message: message.into(),
357            source_file: None,
358            line_number: None,
359            severity: PluginErrorSeverity::Error,
360        }
361    }
362
363    /// Create a new warning.
364    #[must_use]
365    pub fn warning(message: impl Into<String>) -> Self {
366        Self {
367            message: message.into(),
368            source_file: None,
369            line_number: None,
370            severity: PluginErrorSeverity::Warning,
371        }
372    }
373
374    /// Set the source location.
375    #[must_use]
376    pub fn at(mut self, file: impl Into<String>, line: u32) -> Self {
377        self.source_file = Some(file.into());
378        self.line_number = Some(line);
379        self
380    }
381}
382
383// ============================================================================
384// Directive Types
385// ============================================================================
386
387/// A wrapper around directives for serialization.
388///
389/// This wrapper provides a uniform interface for all directive types,
390/// with source location tracking for error reporting.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct DirectiveWrapper {
393    /// The type of directive (derived from data, not serialized to avoid duplicate keys).
394    #[serde(skip_serializing, default)]
395    pub directive_type: String,
396    /// The directive date (YYYY-MM-DD format).
397    pub date: String,
398    /// Source filename (for tracking through plugin processing).
399    /// If None, the directive was created by a plugin.
400    #[serde(skip_serializing_if = "Option::is_none", default)]
401    pub filename: Option<String>,
402    /// Source line number (1-based).
403    /// If None, the directive was created by a plugin.
404    #[serde(skip_serializing_if = "Option::is_none", default)]
405    pub lineno: Option<u32>,
406    /// Directive-specific data as a nested structure.
407    #[serde(flatten)]
408    pub data: DirectiveData,
409}
410
411impl DirectiveWrapper {
412    /// Returns the sort order for directive types, matching Python beancount's `SORT_ORDER`.
413    ///
414    /// Order ensures logical processing:
415    /// - Open (-2): Accounts must be opened first
416    /// - Balance (-1): Balance assertions checked before transactions
417    /// - Default (0): Transactions, Commodity, Pad, Event, Note, Price, Query, Custom
418    /// - Document (1): Documents recorded after transactions
419    /// - Close (2): Accounts closed last
420    #[must_use]
421    pub const fn type_sort_order(&self) -> i8 {
422        match &self.data {
423            DirectiveData::Open(_) => -2,
424            DirectiveData::Balance(_) => -1,
425            DirectiveData::Document(_) => 1,
426            DirectiveData::Close(_) => 2,
427            _ => 0,
428        }
429    }
430
431    /// Returns a sort key tuple matching Python beancount's `entry_sortkey()`.
432    ///
433    /// Sorts by: (date, `type_order`, lineno)
434    #[must_use]
435    pub fn sort_key(&self) -> (&str, i8, u32) {
436        (
437            &self.date,
438            self.type_sort_order(),
439            self.lineno.unwrap_or(u32::MAX),
440        )
441    }
442}
443
444/// Directive-specific data.
445///
446/// Each variant corresponds to a Beancount directive type.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448#[serde(tag = "type")]
449pub enum DirectiveData {
450    /// Transaction data.
451    #[serde(rename = "transaction")]
452    Transaction(TransactionData),
453    /// Balance assertion data.
454    #[serde(rename = "balance")]
455    Balance(BalanceData),
456    /// Open account data.
457    #[serde(rename = "open")]
458    Open(OpenData),
459    /// Close account data.
460    #[serde(rename = "close")]
461    Close(CloseData),
462    /// Commodity declaration data.
463    #[serde(rename = "commodity")]
464    Commodity(CommodityData),
465    /// Pad directive data.
466    #[serde(rename = "pad")]
467    Pad(PadData),
468    /// Event data.
469    #[serde(rename = "event")]
470    Event(EventData),
471    /// Note data.
472    #[serde(rename = "note")]
473    Note(NoteData),
474    /// Document data.
475    #[serde(rename = "document")]
476    Document(DocumentData),
477    /// Price data.
478    #[serde(rename = "price")]
479    Price(PriceData),
480    /// Query data.
481    #[serde(rename = "query")]
482    Query(QueryData),
483    /// Custom directive data.
484    #[serde(rename = "custom")]
485    Custom(CustomData),
486}
487
488// ============================================================================
489// Transaction Types
490// ============================================================================
491
492/// Transaction data for serialization.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct TransactionData {
495    /// Transaction flag (`*` for complete, `!` for incomplete/pending).
496    pub flag: String,
497    /// Optional payee.
498    pub payee: Option<String>,
499    /// Narration/description.
500    pub narration: String,
501    /// Tags without the `#` prefix.
502    pub tags: Vec<String>,
503    /// Links without the `^` prefix.
504    pub links: Vec<String>,
505    /// Metadata key-value pairs.
506    pub metadata: Vec<(String, MetaValueData)>,
507    /// Postings.
508    pub postings: Vec<PostingData>,
509}
510
511/// Source-location metadata for a posting that the host parsed from a
512/// beancount file.
513///
514/// Plugins receive this on every parser-derived posting and **must**
515/// preserve it unchanged when modifying an existing posting (the default
516/// for a typical "edit one field" plugin). When a plugin synthesizes a
517/// brand-new posting, leave [`PostingData::span`] as `None` and the host
518/// will mark it `SYNTHESIZED_FILE_ID`.
519///
520/// Byte offsets are stored as `u64` so the wire format is stable
521/// across 32-bit (WASM) and 64-bit (host) targets, and so very large
522/// concatenated source trees (includes-of-includes) cannot silently
523/// overflow. The contents are otherwise opaque to plugin code: do
524/// not synthesize spans by guessing offsets.
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
526pub struct SourceSpan {
527    /// Start byte offset within the file (inclusive).
528    pub start: u64,
529    /// End byte offset within the file (exclusive).
530    pub end: u64,
531    /// Source file index in the host's source map.
532    pub file_id: u16,
533}
534
535/// Posting data for serialization.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct PostingData {
538    /// Account name (e.g., `Assets:Bank:Checking`).
539    pub account: String,
540    /// Units (amount + currency). None for auto-balanced postings.
541    pub units: Option<AmountData>,
542    /// Cost specification (for lot tracking).
543    pub cost: Option<CostData>,
544    /// Price annotation (@ or @@).
545    pub price: Option<PriceAnnotationData>,
546    /// Optional posting flag.
547    pub flag: Option<String>,
548    /// Posting metadata.
549    pub metadata: Vec<(String, MetaValueData)>,
550    /// Source location of the posting line in the file the host parsed
551    /// from, if any. Plugins **must preserve** this unchanged when
552    /// modifying an existing posting; set to `None` only for postings
553    /// the plugin itself synthesizes. See [`SourceSpan`] for details.
554    #[serde(default)]
555    pub span: Option<SourceSpan>,
556}
557
558/// Amount data for serialization.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct AmountData {
561    /// Number as string (preserves precision).
562    pub number: String,
563    /// Currency code.
564    pub currency: String,
565}
566
567/// The numeric component of a [`CostData`].
568///
569/// Mirrors the host's `rustledger_core::CostNumber` on the wire. The
570/// per-unit vs total axes are mutually exclusive by construction —
571/// pre-#1164 they were split into independent `number_per` /
572/// `number_total` Option fields on `CostData`, which allowed the
573/// invalid both-set state on the wire and forced every plugin to write
574/// "what if both?" defensive branches. Numbers are stringly-typed for
575/// arbitrary precision across the WASM boundary.
576///
577/// `PerUnitFromTotal` is the post-booking shape that plugins see after
578/// the booker has derived a per-unit value from a `{{ total }}` spec.
579/// It carries BOTH the derived per-unit AND the original total so
580/// plugins that care about precision (e.g. `currency_accounts`, which
581/// matches Python's `beancount.core.convert.get_cost`) can use the
582/// original total rather than redividing.
583///
584/// Serializes as `{"kind": "per_unit", "value": "100"}` /
585/// `{"kind": "total", "value": "1500"}` / `{"kind":
586/// "per_unit_from_total", "per_unit": "150", "total": "300"}` — the
587/// `kind`-tagged shape is shared with FFI-WASI, WASM, and Python so
588/// every client language sees one wire contract.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590#[serde(tag = "kind", rename_all = "snake_case")]
591pub enum CostNumberData {
592    /// Per-unit cost: `{150.00 USD}`.
593    PerUnit {
594        /// Per-unit value.
595        value: String,
596    },
597    /// Total cost for the posting's units: `{{ 1500.00 USD }}`.
598    Total {
599        /// Total value.
600        value: String,
601    },
602    /// Compound cost as written: `{5.00 # 10.00 USD}` (beancount
603    /// `compound_amount`) — per-unit AND a lump total; the cost totals
604    /// `N * per_unit + total`. Plugins only see this pre-booking
605    /// (synth pass); booking rewrites it to `PerUnitFromTotal`.
606    Compound {
607        /// Per-unit component (zero when omitted).
608        per_unit: String,
609        /// Lump-total component (zero when omitted).
610        total: String,
611    },
612    /// Post-booking derived per-unit with the original total preserved.
613    /// `per_unit == total / |units|` by host construction; preferring
614    /// `total` for cost-basis-style reads avoids the
615    /// division-then-multiplication precision loss that hits the
616    /// `rust_decimal` 28-digit ceiling on long ledgers.
617    PerUnitFromTotal {
618        /// Derived per-unit value.
619        per_unit: String,
620        /// Original `{{ total }}` as written.
621        total: String,
622    },
623}
624
625impl CostNumberData {
626    /// Per-unit value if the variant carries one ([`Self::PerUnit`] or
627    /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::Total`].
628    #[must_use]
629    pub fn per_unit(&self) -> Option<&str> {
630        match self {
631            Self::PerUnit { value }
632            | Self::PerUnitFromTotal {
633                per_unit: value, ..
634            } => Some(value),
635            // Compound's effective per-unit is (N*per_unit + total)/N —
636            // not derivable without units; same contract as raw Total.
637            Self::Total { .. } | Self::Compound { .. } => None,
638        }
639    }
640
641    /// Total value if the variant carries one ([`Self::Total`] or
642    /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::PerUnit`].
643    #[must_use]
644    pub fn total(&self) -> Option<&str> {
645        match self {
646            Self::Total { value } | Self::PerUnitFromTotal { total: value, .. } => Some(value),
647            // Compound's `total` field is only the lump component, not
648            // the whole cost — exposing it here would recreate the #1700
649            // mis-weighing in any consumer that treats it as the total.
650            Self::PerUnit { .. } | Self::Compound { .. } => None,
651        }
652    }
653}
654
655/// Cost data for serialization.
656///
657/// Represents cost specifications like `{100 USD}` or `{100 USD, 2024-01-01, "lot1"}`.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct CostData {
660    /// The numeric component: per-unit, total, or absent (e.g. `{}`).
661    ///
662    /// Pre-#1164 this was a pair of `Option<String>` fields
663    /// (`number_per` and `number_total`); see [`CostNumberData`] for
664    /// the rationale behind the consolidation.
665    pub number: Option<CostNumberData>,
666    /// Cost currency.
667    pub currency: Option<String>,
668    /// Acquisition date.
669    pub date: Option<String>,
670    /// Lot label.
671    pub label: Option<String>,
672    /// Merge lots flag.
673    pub merge: bool,
674}
675
676/// Price annotation data.
677///
678/// Represents price annotations like `@ 100 USD` or `@@ 1000 USD`
679/// (total price).
680///
681/// # Type-safe consumption (recommended)
682///
683/// Use [`PriceAnnotationData::view`] to get a [`PriceAnnotationView`]
684/// — a typed enum that forces consumers to handle `Unit` and `Total`
685/// arms exhaustively at compile time. **All new code that needs to
686/// distinguish per-unit from total prices MUST use `view()`** rather
687/// than reading `is_total` directly.
688///
689/// This struct is the wire format (kept for serialization stability
690/// across the WASM plugin boundary). The `view()` enum is a shaped
691/// accessor on top.
692///
693/// Pre-refactor (issue #992), the `implicit_prices` plugin read
694/// `posting.price.amount` directly and silently ignored `is_total`,
695/// emitting `@@` total amounts as per-unit prices. The fix in #997
696/// added explicit handling, but the type system didn't catch the bug
697/// originally because nothing forced consumers to read the bool. The
698/// `view()` enum closes that loop: a missing match arm is a compile
699/// error.
700#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct PriceAnnotationData {
702    /// Whether this is a total price (`@@`) vs per-unit (`@`).
703    ///
704    /// **Prefer [`PriceAnnotationData::view`] for new code** — reading
705    /// this field directly is the bug shape that produced #992
706    /// (consumer ignores the field and treats every annotation as
707    /// per-unit). The `view()` enum forces exhaustive handling at
708    /// compile time.
709    pub is_total: bool,
710    /// The price amount (optional for incomplete/empty prices).
711    pub amount: Option<AmountData>,
712    /// The number only (for incomplete prices).
713    pub number: Option<String>,
714    /// The currency only (for incomplete prices).
715    pub currency: Option<String>,
716}
717
718/// Typed view of a [`PriceAnnotationData`].
719///
720/// Each arm distinguishes per-unit (`@`) from total (`@@`) at the
721/// **type level**, so a `match` on the view forces consumers to
722/// handle both cases. This is the recommended way to consume price
723/// annotations — see the docstring on [`PriceAnnotationData`] for the
724/// motivating bug.
725#[derive(Debug, Clone, Copy)]
726pub enum PriceAnnotationView<'a> {
727    /// `@ AMOUNT` — per-unit price with a complete amount.
728    Unit(&'a AmountData),
729    /// `@@ AMOUNT` — total price with a complete amount.
730    ///
731    /// Consumers that compute prices MUST divide by the posting's
732    /// `units.number.abs()` to recover the per-unit price. See
733    /// `rustledger_core::extract_per_unit_price` (in the
734    /// `rustledger-core` crate; not linked because that crate is not a
735    /// dependency of `rustledger-plugin-types`).
736    Total(&'a AmountData),
737    /// `@ NUMBER` / `@ CURRENCY` — per-unit annotation missing one
738    /// or both of (number, currency).
739    UnitIncomplete {
740        /// The number, if present.
741        number: Option<&'a str>,
742        /// The currency, if present.
743        currency: Option<&'a str>,
744    },
745    /// `@@ NUMBER` / `@@ CURRENCY` — incomplete total annotation.
746    TotalIncomplete {
747        /// The number, if present.
748        number: Option<&'a str>,
749        /// The currency, if present.
750        currency: Option<&'a str>,
751    },
752}
753
754impl PriceAnnotationData {
755    /// Get a typed view that distinguishes per-unit from total at
756    /// the type level. **Use this for new code that needs to handle
757    /// the price differently based on `@` vs `@@`.**
758    ///
759    /// Returns one of four variants — a missing match arm at the
760    /// consumer becomes a compile error, eliminating the class of
761    /// bug that produced issue #992.
762    #[must_use]
763    pub fn view(&self) -> PriceAnnotationView<'_> {
764        match (self.is_total, &self.amount) {
765            (false, Some(a)) => PriceAnnotationView::Unit(a),
766            (true, Some(a)) => PriceAnnotationView::Total(a),
767            (false, None) => PriceAnnotationView::UnitIncomplete {
768                number: self.number.as_deref(),
769                currency: self.currency.as_deref(),
770            },
771            (true, None) => PriceAnnotationView::TotalIncomplete {
772                number: self.number.as_deref(),
773                currency: self.currency.as_deref(),
774            },
775        }
776    }
777}
778
779// ============================================================================
780// Metadata Types
781// ============================================================================
782
783/// Metadata value for serialization.
784///
785/// Metadata can hold various types of values, preserving type information
786/// for accurate round-tripping.
787#[derive(Debug, Clone, Serialize, Deserialize)]
788#[serde(tag = "type", content = "value")]
789pub enum MetaValueData {
790    /// String value.
791    #[serde(rename = "string")]
792    String(String),
793    /// Number value (as string to preserve precision).
794    #[serde(rename = "number")]
795    Number(String),
796    /// Date value (YYYY-MM-DD).
797    #[serde(rename = "date")]
798    Date(String),
799    /// Account reference.
800    #[serde(rename = "account")]
801    Account(String),
802    /// Currency reference.
803    #[serde(rename = "currency")]
804    Currency(String),
805    /// Tag reference.
806    #[serde(rename = "tag")]
807    Tag(String),
808    /// Link reference.
809    #[serde(rename = "link")]
810    Link(String),
811    /// Amount value.
812    #[serde(rename = "amount")]
813    Amount(AmountData),
814    /// Boolean value.
815    #[serde(rename = "bool")]
816    Bool(bool),
817}
818
819// ============================================================================
820// Other Directive Types
821// ============================================================================
822
823/// Balance assertion data.
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct BalanceData {
826    /// Account name.
827    pub account: String,
828    /// Expected balance.
829    pub amount: AmountData,
830    /// Tolerance for balance check.
831    pub tolerance: Option<String>,
832    /// Metadata key-value pairs.
833    #[serde(default)]
834    pub metadata: Vec<(String, MetaValueData)>,
835}
836
837/// Open account data.
838#[derive(Debug, Clone, Serialize, Deserialize)]
839pub struct OpenData {
840    /// Account name.
841    pub account: String,
842    /// Allowed currencies (empty means any currency).
843    pub currencies: Vec<String>,
844    /// Booking method (FIFO, LIFO, etc.).
845    pub booking: Option<String>,
846    /// Metadata key-value pairs.
847    #[serde(default)]
848    pub metadata: Vec<(String, MetaValueData)>,
849}
850
851/// Close account data.
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct CloseData {
854    /// Account name.
855    pub account: String,
856    /// Metadata key-value pairs.
857    #[serde(default)]
858    pub metadata: Vec<(String, MetaValueData)>,
859}
860
861/// Commodity declaration data.
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct CommodityData {
864    /// Currency code.
865    pub currency: String,
866    /// Metadata key-value pairs.
867    #[serde(default)]
868    pub metadata: Vec<(String, MetaValueData)>,
869}
870
871/// Pad directive data.
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct PadData {
874    /// Account to pad.
875    pub account: String,
876    /// Source account for padding.
877    pub source_account: String,
878    /// Metadata key-value pairs.
879    #[serde(default)]
880    pub metadata: Vec<(String, MetaValueData)>,
881}
882
883/// Event data.
884#[derive(Debug, Clone, Serialize, Deserialize)]
885pub struct EventData {
886    /// Event type.
887    pub event_type: String,
888    /// Event value.
889    pub value: String,
890    /// Metadata key-value pairs.
891    #[serde(default)]
892    pub metadata: Vec<(String, MetaValueData)>,
893}
894
895/// Note data.
896#[derive(Debug, Clone, Serialize, Deserialize)]
897pub struct NoteData {
898    /// Account name.
899    pub account: String,
900    /// Note comment.
901    pub comment: String,
902    /// Metadata key-value pairs.
903    #[serde(default)]
904    pub metadata: Vec<(String, MetaValueData)>,
905}
906
907/// Document data.
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct DocumentData {
910    /// Account name.
911    pub account: String,
912    /// Document path.
913    pub path: String,
914    /// Tags attached to the document directive. Added to core
915    /// `Document` in #1144; plumbed through the plugin layer in
916    /// #1214 (was previously dropped on both legs of the round-trip).
917    #[serde(default)]
918    pub tags: Vec<String>,
919    /// Links attached to the document directive (issue #1144).
920    #[serde(default)]
921    pub links: Vec<String>,
922    /// Metadata key-value pairs.
923    #[serde(default)]
924    pub metadata: Vec<(String, MetaValueData)>,
925}
926
927/// Price directive data.
928#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct PriceData {
930    /// Currency being priced.
931    pub currency: String,
932    /// Price amount.
933    pub amount: AmountData,
934    /// Metadata key-value pairs.
935    #[serde(default)]
936    pub metadata: Vec<(String, MetaValueData)>,
937}
938
939/// Query directive data.
940#[derive(Debug, Clone, Serialize, Deserialize)]
941pub struct QueryData {
942    /// Query name.
943    pub name: String,
944    /// Query string (BQL).
945    pub query: String,
946    /// Metadata key-value pairs.
947    #[serde(default)]
948    pub metadata: Vec<(String, MetaValueData)>,
949}
950
951/// Custom directive data.
952#[derive(Debug, Clone, Serialize, Deserialize)]
953pub struct CustomData {
954    /// Custom type (first value after `custom` keyword).
955    pub custom_type: String,
956    /// Values preserving their types.
957    pub values: Vec<MetaValueData>,
958    /// Metadata key-value pairs.
959    #[serde(default)]
960    pub metadata: Vec<(String, MetaValueData)>,
961}
962
963// ============================================================================
964// Importer ABI (wave 2.3: WASM-loaded importers)
965// ============================================================================
966//
967// These types are the wire format spoken between the rustledger host and
968// a WASM-loaded importer plugin (e.g. `rustledger-importer-mt940.wasm`).
969//
970// # Sandbox model
971//
972// WASM importers run in the same locked-down sandbox as directive plugins
973// (no filesystem, no network, no environment, no syscalls). The host reads
974// the source file and passes its bytes via [`ImporterInput::content`] —
975// the WASM importer does NOT open the file itself.
976//
977// # MessagePack contract
978//
979// All ABI types travel between host and guest as MessagePack-encoded byte
980// slices via `rmp_serde`. We use rmp-serde's **default positional struct
981// encoding** (compact arrays of values, no field names on the wire). This
982// is faster and smaller than map encoding at the cost of being strict
983// about field order.
984//
985// # Versioning
986//
987// We do not maintain wire-format backward compatibility. Any field
988// addition, removal, reorder, or type change is a major-version break
989// for the WASM ABI. Users of WASM importer modules are expected to
990// rebuild their importer against the host version they're targeting —
991// the host's ABI version (exposed via `wave-2.3 release notes`) is the
992// authoritative reference.
993//
994// Rationale: pre-v1.0 we ship structural changes freely; locking serde
995// `default`-tolerance into v1.0 would force every future ABI evolution
996// to be additive and live with a growing tail of compat shims. We'd
997// rather bump majors.
998
999/// Wire-format input passed from the host to a WASM importer's
1000/// `extract` / `extract_enriched` entry point.
1001///
1002/// # `options` design note
1003///
1004/// The `options` map is `String -> String`. Values that are
1005/// semantically numbers, booleans, or other types (e.g.
1006/// `skip_rows = 5`, `has_header = true`, `delimiter = ","`) are
1007/// string-encoded on the host side and parsed by the WASM importer.
1008/// This keeps the WASM ABI minimal (no `serde_json::Value` or `rmpv`
1009/// dep in the guest crate) at the cost of pushing string parsing into
1010/// every importer. A future additive field (`options_typed`) could
1011/// carry typed values if needed; not in v1.0 scope.
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1013pub struct ImporterInput {
1014    /// Source file path. Informational only — the WASM sandbox cannot
1015    /// open this. Used for diagnostics and fingerprint generation.
1016    pub path: String,
1017    /// File content bytes. The host reads the file and forwards the
1018    /// bytes here so the WASM importer doesn't need filesystem access.
1019    pub content: Vec<u8>,
1020    /// Target account for imported transactions
1021    /// (from `ImporterConfig.account`).
1022    pub account: String,
1023    /// Currency for amounts (from `ImporterConfig.currency`).
1024    pub currency: Option<String>,
1025    /// Free-form importer-specific options. The host serializes
1026    /// `importers.toml` entries' arbitrary fields into this map; the
1027    /// WASM importer reads the keys it knows about. Keeps the
1028    /// wire format independent of any host-side config struct shape.
1029    /// See the type-level doc for the string-encoding trade-off.
1030    pub options: std::collections::HashMap<String, String>,
1031}
1032
1033/// Wire-format input to a WASM importer's `identify` entry point.
1034///
1035/// The WASM importer answers "do I handle this file?" based on the
1036/// path (typically extension) alone — `extract` is the path that
1037/// gets file content.
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct IdentifyInput {
1040    /// Source file path. Informational only, same as
1041    /// [`ImporterInput::path`].
1042    pub path: String,
1043}
1044
1045/// Wire-format output from a WASM importer's `identify`.
1046#[derive(Debug, Clone, Serialize, Deserialize)]
1047pub struct IdentifyOutput {
1048    /// True if this importer handles the file at `IdentifyInput.path`.
1049    pub matches: bool,
1050}
1051
1052/// Wire-format output from a WASM importer's `metadata` entry point.
1053/// Returned once at load time and cached by the host registry — used
1054/// for `Importer::name()` and `Importer::description()` on the wrapper.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct MetadataOutput {
1057    /// Importer name (e.g. `"MT940"`, `"FinTS"`). Used by the registry
1058    /// for `find_by_name` lookups.
1059    pub name: String,
1060    /// Human-readable description for `--list-importers` and similar.
1061    pub description: String,
1062}
1063
1064/// Wire-format output returned from a WASM importer's `extract`.
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1066pub struct ImporterOutput {
1067    /// Extracted directives.
1068    pub directives: Vec<DirectiveWrapper>,
1069    /// Warnings encountered during extraction (non-fatal).
1070    pub warnings: Vec<String>,
1071    /// Fatal-but-recoverable errors (e.g. malformed individual rows
1072    /// the importer chose to skip rather than abort on). Distinct from
1073    /// `warnings` (informational) and from a WASM trap (which the host
1074    /// surfaces as an `anyhow::Error`). Reuses the existing
1075    /// [`PluginError`] shape so importer errors flow into the same
1076    /// `LedgerError::location` path as plugin errors.
1077    pub errors: Vec<PluginError>,
1078}
1079
1080impl ImporterOutput {
1081    /// Create an output with no warnings or errors.
1082    #[must_use]
1083    pub const fn new(directives: Vec<DirectiveWrapper>) -> Self {
1084        Self {
1085            directives,
1086            warnings: Vec::new(),
1087            errors: Vec::new(),
1088        }
1089    }
1090
1091    /// Empty result with no directives, no warnings, no errors.
1092    #[must_use]
1093    pub const fn empty() -> Self {
1094        Self {
1095            directives: Vec::new(),
1096            warnings: Vec::new(),
1097            errors: Vec::new(),
1098        }
1099    }
1100}
1101
1102/// Wire-format output returned from a WASM importer's
1103/// `extract_enriched`. Each directive is paired with per-directive
1104/// categorization metadata.
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106pub struct EnrichedImporterOutput {
1107    /// Directive–enrichment pairs, parallel to `ImporterOutput.directives`.
1108    pub entries: Vec<(DirectiveWrapper, EnrichmentWrapper)>,
1109    /// Warnings encountered during extraction (non-fatal).
1110    pub warnings: Vec<String>,
1111    /// Fatal-but-recoverable errors. Same semantics as
1112    /// [`ImporterOutput::errors`].
1113    pub errors: Vec<PluginError>,
1114}
1115
1116/// Wire-format counterpart to `rustledger_ops::enrichment::Enrichment`.
1117///
1118/// Kept here (rather than in `rustledger-ops`) so the importer ABI is
1119/// self-contained — WASM importers depend on `rustledger-plugin-types`
1120/// and shouldn't pull in the larger `rustledger-ops` graph just for an
1121/// enrichment definition. The host converts between the two shapes at
1122/// the trait boundary.
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124pub struct EnrichmentWrapper {
1125    /// Index of the directive this enrichment applies to (parallel to
1126    /// `EnrichedImporterOutput.entries`).
1127    pub directive_index: usize,
1128    /// Confidence score for the primary categorization (0.0 to 1.0).
1129    pub confidence: f64,
1130    /// How the primary categorization was determined. String-encoded
1131    /// to avoid pinning the `CategorizationMethod` enum's exact variant
1132    /// set into the wire format. Must match
1133    /// `CategorizationMethod::as_meta_value()` in `rustledger-ops`:
1134    /// `"rule"`, `"merchant-dict"`, `"ml"`, `"llm"`, `"default"`,
1135    /// `"manual"`. (Note: `merchant-dict` uses a hyphen, not an
1136    /// underscore — the host string-matches against
1137    /// `as_meta_value()`'s output, so the wire format must agree.)
1138    pub method: String,
1139    /// Other possible categorizations, sorted by confidence descending.
1140    pub alternatives: Vec<AlternativeWrapper>,
1141    /// Stable fingerprint for deduplication, serialized as a hex string.
1142    pub fingerprint: Option<String>,
1143}
1144
1145/// Wire-format counterpart to `rustledger_ops::enrichment::Alternative`.
1146#[derive(Debug, Clone, Serialize, Deserialize)]
1147pub struct AlternativeWrapper {
1148    /// Account this alternative would assign.
1149    pub account: String,
1150    /// Confidence score for this alternative (0.0 to 1.0).
1151    pub confidence: f64,
1152    /// How this alternative was determined. Same encoding rules as
1153    /// [`EnrichmentWrapper::method`].
1154    pub method: String,
1155}
1156
1157// ============================================================================
1158// Utility Functions
1159// ============================================================================
1160
1161/// Sort directives using beancount's standard ordering.
1162///
1163/// This matches Python beancount's `entry_sortkey()`:
1164/// 1. Primary: date
1165/// 2. Secondary: directive type (Open, Balance, default, Document, Close)
1166/// 3. Tertiary: line number (preserves file order for same-date, same-type entries)
1167pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
1168    directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    #[test]
1176    fn op_coverage_accepts_complete_cover_and_rejects_violations() {
1177        use PluginOp::{Delete, Keep};
1178        // Every input index covered exactly once.
1179        assert!(validate_op_coverage(3, &[Keep(0), Delete(1), Keep(2)]).is_ok());
1180        // No input, no ops: trivially complete.
1181        assert!(validate_op_coverage(0, &[]).is_ok());
1182        // Out-of-bounds index.
1183        assert!(
1184            validate_op_coverage(2, &[Keep(0), Keep(2)])
1185                .unwrap_err()
1186                .contains("out-of-bounds")
1187        );
1188        // Same index referenced twice.
1189        assert!(
1190            validate_op_coverage(2, &[Keep(0), Delete(0)])
1191                .unwrap_err()
1192                .contains("more than once")
1193        );
1194        // An input directive omitted entirely.
1195        assert!(
1196            validate_op_coverage(2, &[Keep(0)])
1197                .unwrap_err()
1198                .contains("omitted")
1199        );
1200    }
1201
1202    #[test]
1203    fn test_plugin_error_builder() {
1204        let error = PluginError::error("test error").at("file.beancount", 10);
1205        assert_eq!(error.message, "test error");
1206        assert_eq!(error.source_file, Some("file.beancount".to_string()));
1207        assert_eq!(error.line_number, Some(10));
1208        assert_eq!(error.severity, PluginErrorSeverity::Error);
1209    }
1210
1211    #[test]
1212    fn test_plugin_warning() {
1213        let warning = PluginError::warning("test warning");
1214        assert_eq!(warning.severity, PluginErrorSeverity::Warning);
1215    }
1216
1217    #[test]
1218    fn test_directive_sort_order() {
1219        let open = DirectiveWrapper {
1220            directive_type: String::new(),
1221            date: "2024-01-01".to_string(),
1222            filename: None,
1223            lineno: Some(1),
1224            data: DirectiveData::Open(OpenData {
1225                account: "Assets:Bank".to_string(),
1226                currencies: vec![],
1227                booking: None,
1228                metadata: vec![],
1229            }),
1230        };
1231        assert_eq!(open.type_sort_order(), -2);
1232
1233        let close = DirectiveWrapper {
1234            directive_type: String::new(),
1235            date: "2024-01-01".to_string(),
1236            filename: None,
1237            lineno: Some(2),
1238            data: DirectiveData::Close(CloseData {
1239                account: "Assets:Bank".to_string(),
1240                metadata: vec![],
1241            }),
1242        };
1243        assert_eq!(close.type_sort_order(), 2);
1244    }
1245
1246    #[test]
1247    fn test_serde_roundtrip() {
1248        let input = PluginInput {
1249            directives: vec![DirectiveWrapper {
1250                directive_type: String::new(),
1251                date: "2024-01-15".to_string(),
1252                filename: Some("test.beancount".to_string()),
1253                lineno: Some(42),
1254                data: DirectiveData::Transaction(TransactionData {
1255                    flag: "*".to_string(),
1256                    payee: Some("Coffee Shop".to_string()),
1257                    narration: "Morning coffee".to_string(),
1258                    tags: vec!["food".to_string()],
1259                    links: vec![],
1260                    metadata: vec![],
1261                    postings: vec![PostingData {
1262                        account: "Expenses:Food".to_string(),
1263                        units: Some(AmountData {
1264                            number: "5.00".to_string(),
1265                            currency: "USD".to_string(),
1266                        }),
1267                        cost: None,
1268                        price: None,
1269                        flag: None,
1270                        metadata: vec![],
1271                        span: None,
1272                    }],
1273                }),
1274            }],
1275            options: PluginOptions {
1276                operating_currencies: vec!["USD".to_string()],
1277                title: Some("Test Ledger".to_string()),
1278            },
1279            config: Some("threshold=100".to_string()),
1280        };
1281
1282        // Test JSON roundtrip
1283        let json = serde_json::to_string(&input).unwrap();
1284        let decoded: PluginInput = serde_json::from_str(&json).unwrap();
1285        assert_eq!(decoded.directives.len(), 1);
1286        assert_eq!(decoded.config, Some("threshold=100".to_string()));
1287
1288        // Test MessagePack roundtrip
1289        let msgpack = rmp_serde::to_vec(&input).unwrap();
1290        let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
1291        assert_eq!(decoded.directives.len(), 1);
1292    }
1293
1294    // ===== PriceAnnotationData::view() — all four arms =====
1295    //
1296    // The view() enum is the type-safe interface that prevents the
1297    // #992 bug shape (consumer ignoring the is_total discriminator).
1298    // These tests pin the mapping from (is_total, amount) to each
1299    // PriceAnnotationView variant so a refactor of the underlying
1300    // struct can't silently change the dispatch.
1301
1302    fn amount(number: &str, currency: &str) -> AmountData {
1303        AmountData {
1304            number: number.to_string(),
1305            currency: currency.to_string(),
1306        }
1307    }
1308
1309    #[test]
1310    fn view_unit_complete() {
1311        // `@ 1.40 EUR`
1312        let pad = PriceAnnotationData {
1313            is_total: false,
1314            amount: Some(amount("1.40", "EUR")),
1315            number: None,
1316            currency: None,
1317        };
1318        match pad.view() {
1319            PriceAnnotationView::Unit(a) => {
1320                assert_eq!(a.number, "1.40");
1321                assert_eq!(a.currency, "EUR");
1322            }
1323            other => panic!("expected Unit, got {other:?}"),
1324        }
1325    }
1326
1327    #[test]
1328    fn view_total_complete() {
1329        // `@@ 1500 USD`
1330        let pad = PriceAnnotationData {
1331            is_total: true,
1332            amount: Some(amount("1500", "USD")),
1333            number: None,
1334            currency: None,
1335        };
1336        match pad.view() {
1337            PriceAnnotationView::Total(a) => {
1338                assert_eq!(a.number, "1500");
1339                assert_eq!(a.currency, "USD");
1340            }
1341            other => panic!("expected Total, got {other:?}"),
1342        }
1343    }
1344
1345    #[test]
1346    fn view_unit_incomplete_number_only() {
1347        // `@ 1.40` — number but no currency
1348        let pad = PriceAnnotationData {
1349            is_total: false,
1350            amount: None,
1351            number: Some("1.40".to_string()),
1352            currency: None,
1353        };
1354        match pad.view() {
1355            PriceAnnotationView::UnitIncomplete { number, currency } => {
1356                assert_eq!(number, Some("1.40"));
1357                assert_eq!(currency, None);
1358            }
1359            other => panic!("expected UnitIncomplete, got {other:?}"),
1360        }
1361    }
1362
1363    #[test]
1364    fn view_unit_incomplete_currency_only() {
1365        // `@ EUR` — currency but no number
1366        let pad = PriceAnnotationData {
1367            is_total: false,
1368            amount: None,
1369            number: None,
1370            currency: Some("EUR".to_string()),
1371        };
1372        match pad.view() {
1373            PriceAnnotationView::UnitIncomplete { number, currency } => {
1374                assert_eq!(number, None);
1375                assert_eq!(currency, Some("EUR"));
1376            }
1377            other => panic!("expected UnitIncomplete, got {other:?}"),
1378        }
1379    }
1380
1381    #[test]
1382    fn view_unit_incomplete_neither() {
1383        // `@` — bare annotation, neither number nor currency
1384        let pad = PriceAnnotationData {
1385            is_total: false,
1386            amount: None,
1387            number: None,
1388            currency: None,
1389        };
1390        match pad.view() {
1391            PriceAnnotationView::UnitIncomplete { number, currency } => {
1392                assert_eq!(number, None);
1393                assert_eq!(currency, None);
1394            }
1395            other => panic!("expected UnitIncomplete, got {other:?}"),
1396        }
1397    }
1398
1399    #[test]
1400    fn view_total_incomplete_number_only() {
1401        // `@@ 1500`
1402        let pad = PriceAnnotationData {
1403            is_total: true,
1404            amount: None,
1405            number: Some("1500".to_string()),
1406            currency: None,
1407        };
1408        match pad.view() {
1409            PriceAnnotationView::TotalIncomplete { number, currency } => {
1410                assert_eq!(number, Some("1500"));
1411                assert_eq!(currency, None);
1412            }
1413            other => panic!("expected TotalIncomplete, got {other:?}"),
1414        }
1415    }
1416
1417    #[test]
1418    fn view_total_incomplete_currency_only() {
1419        // `@@ USD`
1420        let pad = PriceAnnotationData {
1421            is_total: true,
1422            amount: None,
1423            number: None,
1424            currency: Some("USD".to_string()),
1425        };
1426        match pad.view() {
1427            PriceAnnotationView::TotalIncomplete { number, currency } => {
1428                assert_eq!(number, None);
1429                assert_eq!(currency, Some("USD"));
1430            }
1431            other => panic!("expected TotalIncomplete, got {other:?}"),
1432        }
1433    }
1434
1435    #[test]
1436    fn view_total_incomplete_neither() {
1437        // `@@` — bare total annotation
1438        let pad = PriceAnnotationData {
1439            is_total: true,
1440            amount: None,
1441            number: None,
1442            currency: None,
1443        };
1444        match pad.view() {
1445            PriceAnnotationView::TotalIncomplete { number, currency } => {
1446                assert_eq!(number, None);
1447                assert_eq!(currency, None);
1448            }
1449            other => panic!("expected TotalIncomplete, got {other:?}"),
1450        }
1451    }
1452
1453    #[test]
1454    fn view_amount_present_takes_priority_over_number_currency_fields() {
1455        // If both `amount` AND the loose `number`/`currency` fields
1456        // are set, `amount` wins — view() returns Unit/Total, never
1457        // an Incomplete variant. This pins the precedence so a
1458        // future field-juggling refactor can't accidentally invert
1459        // it.
1460        let pad = PriceAnnotationData {
1461            is_total: false,
1462            amount: Some(amount("1.40", "EUR")),
1463            number: Some("99".to_string()),    // ignored
1464            currency: Some("XYZ".to_string()), // ignored
1465        };
1466        match pad.view() {
1467            PriceAnnotationView::Unit(a) => {
1468                assert_eq!(a.number, "1.40");
1469                assert_eq!(a.currency, "EUR");
1470            }
1471            other => panic!("expected Unit, got {other:?}"),
1472        }
1473    }
1474
1475    // ===== Importer ABI round-trip tests =====
1476    //
1477    // Pin the MessagePack-roundtrip shape of the WASM importer wire
1478    // format. If any field is renamed, removed, or its type changes,
1479    // these tests catch it — that's a v1.0 ABI breakage we want to
1480    // notice at code-change time.
1481
1482    #[test]
1483    fn importer_input_msgpack_roundtrip() {
1484        let mut options = std::collections::HashMap::new();
1485        options.insert("date_column".to_string(), "Date".to_string());
1486        options.insert("delimiter".to_string(), ",".to_string());
1487
1488        let original = ImporterInput {
1489            path: "/path/to/foo.csv".to_string(),
1490            content: vec![0xDE, 0xAD, 0xBE, 0xEF],
1491            account: "Assets:Bank".to_string(),
1492            currency: Some("USD".to_string()),
1493            options,
1494        };
1495        let bytes = rmp_serde::to_vec(&original).unwrap();
1496        let decoded: ImporterInput = rmp_serde::from_slice(&bytes).unwrap();
1497        assert_eq!(decoded.path, original.path);
1498        assert_eq!(decoded.content, original.content);
1499        assert_eq!(decoded.account, original.account);
1500        assert_eq!(decoded.currency, original.currency);
1501        assert_eq!(decoded.options, original.options);
1502    }
1503
1504    #[test]
1505    fn importer_output_msgpack_roundtrip_empty() {
1506        let original = ImporterOutput::empty();
1507        let bytes = rmp_serde::to_vec(&original).unwrap();
1508        let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1509        assert!(decoded.directives.is_empty());
1510        assert!(decoded.warnings.is_empty());
1511    }
1512
1513    #[test]
1514    fn importer_output_msgpack_roundtrip_with_warning() {
1515        let mut out = ImporterOutput::new(vec![]);
1516        out.warnings.push("Skipped row 3: bad date".to_string());
1517        let bytes = rmp_serde::to_vec(&out).unwrap();
1518        let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1519        assert_eq!(decoded.warnings.len(), 1);
1520        assert!(decoded.warnings[0].contains("bad date"));
1521    }
1522
1523    #[test]
1524    fn enrichment_wrapper_msgpack_roundtrip() {
1525        let original = EnrichmentWrapper {
1526            directive_index: 7,
1527            confidence: 0.85,
1528            method: "rule".to_string(),
1529            alternatives: vec![AlternativeWrapper {
1530                account: "Expenses:Groceries".to_string(),
1531                confidence: 0.75,
1532                method: "merchant-dict".to_string(),
1533            }],
1534            fingerprint: Some("abc123def456".to_string()),
1535        };
1536        let bytes = rmp_serde::to_vec(&original).unwrap();
1537        let decoded: EnrichmentWrapper = rmp_serde::from_slice(&bytes).unwrap();
1538        assert_eq!(decoded.directive_index, original.directive_index);
1539        assert!((decoded.confidence - original.confidence).abs() < f64::EPSILON);
1540        assert_eq!(decoded.method, original.method);
1541        assert_eq!(decoded.alternatives.len(), 1);
1542        assert_eq!(decoded.alternatives[0].account, "Expenses:Groceries");
1543        // Every field on AlternativeWrapper must round-trip — if any drift
1544        // silently (renamed / dropped / type-changed) we want to catch it
1545        // here, not at the WASM boundary where it'd corrupt enriched results.
1546        assert!(
1547            (decoded.alternatives[0].confidence - 0.75).abs() < f64::EPSILON,
1548            "alternative confidence must round-trip exactly"
1549        );
1550        assert_eq!(decoded.alternatives[0].method, "merchant-dict");
1551        assert_eq!(decoded.fingerprint, original.fingerprint);
1552    }
1553
1554    #[test]
1555    fn enriched_importer_output_msgpack_roundtrip() {
1556        // Cover the more complex enriched variant — pair of
1557        // (DirectiveWrapper, EnrichmentWrapper) with metadata,
1558        // plus warnings + errors. Asserts every field individually.
1559        let dir = DirectiveWrapper {
1560            directive_type: "transaction".to_string(),
1561            date: "2024-01-15".to_string(),
1562            filename: Some("/tmp/foo.csv".to_string()),
1563            lineno: Some(7),
1564            data: DirectiveData::Transaction(TransactionData {
1565                flag: "*".to_string(),
1566                payee: Some("Whole Foods".to_string()),
1567                narration: "Groceries".to_string(),
1568                tags: vec![],
1569                links: vec![],
1570                metadata: vec![],
1571                postings: vec![],
1572            }),
1573        };
1574        let enr = EnrichmentWrapper {
1575            directive_index: 0,
1576            confidence: 0.92,
1577            method: "rule".to_string(),
1578            alternatives: vec![AlternativeWrapper {
1579                account: "Expenses:Other".to_string(),
1580                confidence: 0.10,
1581                method: "default".to_string(),
1582            }],
1583            fingerprint: Some("dead-beef".to_string()),
1584        };
1585        let original = EnrichedImporterOutput {
1586            entries: vec![(dir, enr)],
1587            warnings: vec!["row 3 skipped".to_string()],
1588            errors: vec![PluginError::error("row 4 unparsable").at("/tmp/foo.csv", 4)],
1589        };
1590        let bytes = rmp_serde::to_vec(&original).unwrap();
1591        let decoded: EnrichedImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1592        assert_eq!(decoded.entries.len(), 1);
1593        let (dir, enr) = &decoded.entries[0];
1594        // `directive_type` is intentionally `#[serde(skip_serializing, default)]`
1595        // on `DirectiveWrapper` — derived from the `data` variant, not on the
1596        // wire. Don't assert it here.
1597        assert_eq!(dir.date, "2024-01-15");
1598        match &dir.data {
1599            DirectiveData::Transaction(t) => {
1600                assert_eq!(t.payee.as_deref(), Some("Whole Foods"));
1601                assert_eq!(t.narration, "Groceries");
1602            }
1603            other => panic!("expected Transaction, got {other:?}"),
1604        }
1605        assert_eq!(enr.directive_index, 0);
1606        assert!((enr.confidence - 0.92).abs() < f64::EPSILON);
1607        assert_eq!(enr.method, "rule");
1608        assert_eq!(enr.alternatives.len(), 1);
1609        assert_eq!(enr.alternatives[0].method, "default");
1610        assert_eq!(enr.fingerprint, Some("dead-beef".to_string()));
1611        assert_eq!(decoded.warnings, vec!["row 3 skipped".to_string()]);
1612        assert_eq!(decoded.errors.len(), 1);
1613        assert_eq!(decoded.errors[0].message, "row 4 unparsable");
1614        assert_eq!(
1615            decoded.errors[0].source_file,
1616            Some("/tmp/foo.csv".to_string())
1617        );
1618        assert_eq!(decoded.errors[0].line_number, Some(4));
1619    }
1620
1621    #[test]
1622    fn identify_input_output_msgpack_roundtrip() {
1623        let input = IdentifyInput {
1624            path: "/tmp/statement.mt940".to_string(),
1625        };
1626        let input_bytes = rmp_serde::to_vec(&input).unwrap();
1627        let decoded_input: IdentifyInput = rmp_serde::from_slice(&input_bytes).unwrap();
1628        assert_eq!(decoded_input.path, input.path);
1629
1630        let output = IdentifyOutput { matches: true };
1631        let output_bytes = rmp_serde::to_vec(&output).unwrap();
1632        let decoded_output: IdentifyOutput = rmp_serde::from_slice(&output_bytes).unwrap();
1633        assert!(decoded_output.matches);
1634    }
1635
1636    #[test]
1637    fn metadata_output_msgpack_roundtrip() {
1638        let original = MetadataOutput {
1639            name: "MT940".to_string(),
1640            description: "SWIFT MT940 bank statement importer".to_string(),
1641        };
1642        let bytes = rmp_serde::to_vec(&original).unwrap();
1643        let decoded: MetadataOutput = rmp_serde::from_slice(&bytes).unwrap();
1644        assert_eq!(decoded.name, original.name);
1645        assert_eq!(decoded.description, original.description);
1646    }
1647}