rust 137 lines · 3 tabs

Trait-Based Document Transform Pipeline With Ordered Plugins in Rust

Shared by codesnips Aug 2026
3 tabs
use std::collections::HashMap;
use std::fmt;

#[derive(Debug, Clone)]
pub struct Document {
    pub body: String,
    pub metadata: HashMap<String, String>,
}

impl Document {
    pub fn new(body: impl Into<String>) -> Self {
        Document {
            body: body.into(),
            metadata: HashMap::new(),
        }
    }
}

#[derive(Debug)]
pub struct TransformError {
    pub plugin: String,
    pub message: String,
}

impl fmt::Display for TransformError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}] {}", self.plugin, self.message)
    }
}

impl std::error::Error for TransformError {}

pub trait Transform {
    fn name(&self) -> &str;
    fn apply(&self, doc: &mut Document) -> Result<(), TransformError>;
}
3 files · rust Explain with highlit

This snippet builds a composable document processing pipeline where each transformation step is an interchangeable plugin. The design leans on Rust's trait objects to decouple the pipeline runner from the concrete transforms, so new behavior can be added without touching orchestration code.

In plugin.rs, Document is a small owned struct carrying the mutable body plus a metadata map that plugins use to communicate side-band facts (word counts, flags) without widening the core type. The Transform trait is the plugin contract: name for diagnostics and apply which takes &mut Document and returns Result<(), TransformError>. Taking &mut Document rather than consuming and returning it keeps allocations low and lets a plugin mutate in place, while the Result return means any step can abort the run. TransformError derives Debug and implements Display, so failures surface with the offending plugin's name attached.

In plugins.rs, three concrete transforms implement the trait. TrimWhitespace normalizes the body, Redact scrubs a configured secret and records a redacted metadata flag, and WordCount writes a derived count back into metadata. Each is a plain struct, so callers configure them via ordinary fields (Redact { secret }) — configuration lives in the plugin instance, not in global state. Because they share only the trait, they can be stored heterogeneously in one collection.

In pipeline.rs, Pipeline owns a Vec<Box<dyn Transform>>, the classic pattern for an ordered, runtime-assembled chain of trait objects. register uses a builder-style self-returning signature so steps can be chained fluently, and run folds over the plugins in insertion order, short-circuiting on the first Err via the ? operator. Wrapping each error with the plugin name turns an anonymous failure into an actionable one.

The trade-off is dynamic dispatch: each apply call is a vtable indirection, negligible for document-sized work but worth noting in hot loops where a generic, monomorphized pipeline or an enum-based dispatch would be faster. This pattern shines when the set of steps is open-ended, user-configurable, or plugin-loaded, and when insertion order is itself meaningful — exactly the shape of content processors, formatters, and ETL stages.


Related snips

Share this code

Here's the card — post it anywhere.

Trait-Based Document Transform Pipeline With Ordered Plugins in Rust — share card
Link copied