velomorph 1.0.0

High-performance zero-copy struct transformation for Rust with asynchronous background deallocation.
Documentation

⚡ Velomorph

Declarative, type-safe struct transformation for Rust — with zero-copy patterns and optional background cleanup.

Build Status Crates.io Version License Version Rust

Why Velomorph?

Boundary layers (network packets, config blobs, legacy DTOs) often need the same mapping logic repeated across types: rename fields, unwrap options safely, borrow strings when possible, and validate before the rest of the system sees the data. Hand-written glue works, but it drifts, duplicates error handling, and hides intent.

Velomorph encodes those rules in one place with #[derive(Morph)] and attributes, so transformations stay explicit, consistent, and easy to review.

What you get

  1. Predictable mapping semantics — Strict Option<T> → T, passthrough Option, and borrowed Cow paths are generated from types, not scattered unwrap calls.
  2. Less boilerplate — Field renames (from), conversions (with), defaults, skips, and post-checks (validate) without copy-pasting struct initializers.
  3. Zero-copy where it fitsCow<'a, str> can borrow from the source when lifetimes allow.
  4. Optional Janitor — Move expensive drops off your hot path when you enable the janitor feature and use the helper deliberately.

Key features

  • Type-aware derive — The macro chooses strict vs passthrough vs borrowed strategies from your field types.
  • Advanced controls — Enum morphing, with transforms, defaults, skips, and type-level validation hooks.
  • Flexible sources — Map from the default source type or set #[morph(from = "...")] at type or field level.
  • Janitor (opt-in) — Tokio-backed channel to a background thread for deferred deallocation: Janitor::new() / Default (unbounded, default) or Janitor::bounded(n) (capped queue; when full, offload drops on the caller—see below).

🏗 Project Structure

Velomorph is a Cargo workspace:

  • velomorph-lib — Runtime: TryMorph, MorphError, optional Janitor.
  • velomorph-derive — Procedural macro that implements TryMorph.
  • examples/full_showcase — Runnable examples for both janitor and non-janitor paths.

🚀 Quick Start

Add the following to your Cargo.toml:

Cargo.toml

[dependencies]
velomorph = "1.0"

Enable Janitor offloading explicitly when needed:

[dependencies]
velomorph = { version = "1.0", features = ["janitor"] }

Then create src/main.rs:

src/main.rs

use std::borrow::Cow;
use uuid::Uuid;
use velomorph::{TryMorph, Morph};
#[cfg(feature = "janitor")]
use velomorph::Janitor;

// 1. Define your raw source data (e.g., from a network buffer).
pub struct SourcePacket<'a> {
    pub uuid_v4: Option<Uuid>,  // Legacy / external field name
    pub user_str: &'a str,      // Another external/opaque name
    pub payload: Option<Vec<u8>>,
}

// 2. Define your optimized domain model.
//    Here we also *rename* the incoming fields using `#[morph(from = "...")]`.
#[derive(Morph, Debug)]
#[morph(from = "SourcePacket")]
pub struct InternalEvent<'a> {
    #[morph(from = "uuid_v4")]
    pub id: Uuid,               // Strict: Returns Error if None in source

    #[morph(from = "user_str")]
    pub username: Cow<'a, str>, // Zero-copy: Borrows from the source
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize the background cleanup worker when enabled
    #[cfg(feature = "janitor")]
    let janitor = Janitor::new();
    
    let raw = SourcePacket {
        uuid_v4: Some(Uuid::new_v4()),
        user_str: "sensor_alpha_01",
        payload: Some(vec![0u8; 1024 * 1024 * 50]), // 50MB payload
    };

    // Morph!
    // - `janitor` is available in this signature when the feature is enabled.
    // - 'username' is borrowed (zero allocations).
    // - 'id' is unwrapped (fails with `MorphError::MissingField` if None).
    #[cfg(feature = "janitor")]
    let event: InternalEvent = raw.try_morph(&janitor)?;
    #[cfg(not(feature = "janitor"))]
    let event: InternalEvent = raw.try_morph()?;

    println!("Morphed event: {:?}", event);
    Ok(())
}

Showcase Modes (Explicit Janitor Usage)

The full_showcase example demonstrates both execution paths clearly:

  • Without janitor feature:
cargo run -p full_showcase
  • With janitor feature enabled:
cargo run -p full_showcase --features janitor

In janitor mode, the example explicitly offloads the heavy payload via Janitor::offload(...) before morphing.

🛠 How it Works

Background Deallocation (The Janitor Pattern)

In high-load systems, calling drop() on a large Vec or a complex tree can take several milliseconds as the OS reclaims memory. Velomorph provides a Janitor helper that moves those objects to a dedicated OS thread via a Tokio channel.

Janitor supports two modes (same TryMorph API; you still pass &Janitor):

Constructor Queue Behavior
Janitor::new() / Default Unbounded offload never blocks for backpressure. If you enqueue faster than the worker drops, memory can grow without bound and may eventually OOM. Prefer for controlled workloads.
Janitor::bounded(n) Bounded (capacity n > 0) At most n items wait in the channel. When full, offload drops the value on the caller thread (that call is not deferred), so the queue stays capped and this stays safe to call from async runtimes (no blocking send on the Tokio worker).

This keeps your hot path from stalling on large drops when you offload deliberately, while letting you choose latency-first (unbounded) vs capped pending-deferred-work (bounded) semantics.

The Morph Macro Logic

The #[derive(Morph)] macro performs a deep analysis of your struct fields at compile time to generate the most efficient mapping possible:

Target Type Source Type Strategy Result
T Option<T> Strict Returns MorphError::MissingField if None.
Option<T> Option<T> Passthrough Moves the Option as-is.
Cow<'a, str> &'a str Zero-Copy Borrows the string (no heap allocation).

You can also choose a custom source type instead of the default RawInput:

#[derive(Morph)]
#[morph(from = "RawPacket<'a>")]
pub struct InternalEvent<'a> {
    pub id: u64,
    pub tag: std::borrow::Cow<'a, str>,
}

Advanced attributes:

#[derive(Morph)]
#[morph(from = "Source", validate = "validate_target")]
struct Target {
    #[morph(from = "legacy_id", with = "parse_id")]
    id: u64,
    #[morph(default)]
    retries: u32,
    #[morph(skip)]
    cache_key: String,
}

with transform functions currently use the form fn(SourceType) -> Result<TargetType, E>. Enum targets use same-name variant mapping by default, with per-variant overrides via #[morph(from = "...")].

List Mapping (Vec<T> -> Vec<U>)

You can morph whole vectors when each element implements TryMorph to the target type:

use velomorph::TryMorph;

// Works without janitor feature:
// let mapped: Vec<Target> = source_vec.try_morph()?;
//
// Works with janitor feature:
// let mapped: Vec<Target> = source_vec.try_morph(&janitor)?;

This is implemented as TryMorph<Vec<U>> for Vec<T> where T: TryMorph<U>, and short-circuits on the first MorphError.

Memory Safety & Lifetimes

Velomorph is built on top of Rust's strict ownership rules. By using Cow<'a, str>, the compiler guarantees that the source buffer (e.g., your network packet) lives at least as long as your transformed InternalEvent. If the source buffer is dropped, the compiler will catch the error at build time.

When to use Velomorph vs hand-written code

Performance reality

Benchmarks in this repo show that hand-written mapping can be a few nanoseconds faster in tiny morph-only micro-cases. In practice, that difference is often acceptable (or irrelevant) because real bottlenecks are usually elsewhere: I/O, parsing, serialization, network waits, database calls, or large memory copies/drops.

Practical rule of thumb

Use Velomorph by default when you want faster delivery, safer boundaries, and consistent mapping behavior across many structs.

Use hand-written code selectively for tiny, stable, inner-loop hot paths where profiling proves that this exact mapping function is the bottleneck.

For the measured numbers and methodology, see the benchmark section below.


📊 Benchmarks: Performance Proof

These benchmarks are split into multiple groups to avoid misleading conclusions:

  • MorphOnly_NoPayloadClone: measures transform logic only (no 1MB payload clone in loop).
  • PayloadCloneDrop_1MB: measures clone/drop-heavy end-to-end behavior separately.
  • VecMorph_NoPayloadClone: measures vector-morphing overhead (1k elements) without the 1MB payload clone.

Latest Run (Apr 2, 2026)

These numbers are from a local benchmark run. Absolute timings can shift on production servers due to CPU/power settings, scheduler differences, and background contention, but the relative conclusions about "morph-only" vs "clone/drop-heavy" work still hold.

Command:

cargo bench -p velomorph --bench morph_bench

Results:

Group Benchmark Time (range)
MorphOnly_NoPayloadClone Velomorph 21.778 ns - 23.450 ns
MorphOnly_NoPayloadClone ManualBorrowed 17.408 ns - 18.290 ns
PayloadCloneDrop_1MB CloneRawInput 18.061 us - 18.608 us
PayloadCloneDrop_1MB ManualBorrowed_afterClone 17.940 us - 18.409 us
PayloadCloneDrop_1MB Velomorph_afterClone 18.591 us - 19.322 us
VecMorph_NoPayloadClone VelomorphVec_1k 32.099 us - 33.489 us
VecMorph_NoPayloadClone ManualVecBorrowed_1k 20.770 us - 21.670 us

Interpretation

  1. Morph-only cost remains nanosecond scale, so both variants stay highly efficient at pure field mapping.
  2. 1MB clone/drop dominates end-to-end timing (microseconds), which matches the memory movement/allocation pressure expected in this path.
  3. Vector morphing adds additional microsecond overhead (1k elements). In this run, ManualVecBorrowed_1k is faster than VelomorphVec_1k.
  4. Do not compare ns and us rows directly (and avoid mixing vector vs clone/drop categories). They intentionally measure different workloads/layers.
  5. This run reports statistically significant improvements for all shown sub-benchmarks (p < 0.05), with small outlier counts observed by criterion.

Reproducing

cargo bench -p velomorph --bench morph_bench

🗺 1.0 API surface

Velomorph 1.0 commits to semver stability for the public API described in this README and on docs.rs. Highlights:

  • 🧩 Modular Janitor: Optional background cleanup (feature = "janitor"); unbounded (Janitor::new / Default) or bounded (Janitor::bounded).
  • 🏷 Flexible Sources: Type-level and field-level from mapping.
  • 🛠 Custom Transforms: Field-level with transforms.
  • 🧱 Defaults & Skips: Field-level default / default = "..." and skip controls.
  • 🏗 Validation Logic: Type-level post-transformation validation hooks.
  • 🔄 Enum Support: Same-name variant mapping with explicit variant overrides.
  • 📦 List mapping: TryMorph<Vec<U>> for Vec<T> when T: TryMorph<U>.

🤝 Contributing

Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

📜 License

Licensed under either of:


Clear mappings. Safer boundaries. ⚡ Velomorph.