Skip to content

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_plugin

2. 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 symbols

3. 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 --release

The 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 USD

Run rustledger:

bash
rledger check ledger.beancount

Output:

warning: Large transaction: 25000.00 USD in Expenses:Transport
  --> ledger.beancount:10

Examples

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

FieldTypeDescription
directivesVec<Directive>All directives from the ledger
optionsOptionsLedger options (title, operating_currency, etc.)
configOption<String>Configuration string from plugin directive

PluginOutput

FieldTypeDescription
directivesVec<Directive>Modified directives (or unchanged)
errorsVec<PluginError>Errors and warnings to report

PluginError

FieldTypeDescription
messageStringHuman-readable error message
severityString"error" or "warning"
sourceOption<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.beancount

Debugging Tips

  1. Return detailed errors: WASM plugins run in a sandbox without access to stderr, so include context in error messages for debugging:

    rust
    errors.push(PluginError {
        message: format!("Account '{}' has {} postings", account, count),
        severity: "warning".to_string(),
        source_file: None,
                                 line_number: None,
    });
  2. Test incrementally: Start with a minimal plugin and add features one at a time.

Performance Tips

  1. Minimize allocations: Reuse vectors when possible
  2. Use &str over String: For comparisons and lookups
  3. Optimize for size: Use opt-level = "s" in release profile
  4. Enable LTO: Link-time optimization reduces binary size

More Examples

See the rustledger repository for more examples:

See Also