Skip to content

Contributing Native Plugins

This guide explains how to add new native Rust plugins to rustledger itself.

For Custom Plugins

If you want to write a plugin for your own use without modifying rustledger, see the Custom Plugins Guide for WASM plugins.

When to Add a Native Plugin

Add a native plugin when:

  • The functionality is generally useful to many users
  • You want to contribute to rustledger
  • Maximum performance is critical
  • The plugin implements a Python beancount plugin for compatibility

Plugin Architecture

Native plugins live in crates/rustledger-plugin/src/native/plugins/:

crates/rustledger-plugin/
├── src/
│   ├── lib.rs
│   ├── types.rs           # PluginInput, PluginOutput, DirectiveWrapper, etc.
│   └── native/
│       ├── mod.rs         # NativePlugin trait + NativePluginRegistry
│       └── plugins/
│           ├── mod.rs     # Plugin exports
│           ├── auto_accounts.rs
│           ├── implicit_prices.rs
│           ├── no_duplicates.rs
│           └── your_plugin.rs  # Your new plugin

Step-by-Step Guide

1. Create the Plugin File

Create a new file in crates/rustledger-plugin/src/native/plugins/:

rust
// crates/rustledger-plugin/src/native/plugins/my_plugin.rs

use crate::native::NativePlugin;
use crate::types::{PluginError, PluginErrorSeverity, PluginInput, PluginOutput};

/// My Plugin - brief description.
///
/// Longer description explaining what the plugin does,
/// when to use it, and any configuration options.
///
/// # Example
///
/// ```beancount
/// plugin "beancount.plugins.my_plugin" "optional_config"
/// ```
pub struct MyPlugin;

impl NativePlugin for MyPlugin {
    fn name(&self) -> &'static str {
        "my_plugin"
    }

    fn description(&self) -> &'static str {
        "Brief description of what the plugin does"
    }

    fn process(&self, input: PluginInput) -> PluginOutput {
        let mut directives = input.directives;
        let mut errors = Vec::new();

        // Your plugin logic here
        // Access config via: input.config
        // Access options via: input.options

        PluginOutput { directives, errors }
    }
}

2. Register the Plugin

Add your plugin to crates/rustledger-plugin/src/native/plugins/mod.rs:

rust
// Add the module
mod my_plugin;

// Re-export
pub use my_plugin::MyPlugin;

Then register it in crates/rustledger-plugin/src/native/mod.rs:

rust
impl NativePluginRegistry {
    pub fn new() -> Self {
        Self {
            plugins: vec![
                // ... existing plugins ...
                Box::new(ImplicitPricesPlugin),
                Box::new(CheckCommodityPlugin),
                // Add your plugin to the list
                Box::new(MyPlugin),
            ],
        }
    }
}

3. Add Aliases (Optional)

For Python beancount compatibility, add aliases in registry.rs:

rust
// Allow both "my_plugin" and "beancount.plugins.my_plugin"
registry.add_alias("beancount.plugins.my_plugin", "my_plugin");

4. Write Tests

Add tests in your plugin file:

rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::*;

    fn make_transaction(narration: &str) -> DirectiveWrapper {
        DirectiveWrapper {
            directive_type: "transaction".to_string(),
            date: "2024-01-15".to_string(),
            filename: Some("test.beancount".to_string()),
            lineno: Some(1),
            data: DirectiveData::Transaction(TransactionData {
                flag: "*".to_string(),
                payee: None,
                narration: narration.to_string(),
                tags: vec![],
                links: vec![],
                metadata: vec![],
                postings: vec![],
            }),
        }
    }

    #[test]
    fn test_basic_functionality() {
        let plugin = MyPlugin;
        let input = PluginInput {
            directives: vec![make_transaction("Test")],
            options: PluginOptions::default(),
            config: None,
        };

        let output = plugin.process(input);

        assert!(output.errors.is_empty());
        assert_eq!(output.directives.len(), 1);
    }

    #[test]
    fn test_with_config() {
        let plugin = MyPlugin;
        let input = PluginInput {
            directives: vec![],
            options: PluginOptions::default(),
            config: Some("threshold=100".to_string()),
        };

        let output = plugin.process(input);
        // Assert based on config
    }

    #[test]
    fn test_error_case() {
        let plugin = MyPlugin;
        // Create input that should trigger an error
        let input = PluginInput {
            directives: vec![make_transaction("problematic")],
            options: PluginOptions::default(),
            config: None,
        };

        let output = plugin.process(input);

        assert!(!output.errors.is_empty());
        assert_eq!(output.errors[0].severity, PluginErrorSeverity::Error);
    }
}

5. Add Integration Tests

Add integration tests in crates/rustledger-plugin/tests/:

rust
// crates/rustledger-plugin/tests/my_plugin_test.rs

use rustledger_plugin::native::{NativePlugin, NativePluginRegistry};
use rustledger_plugin::types::*;

// Helper to create test input
fn make_input(directives: Vec<DirectiveWrapper>) -> PluginInput {
    PluginInput {
        directives,
        options: PluginOptions {
            operating_currencies: vec!["USD".to_string()],
            title: None,
        },
        config: None,
    }
}

fn make_transaction(date: &str, narration: &str) -> DirectiveWrapper {
    DirectiveWrapper {
        directive_type: "transaction".to_string(),
        date: date.to_string(),
        filename: Some("test.beancount".to_string()),
        lineno: Some(1),
        data: DirectiveData::Transaction(TransactionData {
            flag: "*".to_string(),
            payee: None,
            narration: narration.to_string(),
            tags: vec![],
            links: vec![],
            metadata: vec![],
            postings: vec![],
        }),
    }
}

#[test]
fn test_my_plugin_integration() {
    let registry = NativePluginRegistry::new();
    let plugin = registry.find("my_plugin").expect("plugin should exist");

    let input = make_input(vec![
        make_transaction("2024-01-15", "Test transaction"),
    ]);

    let output = plugin.process(input);

    // Verify no errors
    assert!(output.errors.is_empty(), "expected no errors");

    // Verify directives were processed
    assert_eq!(output.directives.len(), 1);
}

The NativePlugin Trait

rust
pub trait NativePlugin: Send + Sync {
    /// Plugin identifier (used in `plugin "name"`)
    fn name(&self) -> &'static str;

    /// Human-readable description
    fn description(&self) -> &'static str;

    /// Process directives and return results
    fn process(&self, input: PluginInput) -> PluginOutput;
}

Working with Directives

Plugin Types vs Core Types

Plugins receive DirectiveWrapper with DirectiveData (from crate::types), not rustledger_core::Directive. These wrapper types use strings for dates and decimals to simplify serialization. See crates/rustledger-plugin/src/types.rs for the complete type definitions.

Iterating Over Directives

rust
use crate::types::{DirectiveWrapper, DirectiveData, TransactionData};

for wrapper in &input.directives {
    match &wrapper.data {
        DirectiveData::Transaction(txn) => {
            // txn.flag, txn.payee, txn.narration, txn.postings, etc.
        }
        DirectiveData::Open(open) => {
            // open.account, open.currencies, open.booking
        }
        DirectiveData::Close(close) => {
            // close.account
        }
        DirectiveData::Balance(bal) => {
            // bal.account, bal.amount
        }
        DirectiveData::Price(price) => {
            // price.currency, price.amount
        }
        _ => {}
    }
}

Modifying Directives

rust
fn process(&self, input: PluginInput) -> PluginOutput {
    let directives: Vec<_> = input.directives
        .into_iter()
        .map(|mut wrapper| {
            if let DirectiveData::Transaction(ref mut txn) = wrapper.data {
                // Modify transaction
                txn.tags.push("processed".to_string());
            }
            wrapper
        })
        .collect();

    PluginOutput {
        directives,
        errors: vec![],
    }
}

Adding New Directives

rust
fn process(&self, input: PluginInput) -> PluginOutput {
    let mut directives = input.directives;

    // Generate new directives using wrapper types
    let new_price = DirectiveWrapper {
        directive_type: String::new(),
        date: "2024-01-15".to_string(),
        filename: None,
        lineno: None,
        data: DirectiveData::Price(PriceData {
            currency: "AAPL".to_string(),
            amount: AmountData {
                number: "150.00".to_string(),
                currency: "USD".to_string(),
            },
            metadata: vec![],
        }),
    };

    directives.push(new_price);

    PluginOutput {
        directives,
        errors: vec![],
    }
}

Reporting Errors

rust
use crate::types::{PluginError, PluginErrorSeverity};

fn process(&self, input: PluginInput) -> PluginOutput {
    let mut errors = Vec::new();

    for directive in &input.directives {
        if let Directive::Transaction(txn) = directive {
            if txn.postings.is_empty() {
                errors.push(PluginError::error("Transaction has no postings"));
            }
        }
    }

    PluginOutput {
        directives: input.directives,
        errors,
    }
}

Best Practices

Performance

  1. Avoid cloning when possible: Use references and iterators
  2. Early returns: Skip directives that don't need processing
  3. Batch operations: Collect changes before applying
rust
// Good: Filter first, then process
let transactions: Vec<_> = input.directives
    .iter()
    .filter_map(|d| match d {
        Directive::Transaction(t) => Some(t),
        _ => None,
    })
    .collect();

// Process only relevant directives
for txn in &transactions {
    // ...
}

Error Messages

Write clear, actionable error messages:

rust
// Good - use builder methods and include context
PluginError::error(format!(
    "Account '{}' uses currency {} but was opened with {:?}",
    posting.account, currency, allowed_currencies
))

// Bad - vague message with no context
PluginError::error("Invalid currency")

Configuration Parsing

Handle configuration gracefully:

rust
fn process(&self, input: PluginInput) -> PluginOutput {
    // Parse config with defaults
    let threshold: f64 = input.config
        .as_ref()
        .and_then(|c| c.strip_prefix("threshold="))
        .and_then(|s| s.parse().ok())
        .unwrap_or(1000.0);

    // Or for more complex config:
    let config = parse_config(&input.config);

    // ...
}

fn parse_config(config: &Option<String>) -> PluginConfig {
    let Some(config_str) = config else {
        return PluginConfig::default();
    };

    // Parse key=value pairs
    let mut result = PluginConfig::default();
    for part in config_str.split(',') {
        if let Some((key, value)) = part.split_once('=') {
            match key.trim() {
                "threshold" => result.threshold = value.parse().unwrap_or(1000.0),
                "strict" => result.strict = value == "true",
                _ => {} // Ignore unknown keys
            }
        }
    }
    result
}

Documentation Requirements

  1. Module docs: Explain what the plugin does
  2. Example usage: Show beancount syntax
  3. Configuration: Document all options
  4. Error codes: List possible errors
rust
//! Check that all accounts have a specific prefix.
//!
//! This plugin validates that all account names start with one of the
//! standard prefixes (Assets, Liabilities, Equity, Income, Expenses).
//!
//! # Usage
//!
//! ```beancount
//! plugin "beancount.plugins.check_prefix"
//! ```
//!
//! # Configuration
//!
//! Optional: specify custom allowed prefixes:
//!
//! ```beancount
//! plugin "beancount.plugins.check_prefix" "Assets,Liabilities,Equity"
//! ```
//!
//! # Errors
//!
//! - `E1001`: Account name does not start with an allowed prefix

Submitting Your Plugin

  1. Fork rustledger and create a feature branch
  2. Add your plugin following this guide
  3. Write tests for all functionality
  4. Update documentation in docs/reference/plugins.md
  5. Run the test suite: cargo test -p rustledger-plugin
  6. Submit a PR with a clear description

See Also