Expand description
§morphix
A Rust library for observing and serializing mutations.
§Installation
Add this to your Cargo.toml:
[dependencies]
morphix = { version = "0.18", features = ["json"] }§Basic Usage
use serde::Serialize;
use serde_json::json;
use morphix::adapter::Json;
use morphix::{Mutation, MutationKind, Observe, observe};
// 1. Define any data structure with `#[derive(Observe)]`.
#[derive(Serialize, PartialEq, Debug, Observe)]
struct Foo {
pub bar: Bar,
pub qux: String,
}
#[derive(Serialize, PartialEq, Debug, Observe)]
struct Bar {
pub baz: i32,
}
let mut foo = Foo {
bar: Bar { baz: 42 },
qux: "hello".to_string(),
};
// 2. Use `observe!` to mutate data and track mutations.
let Json(mutation) = observe!(foo => {
foo.bar.baz += 1;
foo.qux.push(' ');
foo.qux += "world";
}).unwrap();
// 3. Inspect the mutations.
assert_eq!(
mutation,
Some(Mutation {
path: vec![].into(),
kind: MutationKind::Batch(vec![
Mutation {
path: vec!["bar".into()].into(),
kind: MutationKind::Replace(json!({"baz": 43})),
},
Mutation {
path: vec!["qux".into()].into(),
kind: MutationKind::Append(json!(" world")),
},
]),
}),
);
// 4. The original data structure is also mutated.
assert_eq!(
foo,
Foo {
bar: Bar { baz: 43 },
qux: "hello world".to_string(),
},
);§Mutation Types
Morphix recognizes three types of mutations:
§Replace
The most general mutation type, used for any mutation that replaces a value:
foo.a.b = 1; // Replace at .a.b
foo.num *= 2; // Replace at .num
foo.vec.clear(); // Replace at .vec§Append
Optimized for appending to strings and vectors:
foo.a.b += "text"; // Append to .a.b
foo.a.b.push_str("text"); // Append to .a.b
foo.vec.push(1); // Append to .vec
foo.vec.extend(iter); // Append to .vec§Truncate
Optimized for truncating strings and vectors:
foo.a.b.truncate(5); // Truncate n-5 chars from .a.b
foo.vec.pop(); // Truncate 1 element from .vec§Delete
Used for deleting values from maps or conditionally skipping mutations:
foo.map.remove("key"); // Delete at .map.key
// #[serde(skip_serializing_if = "Option::is_none")]
foo.value = None; // Delete at .value§Batch
Multiple mutations combined into a single operation.
§Observer Mechanism
This section describes the internal mechanism of morphix’s observer system. It is intended for contributors and advanced users who want to understand how mutation tracking works under the hood.
§How Observers Work
An observer is a wrapper type that implements Deref and DerefMut to the type it observes. This lets the observer intercept all &mut self method calls through Rust’s auto-deref mechanism. For example, a StringObserver dereferences to String, so calling .push_str("hello") on the observer transparently reaches the underlying String while the observer tracks the mutation.
For specific methods like String::push_str and Vec::push, observers provide specialized implementations that record precise mutations (e.g., Append). For any &mut self method that does not have a specialized implementation, the call falls through to DerefMut, which triggers a conservative Replace mutation covering the entire value. This means observers are always correct — they never miss a mutation — but unimplemented methods produce coarser-grained output.
§The Dereference Chain
For simple types like String or i32, an observer can deref directly to the target. But for types that already implement Deref — such as Vec<T>, which dereferences to [T] — a straightforward approach breaks down. If type A dereferences to B, and we have corresponding observers A' and B', where should A' deref to?
- If
A'→A→B: mutations onBcannot be precisely tracked (noB'in the chain). - If
A'→B'→B: properties and methods onAbecome inaccessible (noAin the chain).
The solution is to introduce a Pointer<A> to break the chain:
A' → B' → Pointer<A> → A → BThis allows tracking mutations on both A and B. The chain is split into two segments:
Self ──[OuterDepth]──> Pointer<Head> ───> Head ──[InnerDepth]──> Target
coinductive inductive- OuterDepth: The number of coinductive dereferences from the observer to its internal
Pointer. For most observers (e.g.,StringObserver,HashMapObserver), this is 1. For composite observers likeVecObserver, which wrapsSliceObserver, it is 2. ForPointer<T>itself, it is 0. - InnerDepth: The number of inductive dereferences from the
Head(the type stored in thePointer) to the final observedTarget. For example, aVecObserverhasHead = Vec<T>andTarget = [T], soInnerDepth = 1(oneDerefstep).
These depths are tracked at the type level using Zero and Succ<N>, enabling the compiler to verify the chain is well-formed.
§Tail and Non-Tail Observers
Observers are classified by their Deref target:
- Tail observers deref directly to
Pointer<S>(e.g.,StringObserver,SliceObserver,HashMapObserver). They are the innermost observer layer in the chain, sitting right next to thePointer. - Non-tail observers deref to another observer (e.g.,
VecObserverderefs toSliceObserver). They form outer layers in the chain.
This distinction matters for mutation tracking, as described in the next section.
§Primitives of Mutation Tracking
When a mutable method is called on an observer, one of three things can happen, depending on how the method is implemented:
§Fully tracked operations (untracked_mut)
Methods like Vec::push or String::push_str have explicit observer implementations that know exactly what mutation occurred. These methods use untracked_mut() to access the underlying value without triggering any invalidation, then update the observer’s diff state manually (e.g., incrementing an append_index).
No invalidation is needed because the observer already knows the precise mutation.
// Simplified implementation of Vec::push on VecObserver
fn push(&mut self, value: T) {
self.untracked_mut().push(value);
// The append_index tracking handles the rest —
// flush will emit an Append mutation.
}§Coarse-grained operations (tracked_mut)
Methods like Vec::retain or String::insert modify the value in ways the observer cannot express with a granular mutation kind. These methods use tracked_mut(), which:
- Calls
invalidateon the current observer, resetting its diff state. - Propagates invalidation to all sibling observers that sit between this observer and the
Pointer(i.e., observers in the “outer” direction). - Returns a mutable reference to the value via
DerefMutUntracked, bypassing allDerefMuthooks.
After invalidation, the next flush will produce a Replace mutation for the affected value.
// Simplified implementation of Vec::retain on VecObserver
fn retain<F: FnMut(&T) -> bool>(&mut self, f: F) {
self.tracked_mut().retain(f);
// The observer's state is now invalidated —
// flush will emit a Replace mutation.
}§Unimplemented methods (fallback invalidation)
For any &mut self method that has no explicit observer implementation, the call falls through to Rust’s DerefMut. On a tail observer, DerefMut triggers fallback invalidation: it calls Pointer::invalidate, which iterates all registered observer states and invalidates them. This ensures that every observer in the chain is aware that an uncontrolled mutation may have occurred.
On a non-tail observer, DerefMut is a no-op pass-through to the inner observer. The inner observer’s DerefMut (or the tail observer’s fallback invalidation) handles the actual invalidation.
Fallback invalidation is maximally conservative: it invalidates the entire chain, causing a full Replace on the next flush. This guarantees correctness — no mutation is ever lost — at the cost of granularity for unimplemented methods.
§The QuasiObserver Trait
The QuasiObserver trait formalizes the dereference chain and provides the three primitives above as methods:
trait QuasiObserver {
type Head: ?Sized;
type OuterDepth: Unsigned;
type InnerDepth: Unsigned;
fn invalidate(this: &mut Self);
fn untracked_ref(&self) -> &Target { .. }
fn untracked_mut(&mut self) -> &mut Target { .. }
fn tracked_mut(&mut self) -> &mut Target { .. }
}Each method traverses the chain differently:
untracked_ref()performs a read-only traversal: coinductive deref to thePointer, thenDeref(no side effects), then inductive deref to theTarget. Since reads do not mutate, no invalidation is needed.tracked_mut()first callsinvalidateonself, then reaches theTargetviaDerefMutUntracked— a special trait that bypasses allDerefMuthooks by usingPointer’s interior mutability to obtain&mutaccess through an immutable coinductive traversal. Only the observer on whichtracked_mut()is called (and observers between it and thePointer) are invalidated; outer observers are unaffected.untracked_mut()uses the sameDerefMutUntrackedpath astracked_mut(), but skips theinvalidatecall entirely. The caller is responsible for updating the diff state.
§Autoref-Based Specialization
The observe! macro needs to transform assignment and comparison expressions to work uniformly with both observers and plain values. This creates two problems:
- Assignment: Writing
observer.field = valuewould replace the observer itself rather than assigning to the observed field. The macro transforms this to*(&mut observer.field).tracked_mut() = value. - Comparison: Implementing both
Observer<T>: PartialEq<U>andObserver<T>: PartialEq<Observer<U>>would conflict. The macro transformslhs == rhsto*(&lhs).untracked_ref() == *(&rhs).untracked_ref().
For these transformations to work, tracked_mut and untracked_ref must be callable on both observers and plain references. This is achieved through autoref-based specialization: QuasiObserver is implemented for &T and &mut T (where all methods reduce to identity), and Rust’s method resolution naturally selects the observer implementation when called on an observer, or the reference implementation when called on a plain value. The name “quasi-observer” reflects this dual nature — plain references are not real observers, but they participate in the same interface.
§Minimum Supported Rust Version
The MSRV of morphix is 1.89.0.
Some APIs require newer Rust versions and are gated with #[rustversion::since(...)].
§Features
-
derive(default): Enables thederive(Observe)andobserve!macros -
Mutation Kinds:
append(default): EnablesAppendmutation kinddelete(default): EnablesDeletemutation kindtruncate(default): EnablesTruncatemutation kind
-
Adapters:
json: Includes JSON serialization support viaserde_jsonyaml: Includes YAML serialization support viaserde_yaml_ng
-
Third party integrations:
chronoindexmapuuid
Re-exports§
Modules§
- adapter
- Adapters for serializing mutations to different formats.
- general
- General observation strategies.
- helper
- Helper utilities for internal implementation details.
- impls
- Observer implementations for library types.
- observe
- Types and traits for observing mutations to data structures.
Macros§
- observe
derive - Observe and collect mutations within a closure.
Structs§
- Batch
Tree - A batch collector for aggregating and optimizing multiple mutations.
- Mutation
- A mutation representing a change to a value at a specific path.
- Mutations
- A collection of mutations collected during observation.
- Path
- A path to a nested value within a data structure.
Enums§
- Mutation
Error - Error types for mutation operations.
- Mutation
Kind - The kind of mutation that occurred.
- Path
Segment - A segment of a mutation path.