delta-struct-macros 0.5.0

Delta struct provides a rust-lang Deriveable trait, Delta, that can be used to compute the difference (aka delta) between two instances of a type.
Documentation

delta_struct Crates listing CI

Agentic development disclosure

Large parts of this crate are developed in collaboration with Claude. All output from Claude is human reviewed and tested.

About

Delta struct provides a rust-lang Deriveable trait, Delta, that can be used to compute the difference (aka delta) between two instances of a type.

This can be combined with serde to only transmit changes to structures, when updates are necessary.

Installation

[dependencies]
delta-struct = "0.5"

Usage

Deriving Delta on a struct generates a companion struct holding only what changed, along with the implementation that produces and applies one.

use delta_struct::{Delta, ScalarDelta};

#[derive(Delta)]
struct Config {
    host: String,
    port: u16,
}

let old = Config { host: "localhost".to_string(), port: 80 };
let new = Config { host: "localhost".to_string(), port: 8080 };

// `Config` gained a companion struct named `ConfigDelta`.
let delta = Delta::delta(old, new).expect("the port changed");
assert_eq!(delta.host, ScalarDelta::Unchanged);
assert_eq!(delta.port, ScalarDelta::Changed(8080));

// Applying the delta to an older copy brings it up to date.
let mut current = Config { host: "localhost".to_string(), port: 80 };
current.apply_delta(delta).unwrap();
assert_eq!(current.port, 8080);

Delta::delta returns None when nothing changed, which is what makes it worth reaching for: an update that would do nothing never has to be sent.

Field types

Each field is diffed according to a field type, set with #[delta_struct(field_type = "...")]:

Value Delta representation Notes
"scalar" (default) ScalarDelta<T> ScalarDelta::Changed(new) when the values differ.
"unordered" <T as Unordered>::Delta — a BagDelta<Item> for a set, an EntryDelta<K, V> for a map For a set or map whose order carries no meaning.
"unordered-delta" MapDelta<K, V, D>, an add, a remove, and a change For a map: values under a surviving key are diffed rather than resent.
"ordered" SeqDelta<Item>, a Myers edit script For a sequence where position matters — the one field type that takes a Vec. Items need Hash + Eq.
"delta" Option<<T as Delta>::Output> Diffs the field recursively; the field's type must derive Delta too.

apply_delta returns Result<(), Mismatch>. Only an enum can fail — a struct's delta always fits the struct — so the unwrap above is safe rather than lazy.

#[delta_struct(default = "...")] on the struct changes the default for its fields. BagDelta, MapDelta, and SeqDelta are types from this crate, so enable the serde feature to serialize a delta struct holding any of them.

use delta_struct::{Delta, ScalarDelta};
use std::collections::HashSet;

#[derive(Delta)]
struct Device {
    #[delta_struct(field_type = "unordered")]
    services: HashSet<String>,
    online: bool,
}

let device = |service: &str, online| Device {
    services: vec![service.to_string()].into_iter().collect(),
    online,
};

let delta = Delta::delta(device("ssh", false), device("mqtt", true)).unwrap();
assert_eq!(delta.services.add, vec!["mqtt".to_string()]);
assert_eq!(delta.services.remove, vec!["ssh".to_string()]);
assert_eq!(delta.online, ScalarDelta::Changed(true));

Both unordered field types diff through TryIndex, this crate's fallible answer to std::ops::Index, so each element is looked up once instead of scanned for. The cost of a diff is the cost of the collection you picked: O(n) for a HashSet/HashMap, O(n log n) for a BTreeSet/BTreeMap. Vec is deliberately not supported here — implementing TryIndex for it would only hide a quadratic scan behind an O(1)-looking call.

A map is an unordered field too, and this — not unordered-delta — is what to reach for when the values are scalars with no Delta impl of their own, as a map of labels, tags, or config usually is. A map's delta is a different shape from a set's, because a map has a rule a set has no equivalent of: no two entries share a key. So add carries whole entries, but remove carries bare keys:

use delta_struct::Delta;
use std::collections::BTreeMap;

#[derive(Delta)]
struct Deployment {
    #[delta_struct(field_type = "unordered")]
    labels: BTreeMap<String, String>,
}

let deployment = |labels: &[(&str, &str)]| Deployment {
    labels: labels.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
};

let delta = Delta::delta(
    deployment(&[("tier", "web"), ("zone", "a")]),
    deployment(&[("tier", "edge")]),
)
.unwrap();
// `tier` survived, so only what it holds now travels — the receiver keeps the
// old value it already has. `zone` left, and its key alone says so.
assert_eq!(delta.labels.add, vec![("tier".to_string(), "edge".to_string())]);
assert_eq!(delta.labels.remove, vec!["zone".to_string()]);

A key that survived with a new value under it is an addition, not a removal followed by one — applying an addition overwrites whatever the key held. Which of the two shapes a field gets is the collection's business: the delta field is declared as <T as Unordered>::Delta, and the collection's Unordered impl picks, which is what lets one field type cover both.

Keyed collections

unordered-delta handles a map whose values are themselves worth diffing. Entries are paired by the collection's own key, so a value that merely changed a field travels as a delta rather than as a removal plus a full re-send:

use delta_struct::{Delta, ScalarDelta};
use std::collections::HashMap;

#[derive(Delta)]
struct Service {
    port: u16,
    healthy: bool,
}

#[derive(Delta)]
struct Cluster {
    #[delta_struct(field_type = "unordered-delta")]
    services: HashMap<String, Service>,
}

let cluster = |port| Cluster {
    services: vec![("web".to_string(), Service { port, healthy: true })]
        .into_iter()
        .collect(),
};

let delta = Delta::delta(cluster(80), cluster(8080)).unwrap();
assert!(delta.services.add.is_empty());
assert!(delta.services.remove.is_empty());
assert_eq!(delta.services.change[0].key, "web");
assert_eq!(delta.services.change[0].delta.port, ScalarDelta::Changed(8080));
assert_eq!(delta.services.change[0].delta.healthy, ScalarDelta::Unchanged);

The three parts are deliberately asymmetric: add carries whole entries because the receiver has never seen them, while remove carries bare keys and change carries deltas, because for those the receiver already holds the rest. That last part is the trade against unordered over the same map, which sends a changed value whole but asks nothing of the value type beyond PartialEq. The field must be a map — applying a change mutates a value where it sits, which is why this needs TryIndexMut and a set will not do. Enable the serde feature to serialize a delta containing an unordered-delta field.

Decorating the generated struct

The generated struct derives nothing by default. delta_leader emits arbitrary tokens above it — or above an individual field — which is how derives, doc comments, and serde attributes get onto a type you never write by hand.

use delta_struct::Delta;

#[derive(Delta)]
#[delta_struct(delta_leader = "#[derive(serde::Serialize, serde::Deserialize)]")]
struct Config {
    host: String,
    port: u16,
}

let old = Config { host: "localhost".to_string(), port: 80 };
let new = Config { host: "localhost".to_string(), port: 8080 };

// Sender: there is no message to send at all when nothing changed.
let payload = Delta::delta(old, new).map(|delta| serde_json::to_string(&delta).unwrap());
assert_eq!(payload.as_deref(), Some(r#"{"host":"unchanged","port":{"changed":8080}}"#));

// Receiver applies it to whatever it already had.
let mut config = Config { host: "localhost".to_string(), port: 80 };
config.apply_delta(serde_json::from_str::<ConfigDelta>(&payload.unwrap()).unwrap()).unwrap();
assert_eq!(config.port, 8080);

#[serde(default, skip_serializing_if = "::delta_struct::ScalarDelta::is_unchanged")] through a field-level delta_leader keeps unchanged fields out of the payload entirely, rather than sending them as "unchanged".

Ordered sequences

ordered diffs a field positionally with Myers' algorithm (via similar), producing a minimal edit script instead of a membership delta:

use delta_struct::{Delta, Splice};

#[derive(Delta)]
struct Playlist {
    #[delta_struct(field_type = "ordered")]
    tracks: Vec<String>,
}

let old = Playlist { tracks: vec!["intro".to_string(), "b".to_string(), "outro".to_string()] };
let new = Playlist { tracks: vec!["intro".to_string(), "x".to_string(), "outro".to_string()] };

let delta = Delta::delta(old, new).unwrap();
assert_eq!(
    delta.tracks.splices,
    vec![Splice { at: 1, remove: 1, insert: vec!["x".to_string()] }],
);

Splice positions index the old sequence and arrive sorted and non-overlapping, so applying one is a single forward pass and reproduces the new sequence exactly. Items need Hash + Eq, which is what Myers requires — so a Vec<f64> has nowhere to go but scalar. Enable the serde feature to serialize a delta containing an ordered field.

Enums

An enum changes in two ways a struct cannot, and its delta says which. Same variant on both sides: diffed field by field, like a struct. Different variants: no difference to describe, so the whole value travels.

use delta_struct::{Delta, EnumDelta, ScalarDelta};

#[derive(Delta)]
#[delta_struct(delta_leader = "#[derive(Debug)]")]
enum Shape {
    Empty,
    Circle { r: u32 },
}

// Same variant: only the field that moved travels.
let delta = Delta::delta(Shape::Circle { r: 1 }, Shape::Circle { r: 2 }).unwrap();
match delta {
    EnumDelta::Delta(ShapeDelta::Circle { r }) => assert_eq!(r, ScalarDelta::Changed(2)),
    _ => panic!("same variant"),
}

// Different variant: a replacement, not a difference.
let delta = Delta::delta(Shape::Empty, Shape::Circle { r: 3 }).unwrap();
assert!(matches!(delta, EnumDelta::Became(Shape::Circle { r: 3 })));

So Output is EnumDelta<Self, {Self}Delta> rather than the bare companion, and the generated enum carries one variant per diffable source variant — a field-less variant gets none, since two of those can never differ. Keeping Became on a crate type rather than as an arm of the generated enum is what lets you have a variant of your own called Became.

This is why apply_delta is fallible. A delta built while a value was one variant can arrive at a value that is now another, and that's real divergence:

use delta_struct::{Delta, Mismatch};

#[derive(Delta)]
enum Shape {
    Empty,
    Circle { r: u32 },
}

let delta = Delta::delta(Shape::Circle { r: 1 }, Shape::Circle { r: 2 }).unwrap();
let mut diverged = Shape::Empty;
assert_eq!(
    diverged.apply_delta(delta),
    Err(Mismatch { type_name: "Shape", expected: "Circle", found: "Empty" }),
);

Nested deltas propagate the innermost mismatch, so you get the enum that actually disagreed rather than the outermost struct you called apply_delta on.

Checking that a delta belongs

apply_delta assumes the value it is handed equals the old the delta came from, and checks nothing — so over a wire, a dropped or duplicated message diverges the two sides in silence. Versioned is the opt-in fix:

use delta_struct::{Applied, Delta, Fingerprint, Versioned};

#[derive(Clone, Debug, Delta, Fingerprint, PartialEq)]
#[delta_struct(delta_leader = "#[derive(Clone)]")]
struct Config {
    host: String,
    port: u16,
}

let config = |port| Config { host: "localhost".to_string(), port };

let mut sender = Versioned::new(config(80));
let mut receiver = Versioned::new(config(80));

let message = sender.commit(config(8080)).expect("the port changed");

// Delivered twice: applied once, then recognised and ignored.
assert_eq!(receiver.apply(message.clone()), Ok(Applied::Updated));
assert_eq!(receiver.apply(message), Ok(Applied::Stale));
assert_eq!(receiver.get(), sender.get());

Each VersionedDelta carries four numbers, and each catches something the others can't:

Field Catches
from, to A message dropped, reordered, or replayed.
base A receiver whose state drifted for any reason, including one that never came through this stream.
result The delta itself being wrong — mismatched schema versions, or a bug.

A rejected delta leaves the receiver untouched and its version unmoved, so a later delta fails too rather than papering over the hole. The answer to any Mismatch is to resend the whole Versioned, which serializes as a unit and carries the version to resume from.

Fingerprint is its own derive because std::hash::Hash can't do the job — it isn't implemented for HashSet or HashMap, which are exactly what the unordered field types require, and its standard hasher may change between Rust releases. This one folds sets and maps commutatively and pins itself to FNV-1a constants, so the same value fingerprints identically on any platform and any Rust version. It derives on enums too.

None of this touches the Delta trait, the derive, or any generated struct. Diffing locally costs you nothing for it.

Limitations

  • Unions are rejected; structs and enums are both supported.
  • An enum with no variants is rejected — an uninhabited type has no two values that could differ.
  • Applying an unordered or unordered-delta delta preserves membership, not position; use ordered when position matters.
  • ordered items must be Hash + Eq.
  • A Vec cannot be an unordered field — use a HashSet/BTreeSet/HashMap/BTreeMap, or ordered.
  • unordered-delta keys come from the collection, so there is no way to nominate a field of the value as the key.
  • Versioned assumes one writer per stream; concurrent writers are detected, not reconciled.

Full documentation, including trait bounds and the exact shape of the generated code, is on docs.rs.

License

Licensed under either of

  • Apache License, Version 2.0
  • MIT license

at your option.