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...
31//!     let output = PluginOutput {
32//!         directives: input.directives,
33//!         errors: vec![],
34//!     };
35//!
36//!     let output_bytes = rmp_serde::to_vec(&output).unwrap();
37//!     let output_ptr = alloc(output_bytes.len() as u32);
38//!     unsafe {
39//!         std::ptr::copy_nonoverlapping(
40//!             output_bytes.as_ptr(),
41//!             output_ptr,
42//!             output_bytes.len(),
43//!         );
44//!     }
45//!     ((output_ptr as u64) << 32) | (output_bytes.len() as u64)
46//! }
47//!
48//! #[no_mangle]
49//! pub extern "C" fn alloc(size: u32) -> *mut u8 {
50//!     let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
51//!     unsafe { std::alloc::alloc(layout) }
52//! }
53//! ```
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.10.x` for rustledger `0.10.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
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#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PluginOutput {
118    /// Processed directives (may be modified, added, or removed).
119    pub directives: Vec<DirectiveWrapper>,
120    /// Errors generated by the plugin.
121    pub errors: Vec<PluginError>,
122}
123
124impl PluginOutput {
125    /// Create an output that passes through directives unchanged.
126    #[must_use]
127    pub const fn passthrough(directives: Vec<DirectiveWrapper>) -> Self {
128        Self {
129            directives,
130            errors: Vec::new(),
131        }
132    }
133}
134
135/// Ledger options passed to plugins.
136#[derive(Debug, Clone, Default, Serialize, Deserialize)]
137pub struct PluginOptions {
138    /// Operating currencies (from `option "operating_currency" "USD"`).
139    pub operating_currencies: Vec<String>,
140    /// Ledger title (from `option "title" "My Ledger"`).
141    pub title: Option<String>,
142}
143
144// ============================================================================
145// Plugin Errors
146// ============================================================================
147
148/// Error generated by a plugin.
149///
150/// Use [`PluginError::error`] or [`PluginError::warning`] to create errors,
151/// and optionally chain [`PluginError::at`] to set the source location.
152///
153/// # Example
154///
155/// ```
156/// use rustledger_plugin_types::{PluginError, PluginErrorSeverity};
157///
158/// let error = PluginError::error("Invalid transaction")
159///     .at("ledger.beancount", 42);
160///
161/// let warning = PluginError::warning("Duplicate entry detected");
162/// ```
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PluginError {
165    /// Error message.
166    pub message: String,
167    /// Source file (if known).
168    pub source_file: Option<String>,
169    /// Line number (if known).
170    pub line_number: Option<u32>,
171    /// Error severity.
172    pub severity: PluginErrorSeverity,
173}
174
175/// Severity of a plugin error.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177pub enum PluginErrorSeverity {
178    /// Warning - processing continues.
179    #[serde(rename = "warning")]
180    Warning,
181    /// Error - ledger is marked invalid.
182    #[serde(rename = "error")]
183    Error,
184}
185
186impl PluginError {
187    /// Create a new error.
188    #[must_use]
189    pub fn error(message: impl Into<String>) -> Self {
190        Self {
191            message: message.into(),
192            source_file: None,
193            line_number: None,
194            severity: PluginErrorSeverity::Error,
195        }
196    }
197
198    /// Create a new warning.
199    #[must_use]
200    pub fn warning(message: impl Into<String>) -> Self {
201        Self {
202            message: message.into(),
203            source_file: None,
204            line_number: None,
205            severity: PluginErrorSeverity::Warning,
206        }
207    }
208
209    /// Set the source location.
210    #[must_use]
211    pub fn at(mut self, file: impl Into<String>, line: u32) -> Self {
212        self.source_file = Some(file.into());
213        self.line_number = Some(line);
214        self
215    }
216}
217
218// ============================================================================
219// Directive Types
220// ============================================================================
221
222/// A wrapper around directives for serialization.
223///
224/// This wrapper provides a uniform interface for all directive types,
225/// with source location tracking for error reporting.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct DirectiveWrapper {
228    /// The type of directive (derived from data, not serialized to avoid duplicate keys).
229    #[serde(skip_serializing, default)]
230    pub directive_type: String,
231    /// The directive date (YYYY-MM-DD format).
232    pub date: String,
233    /// Source filename (for tracking through plugin processing).
234    /// If None, the directive was created by a plugin.
235    #[serde(skip_serializing_if = "Option::is_none", default)]
236    pub filename: Option<String>,
237    /// Source line number (1-based).
238    /// If None, the directive was created by a plugin.
239    #[serde(skip_serializing_if = "Option::is_none", default)]
240    pub lineno: Option<u32>,
241    /// Directive-specific data as a nested structure.
242    #[serde(flatten)]
243    pub data: DirectiveData,
244}
245
246impl DirectiveWrapper {
247    /// Returns the sort order for directive types, matching Python beancount's `SORT_ORDER`.
248    ///
249    /// Order ensures logical processing:
250    /// - Open (-2): Accounts must be opened first
251    /// - Balance (-1): Balance assertions checked before transactions
252    /// - Default (0): Transactions, Commodity, Pad, Event, Note, Price, Query, Custom
253    /// - Document (1): Documents recorded after transactions
254    /// - Close (2): Accounts closed last
255    #[must_use]
256    pub const fn type_sort_order(&self) -> i8 {
257        match &self.data {
258            DirectiveData::Open(_) => -2,
259            DirectiveData::Balance(_) => -1,
260            DirectiveData::Document(_) => 1,
261            DirectiveData::Close(_) => 2,
262            _ => 0,
263        }
264    }
265
266    /// Returns a sort key tuple matching Python beancount's `entry_sortkey()`.
267    ///
268    /// Sorts by: (date, `type_order`, lineno)
269    #[must_use]
270    pub fn sort_key(&self) -> (&str, i8, u32) {
271        (
272            &self.date,
273            self.type_sort_order(),
274            self.lineno.unwrap_or(u32::MAX),
275        )
276    }
277}
278
279/// Directive-specific data.
280///
281/// Each variant corresponds to a Beancount directive type.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(tag = "type")]
284pub enum DirectiveData {
285    /// Transaction data.
286    #[serde(rename = "transaction")]
287    Transaction(TransactionData),
288    /// Balance assertion data.
289    #[serde(rename = "balance")]
290    Balance(BalanceData),
291    /// Open account data.
292    #[serde(rename = "open")]
293    Open(OpenData),
294    /// Close account data.
295    #[serde(rename = "close")]
296    Close(CloseData),
297    /// Commodity declaration data.
298    #[serde(rename = "commodity")]
299    Commodity(CommodityData),
300    /// Pad directive data.
301    #[serde(rename = "pad")]
302    Pad(PadData),
303    /// Event data.
304    #[serde(rename = "event")]
305    Event(EventData),
306    /// Note data.
307    #[serde(rename = "note")]
308    Note(NoteData),
309    /// Document data.
310    #[serde(rename = "document")]
311    Document(DocumentData),
312    /// Price data.
313    #[serde(rename = "price")]
314    Price(PriceData),
315    /// Query data.
316    #[serde(rename = "query")]
317    Query(QueryData),
318    /// Custom directive data.
319    #[serde(rename = "custom")]
320    Custom(CustomData),
321}
322
323// ============================================================================
324// Transaction Types
325// ============================================================================
326
327/// Transaction data for serialization.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct TransactionData {
330    /// Transaction flag (`*` for complete, `!` for incomplete/pending).
331    pub flag: String,
332    /// Optional payee.
333    pub payee: Option<String>,
334    /// Narration/description.
335    pub narration: String,
336    /// Tags without the `#` prefix.
337    pub tags: Vec<String>,
338    /// Links without the `^` prefix.
339    pub links: Vec<String>,
340    /// Metadata key-value pairs.
341    pub metadata: Vec<(String, MetaValueData)>,
342    /// Postings.
343    pub postings: Vec<PostingData>,
344}
345
346/// Posting data for serialization.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct PostingData {
349    /// Account name (e.g., `Assets:Bank:Checking`).
350    pub account: String,
351    /// Units (amount + currency). None for auto-balanced postings.
352    pub units: Option<AmountData>,
353    /// Cost specification (for lot tracking).
354    pub cost: Option<CostData>,
355    /// Price annotation (@ or @@).
356    pub price: Option<PriceAnnotationData>,
357    /// Optional posting flag.
358    pub flag: Option<String>,
359    /// Posting metadata.
360    pub metadata: Vec<(String, MetaValueData)>,
361}
362
363/// Amount data for serialization.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct AmountData {
366    /// Number as string (preserves precision).
367    pub number: String,
368    /// Currency code.
369    pub currency: String,
370}
371
372/// Cost data for serialization.
373///
374/// Represents cost specifications like `{100 USD}` or `{100 USD, 2024-01-01, "lot1"}`.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct CostData {
377    /// Per-unit cost number.
378    pub number_per: Option<String>,
379    /// Total cost number.
380    pub number_total: Option<String>,
381    /// Cost currency.
382    pub currency: Option<String>,
383    /// Acquisition date.
384    pub date: Option<String>,
385    /// Lot label.
386    pub label: Option<String>,
387    /// Merge lots flag.
388    pub merge: bool,
389}
390
391/// Price annotation data.
392///
393/// Represents price annotations like `@ 100 USD` or `@@ 1000 USD` (total price).
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct PriceAnnotationData {
396    /// Whether this is a total price (`@@`) vs per-unit (`@`).
397    pub is_total: bool,
398    /// The price amount (optional for incomplete/empty prices).
399    pub amount: Option<AmountData>,
400    /// The number only (for incomplete prices).
401    pub number: Option<String>,
402    /// The currency only (for incomplete prices).
403    pub currency: Option<String>,
404}
405
406// ============================================================================
407// Metadata Types
408// ============================================================================
409
410/// Metadata value for serialization.
411///
412/// Metadata can hold various types of values, preserving type information
413/// for accurate round-tripping.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(tag = "type", content = "value")]
416pub enum MetaValueData {
417    /// String value.
418    #[serde(rename = "string")]
419    String(String),
420    /// Number value (as string to preserve precision).
421    #[serde(rename = "number")]
422    Number(String),
423    /// Date value (YYYY-MM-DD).
424    #[serde(rename = "date")]
425    Date(String),
426    /// Account reference.
427    #[serde(rename = "account")]
428    Account(String),
429    /// Currency reference.
430    #[serde(rename = "currency")]
431    Currency(String),
432    /// Tag reference.
433    #[serde(rename = "tag")]
434    Tag(String),
435    /// Link reference.
436    #[serde(rename = "link")]
437    Link(String),
438    /// Amount value.
439    #[serde(rename = "amount")]
440    Amount(AmountData),
441    /// Boolean value.
442    #[serde(rename = "bool")]
443    Bool(bool),
444}
445
446// ============================================================================
447// Other Directive Types
448// ============================================================================
449
450/// Balance assertion data.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct BalanceData {
453    /// Account name.
454    pub account: String,
455    /// Expected balance.
456    pub amount: AmountData,
457    /// Tolerance for balance check.
458    pub tolerance: Option<String>,
459    /// Metadata key-value pairs.
460    #[serde(default)]
461    pub metadata: Vec<(String, MetaValueData)>,
462}
463
464/// Open account data.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct OpenData {
467    /// Account name.
468    pub account: String,
469    /// Allowed currencies (empty means any currency).
470    pub currencies: Vec<String>,
471    /// Booking method (FIFO, LIFO, etc.).
472    pub booking: Option<String>,
473    /// Metadata key-value pairs.
474    #[serde(default)]
475    pub metadata: Vec<(String, MetaValueData)>,
476}
477
478/// Close account data.
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct CloseData {
481    /// Account name.
482    pub account: String,
483    /// Metadata key-value pairs.
484    #[serde(default)]
485    pub metadata: Vec<(String, MetaValueData)>,
486}
487
488/// Commodity declaration data.
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct CommodityData {
491    /// Currency code.
492    pub currency: String,
493    /// Metadata key-value pairs.
494    #[serde(default)]
495    pub metadata: Vec<(String, MetaValueData)>,
496}
497
498/// Pad directive data.
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct PadData {
501    /// Account to pad.
502    pub account: String,
503    /// Source account for padding.
504    pub source_account: String,
505    /// Metadata key-value pairs.
506    #[serde(default)]
507    pub metadata: Vec<(String, MetaValueData)>,
508}
509
510/// Event data.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct EventData {
513    /// Event type.
514    pub event_type: String,
515    /// Event value.
516    pub value: String,
517    /// Metadata key-value pairs.
518    #[serde(default)]
519    pub metadata: Vec<(String, MetaValueData)>,
520}
521
522/// Note data.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct NoteData {
525    /// Account name.
526    pub account: String,
527    /// Note comment.
528    pub comment: String,
529    /// Metadata key-value pairs.
530    #[serde(default)]
531    pub metadata: Vec<(String, MetaValueData)>,
532}
533
534/// Document data.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct DocumentData {
537    /// Account name.
538    pub account: String,
539    /// Document path.
540    pub path: String,
541    /// Metadata key-value pairs.
542    #[serde(default)]
543    pub metadata: Vec<(String, MetaValueData)>,
544}
545
546/// Price directive data.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct PriceData {
549    /// Currency being priced.
550    pub currency: String,
551    /// Price amount.
552    pub amount: AmountData,
553    /// Metadata key-value pairs.
554    #[serde(default)]
555    pub metadata: Vec<(String, MetaValueData)>,
556}
557
558/// Query directive data.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct QueryData {
561    /// Query name.
562    pub name: String,
563    /// Query string (BQL).
564    pub query: String,
565    /// Metadata key-value pairs.
566    #[serde(default)]
567    pub metadata: Vec<(String, MetaValueData)>,
568}
569
570/// Custom directive data.
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct CustomData {
573    /// Custom type (first value after `custom` keyword).
574    pub custom_type: String,
575    /// Values preserving their types.
576    pub values: Vec<MetaValueData>,
577    /// Metadata key-value pairs.
578    #[serde(default)]
579    pub metadata: Vec<(String, MetaValueData)>,
580}
581
582// ============================================================================
583// Utility Functions
584// ============================================================================
585
586/// Sort directives using beancount's standard ordering.
587///
588/// This matches Python beancount's `entry_sortkey()`:
589/// 1. Primary: date
590/// 2. Secondary: directive type (Open, Balance, default, Document, Close)
591/// 3. Tertiary: line number (preserves file order for same-date, same-type entries)
592pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
593    directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_plugin_error_builder() {
602        let error = PluginError::error("test error").at("file.beancount", 10);
603        assert_eq!(error.message, "test error");
604        assert_eq!(error.source_file, Some("file.beancount".to_string()));
605        assert_eq!(error.line_number, Some(10));
606        assert_eq!(error.severity, PluginErrorSeverity::Error);
607    }
608
609    #[test]
610    fn test_plugin_warning() {
611        let warning = PluginError::warning("test warning");
612        assert_eq!(warning.severity, PluginErrorSeverity::Warning);
613    }
614
615    #[test]
616    fn test_directive_sort_order() {
617        let open = DirectiveWrapper {
618            directive_type: String::new(),
619            date: "2024-01-01".to_string(),
620            filename: None,
621            lineno: Some(1),
622            data: DirectiveData::Open(OpenData {
623                account: "Assets:Bank".to_string(),
624                currencies: vec![],
625                booking: None,
626                metadata: vec![],
627            }),
628        };
629        assert_eq!(open.type_sort_order(), -2);
630
631        let close = DirectiveWrapper {
632            directive_type: String::new(),
633            date: "2024-01-01".to_string(),
634            filename: None,
635            lineno: Some(2),
636            data: DirectiveData::Close(CloseData {
637                account: "Assets:Bank".to_string(),
638                metadata: vec![],
639            }),
640        };
641        assert_eq!(close.type_sort_order(), 2);
642    }
643
644    #[test]
645    fn test_serde_roundtrip() {
646        let input = PluginInput {
647            directives: vec![DirectiveWrapper {
648                directive_type: String::new(),
649                date: "2024-01-15".to_string(),
650                filename: Some("test.beancount".to_string()),
651                lineno: Some(42),
652                data: DirectiveData::Transaction(TransactionData {
653                    flag: "*".to_string(),
654                    payee: Some("Coffee Shop".to_string()),
655                    narration: "Morning coffee".to_string(),
656                    tags: vec!["food".to_string()],
657                    links: vec![],
658                    metadata: vec![],
659                    postings: vec![PostingData {
660                        account: "Expenses:Food".to_string(),
661                        units: Some(AmountData {
662                            number: "5.00".to_string(),
663                            currency: "USD".to_string(),
664                        }),
665                        cost: None,
666                        price: None,
667                        flag: None,
668                        metadata: vec![],
669                    }],
670                }),
671            }],
672            options: PluginOptions {
673                operating_currencies: vec!["USD".to_string()],
674                title: Some("Test Ledger".to_string()),
675            },
676            config: Some("threshold=100".to_string()),
677        };
678
679        // Test JSON roundtrip
680        let json = serde_json::to_string(&input).unwrap();
681        let decoded: PluginInput = serde_json::from_str(&json).unwrap();
682        assert_eq!(decoded.directives.len(), 1);
683        assert_eq!(decoded.config, Some("threshold=100".to_string()));
684
685        // Test MessagePack roundtrip
686        let msgpack = rmp_serde::to_vec(&input).unwrap();
687        let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
688        assert_eq!(decoded.directives.len(), 1);
689    }
690}