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>;
}
use crate::plugin::{Document, Transform, TransformError};
pub struct TrimWhitespace;
impl Transform for TrimWhitespace {
fn name(&self) -> &str {
"trim_whitespace"
}
fn apply(&self, doc: &mut Document) -> Result<(), TransformError> {
doc.body = doc.body.split_whitespace().collect::<Vec<_>>().join(" ");
Ok(())
}
}
pub struct Redact {
pub secret: String,
}
impl Transform for Redact {
fn name(&self) -> &str {
"redact"
}
fn apply(&self, doc: &mut Document) -> Result<(), TransformError> {
if self.secret.is_empty() {
return Err(TransformError {
plugin: self.name().to_string(),
message: "secret pattern must not be empty".into(),
});
}
if doc.body.contains(&self.secret) {
doc.body = doc.body.replace(&self.secret, "[REDACTED]");
doc.metadata.insert("redacted".into(), "true".into());
}
Ok(())
}
}
pub struct WordCount;
impl Transform for WordCount {
fn name(&self) -> &str {
"word_count"
}
fn apply(&self, doc: &mut Document) -> Result<(), TransformError> {
let count = doc.body.split_whitespace().count();
doc.metadata.insert("word_count".into(), count.to_string());
Ok(())
}
}
use crate::plugin::{Document, Transform, TransformError};
pub struct Pipeline {
steps: Vec<Box<dyn Transform>>,
}
impl Pipeline {
pub fn new() -> Self {
Pipeline { steps: Vec::new() }
}
pub fn register(mut self, step: Box<dyn Transform>) -> Self {
self.steps.push(step);
self
}
pub fn run(&self, mut doc: Document) -> Result<Document, TransformError> {
for step in &self.steps {
step.apply(&mut doc).map_err(|mut e| {
if e.plugin.is_empty() {
e.plugin = step.name().to_string();
}
e
})?;
}
Ok(doc)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::{Redact, TrimWhitespace, WordCount};
#[test]
fn processes_document_in_order() {
let pipeline = Pipeline::new()
.register(Box::new(TrimWhitespace))
.register(Box::new(Redact { secret: "token=abc".into() }))
.register(Box::new(WordCount));
let doc = Document::new(" hello token=abc world ");
let out = pipeline.run(doc).expect("pipeline should succeed");
assert_eq!(out.body, "hello [REDACTED] world");
assert_eq!(out.metadata.get("redacted").map(String::as_str), Some("true"));
assert_eq!(out.metadata.get("word_count").map(String::as_str), Some("3"));
}
}
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
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
Share this code
Here's the card — post it anywhere.