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 pluginStep-by-Step Guide
1. Create the Plugin File
Create a new file in crates/rustledger-plugin/src/native/plugins/:
// 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:
// Add the module
mod my_plugin;
// Re-export
pub use my_plugin::MyPlugin;Then register it in crates/rustledger-plugin/src/native/mod.rs:
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:
// 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:
#[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/:
// 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
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
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
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
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
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
- Avoid cloning when possible: Use references and iterators
- Early returns: Skip directives that don't need processing
- Batch operations: Collect changes before applying
// 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:
// 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:
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
- Module docs: Explain what the plugin does
- Example usage: Show beancount syntax
- Configuration: Document all options
- Error codes: List possible errors
//! 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 prefixSubmitting Your Plugin
- Fork rustledger and create a feature branch
- Add your plugin following this guide
- Write tests for all functionality
- Update documentation in
docs/reference/plugins.md - Run the test suite:
cargo test -p rustledger-plugin - Submit a PR with a clear description
See Also
- Custom Plugins Guide - WASM plugins for personal use
- Plugins Reference - All available plugins
- Contributing Guide - General contribution guidelines