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 /// Post-booking derived per-unit with the original total preserved.
603 /// `per_unit == total / |units|` by host construction; preferring
604 /// `total` for cost-basis-style reads avoids the
605 /// division-then-multiplication precision loss that hits the
606 /// `rust_decimal` 28-digit ceiling on long ledgers.
607 PerUnitFromTotal {
608 /// Derived per-unit value.
609 per_unit: String,
610 /// Original `{{ total }}` as written.
611 total: String,
612 },
613}
614
615impl CostNumberData {
616 /// Per-unit value if the variant carries one ([`Self::PerUnit`] or
617 /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::Total`].
618 #[must_use]
619 pub fn per_unit(&self) -> Option<&str> {
620 match self {
621 Self::PerUnit { value }
622 | Self::PerUnitFromTotal {
623 per_unit: value, ..
624 } => Some(value),
625 Self::Total { .. } => None,
626 }
627 }
628
629 /// Total value if the variant carries one ([`Self::Total`] or
630 /// [`Self::PerUnitFromTotal`]); `None` for raw [`Self::PerUnit`].
631 #[must_use]
632 pub fn total(&self) -> Option<&str> {
633 match self {
634 Self::Total { value } | Self::PerUnitFromTotal { total: value, .. } => Some(value),
635 Self::PerUnit { .. } => None,
636 }
637 }
638}
639
640/// Cost data for serialization.
641///
642/// Represents cost specifications like `{100 USD}` or `{100 USD, 2024-01-01, "lot1"}`.
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct CostData {
645 /// The numeric component: per-unit, total, or absent (e.g. `{}`).
646 ///
647 /// Pre-#1164 this was a pair of `Option<String>` fields
648 /// (`number_per` and `number_total`); see [`CostNumberData`] for
649 /// the rationale behind the consolidation.
650 pub number: Option<CostNumberData>,
651 /// Cost currency.
652 pub currency: Option<String>,
653 /// Acquisition date.
654 pub date: Option<String>,
655 /// Lot label.
656 pub label: Option<String>,
657 /// Merge lots flag.
658 pub merge: bool,
659}
660
661/// Price annotation data.
662///
663/// Represents price annotations like `@ 100 USD` or `@@ 1000 USD`
664/// (total price).
665///
666/// # Type-safe consumption (recommended)
667///
668/// Use [`PriceAnnotationData::view`] to get a [`PriceAnnotationView`]
669/// — a typed enum that forces consumers to handle `Unit` and `Total`
670/// arms exhaustively at compile time. **All new code that needs to
671/// distinguish per-unit from total prices MUST use `view()`** rather
672/// than reading `is_total` directly.
673///
674/// This struct is the wire format (kept for serialization stability
675/// across the WASM plugin boundary). The `view()` enum is a shaped
676/// accessor on top.
677///
678/// Pre-refactor (issue #992), the `implicit_prices` plugin read
679/// `posting.price.amount` directly and silently ignored `is_total`,
680/// emitting `@@` total amounts as per-unit prices. The fix in #997
681/// added explicit handling, but the type system didn't catch the bug
682/// originally because nothing forced consumers to read the bool. The
683/// `view()` enum closes that loop: a missing match arm is a compile
684/// error.
685#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct PriceAnnotationData {
687 /// Whether this is a total price (`@@`) vs per-unit (`@`).
688 ///
689 /// **Prefer [`PriceAnnotationData::view`] for new code** — reading
690 /// this field directly is the bug shape that produced #992
691 /// (consumer ignores the field and treats every annotation as
692 /// per-unit). The `view()` enum forces exhaustive handling at
693 /// compile time.
694 pub is_total: bool,
695 /// The price amount (optional for incomplete/empty prices).
696 pub amount: Option<AmountData>,
697 /// The number only (for incomplete prices).
698 pub number: Option<String>,
699 /// The currency only (for incomplete prices).
700 pub currency: Option<String>,
701}
702
703/// Typed view of a [`PriceAnnotationData`].
704///
705/// Each arm distinguishes per-unit (`@`) from total (`@@`) at the
706/// **type level**, so a `match` on the view forces consumers to
707/// handle both cases. This is the recommended way to consume price
708/// annotations — see the docstring on [`PriceAnnotationData`] for the
709/// motivating bug.
710#[derive(Debug, Clone, Copy)]
711pub enum PriceAnnotationView<'a> {
712 /// `@ AMOUNT` — per-unit price with a complete amount.
713 Unit(&'a AmountData),
714 /// `@@ AMOUNT` — total price with a complete amount.
715 ///
716 /// Consumers that compute prices MUST divide by the posting's
717 /// `units.number.abs()` to recover the per-unit price. See
718 /// `rustledger_core::extract_per_unit_price` (in the
719 /// `rustledger-core` crate; not linked because that crate is not a
720 /// dependency of `rustledger-plugin-types`).
721 Total(&'a AmountData),
722 /// `@ NUMBER` / `@ CURRENCY` — per-unit annotation missing one
723 /// or both of (number, currency).
724 UnitIncomplete {
725 /// The number, if present.
726 number: Option<&'a str>,
727 /// The currency, if present.
728 currency: Option<&'a str>,
729 },
730 /// `@@ NUMBER` / `@@ CURRENCY` — incomplete total annotation.
731 TotalIncomplete {
732 /// The number, if present.
733 number: Option<&'a str>,
734 /// The currency, if present.
735 currency: Option<&'a str>,
736 },
737}
738
739impl PriceAnnotationData {
740 /// Get a typed view that distinguishes per-unit from total at
741 /// the type level. **Use this for new code that needs to handle
742 /// the price differently based on `@` vs `@@`.**
743 ///
744 /// Returns one of four variants — a missing match arm at the
745 /// consumer becomes a compile error, eliminating the class of
746 /// bug that produced issue #992.
747 #[must_use]
748 pub fn view(&self) -> PriceAnnotationView<'_> {
749 match (self.is_total, &self.amount) {
750 (false, Some(a)) => PriceAnnotationView::Unit(a),
751 (true, Some(a)) => PriceAnnotationView::Total(a),
752 (false, None) => PriceAnnotationView::UnitIncomplete {
753 number: self.number.as_deref(),
754 currency: self.currency.as_deref(),
755 },
756 (true, None) => PriceAnnotationView::TotalIncomplete {
757 number: self.number.as_deref(),
758 currency: self.currency.as_deref(),
759 },
760 }
761 }
762}
763
764// ============================================================================
765// Metadata Types
766// ============================================================================
767
768/// Metadata value for serialization.
769///
770/// Metadata can hold various types of values, preserving type information
771/// for accurate round-tripping.
772#[derive(Debug, Clone, Serialize, Deserialize)]
773#[serde(tag = "type", content = "value")]
774pub enum MetaValueData {
775 /// String value.
776 #[serde(rename = "string")]
777 String(String),
778 /// Number value (as string to preserve precision).
779 #[serde(rename = "number")]
780 Number(String),
781 /// Date value (YYYY-MM-DD).
782 #[serde(rename = "date")]
783 Date(String),
784 /// Account reference.
785 #[serde(rename = "account")]
786 Account(String),
787 /// Currency reference.
788 #[serde(rename = "currency")]
789 Currency(String),
790 /// Tag reference.
791 #[serde(rename = "tag")]
792 Tag(String),
793 /// Link reference.
794 #[serde(rename = "link")]
795 Link(String),
796 /// Amount value.
797 #[serde(rename = "amount")]
798 Amount(AmountData),
799 /// Boolean value.
800 #[serde(rename = "bool")]
801 Bool(bool),
802}
803
804// ============================================================================
805// Other Directive Types
806// ============================================================================
807
808/// Balance assertion data.
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct BalanceData {
811 /// Account name.
812 pub account: String,
813 /// Expected balance.
814 pub amount: AmountData,
815 /// Tolerance for balance check.
816 pub tolerance: Option<String>,
817 /// Metadata key-value pairs.
818 #[serde(default)]
819 pub metadata: Vec<(String, MetaValueData)>,
820}
821
822/// Open account data.
823#[derive(Debug, Clone, Serialize, Deserialize)]
824pub struct OpenData {
825 /// Account name.
826 pub account: String,
827 /// Allowed currencies (empty means any currency).
828 pub currencies: Vec<String>,
829 /// Booking method (FIFO, LIFO, etc.).
830 pub booking: Option<String>,
831 /// Metadata key-value pairs.
832 #[serde(default)]
833 pub metadata: Vec<(String, MetaValueData)>,
834}
835
836/// Close account data.
837#[derive(Debug, Clone, Serialize, Deserialize)]
838pub struct CloseData {
839 /// Account name.
840 pub account: String,
841 /// Metadata key-value pairs.
842 #[serde(default)]
843 pub metadata: Vec<(String, MetaValueData)>,
844}
845
846/// Commodity declaration data.
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct CommodityData {
849 /// Currency code.
850 pub currency: String,
851 /// Metadata key-value pairs.
852 #[serde(default)]
853 pub metadata: Vec<(String, MetaValueData)>,
854}
855
856/// Pad directive data.
857#[derive(Debug, Clone, Serialize, Deserialize)]
858pub struct PadData {
859 /// Account to pad.
860 pub account: String,
861 /// Source account for padding.
862 pub source_account: String,
863 /// Metadata key-value pairs.
864 #[serde(default)]
865 pub metadata: Vec<(String, MetaValueData)>,
866}
867
868/// Event data.
869#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct EventData {
871 /// Event type.
872 pub event_type: String,
873 /// Event value.
874 pub value: String,
875 /// Metadata key-value pairs.
876 #[serde(default)]
877 pub metadata: Vec<(String, MetaValueData)>,
878}
879
880/// Note data.
881#[derive(Debug, Clone, Serialize, Deserialize)]
882pub struct NoteData {
883 /// Account name.
884 pub account: String,
885 /// Note comment.
886 pub comment: String,
887 /// Metadata key-value pairs.
888 #[serde(default)]
889 pub metadata: Vec<(String, MetaValueData)>,
890}
891
892/// Document data.
893#[derive(Debug, Clone, Serialize, Deserialize)]
894pub struct DocumentData {
895 /// Account name.
896 pub account: String,
897 /// Document path.
898 pub path: String,
899 /// Tags attached to the document directive. Added to core
900 /// `Document` in #1144; plumbed through the plugin layer in
901 /// #1214 (was previously dropped on both legs of the round-trip).
902 #[serde(default)]
903 pub tags: Vec<String>,
904 /// Links attached to the document directive (issue #1144).
905 #[serde(default)]
906 pub links: Vec<String>,
907 /// Metadata key-value pairs.
908 #[serde(default)]
909 pub metadata: Vec<(String, MetaValueData)>,
910}
911
912/// Price directive data.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct PriceData {
915 /// Currency being priced.
916 pub currency: String,
917 /// Price amount.
918 pub amount: AmountData,
919 /// Metadata key-value pairs.
920 #[serde(default)]
921 pub metadata: Vec<(String, MetaValueData)>,
922}
923
924/// Query directive data.
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct QueryData {
927 /// Query name.
928 pub name: String,
929 /// Query string (BQL).
930 pub query: String,
931 /// Metadata key-value pairs.
932 #[serde(default)]
933 pub metadata: Vec<(String, MetaValueData)>,
934}
935
936/// Custom directive data.
937#[derive(Debug, Clone, Serialize, Deserialize)]
938pub struct CustomData {
939 /// Custom type (first value after `custom` keyword).
940 pub custom_type: String,
941 /// Values preserving their types.
942 pub values: Vec<MetaValueData>,
943 /// Metadata key-value pairs.
944 #[serde(default)]
945 pub metadata: Vec<(String, MetaValueData)>,
946}
947
948// ============================================================================
949// Importer ABI (wave 2.3: WASM-loaded importers)
950// ============================================================================
951//
952// These types are the wire format spoken between the rustledger host and
953// a WASM-loaded importer plugin (e.g. `rustledger-importer-mt940.wasm`).
954//
955// # Sandbox model
956//
957// WASM importers run in the same locked-down sandbox as directive plugins
958// (no filesystem, no network, no environment, no syscalls). The host reads
959// the source file and passes its bytes via [`ImporterInput::content`] —
960// the WASM importer does NOT open the file itself.
961//
962// # MessagePack contract
963//
964// All ABI types travel between host and guest as MessagePack-encoded byte
965// slices via `rmp_serde`. We use rmp-serde's **default positional struct
966// encoding** (compact arrays of values, no field names on the wire). This
967// is faster and smaller than map encoding at the cost of being strict
968// about field order.
969//
970// # Versioning
971//
972// We do not maintain wire-format backward compatibility. Any field
973// addition, removal, reorder, or type change is a major-version break
974// for the WASM ABI. Users of WASM importer modules are expected to
975// rebuild their importer against the host version they're targeting —
976// the host's ABI version (exposed via `wave-2.3 release notes`) is the
977// authoritative reference.
978//
979// Rationale: pre-v1.0 we ship structural changes freely; locking serde
980// `default`-tolerance into v1.0 would force every future ABI evolution
981// to be additive and live with a growing tail of compat shims. We'd
982// rather bump majors.
983
984/// Wire-format input passed from the host to a WASM importer's
985/// `extract` / `extract_enriched` entry point.
986///
987/// # `options` design note
988///
989/// The `options` map is `String -> String`. Values that are
990/// semantically numbers, booleans, or other types (e.g.
991/// `skip_rows = 5`, `has_header = true`, `delimiter = ","`) are
992/// string-encoded on the host side and parsed by the WASM importer.
993/// This keeps the WASM ABI minimal (no `serde_json::Value` or `rmpv`
994/// dep in the guest crate) at the cost of pushing string parsing into
995/// every importer. A future additive field (`options_typed`) could
996/// carry typed values if needed; not in v1.0 scope.
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct ImporterInput {
999 /// Source file path. Informational only — the WASM sandbox cannot
1000 /// open this. Used for diagnostics and fingerprint generation.
1001 pub path: String,
1002 /// File content bytes. The host reads the file and forwards the
1003 /// bytes here so the WASM importer doesn't need filesystem access.
1004 pub content: Vec<u8>,
1005 /// Target account for imported transactions
1006 /// (from `ImporterConfig.account`).
1007 pub account: String,
1008 /// Currency for amounts (from `ImporterConfig.currency`).
1009 pub currency: Option<String>,
1010 /// Free-form importer-specific options. The host serializes
1011 /// `importers.toml` entries' arbitrary fields into this map; the
1012 /// WASM importer reads the keys it knows about. Keeps the
1013 /// wire format independent of any host-side config struct shape.
1014 /// See the type-level doc for the string-encoding trade-off.
1015 pub options: std::collections::HashMap<String, String>,
1016}
1017
1018/// Wire-format input to a WASM importer's `identify` entry point.
1019///
1020/// The WASM importer answers "do I handle this file?" based on the
1021/// path (typically extension) alone — `extract` is the path that
1022/// gets file content.
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct IdentifyInput {
1025 /// Source file path. Informational only, same as
1026 /// [`ImporterInput::path`].
1027 pub path: String,
1028}
1029
1030/// Wire-format output from a WASM importer's `identify`.
1031#[derive(Debug, Clone, Serialize, Deserialize)]
1032pub struct IdentifyOutput {
1033 /// True if this importer handles the file at `IdentifyInput.path`.
1034 pub matches: bool,
1035}
1036
1037/// Wire-format output from a WASM importer's `metadata` entry point.
1038/// Returned once at load time and cached by the host registry — used
1039/// for `Importer::name()` and `Importer::description()` on the wrapper.
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1041pub struct MetadataOutput {
1042 /// Importer name (e.g. `"MT940"`, `"FinTS"`). Used by the registry
1043 /// for `find_by_name` lookups.
1044 pub name: String,
1045 /// Human-readable description for `--list-importers` and similar.
1046 pub description: String,
1047}
1048
1049/// Wire-format output returned from a WASM importer's `extract`.
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1051pub struct ImporterOutput {
1052 /// Extracted directives.
1053 pub directives: Vec<DirectiveWrapper>,
1054 /// Warnings encountered during extraction (non-fatal).
1055 pub warnings: Vec<String>,
1056 /// Fatal-but-recoverable errors (e.g. malformed individual rows
1057 /// the importer chose to skip rather than abort on). Distinct from
1058 /// `warnings` (informational) and from a WASM trap (which the host
1059 /// surfaces as an `anyhow::Error`). Reuses the existing
1060 /// [`PluginError`] shape so importer errors flow into the same
1061 /// `LedgerError::location` path as plugin errors.
1062 pub errors: Vec<PluginError>,
1063}
1064
1065impl ImporterOutput {
1066 /// Create an output with no warnings or errors.
1067 #[must_use]
1068 pub const fn new(directives: Vec<DirectiveWrapper>) -> Self {
1069 Self {
1070 directives,
1071 warnings: Vec::new(),
1072 errors: Vec::new(),
1073 }
1074 }
1075
1076 /// Empty result with no directives, no warnings, no errors.
1077 #[must_use]
1078 pub const fn empty() -> Self {
1079 Self {
1080 directives: Vec::new(),
1081 warnings: Vec::new(),
1082 errors: Vec::new(),
1083 }
1084 }
1085}
1086
1087/// Wire-format output returned from a WASM importer's
1088/// `extract_enriched`. Each directive is paired with per-directive
1089/// categorization metadata.
1090#[derive(Debug, Clone, Serialize, Deserialize)]
1091pub struct EnrichedImporterOutput {
1092 /// Directive–enrichment pairs, parallel to `ImporterOutput.directives`.
1093 pub entries: Vec<(DirectiveWrapper, EnrichmentWrapper)>,
1094 /// Warnings encountered during extraction (non-fatal).
1095 pub warnings: Vec<String>,
1096 /// Fatal-but-recoverable errors. Same semantics as
1097 /// [`ImporterOutput::errors`].
1098 pub errors: Vec<PluginError>,
1099}
1100
1101/// Wire-format counterpart to `rustledger_ops::enrichment::Enrichment`.
1102///
1103/// Kept here (rather than in `rustledger-ops`) so the importer ABI is
1104/// self-contained — WASM importers depend on `rustledger-plugin-types`
1105/// and shouldn't pull in the larger `rustledger-ops` graph just for an
1106/// enrichment definition. The host converts between the two shapes at
1107/// the trait boundary.
1108#[derive(Debug, Clone, Serialize, Deserialize)]
1109pub struct EnrichmentWrapper {
1110 /// Index of the directive this enrichment applies to (parallel to
1111 /// `EnrichedImporterOutput.entries`).
1112 pub directive_index: usize,
1113 /// Confidence score for the primary categorization (0.0 to 1.0).
1114 pub confidence: f64,
1115 /// How the primary categorization was determined. String-encoded
1116 /// to avoid pinning the `CategorizationMethod` enum's exact variant
1117 /// set into the wire format. Must match
1118 /// `CategorizationMethod::as_meta_value()` in `rustledger-ops`:
1119 /// `"rule"`, `"merchant-dict"`, `"ml"`, `"llm"`, `"default"`,
1120 /// `"manual"`. (Note: `merchant-dict` uses a hyphen, not an
1121 /// underscore — the host string-matches against
1122 /// `as_meta_value()`'s output, so the wire format must agree.)
1123 pub method: String,
1124 /// Other possible categorizations, sorted by confidence descending.
1125 pub alternatives: Vec<AlternativeWrapper>,
1126 /// Stable fingerprint for deduplication, serialized as a hex string.
1127 pub fingerprint: Option<String>,
1128}
1129
1130/// Wire-format counterpart to `rustledger_ops::enrichment::Alternative`.
1131#[derive(Debug, Clone, Serialize, Deserialize)]
1132pub struct AlternativeWrapper {
1133 /// Account this alternative would assign.
1134 pub account: String,
1135 /// Confidence score for this alternative (0.0 to 1.0).
1136 pub confidence: f64,
1137 /// How this alternative was determined. Same encoding rules as
1138 /// [`EnrichmentWrapper::method`].
1139 pub method: String,
1140}
1141
1142// ============================================================================
1143// Utility Functions
1144// ============================================================================
1145
1146/// Sort directives using beancount's standard ordering.
1147///
1148/// This matches Python beancount's `entry_sortkey()`:
1149/// 1. Primary: date
1150/// 2. Secondary: directive type (Open, Balance, default, Document, Close)
1151/// 3. Tertiary: line number (preserves file order for same-date, same-type entries)
1152pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
1153 directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159
1160 #[test]
1161 fn op_coverage_accepts_complete_cover_and_rejects_violations() {
1162 use PluginOp::{Delete, Keep};
1163 // Every input index covered exactly once.
1164 assert!(validate_op_coverage(3, &[Keep(0), Delete(1), Keep(2)]).is_ok());
1165 // No input, no ops: trivially complete.
1166 assert!(validate_op_coverage(0, &[]).is_ok());
1167 // Out-of-bounds index.
1168 assert!(
1169 validate_op_coverage(2, &[Keep(0), Keep(2)])
1170 .unwrap_err()
1171 .contains("out-of-bounds")
1172 );
1173 // Same index referenced twice.
1174 assert!(
1175 validate_op_coverage(2, &[Keep(0), Delete(0)])
1176 .unwrap_err()
1177 .contains("more than once")
1178 );
1179 // An input directive omitted entirely.
1180 assert!(
1181 validate_op_coverage(2, &[Keep(0)])
1182 .unwrap_err()
1183 .contains("omitted")
1184 );
1185 }
1186
1187 #[test]
1188 fn test_plugin_error_builder() {
1189 let error = PluginError::error("test error").at("file.beancount", 10);
1190 assert_eq!(error.message, "test error");
1191 assert_eq!(error.source_file, Some("file.beancount".to_string()));
1192 assert_eq!(error.line_number, Some(10));
1193 assert_eq!(error.severity, PluginErrorSeverity::Error);
1194 }
1195
1196 #[test]
1197 fn test_plugin_warning() {
1198 let warning = PluginError::warning("test warning");
1199 assert_eq!(warning.severity, PluginErrorSeverity::Warning);
1200 }
1201
1202 #[test]
1203 fn test_directive_sort_order() {
1204 let open = DirectiveWrapper {
1205 directive_type: String::new(),
1206 date: "2024-01-01".to_string(),
1207 filename: None,
1208 lineno: Some(1),
1209 data: DirectiveData::Open(OpenData {
1210 account: "Assets:Bank".to_string(),
1211 currencies: vec![],
1212 booking: None,
1213 metadata: vec![],
1214 }),
1215 };
1216 assert_eq!(open.type_sort_order(), -2);
1217
1218 let close = DirectiveWrapper {
1219 directive_type: String::new(),
1220 date: "2024-01-01".to_string(),
1221 filename: None,
1222 lineno: Some(2),
1223 data: DirectiveData::Close(CloseData {
1224 account: "Assets:Bank".to_string(),
1225 metadata: vec![],
1226 }),
1227 };
1228 assert_eq!(close.type_sort_order(), 2);
1229 }
1230
1231 #[test]
1232 fn test_serde_roundtrip() {
1233 let input = PluginInput {
1234 directives: vec![DirectiveWrapper {
1235 directive_type: String::new(),
1236 date: "2024-01-15".to_string(),
1237 filename: Some("test.beancount".to_string()),
1238 lineno: Some(42),
1239 data: DirectiveData::Transaction(TransactionData {
1240 flag: "*".to_string(),
1241 payee: Some("Coffee Shop".to_string()),
1242 narration: "Morning coffee".to_string(),
1243 tags: vec!["food".to_string()],
1244 links: vec![],
1245 metadata: vec![],
1246 postings: vec![PostingData {
1247 account: "Expenses:Food".to_string(),
1248 units: Some(AmountData {
1249 number: "5.00".to_string(),
1250 currency: "USD".to_string(),
1251 }),
1252 cost: None,
1253 price: None,
1254 flag: None,
1255 metadata: vec![],
1256 span: None,
1257 }],
1258 }),
1259 }],
1260 options: PluginOptions {
1261 operating_currencies: vec!["USD".to_string()],
1262 title: Some("Test Ledger".to_string()),
1263 },
1264 config: Some("threshold=100".to_string()),
1265 };
1266
1267 // Test JSON roundtrip
1268 let json = serde_json::to_string(&input).unwrap();
1269 let decoded: PluginInput = serde_json::from_str(&json).unwrap();
1270 assert_eq!(decoded.directives.len(), 1);
1271 assert_eq!(decoded.config, Some("threshold=100".to_string()));
1272
1273 // Test MessagePack roundtrip
1274 let msgpack = rmp_serde::to_vec(&input).unwrap();
1275 let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
1276 assert_eq!(decoded.directives.len(), 1);
1277 }
1278
1279 // ===== PriceAnnotationData::view() — all four arms =====
1280 //
1281 // The view() enum is the type-safe interface that prevents the
1282 // #992 bug shape (consumer ignoring the is_total discriminator).
1283 // These tests pin the mapping from (is_total, amount) to each
1284 // PriceAnnotationView variant so a refactor of the underlying
1285 // struct can't silently change the dispatch.
1286
1287 fn amount(number: &str, currency: &str) -> AmountData {
1288 AmountData {
1289 number: number.to_string(),
1290 currency: currency.to_string(),
1291 }
1292 }
1293
1294 #[test]
1295 fn view_unit_complete() {
1296 // `@ 1.40 EUR`
1297 let pad = PriceAnnotationData {
1298 is_total: false,
1299 amount: Some(amount("1.40", "EUR")),
1300 number: None,
1301 currency: None,
1302 };
1303 match pad.view() {
1304 PriceAnnotationView::Unit(a) => {
1305 assert_eq!(a.number, "1.40");
1306 assert_eq!(a.currency, "EUR");
1307 }
1308 other => panic!("expected Unit, got {other:?}"),
1309 }
1310 }
1311
1312 #[test]
1313 fn view_total_complete() {
1314 // `@@ 1500 USD`
1315 let pad = PriceAnnotationData {
1316 is_total: true,
1317 amount: Some(amount("1500", "USD")),
1318 number: None,
1319 currency: None,
1320 };
1321 match pad.view() {
1322 PriceAnnotationView::Total(a) => {
1323 assert_eq!(a.number, "1500");
1324 assert_eq!(a.currency, "USD");
1325 }
1326 other => panic!("expected Total, got {other:?}"),
1327 }
1328 }
1329
1330 #[test]
1331 fn view_unit_incomplete_number_only() {
1332 // `@ 1.40` — number but no currency
1333 let pad = PriceAnnotationData {
1334 is_total: false,
1335 amount: None,
1336 number: Some("1.40".to_string()),
1337 currency: None,
1338 };
1339 match pad.view() {
1340 PriceAnnotationView::UnitIncomplete { number, currency } => {
1341 assert_eq!(number, Some("1.40"));
1342 assert_eq!(currency, None);
1343 }
1344 other => panic!("expected UnitIncomplete, got {other:?}"),
1345 }
1346 }
1347
1348 #[test]
1349 fn view_unit_incomplete_currency_only() {
1350 // `@ EUR` — currency but no number
1351 let pad = PriceAnnotationData {
1352 is_total: false,
1353 amount: None,
1354 number: None,
1355 currency: Some("EUR".to_string()),
1356 };
1357 match pad.view() {
1358 PriceAnnotationView::UnitIncomplete { number, currency } => {
1359 assert_eq!(number, None);
1360 assert_eq!(currency, Some("EUR"));
1361 }
1362 other => panic!("expected UnitIncomplete, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1367 fn view_unit_incomplete_neither() {
1368 // `@` — bare annotation, neither number nor currency
1369 let pad = PriceAnnotationData {
1370 is_total: false,
1371 amount: None,
1372 number: None,
1373 currency: None,
1374 };
1375 match pad.view() {
1376 PriceAnnotationView::UnitIncomplete { number, currency } => {
1377 assert_eq!(number, None);
1378 assert_eq!(currency, None);
1379 }
1380 other => panic!("expected UnitIncomplete, got {other:?}"),
1381 }
1382 }
1383
1384 #[test]
1385 fn view_total_incomplete_number_only() {
1386 // `@@ 1500`
1387 let pad = PriceAnnotationData {
1388 is_total: true,
1389 amount: None,
1390 number: Some("1500".to_string()),
1391 currency: None,
1392 };
1393 match pad.view() {
1394 PriceAnnotationView::TotalIncomplete { number, currency } => {
1395 assert_eq!(number, Some("1500"));
1396 assert_eq!(currency, None);
1397 }
1398 other => panic!("expected TotalIncomplete, got {other:?}"),
1399 }
1400 }
1401
1402 #[test]
1403 fn view_total_incomplete_currency_only() {
1404 // `@@ USD`
1405 let pad = PriceAnnotationData {
1406 is_total: true,
1407 amount: None,
1408 number: None,
1409 currency: Some("USD".to_string()),
1410 };
1411 match pad.view() {
1412 PriceAnnotationView::TotalIncomplete { number, currency } => {
1413 assert_eq!(number, None);
1414 assert_eq!(currency, Some("USD"));
1415 }
1416 other => panic!("expected TotalIncomplete, got {other:?}"),
1417 }
1418 }
1419
1420 #[test]
1421 fn view_total_incomplete_neither() {
1422 // `@@` — bare total annotation
1423 let pad = PriceAnnotationData {
1424 is_total: true,
1425 amount: None,
1426 number: None,
1427 currency: None,
1428 };
1429 match pad.view() {
1430 PriceAnnotationView::TotalIncomplete { number, currency } => {
1431 assert_eq!(number, None);
1432 assert_eq!(currency, None);
1433 }
1434 other => panic!("expected TotalIncomplete, got {other:?}"),
1435 }
1436 }
1437
1438 #[test]
1439 fn view_amount_present_takes_priority_over_number_currency_fields() {
1440 // If both `amount` AND the loose `number`/`currency` fields
1441 // are set, `amount` wins — view() returns Unit/Total, never
1442 // an Incomplete variant. This pins the precedence so a
1443 // future field-juggling refactor can't accidentally invert
1444 // it.
1445 let pad = PriceAnnotationData {
1446 is_total: false,
1447 amount: Some(amount("1.40", "EUR")),
1448 number: Some("99".to_string()), // ignored
1449 currency: Some("XYZ".to_string()), // ignored
1450 };
1451 match pad.view() {
1452 PriceAnnotationView::Unit(a) => {
1453 assert_eq!(a.number, "1.40");
1454 assert_eq!(a.currency, "EUR");
1455 }
1456 other => panic!("expected Unit, got {other:?}"),
1457 }
1458 }
1459
1460 // ===== Importer ABI round-trip tests =====
1461 //
1462 // Pin the MessagePack-roundtrip shape of the WASM importer wire
1463 // format. If any field is renamed, removed, or its type changes,
1464 // these tests catch it — that's a v1.0 ABI breakage we want to
1465 // notice at code-change time.
1466
1467 #[test]
1468 fn importer_input_msgpack_roundtrip() {
1469 let mut options = std::collections::HashMap::new();
1470 options.insert("date_column".to_string(), "Date".to_string());
1471 options.insert("delimiter".to_string(), ",".to_string());
1472
1473 let original = ImporterInput {
1474 path: "/path/to/foo.csv".to_string(),
1475 content: vec![0xDE, 0xAD, 0xBE, 0xEF],
1476 account: "Assets:Bank".to_string(),
1477 currency: Some("USD".to_string()),
1478 options,
1479 };
1480 let bytes = rmp_serde::to_vec(&original).unwrap();
1481 let decoded: ImporterInput = rmp_serde::from_slice(&bytes).unwrap();
1482 assert_eq!(decoded.path, original.path);
1483 assert_eq!(decoded.content, original.content);
1484 assert_eq!(decoded.account, original.account);
1485 assert_eq!(decoded.currency, original.currency);
1486 assert_eq!(decoded.options, original.options);
1487 }
1488
1489 #[test]
1490 fn importer_output_msgpack_roundtrip_empty() {
1491 let original = ImporterOutput::empty();
1492 let bytes = rmp_serde::to_vec(&original).unwrap();
1493 let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1494 assert!(decoded.directives.is_empty());
1495 assert!(decoded.warnings.is_empty());
1496 }
1497
1498 #[test]
1499 fn importer_output_msgpack_roundtrip_with_warning() {
1500 let mut out = ImporterOutput::new(vec![]);
1501 out.warnings.push("Skipped row 3: bad date".to_string());
1502 let bytes = rmp_serde::to_vec(&out).unwrap();
1503 let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1504 assert_eq!(decoded.warnings.len(), 1);
1505 assert!(decoded.warnings[0].contains("bad date"));
1506 }
1507
1508 #[test]
1509 fn enrichment_wrapper_msgpack_roundtrip() {
1510 let original = EnrichmentWrapper {
1511 directive_index: 7,
1512 confidence: 0.85,
1513 method: "rule".to_string(),
1514 alternatives: vec![AlternativeWrapper {
1515 account: "Expenses:Groceries".to_string(),
1516 confidence: 0.75,
1517 method: "merchant-dict".to_string(),
1518 }],
1519 fingerprint: Some("abc123def456".to_string()),
1520 };
1521 let bytes = rmp_serde::to_vec(&original).unwrap();
1522 let decoded: EnrichmentWrapper = rmp_serde::from_slice(&bytes).unwrap();
1523 assert_eq!(decoded.directive_index, original.directive_index);
1524 assert!((decoded.confidence - original.confidence).abs() < f64::EPSILON);
1525 assert_eq!(decoded.method, original.method);
1526 assert_eq!(decoded.alternatives.len(), 1);
1527 assert_eq!(decoded.alternatives[0].account, "Expenses:Groceries");
1528 // Every field on AlternativeWrapper must round-trip — if any drift
1529 // silently (renamed / dropped / type-changed) we want to catch it
1530 // here, not at the WASM boundary where it'd corrupt enriched results.
1531 assert!(
1532 (decoded.alternatives[0].confidence - 0.75).abs() < f64::EPSILON,
1533 "alternative confidence must round-trip exactly"
1534 );
1535 assert_eq!(decoded.alternatives[0].method, "merchant-dict");
1536 assert_eq!(decoded.fingerprint, original.fingerprint);
1537 }
1538
1539 #[test]
1540 fn enriched_importer_output_msgpack_roundtrip() {
1541 // Cover the more complex enriched variant — pair of
1542 // (DirectiveWrapper, EnrichmentWrapper) with metadata,
1543 // plus warnings + errors. Asserts every field individually.
1544 let dir = DirectiveWrapper {
1545 directive_type: "transaction".to_string(),
1546 date: "2024-01-15".to_string(),
1547 filename: Some("/tmp/foo.csv".to_string()),
1548 lineno: Some(7),
1549 data: DirectiveData::Transaction(TransactionData {
1550 flag: "*".to_string(),
1551 payee: Some("Whole Foods".to_string()),
1552 narration: "Groceries".to_string(),
1553 tags: vec![],
1554 links: vec![],
1555 metadata: vec![],
1556 postings: vec![],
1557 }),
1558 };
1559 let enr = EnrichmentWrapper {
1560 directive_index: 0,
1561 confidence: 0.92,
1562 method: "rule".to_string(),
1563 alternatives: vec![AlternativeWrapper {
1564 account: "Expenses:Other".to_string(),
1565 confidence: 0.10,
1566 method: "default".to_string(),
1567 }],
1568 fingerprint: Some("dead-beef".to_string()),
1569 };
1570 let original = EnrichedImporterOutput {
1571 entries: vec![(dir, enr)],
1572 warnings: vec!["row 3 skipped".to_string()],
1573 errors: vec![PluginError::error("row 4 unparsable").at("/tmp/foo.csv", 4)],
1574 };
1575 let bytes = rmp_serde::to_vec(&original).unwrap();
1576 let decoded: EnrichedImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
1577 assert_eq!(decoded.entries.len(), 1);
1578 let (dir, enr) = &decoded.entries[0];
1579 // `directive_type` is intentionally `#[serde(skip_serializing, default)]`
1580 // on `DirectiveWrapper` — derived from the `data` variant, not on the
1581 // wire. Don't assert it here.
1582 assert_eq!(dir.date, "2024-01-15");
1583 match &dir.data {
1584 DirectiveData::Transaction(t) => {
1585 assert_eq!(t.payee.as_deref(), Some("Whole Foods"));
1586 assert_eq!(t.narration, "Groceries");
1587 }
1588 other => panic!("expected Transaction, got {other:?}"),
1589 }
1590 assert_eq!(enr.directive_index, 0);
1591 assert!((enr.confidence - 0.92).abs() < f64::EPSILON);
1592 assert_eq!(enr.method, "rule");
1593 assert_eq!(enr.alternatives.len(), 1);
1594 assert_eq!(enr.alternatives[0].method, "default");
1595 assert_eq!(enr.fingerprint, Some("dead-beef".to_string()));
1596 assert_eq!(decoded.warnings, vec!["row 3 skipped".to_string()]);
1597 assert_eq!(decoded.errors.len(), 1);
1598 assert_eq!(decoded.errors[0].message, "row 4 unparsable");
1599 assert_eq!(
1600 decoded.errors[0].source_file,
1601 Some("/tmp/foo.csv".to_string())
1602 );
1603 assert_eq!(decoded.errors[0].line_number, Some(4));
1604 }
1605
1606 #[test]
1607 fn identify_input_output_msgpack_roundtrip() {
1608 let input = IdentifyInput {
1609 path: "/tmp/statement.mt940".to_string(),
1610 };
1611 let input_bytes = rmp_serde::to_vec(&input).unwrap();
1612 let decoded_input: IdentifyInput = rmp_serde::from_slice(&input_bytes).unwrap();
1613 assert_eq!(decoded_input.path, input.path);
1614
1615 let output = IdentifyOutput { matches: true };
1616 let output_bytes = rmp_serde::to_vec(&output).unwrap();
1617 let decoded_output: IdentifyOutput = rmp_serde::from_slice(&output_bytes).unwrap();
1618 assert!(decoded_output.matches);
1619 }
1620
1621 #[test]
1622 fn metadata_output_msgpack_roundtrip() {
1623 let original = MetadataOutput {
1624 name: "MT940".to_string(),
1625 description: "SWIFT MT940 bank statement importer".to_string(),
1626 };
1627 let bytes = rmp_serde::to_vec(&original).unwrap();
1628 let decoded: MetadataOutput = rmp_serde::from_slice(&bytes).unwrap();
1629 assert_eq!(decoded.name, original.name);
1630 assert_eq!(decoded.description, original.description);
1631 }
1632}