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}