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//! # Quick Start
8//!
9//! Add this to your plugin's `Cargo.toml`:
10//!
11//! ```toml
12//! [dependencies]
13//! rustledger-plugin-types = "0.10"
14//! rmp-serde = "1"
15//! ```
16//!
17//! Then in your plugin:
18//!
19//! ```rust,ignore
20//! use rustledger_plugin_types::*;
21//!
22//! #[no_mangle]
23//! pub extern "C" fn process(input_ptr: u32, input_len: u32) -> u64 {
24//! let input_bytes = unsafe {
25//! std::slice::from_raw_parts(input_ptr as *const u8, input_len as usize)
26//! };
27//!
28//! let input: PluginInput = rmp_serde::from_slice(input_bytes).unwrap();
29//!
30//! // Process directives — emit ops describing the output list.
31//! // Simplest case: keep every input unchanged.
32//! let output = PluginOutput::passthrough(input.directives.len());
33//!
34//! let output_bytes = rmp_serde::to_vec(&output).unwrap();
35//! let output_ptr = alloc(output_bytes.len() as u32);
36//! unsafe {
37//! std::ptr::copy_nonoverlapping(
38//! output_bytes.as_ptr(),
39//! output_ptr,
40//! output_bytes.len(),
41//! );
42//! }
43//! ((output_ptr as u64) << 32) | (output_bytes.len() as u64)
44//! }
45//!
46//! #[no_mangle]
47//! pub extern "C" fn alloc(size: u32) -> *mut u8 {
48//! let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
49//! unsafe { std::alloc::alloc(layout) }
50//! }
51//! ```
52//!
53//! # Serialization Format
54//!
55//! Plugins communicate with the host via `MessagePack` serialization. The host
56//! calls `process(ptr, len)` with a pointer to MessagePack-encoded [`PluginInput`].
57//! The plugin returns a packed u64 containing a pointer and length to
58//! MessagePack-encoded [`PluginOutput`].
59//!
60//! # Memory Management
61//!
62//! Plugins must export an `alloc(size: u32) -> *mut u8` function. The host uses
63//! this to allocate memory in the WASM linear memory for passing input data.
64//! The plugin uses it to allocate memory for output data.
65//!
66//! Optionally, plugins can export a `dealloc(ptr: *mut u8, size: u32)` function
67//! to free memory. This is not required by the host but can be useful for
68//! memory management within longer-running plugin operations.
69//!
70//! # Version Compatibility
71//!
72//! Plugin types are versioned with rustledger. For best compatibility, use the
73//! same minor version of `rustledger-plugin-types` as the rustledger host you're
74//! targeting (e.g., `0.10.x` for rustledger `0.10.x`).
75//!
76//! # Building
77//!
78//! Build your plugin for the WASM target:
79//!
80//! ```sh
81//! rustup target add wasm32-unknown-unknown
82//! cargo build --target wasm32-unknown-unknown --release
83//! ```
84//!
85//! The output will be in `target/wasm32-unknown-unknown/release/your_plugin.wasm`
86
87#![warn(missing_docs)]
88
89use serde::{Deserialize, Serialize};
90
91// ============================================================================
92// Top-Level Plugin Interface
93// ============================================================================
94
95/// Input passed to a plugin.
96///
97/// The host serializes this struct via `MessagePack` and passes it to the
98/// plugin's `process` function.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PluginInput {
101 /// All directives to process.
102 pub directives: Vec<DirectiveWrapper>,
103 /// Ledger options.
104 pub options: PluginOptions,
105 /// Plugin-specific configuration string (from the plugin directive).
106 ///
107 /// For example, `plugin "myplugin.wasm" "threshold=100"` would set
108 /// `config` to `Some("threshold=100")`.
109 pub config: Option<String>,
110}
111
112/// Output returned from a plugin.
113///
114/// The plugin serializes this struct via `MessagePack` and returns a pointer
115/// to it from the `process` function.
116///
117/// Output is an **ordered sequence of operations** ([`PluginOp`]) — not a
118/// replacement list of directives. The host materializes the resulting
119/// directive list by walking the ops in order, preserving the original
120/// source span / `file_id` for `Keep` and `Modify` ops so plugin-transformed
121/// directives retain byte-precise source locations for error reporting.
122///
123/// Every input directive index must appear in EXACTLY ONE op across
124/// `Keep` / `Modify` / `Delete`; the host validates this and emits a
125/// plugin error if the invariant is violated.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PluginOutput {
128 /// Ordered operations that describe the resulting directive list.
129 pub ops: Vec<PluginOp>,
130 /// Errors generated by the plugin.
131 pub errors: Vec<PluginError>,
132}
133
134impl PluginOutput {
135 /// Create an output that passes through every input directive unchanged.
136 /// `len` is the number of input directives.
137 #[must_use]
138 pub fn passthrough(len: usize) -> Self {
139 Self {
140 ops: (0..len).map(PluginOp::Keep).collect(),
141 errors: Vec::new(),
142 }
143 }
144}
145
146/// One operation in a [`PluginOutput`]'s ordered op list.
147///
148/// Ops describe how each output directive relates to the input:
149/// - [`PluginOp::Keep`] — reuse `input[i]` unchanged. Span and
150/// `file_id` preserved.
151/// - [`PluginOp::Modify`] — output a new wrapper, but inherit `input[i]`'s
152/// source identity (span / `file_id`). Plugins use this when transforming
153/// an existing directive's content (e.g., adding tags) so error
154/// reporting still points at the original source location.
155/// - [`PluginOp::Insert`] — emit a fresh directive with synthesized
156/// source location (`SYNTHESIZED_FILE_ID`, zero span). Use for
157/// directives the plugin invents from scratch.
158/// - [`PluginOp::Delete`] — drop `input[i]`. Must be explicit; omitting
159/// an index without `Delete` is a protocol violation that the host
160/// reports as a plugin error.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub enum PluginOp {
163 /// Reuse `input[i]` unchanged (preserves original span + `file_id`).
164 Keep(usize),
165 /// Replace `input[i]`'s content with `wrapper`, but inherit
166 /// `input[i]`'s source identity (span + `file_id`).
167 Modify(usize, DirectiveWrapper),
168 /// Insert a fresh directive with synthesized source location.
169 Insert(DirectiveWrapper),
170 /// Drop `input[i]`. Must be explicit — see type-level docs.
171 Delete(usize),
172}
173
174/// Ledger options passed to plugins.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct PluginOptions {
177 /// Operating currencies (from `option "operating_currency" "USD"`).
178 pub operating_currencies: Vec<String>,
179 /// Ledger title (from `option "title" "My Ledger"`).
180 pub title: Option<String>,
181}
182
183// ============================================================================
184// Plugin Errors
185// ============================================================================
186
187/// Error generated by a plugin.
188///
189/// Use [`PluginError::error`] or [`PluginError::warning`] to create errors,
190/// and optionally chain [`PluginError::at`] to set the source location.
191///
192/// # Example
193///
194/// ```
195/// use rustledger_plugin_types::{PluginError, PluginErrorSeverity};
196///
197/// let error = PluginError::error("Invalid transaction")
198/// .at("ledger.beancount", 42);
199///
200/// let warning = PluginError::warning("Duplicate entry detected");
201/// ```
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PluginError {
204 /// Error message.
205 pub message: String,
206 /// Source file (if known).
207 pub source_file: Option<String>,
208 /// Line number (if known).
209 pub line_number: Option<u32>,
210 /// Error severity.
211 pub severity: PluginErrorSeverity,
212}
213
214/// Severity of a plugin error.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub enum PluginErrorSeverity {
217 /// Warning - processing continues.
218 #[serde(rename = "warning")]
219 Warning,
220 /// Error - ledger is marked invalid.
221 #[serde(rename = "error")]
222 Error,
223}
224
225impl PluginError {
226 /// Create a new error.
227 #[must_use]
228 pub fn error(message: impl Into<String>) -> Self {
229 Self {
230 message: message.into(),
231 source_file: None,
232 line_number: None,
233 severity: PluginErrorSeverity::Error,
234 }
235 }
236
237 /// Create a new warning.
238 #[must_use]
239 pub fn warning(message: impl Into<String>) -> Self {
240 Self {
241 message: message.into(),
242 source_file: None,
243 line_number: None,
244 severity: PluginErrorSeverity::Warning,
245 }
246 }
247
248 /// Set the source location.
249 #[must_use]
250 pub fn at(mut self, file: impl Into<String>, line: u32) -> Self {
251 self.source_file = Some(file.into());
252 self.line_number = Some(line);
253 self
254 }
255}
256
257// ============================================================================
258// Directive Types
259// ============================================================================
260
261/// A wrapper around directives for serialization.
262///
263/// This wrapper provides a uniform interface for all directive types,
264/// with source location tracking for error reporting.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct DirectiveWrapper {
267 /// The type of directive (derived from data, not serialized to avoid duplicate keys).
268 #[serde(skip_serializing, default)]
269 pub directive_type: String,
270 /// The directive date (YYYY-MM-DD format).
271 pub date: String,
272 /// Source filename (for tracking through plugin processing).
273 /// If None, the directive was created by a plugin.
274 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub filename: Option<String>,
276 /// Source line number (1-based).
277 /// If None, the directive was created by a plugin.
278 #[serde(skip_serializing_if = "Option::is_none", default)]
279 pub lineno: Option<u32>,
280 /// Directive-specific data as a nested structure.
281 #[serde(flatten)]
282 pub data: DirectiveData,
283}
284
285impl DirectiveWrapper {
286 /// Returns the sort order for directive types, matching Python beancount's `SORT_ORDER`.
287 ///
288 /// Order ensures logical processing:
289 /// - Open (-2): Accounts must be opened first
290 /// - Balance (-1): Balance assertions checked before transactions
291 /// - Default (0): Transactions, Commodity, Pad, Event, Note, Price, Query, Custom
292 /// - Document (1): Documents recorded after transactions
293 /// - Close (2): Accounts closed last
294 #[must_use]
295 pub const fn type_sort_order(&self) -> i8 {
296 match &self.data {
297 DirectiveData::Open(_) => -2,
298 DirectiveData::Balance(_) => -1,
299 DirectiveData::Document(_) => 1,
300 DirectiveData::Close(_) => 2,
301 _ => 0,
302 }
303 }
304
305 /// Returns a sort key tuple matching Python beancount's `entry_sortkey()`.
306 ///
307 /// Sorts by: (date, `type_order`, lineno)
308 #[must_use]
309 pub fn sort_key(&self) -> (&str, i8, u32) {
310 (
311 &self.date,
312 self.type_sort_order(),
313 self.lineno.unwrap_or(u32::MAX),
314 )
315 }
316}
317
318/// Directive-specific data.
319///
320/// Each variant corresponds to a Beancount directive type.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(tag = "type")]
323pub enum DirectiveData {
324 /// Transaction data.
325 #[serde(rename = "transaction")]
326 Transaction(TransactionData),
327 /// Balance assertion data.
328 #[serde(rename = "balance")]
329 Balance(BalanceData),
330 /// Open account data.
331 #[serde(rename = "open")]
332 Open(OpenData),
333 /// Close account data.
334 #[serde(rename = "close")]
335 Close(CloseData),
336 /// Commodity declaration data.
337 #[serde(rename = "commodity")]
338 Commodity(CommodityData),
339 /// Pad directive data.
340 #[serde(rename = "pad")]
341 Pad(PadData),
342 /// Event data.
343 #[serde(rename = "event")]
344 Event(EventData),
345 /// Note data.
346 #[serde(rename = "note")]
347 Note(NoteData),
348 /// Document data.
349 #[serde(rename = "document")]
350 Document(DocumentData),
351 /// Price data.
352 #[serde(rename = "price")]
353 Price(PriceData),
354 /// Query data.
355 #[serde(rename = "query")]
356 Query(QueryData),
357 /// Custom directive data.
358 #[serde(rename = "custom")]
359 Custom(CustomData),
360}
361
362// ============================================================================
363// Transaction Types
364// ============================================================================
365
366/// Transaction data for serialization.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct TransactionData {
369 /// Transaction flag (`*` for complete, `!` for incomplete/pending).
370 pub flag: String,
371 /// Optional payee.
372 pub payee: Option<String>,
373 /// Narration/description.
374 pub narration: String,
375 /// Tags without the `#` prefix.
376 pub tags: Vec<String>,
377 /// Links without the `^` prefix.
378 pub links: Vec<String>,
379 /// Metadata key-value pairs.
380 pub metadata: Vec<(String, MetaValueData)>,
381 /// Postings.
382 pub postings: Vec<PostingData>,
383}
384
385/// Posting data for serialization.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct PostingData {
388 /// Account name (e.g., `Assets:Bank:Checking`).
389 pub account: String,
390 /// Units (amount + currency). None for auto-balanced postings.
391 pub units: Option<AmountData>,
392 /// Cost specification (for lot tracking).
393 pub cost: Option<CostData>,
394 /// Price annotation (@ or @@).
395 pub price: Option<PriceAnnotationData>,
396 /// Optional posting flag.
397 pub flag: Option<String>,
398 /// Posting metadata.
399 pub metadata: Vec<(String, MetaValueData)>,
400}
401
402/// Amount data for serialization.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct AmountData {
405 /// Number as string (preserves precision).
406 pub number: String,
407 /// Currency code.
408 pub currency: String,
409}
410
411/// Cost data for serialization.
412///
413/// Represents cost specifications like `{100 USD}` or `{100 USD, 2024-01-01, "lot1"}`.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct CostData {
416 /// Per-unit cost number.
417 pub number_per: Option<String>,
418 /// Total cost number.
419 pub number_total: Option<String>,
420 /// Cost currency.
421 pub currency: Option<String>,
422 /// Acquisition date.
423 pub date: Option<String>,
424 /// Lot label.
425 pub label: Option<String>,
426 /// Merge lots flag.
427 pub merge: bool,
428}
429
430/// Price annotation data.
431///
432/// Represents price annotations like `@ 100 USD` or `@@ 1000 USD`
433/// (total price).
434///
435/// # Type-safe consumption (recommended)
436///
437/// Use [`PriceAnnotationData::view`] to get a [`PriceAnnotationView`]
438/// — a typed enum that forces consumers to handle `Unit` and `Total`
439/// arms exhaustively at compile time. **All new code that needs to
440/// distinguish per-unit from total prices MUST use `view()`** rather
441/// than reading `is_total` directly.
442///
443/// This struct is the wire format (kept for serialization stability
444/// across the WASM plugin boundary). The `view()` enum is a shaped
445/// accessor on top.
446///
447/// Pre-refactor (issue #992), the `implicit_prices` plugin read
448/// `posting.price.amount` directly and silently ignored `is_total`,
449/// emitting `@@` total amounts as per-unit prices. The fix in #997
450/// added explicit handling, but the type system didn't catch the bug
451/// originally because nothing forced consumers to read the bool. The
452/// `view()` enum closes that loop: a missing match arm is a compile
453/// error.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct PriceAnnotationData {
456 /// Whether this is a total price (`@@`) vs per-unit (`@`).
457 ///
458 /// **Prefer [`PriceAnnotationData::view`] for new code** — reading
459 /// this field directly is the bug shape that produced #992
460 /// (consumer ignores the field and treats every annotation as
461 /// per-unit). The `view()` enum forces exhaustive handling at
462 /// compile time.
463 pub is_total: bool,
464 /// The price amount (optional for incomplete/empty prices).
465 pub amount: Option<AmountData>,
466 /// The number only (for incomplete prices).
467 pub number: Option<String>,
468 /// The currency only (for incomplete prices).
469 pub currency: Option<String>,
470}
471
472/// Typed view of a [`PriceAnnotationData`].
473///
474/// Each arm distinguishes per-unit (`@`) from total (`@@`) at the
475/// **type level**, so a `match` on the view forces consumers to
476/// handle both cases. This is the recommended way to consume price
477/// annotations — see the docstring on [`PriceAnnotationData`] for the
478/// motivating bug.
479#[derive(Debug, Clone, Copy)]
480pub enum PriceAnnotationView<'a> {
481 /// `@ AMOUNT` — per-unit price with a complete amount.
482 Unit(&'a AmountData),
483 /// `@@ AMOUNT` — total price with a complete amount.
484 ///
485 /// Consumers that compute prices MUST divide by the posting's
486 /// `units.number.abs()` to recover the per-unit price. See
487 /// `rustledger_core::extract_per_unit_price` (in the
488 /// `rustledger-core` crate; not linked because that crate is not a
489 /// dependency of `rustledger-plugin-types`).
490 Total(&'a AmountData),
491 /// `@ NUMBER` / `@ CURRENCY` — per-unit annotation missing one
492 /// or both of (number, currency).
493 UnitIncomplete {
494 /// The number, if present.
495 number: Option<&'a str>,
496 /// The currency, if present.
497 currency: Option<&'a str>,
498 },
499 /// `@@ NUMBER` / `@@ CURRENCY` — incomplete total annotation.
500 TotalIncomplete {
501 /// The number, if present.
502 number: Option<&'a str>,
503 /// The currency, if present.
504 currency: Option<&'a str>,
505 },
506}
507
508impl PriceAnnotationData {
509 /// Get a typed view that distinguishes per-unit from total at
510 /// the type level. **Use this for new code that needs to handle
511 /// the price differently based on `@` vs `@@`.**
512 ///
513 /// Returns one of four variants — a missing match arm at the
514 /// consumer becomes a compile error, eliminating the class of
515 /// bug that produced issue #992.
516 #[must_use]
517 pub fn view(&self) -> PriceAnnotationView<'_> {
518 match (self.is_total, &self.amount) {
519 (false, Some(a)) => PriceAnnotationView::Unit(a),
520 (true, Some(a)) => PriceAnnotationView::Total(a),
521 (false, None) => PriceAnnotationView::UnitIncomplete {
522 number: self.number.as_deref(),
523 currency: self.currency.as_deref(),
524 },
525 (true, None) => PriceAnnotationView::TotalIncomplete {
526 number: self.number.as_deref(),
527 currency: self.currency.as_deref(),
528 },
529 }
530 }
531}
532
533// ============================================================================
534// Metadata Types
535// ============================================================================
536
537/// Metadata value for serialization.
538///
539/// Metadata can hold various types of values, preserving type information
540/// for accurate round-tripping.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[serde(tag = "type", content = "value")]
543pub enum MetaValueData {
544 /// String value.
545 #[serde(rename = "string")]
546 String(String),
547 /// Number value (as string to preserve precision).
548 #[serde(rename = "number")]
549 Number(String),
550 /// Date value (YYYY-MM-DD).
551 #[serde(rename = "date")]
552 Date(String),
553 /// Account reference.
554 #[serde(rename = "account")]
555 Account(String),
556 /// Currency reference.
557 #[serde(rename = "currency")]
558 Currency(String),
559 /// Tag reference.
560 #[serde(rename = "tag")]
561 Tag(String),
562 /// Link reference.
563 #[serde(rename = "link")]
564 Link(String),
565 /// Amount value.
566 #[serde(rename = "amount")]
567 Amount(AmountData),
568 /// Boolean value.
569 #[serde(rename = "bool")]
570 Bool(bool),
571}
572
573// ============================================================================
574// Other Directive Types
575// ============================================================================
576
577/// Balance assertion data.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct BalanceData {
580 /// Account name.
581 pub account: String,
582 /// Expected balance.
583 pub amount: AmountData,
584 /// Tolerance for balance check.
585 pub tolerance: Option<String>,
586 /// Metadata key-value pairs.
587 #[serde(default)]
588 pub metadata: Vec<(String, MetaValueData)>,
589}
590
591/// Open account data.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct OpenData {
594 /// Account name.
595 pub account: String,
596 /// Allowed currencies (empty means any currency).
597 pub currencies: Vec<String>,
598 /// Booking method (FIFO, LIFO, etc.).
599 pub booking: Option<String>,
600 /// Metadata key-value pairs.
601 #[serde(default)]
602 pub metadata: Vec<(String, MetaValueData)>,
603}
604
605/// Close account data.
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CloseData {
608 /// Account name.
609 pub account: String,
610 /// Metadata key-value pairs.
611 #[serde(default)]
612 pub metadata: Vec<(String, MetaValueData)>,
613}
614
615/// Commodity declaration data.
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct CommodityData {
618 /// Currency code.
619 pub currency: String,
620 /// Metadata key-value pairs.
621 #[serde(default)]
622 pub metadata: Vec<(String, MetaValueData)>,
623}
624
625/// Pad directive data.
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct PadData {
628 /// Account to pad.
629 pub account: String,
630 /// Source account for padding.
631 pub source_account: String,
632 /// Metadata key-value pairs.
633 #[serde(default)]
634 pub metadata: Vec<(String, MetaValueData)>,
635}
636
637/// Event data.
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct EventData {
640 /// Event type.
641 pub event_type: String,
642 /// Event value.
643 pub value: String,
644 /// Metadata key-value pairs.
645 #[serde(default)]
646 pub metadata: Vec<(String, MetaValueData)>,
647}
648
649/// Note data.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct NoteData {
652 /// Account name.
653 pub account: String,
654 /// Note comment.
655 pub comment: String,
656 /// Metadata key-value pairs.
657 #[serde(default)]
658 pub metadata: Vec<(String, MetaValueData)>,
659}
660
661/// Document data.
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct DocumentData {
664 /// Account name.
665 pub account: String,
666 /// Document path.
667 pub path: String,
668 /// Metadata key-value pairs.
669 #[serde(default)]
670 pub metadata: Vec<(String, MetaValueData)>,
671}
672
673/// Price directive data.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct PriceData {
676 /// Currency being priced.
677 pub currency: String,
678 /// Price amount.
679 pub amount: AmountData,
680 /// Metadata key-value pairs.
681 #[serde(default)]
682 pub metadata: Vec<(String, MetaValueData)>,
683}
684
685/// Query directive data.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct QueryData {
688 /// Query name.
689 pub name: String,
690 /// Query string (BQL).
691 pub query: String,
692 /// Metadata key-value pairs.
693 #[serde(default)]
694 pub metadata: Vec<(String, MetaValueData)>,
695}
696
697/// Custom directive data.
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct CustomData {
700 /// Custom type (first value after `custom` keyword).
701 pub custom_type: String,
702 /// Values preserving their types.
703 pub values: Vec<MetaValueData>,
704 /// Metadata key-value pairs.
705 #[serde(default)]
706 pub metadata: Vec<(String, MetaValueData)>,
707}
708
709// ============================================================================
710// Utility Functions
711// ============================================================================
712
713/// Sort directives using beancount's standard ordering.
714///
715/// This matches Python beancount's `entry_sortkey()`:
716/// 1. Primary: date
717/// 2. Secondary: directive type (Open, Balance, default, Document, Close)
718/// 3. Tertiary: line number (preserves file order for same-date, same-type entries)
719pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
720 directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn test_plugin_error_builder() {
729 let error = PluginError::error("test error").at("file.beancount", 10);
730 assert_eq!(error.message, "test error");
731 assert_eq!(error.source_file, Some("file.beancount".to_string()));
732 assert_eq!(error.line_number, Some(10));
733 assert_eq!(error.severity, PluginErrorSeverity::Error);
734 }
735
736 #[test]
737 fn test_plugin_warning() {
738 let warning = PluginError::warning("test warning");
739 assert_eq!(warning.severity, PluginErrorSeverity::Warning);
740 }
741
742 #[test]
743 fn test_directive_sort_order() {
744 let open = DirectiveWrapper {
745 directive_type: String::new(),
746 date: "2024-01-01".to_string(),
747 filename: None,
748 lineno: Some(1),
749 data: DirectiveData::Open(OpenData {
750 account: "Assets:Bank".to_string(),
751 currencies: vec![],
752 booking: None,
753 metadata: vec![],
754 }),
755 };
756 assert_eq!(open.type_sort_order(), -2);
757
758 let close = DirectiveWrapper {
759 directive_type: String::new(),
760 date: "2024-01-01".to_string(),
761 filename: None,
762 lineno: Some(2),
763 data: DirectiveData::Close(CloseData {
764 account: "Assets:Bank".to_string(),
765 metadata: vec![],
766 }),
767 };
768 assert_eq!(close.type_sort_order(), 2);
769 }
770
771 #[test]
772 fn test_serde_roundtrip() {
773 let input = PluginInput {
774 directives: vec![DirectiveWrapper {
775 directive_type: String::new(),
776 date: "2024-01-15".to_string(),
777 filename: Some("test.beancount".to_string()),
778 lineno: Some(42),
779 data: DirectiveData::Transaction(TransactionData {
780 flag: "*".to_string(),
781 payee: Some("Coffee Shop".to_string()),
782 narration: "Morning coffee".to_string(),
783 tags: vec!["food".to_string()],
784 links: vec![],
785 metadata: vec![],
786 postings: vec![PostingData {
787 account: "Expenses:Food".to_string(),
788 units: Some(AmountData {
789 number: "5.00".to_string(),
790 currency: "USD".to_string(),
791 }),
792 cost: None,
793 price: None,
794 flag: None,
795 metadata: vec![],
796 }],
797 }),
798 }],
799 options: PluginOptions {
800 operating_currencies: vec!["USD".to_string()],
801 title: Some("Test Ledger".to_string()),
802 },
803 config: Some("threshold=100".to_string()),
804 };
805
806 // Test JSON roundtrip
807 let json = serde_json::to_string(&input).unwrap();
808 let decoded: PluginInput = serde_json::from_str(&json).unwrap();
809 assert_eq!(decoded.directives.len(), 1);
810 assert_eq!(decoded.config, Some("threshold=100".to_string()));
811
812 // Test MessagePack roundtrip
813 let msgpack = rmp_serde::to_vec(&input).unwrap();
814 let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
815 assert_eq!(decoded.directives.len(), 1);
816 }
817
818 // ===== PriceAnnotationData::view() — all four arms =====
819 //
820 // The view() enum is the type-safe interface that prevents the
821 // #992 bug shape (consumer ignoring the is_total discriminator).
822 // These tests pin the mapping from (is_total, amount) to each
823 // PriceAnnotationView variant so a refactor of the underlying
824 // struct can't silently change the dispatch.
825
826 fn amount(number: &str, currency: &str) -> AmountData {
827 AmountData {
828 number: number.to_string(),
829 currency: currency.to_string(),
830 }
831 }
832
833 #[test]
834 fn view_unit_complete() {
835 // `@ 1.40 EUR`
836 let pad = PriceAnnotationData {
837 is_total: false,
838 amount: Some(amount("1.40", "EUR")),
839 number: None,
840 currency: None,
841 };
842 match pad.view() {
843 PriceAnnotationView::Unit(a) => {
844 assert_eq!(a.number, "1.40");
845 assert_eq!(a.currency, "EUR");
846 }
847 other => panic!("expected Unit, got {other:?}"),
848 }
849 }
850
851 #[test]
852 fn view_total_complete() {
853 // `@@ 1500 USD`
854 let pad = PriceAnnotationData {
855 is_total: true,
856 amount: Some(amount("1500", "USD")),
857 number: None,
858 currency: None,
859 };
860 match pad.view() {
861 PriceAnnotationView::Total(a) => {
862 assert_eq!(a.number, "1500");
863 assert_eq!(a.currency, "USD");
864 }
865 other => panic!("expected Total, got {other:?}"),
866 }
867 }
868
869 #[test]
870 fn view_unit_incomplete_number_only() {
871 // `@ 1.40` — number but no currency
872 let pad = PriceAnnotationData {
873 is_total: false,
874 amount: None,
875 number: Some("1.40".to_string()),
876 currency: None,
877 };
878 match pad.view() {
879 PriceAnnotationView::UnitIncomplete { number, currency } => {
880 assert_eq!(number, Some("1.40"));
881 assert_eq!(currency, None);
882 }
883 other => panic!("expected UnitIncomplete, got {other:?}"),
884 }
885 }
886
887 #[test]
888 fn view_unit_incomplete_currency_only() {
889 // `@ EUR` — currency but no number
890 let pad = PriceAnnotationData {
891 is_total: false,
892 amount: None,
893 number: None,
894 currency: Some("EUR".to_string()),
895 };
896 match pad.view() {
897 PriceAnnotationView::UnitIncomplete { number, currency } => {
898 assert_eq!(number, None);
899 assert_eq!(currency, Some("EUR"));
900 }
901 other => panic!("expected UnitIncomplete, got {other:?}"),
902 }
903 }
904
905 #[test]
906 fn view_unit_incomplete_neither() {
907 // `@` — bare annotation, neither number nor currency
908 let pad = PriceAnnotationData {
909 is_total: false,
910 amount: None,
911 number: None,
912 currency: None,
913 };
914 match pad.view() {
915 PriceAnnotationView::UnitIncomplete { number, currency } => {
916 assert_eq!(number, None);
917 assert_eq!(currency, None);
918 }
919 other => panic!("expected UnitIncomplete, got {other:?}"),
920 }
921 }
922
923 #[test]
924 fn view_total_incomplete_number_only() {
925 // `@@ 1500`
926 let pad = PriceAnnotationData {
927 is_total: true,
928 amount: None,
929 number: Some("1500".to_string()),
930 currency: None,
931 };
932 match pad.view() {
933 PriceAnnotationView::TotalIncomplete { number, currency } => {
934 assert_eq!(number, Some("1500"));
935 assert_eq!(currency, None);
936 }
937 other => panic!("expected TotalIncomplete, got {other:?}"),
938 }
939 }
940
941 #[test]
942 fn view_total_incomplete_currency_only() {
943 // `@@ USD`
944 let pad = PriceAnnotationData {
945 is_total: true,
946 amount: None,
947 number: None,
948 currency: Some("USD".to_string()),
949 };
950 match pad.view() {
951 PriceAnnotationView::TotalIncomplete { number, currency } => {
952 assert_eq!(number, None);
953 assert_eq!(currency, Some("USD"));
954 }
955 other => panic!("expected TotalIncomplete, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn view_total_incomplete_neither() {
961 // `@@` — bare total annotation
962 let pad = PriceAnnotationData {
963 is_total: true,
964 amount: None,
965 number: None,
966 currency: None,
967 };
968 match pad.view() {
969 PriceAnnotationView::TotalIncomplete { number, currency } => {
970 assert_eq!(number, None);
971 assert_eq!(currency, None);
972 }
973 other => panic!("expected TotalIncomplete, got {other:?}"),
974 }
975 }
976
977 #[test]
978 fn view_amount_present_takes_priority_over_number_currency_fields() {
979 // If both `amount` AND the loose `number`/`currency` fields
980 // are set, `amount` wins — view() returns Unit/Total, never
981 // an Incomplete variant. This pins the precedence so a
982 // future field-juggling refactor can't accidentally invert
983 // it.
984 let pad = PriceAnnotationData {
985 is_total: false,
986 amount: Some(amount("1.40", "EUR")),
987 number: Some("99".to_string()), // ignored
988 currency: Some("XYZ".to_string()), // ignored
989 };
990 match pad.view() {
991 PriceAnnotationView::Unit(a) => {
992 assert_eq!(a.number, "1.40");
993 assert_eq!(a.currency, "EUR");
994 }
995 other => panic!("expected Unit, got {other:?}"),
996 }
997 }
998}