Custom Plugins Guide
This guide walks you through creating custom plugins for rustledger using WebAssembly (WASM).
Overview
WASM plugins let you extend rustledger without modifying its source code. You write your plugin in Rust, compile it to WebAssembly, and reference it in your beancount file.
Why WASM?
- Sandboxed: Plugins run in isolation, can't access your filesystem or network
- Portable: Same binary works on any platform
- Fast: Near-native performance after compilation
- Safe: Memory-safe by design
Prerequisites
- Rust toolchain (
rustup) - WASM target:
rustup target add wasm32-unknown-unknown
Quick Start
1. Create a New Plugin Project
bash
cargo new --lib my_plugin
cd my_plugin2. Configure Cargo.toml
toml
[package]
name = "my_plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
rmp-serde = "1.3"
serde = { version = "1.0", features = ["derive"] }
[profile.release]
opt-level = "s" # Optimize for size
lto = true # Link-time optimization
strip = true # Strip symbols3. Write Your Plugin
rust
// src/lib.rs
use serde::{Deserialize, Serialize};
// Plugin input structure
#[derive(Deserialize)]
struct PluginInput {
directives: Vec<Directive>,
options: Options,
config: Option<String>,
}
// Plugin output structure
#[derive(Serialize)]
struct PluginOutput {
directives: Vec<Directive>,
errors: Vec<PluginError>,
}
#[derive(Serialize)]
struct PluginError {
message: String,
source_file: Option<String>,
line_number: Option<u32>,
severity: String, // "error" or "warning"
}
// Directive types - copy these from the wasm-plugin-template or define your own
// matching the serialization format rustledger uses
#[derive(Deserialize, Serialize, Clone)]
#[serde(tag = "type")]
enum Directive {
Transaction(Transaction),
Open(Open),
Close(Close),
Balance(Balance),
Price(Price),
// ... other directive types
}
#[derive(Deserialize, Serialize, Clone)]
struct Transaction {
date: String,
flag: String,
payee: Option<String>,
narration: String,
tags: Vec<String>,
links: Vec<String>,
metadata: Vec<(String, MetaValue)>,
postings: Vec<Posting>,
}
#[derive(Deserialize, Serialize, Clone)]
enum MetaValue {
String(String),
Number(String),
Date(String),
Account(String),
Currency(String),
Tag(String),
Amount(Amount),
Boolean(bool),
}
#[derive(Deserialize, Serialize, Clone)]
struct Posting {
account: String,
units: Option<Amount>,
cost: Option<CostSpec>,
price: Option<Amount>,
flag: Option<String>,
metadata: Vec<(String, MetaValue)>,
}
#[derive(Deserialize, Serialize, Clone)]
struct Amount {
number: String,
currency: String,
}
#[derive(Deserialize, Serialize, Clone)]
struct CostSpec {
number_per: Option<String>,
number_total: Option<String>,
currency: Option<String>,
date: Option<String>,
label: Option<String>,
merge: bool,
}
#[derive(Deserialize, Serialize, Clone)]
struct Open {
account: String,
currencies: Vec<String>,
booking: Option<String>,
metadata: Vec<(String, MetaValue)>,
}
#[derive(Deserialize, Serialize, Clone)]
struct Close {
account: String,
metadata: Vec<(String, MetaValue)>,
}
#[derive(Deserialize, Serialize, Clone)]
struct Balance {
account: String,
amount: Amount,
tolerance: Option<String>,
metadata: Vec<(String, MetaValue)>,
}
#[derive(Deserialize, Serialize, Clone)]
struct Price {
currency: String,
amount: Amount,
metadata: Vec<(String, MetaValue)>,
}
#[derive(Deserialize, Serialize, Clone)]
struct Options {
title: Option<String>,
operating_currencies: Vec<String>,
}
// Memory allocation for WASM
#[no_mangle]
pub extern "C" fn alloc(size: u32) -> *mut u8 {
let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
unsafe { std::alloc::alloc(layout) }
}
// Main plugin entry point
#[no_mangle]
pub extern "C" fn process(input_ptr: u32, input_len: u32) -> u64 {
// Read input
let input_bytes = unsafe {
std::slice::from_raw_parts(input_ptr as *const u8, input_len as usize)
};
// Deserialize input
let input: PluginInput = match rmp_serde::from_slice(input_bytes) {
Ok(i) => i,
Err(e) => {
return write_error(&format!("Failed to parse input: {}", e));
}
};
// Process directives
let output = process_directives(input);
// Serialize output
let output_bytes = match rmp_serde::to_vec(&output) {
Ok(b) => b,
Err(e) => {
return write_error(&format!("Failed to serialize output: {}", e));
}
};
// Return pointer and length packed into u64
let ptr = output_bytes.as_ptr() as u64;
let len = output_bytes.len() as u64;
std::mem::forget(output_bytes);
(ptr << 32) | len
}
fn write_error(message: &str) -> u64 {
let output = PluginOutput {
directives: vec![],
errors: vec![PluginError {
message: message.to_string(),
severity: "error".to_string(),
source: None,
}],
};
let bytes = rmp_serde::to_vec(&output).unwrap_or_default();
let ptr = bytes.as_ptr() as u64;
let len = bytes.len() as u64;
std::mem::forget(bytes);
(ptr << 32) | len
}
// Your plugin logic goes here
fn process_directives(input: PluginInput) -> PluginOutput {
let mut directives = input.directives;
let mut errors = Vec::new();
// Example: Add a tag to all transactions
for directive in &mut directives {
if let Directive::Transaction(txn) = directive {
if !txn.tags.contains(&"processed".to_string()) {
txn.tags.push("processed".to_string());
}
}
}
// Example: Warn about large transactions
let threshold: f64 = input.config
.as_ref()
.and_then(|c| c.strip_prefix("threshold="))
.and_then(|s| s.parse().ok())
.unwrap_or(10000.0);
for directive in &directives {
if let Directive::Transaction(txn) = directive {
for posting in &txn.postings {
if let Some(units) = &posting.units {
if let Ok(amount) = units.number.parse::<f64>() {
if amount.abs() > threshold {
errors.push(PluginError {
message: format!(
"Large transaction: {} {} in {}",
units.number, units.currency, posting.account
),
severity: "warning".to_string(),
source_file: None,
line_number: None,
});
}
}
}
}
}
}
PluginOutput { directives, errors }
}4. Build the Plugin
bash
cargo build --target wasm32-unknown-unknown --releaseThe plugin will be at target/wasm32-unknown-unknown/release/my_plugin.wasm.
5. Use the Plugin
In your beancount file:
beancount
plugin "/path/to/my_plugin.wasm" "threshold=5000"
2024-01-01 open Assets:Bank USD
2024-01-01 open Expenses:Food USD
2024-01-15 * "Coffee Shop" "Morning coffee"
Expenses:Food 5.00 USD
Assets:Bank -5.00 USD
2024-01-20 * "Car Dealer" "New car"
Expenses:Transport 25000.00 USD
Assets:Bank -25000.00 USDRun rustledger:
bash
rledger check ledger.beancountOutput:
warning: Large transaction: 25000.00 USD in Expenses:Transport
--> ledger.beancount:10Examples
Example: Require Tags on Expenses
rust
fn process_directives(input: PluginInput) -> PluginOutput {
let mut errors = Vec::new();
for directive in &input.directives {
if let Directive::Transaction(txn) = directive {
// Check if any posting is to an Expenses account
let has_expense = txn.postings.iter()
.any(|p| p.account.starts_with("Expenses:"));
// Require at least one tag on expense transactions
if has_expense && txn.tags.is_empty() {
errors.push(PluginError {
message: format!(
"Expense transaction missing tags: {}",
txn.narration
),
severity: "error".to_string(),
source_file: None,
line_number: None,
});
}
}
}
PluginOutput {
directives: input.directives,
errors,
}
}Example: Auto-Generate Metadata
rust
fn process_directives(input: PluginInput) -> PluginOutput {
let mut directives = input.directives;
for directive in &mut directives {
if let Directive::Transaction(txn) = directive {
// Add review-status metadata if not present
if !txn.meta.contains_key("review-status") {
txn.meta.insert(
"review-status".to_string(),
"pending".to_string()
);
}
}
}
PluginOutput {
directives,
errors: vec![],
}
}Example: Currency Validation
rust
fn process_directives(input: PluginInput) -> PluginOutput {
let mut errors = Vec::new();
// Get allowed currencies from options or config
let allowed: Vec<&str> = input.config
.as_ref()
.map(|c| c.split(',').collect())
.unwrap_or_else(|| vec!["USD", "EUR", "GBP"]);
for directive in &input.directives {
if let Directive::Transaction(txn) = directive {
for posting in &txn.postings {
if let Some(units) = &posting.units {
if !allowed.contains(&units.currency.as_str()) {
errors.push(PluginError {
message: format!(
"Currency {} not in allowed list: {:?}",
units.currency, allowed
),
severity: "error".to_string(),
source_file: None,
line_number: None,
});
}
}
}
}
}
PluginOutput {
directives: input.directives,
errors,
}
}Plugin Interface Reference
PluginInput
| Field | Type | Description |
|---|---|---|
directives | Vec<Directive> | All directives from the ledger |
options | Options | Ledger options (title, operating_currency, etc.) |
config | Option<String> | Configuration string from plugin directive |
PluginOutput
| Field | Type | Description |
|---|---|---|
directives | Vec<Directive> | Modified directives (or unchanged) |
errors | Vec<PluginError> | Errors and warnings to report |
PluginError
| Field | Type | Description |
|---|---|---|
message | String | Human-readable error message |
severity | String | "error" or "warning" |
source | Option<Source> | Location in source file |
Testing Your Plugin
Unit Tests
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adds_tag() {
let input = PluginInput {
directives: vec![Directive::Transaction(Transaction {
date: "2024-01-15".to_string(),
flag: "*".to_string(),
payee: None,
narration: "Test".to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
postings: vec![],
source: None,
})],
options: Options {
title: None,
operating_currency: vec![],
},
config: None,
};
let output = process_directives(input);
if let Directive::Transaction(txn) = &output.directives[0] {
assert!(txn.tags.contains(&"processed".to_string()));
}
}
}Integration Testing
bash
# Create a test ledger
cat > test.beancount << 'EOF'
plugin "./target/wasm32-unknown-unknown/release/my_plugin.wasm"
2024-01-01 open Assets:Bank USD
2024-01-15 * "Test Transaction"
Assets:Bank 100.00 USD
Income:Test -100.00 USD
EOF
# Run rustledger
rledger check test.beancountDebugging Tips
Return detailed errors: WASM plugins run in a sandbox without access to stderr, so include context in error messages for debugging:
rusterrors.push(PluginError { message: format!("Account '{}' has {} postings", account, count), severity: "warning".to_string(), source_file: None, line_number: None, });Test incrementally: Start with a minimal plugin and add features one at a time.
Performance Tips
- Minimize allocations: Reuse vectors when possible
- Use
&stroverString: For comparisons and lookups - Optimize for size: Use
opt-level = "s"in release profile - Enable LTO: Link-time optimization reduces binary size
More Examples
See the rustledger repository for more examples:
examples/wasm-plugin- Basic exampleexamples/wasm-plugin-currency-check- Currency validationexamples/wasm-plugin-duplicate-detect- Duplicate detectionexamples/wasm-plugin-template- Starter template
See Also
- Plugins Reference - Built-in plugins and architecture
- Contributing Plugins - Adding native plugins to rustledger