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//! # 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}