rustledger-plugin-types 0.16.1

WASM plugin interface types for rustledger - use in your plugin crate
Documentation

rustledger-plugin-types

WASM plugin interface types for rustledger.

This crate provides the canonical type definitions that plugins must use to communicate with the rustledger host. Using this crate ensures your plugin's types are always compatible with the host.

There are two distinct WASM plugin subsystems, and this crate hosts the shared types for both:

  • Directive plugins transform the directive stream after parsing (tagging, dedup, categorization). Required export: process. Host loader: rustledger-plugin. See the "Directive Plugin Quick Start" section below.
  • WASM importers turn bank-statement files into directives (CSV, OFX, custom formats). Required exports: metadata, identify, extract, extract_enriched. Host loader: rustledger-importer::WasmImporter. See the "WASM Importer Quick Start" section below, and use the wasm_importer_main! macro (behind the guest feature) to skip writing the export boilerplate yourself.

Installation

Add to your plugin's Cargo.toml:

[package]
name = "my-plugin"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
rustledger-plugin-types = "0.15"
rmp-serde = "1"

Version compatibility: Use the same minor version as your target rustledger host (e.g., 0.15.x types for rustledger 0.15.x).

Directive Plugin Quick Start

The wasm_plugin_main! macro (behind the guest feature) generates the required alloc + process exports from a single user fn(PluginInput) -> PluginOutput. This is the recommended path — collapses what used to be ~50 lines of #[unsafe(no_mangle)] extern "C" boilerplate down to a 5-line invocation. Add the feature to your Cargo.toml:

[dependencies]
rustledger-plugin-types = { version = "0.15", features = ["guest"] }

Then:

use rustledger_plugin_types::{
    DirectiveData, PluginInput, PluginOp, PluginOutput,
    wasm_plugin_main,
};

fn process(input: PluginInput) -> PluginOutput {
    // Emit one op per input directive:
    //   Keep(i)        — unchanged (preserves span)
    //   Modify(i, w)   — transformed content, inherits input[i]'s span
    //   Delete(i)      — drop input[i]
    //   Insert(w)      — fresh directive (synthesized location)
    let mut ops = Vec::with_capacity(input.directives.len());
    for (i, mut wrapper) in input.directives.into_iter().enumerate() {
        if let DirectiveData::Transaction(ref mut txn) = wrapper.data {
            txn.tags.push("processed".to_string());
            ops.push(PluginOp::Modify(i, wrapper));
        } else {
            ops.push(PluginOp::Keep(i));
        }
    }
    PluginOutput { ops, errors: vec![] }
}

wasm_plugin_main! {
    process: process,
}

Pure-passthrough validators that emit no transformations can short-circuit with the convenience constructor:

fn process(input: PluginInput) -> PluginOutput {
    PluginOutput::passthrough(input.directives.len())
}

Invoke wasm_plugin_main! once per cdylib crate. It emits exports named alloc and process — symbols the host loader looks up by name. Two invocations cause a duplicate-symbol linker error on wasm32. If you need multiple plugins, build them as separate cdylib crates.

The macro saves you from writing this, but if you need finer control — or you want to understand what the wire format looks like — here's the expansion in raw extern "C" form:

use rustledger_plugin_types::*;

#[unsafe(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) }
}

#[unsafe(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 with error handling
    let input: PluginInput = match rmp_serde::from_slice(input_bytes) {
        Ok(i) => i,
        Err(e) => return error_response(&format!("Deserialize failed: {}", e)),
    };

    // (process logic — see the macro example above)
    let mut ops = Vec::with_capacity(input.directives.len());
    for (i, mut wrapper) in input.directives.into_iter().enumerate() {
        if let DirectiveData::Transaction(ref mut txn) = wrapper.data {
            txn.tags.push("processed".to_string());
            ops.push(PluginOp::Modify(i, wrapper));
        } else {
            ops.push(PluginOp::Keep(i));
        }
    }

    // Serialize output
    let output = PluginOutput { ops, errors: vec![] };
    let output_bytes = match rmp_serde::to_vec(&output) {
        Ok(b) => b,
        Err(e) => return error_response(&format!("Serialize failed: {}", e)),
    };

    let output_ptr = alloc(output_bytes.len() as u32);
    unsafe {
        std::ptr::copy_nonoverlapping(
            output_bytes.as_ptr(),
            output_ptr,
            output_bytes.len(),
        );
    }

    ((output_ptr as u64) << 32) | (output_bytes.len() as u64)
}

fn error_response(message: &str) -> u64 {
    let output = PluginOutput {
        ops: vec![],
        errors: vec![PluginError::error(message)],
    };
    let bytes = rmp_serde::to_vec(&output).unwrap_or_default();
    let ptr = alloc(bytes.len() as u32);
    unsafe {
        std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
    }
    ((ptr as u64) << 32) | (bytes.len() as u64)
}

// Required: advertise the ABI version so the host can confirm wire
// compatibility right after instantiation. Without this export the
// host refuses to run the plugin with a clear error rather than
// trapping opaquely once `process` misreads its input.
#[unsafe(no_mangle)]
pub extern "C" fn __rustledger_abi_version() -> u32 {
    ABI_VERSION
}

Building

# Install WASM target
rustup target add wasm32-unknown-unknown

# Build your plugin
cargo build --target wasm32-unknown-unknown --release

The plugin will be at target/wasm32-unknown-unknown/release/my_plugin.wasm.

Using Your Plugin

In your beancount file:

plugin "path/to/my_plugin.wasm" "optional-config-string"

2024-01-01 open Assets:Bank USD

Types Overview

Type Description
PluginInput Input from host: directives, options, config
PluginOutput Output to host: ops: Vec<PluginOp> describing the resulting directive list, plus errors
PluginOp One Keep/Modify/Insert/Delete operation against the input
DirectiveWrapper Wrapper with date, source location, and data
DirectiveData Enum of all directive types
PluginError Error/warning with optional source location

PluginOp Variants

PluginOutput.ops is an ordered list of operations, not a replacement directive list. Every input index must appear in exactly one of Keep, Modify, or Delete; the host validates this and emits a plugin error if violated.

Variant Semantics
Keep(i) Reuse input[i] unchanged. Span and file_id preserved.
Modify(i, wrapper) Replace input[i]'s content with wrapper, inheriting input[i]'s source identity so errors still point at the original line.
Insert(wrapper) Emit a fresh directive with synthesized location (SYNTHESIZED_FILE_ID, zero span). Use for directives the plugin invents.
Delete(i) Drop input[i]. Must be explicit — omitting an index is a protocol violation.

Creating Errors

use rustledger_plugin_types::{PluginError, PluginErrorSeverity};

// Simple error
let error = PluginError::error("Something went wrong");

// Warning with source location
let warning = PluginError::warning("Duplicate entry")
    .at("ledger.beancount", 42);

Memory Management

Plugins must export:

  • alloc(size: u32) -> *mut u8 - Required. The host calls this to allocate memory for input data.
  • __rustledger_abi_version() -> u32 - Required. Returns ABI_VERSION; the host checks it right after instantiation and refuses to run a guest built against an incompatible plugin-types. The wasm_plugin_main! / wasm_importer_main! macros emit it for you.

Plugins may optionally export:

  • dealloc(ptr: *mut u8, size: u32) - Optional. For freeing memory within the plugin.

WASM Importer Quick Start

Importers read source files (CSV, OFX, …) and emit directives. The host loader is in rustledger-importer (WasmImporter::load); the wire format lives in this crate.

Use the wasm_importer_main! macro to generate the required exports. Enable it with the guest feature:

[dependencies]
rustledger-plugin-types = { version = "0.15", features = ["guest"] }
use rustledger_plugin_types::{
    DirectiveData, DirectiveWrapper, ImporterInput, ImporterOutput,
    OpenData, wasm_importer_main,
};

fn identify(path: &str) -> bool {
    path.ends_with(".mybank")
}

fn extract(input: ImporterInput) -> ImporterOutput {
    // Parse input.content (Vec<u8>) and emit DirectiveWrapper values.
    // input.account / input.currency / input.options carry per-call config.
    ImporterOutput::new(vec![DirectiveWrapper {
        directive_type: String::new(),
        date: "2024-01-01".into(),
        filename: None,
        lineno: None,
        data: DirectiveData::Open(OpenData {
            account: input.account,
            currencies: input.currency.into_iter().collect(),
            booking: None,
            metadata: vec![],
        }),
    }])
}

wasm_importer_main! {
    name: "my-bank",
    description: "Importer for MyBank CSV statements",
    identify: identify,
    extract: extract,
    // `extract_enriched` is auto-generated as a passthrough that wraps
    // each directive with `CategorizationMethod::Default`. Add an
    // `extract_enriched:` entry to override (and provide real
    // categorization confidence + fingerprints).
}

The macro emits the required exports (memory, alloc, metadata, identify, extract, extract_enriched) gated on #[cfg_attr(target_arch = "wasm32", ...)] so the host-target build of your crate (used by tests) doesn't collide with the WASM linker's symbol namespace.

See examples/wasm-importer-csv-example in the rustledger repo for a complete reference implementation.

Importer ABI types

Type Description
ImporterInput Input to extract/extract_enriched: path, content bytes, account, currency, options map
IdentifyInput Input to identify: path only (content isn't read until extract)
ImporterOutput Result of extract: directives + warnings + structured errors
EnrichedImporterOutput Result of extract_enriched: (DirectiveWrapper, EnrichmentWrapper) pairs
IdentifyOutput Result of identify: bool
MetadataOutput Result of metadata: name + description, called once at load
EnrichmentWrapper Per-directive categorization metadata (method, confidence, fingerprint, alternatives)
AlternativeWrapper Alternative account categorization with confidence

Categorization method strings

EnrichmentWrapper::method is a wire-format string that must match one of: "rule", "merchant-dict" (hyphen, not underscore!), "ml", "llm", "manual", "default". Unknown strings cause the host to emit a warning and fall back to Default. The host pinning lives in rustledger_ops::enrichment::CategorizationMethod::as_meta_value.

License

GPL-3.0-only