Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
27f4989
docs: add enum derive support design spec
ShapelessCat Mar 27, 2026
b0764e4
chore: ignore repo-local worktrees
ShapelessCat Mar 27, 2026
c804780
test: add failing enum derive coverage
ShapelessCat Mar 27, 2026
37d1414
feat: add enum recallable and memento generation
ShapelessCat Mar 27, 2026
1bb427c
feat: add assignment-only enum recall derive
ShapelessCat Mar 27, 2026
a356158
feat: support recallable_model on assignment-only enums
ShapelessCat Mar 27, 2026
b3a0813
docs: describe enum derive support and assignment-only gating
ShapelessCat Mar 27, 2026
085c437
docs: specify derive module split
ShapelessCat Mar 27, 2026
cbc68ad
test: lock derive module split seams
ShapelessCat Mar 27, 2026
d7f1042
refactor(macro): split derive backend by item kind
ShapelessCat Mar 27, 2026
a85def1
refactor(macro): remove obsolete flat emitter files
ShapelessCat Mar 27, 2026
1200be7
docs: specify derive backend cleanup
ShapelessCat Mar 27, 2026
d967a74
refactor(macro): clarify derive boundaries
ShapelessCat Mar 27, 2026
27fb5e0
refactor(macro): normalize flat module layout
ShapelessCat Mar 27, 2026
d980dc9
refactor(macro): simplify code
ShapelessCat Mar 28, 2026
b03736c
test: eliminate warnings from tests
ShapelessCat Mar 28, 2026
2d3b56b
2026-03-28-derive-backend-cleanup.md
ShapelessCat Mar 28, 2026
e472049
fix(macro): remove warning-producing derive output
ShapelessCat Mar 28, 2026
cb8f5ac
refactor(macro): append impl_from output directly
ShapelessCat Mar 28, 2026
83d8a53
refactor: remove `&` on values that are `Copy`
ShapelessCat Mar 28, 2026
db1354f
refactor: make some function return iterator instead of vectors
ShapelessCat Mar 28, 2026
454413b
refactor(macro): deduplicate build_binding_ident helper
ShapelessCat Mar 28, 2026
4c0ef1d
refactor(macro): optimize ModelItem::parse
ShapelessCat Mar 28, 2026
a0dcff0
fix(macro): restore impl_from codegen
ShapelessCat Mar 28, 2026
18a00f5
refactor(macro): share item codegen helpers
ShapelessCat Mar 28, 2026
8c392c0
refactor(macro): reduce allocations in codegen
ShapelessCat Mar 28, 2026
1db71b7
refactor(macro): move #[automatically_derived] to individual codegen …
ShapelessCat Mar 28, 2026
17aa4e8
chore: enhance Makefile
ShapelessCat Mar 28, 2026
1e23d51
fix(macro): auto-skip phantom data fields
ShapelessCat Mar 28, 2026
b3189ac
chore: add .claude/ to .gitignore
ShapelessCat Mar 28, 2026
72626b4
docs: clarify skipped phantom enum recall support
ShapelessCat Mar 28, 2026
f0fba01
docs: explain hidden markers for retained generics
ShapelessCat Mar 28, 2026
119c570
test: cover enum impl_from bare generic bounds
ShapelessCat Mar 28, 2026
324656b
refactor(macro): simplify unit From emission
ShapelessCat Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
.idea/
.vscode/
.worktrees/

.claude/

target/
fuzz/artifacts/
Expand Down
110 changes: 99 additions & 11 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ Base dependency:

```toml
[dependencies]
recallable = "0.1.0"
recallable = "0.2.0"
```

MSRV is Rust 1.88 with edition 2024.
Expand All @@ -182,15 +182,15 @@ Example dependency sets:
```toml
[dependencies]
# Readable std example
recallable = "0.1.0"
recallable = "0.2.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```

```toml
[dependencies]
# no_std + serde example
recallable = { version = "0.1.0", default-features = false, features = ["serde"] }
recallable = { version = "0.2.0", default-features = false, features = ["serde"] }
serde = { version = "1", default-features = false, features = ["derive"] }
postcard = { version = "1", default-features = false, features = ["heapless"] }
heapless = { version = "0.9.2", default-features = false }
Expand Down Expand Up @@ -255,6 +255,13 @@ With the default `serde` feature enabled, it also injects:
- `#[derive(serde::Serialize)]` on the source struct
- `#[serde(skip)]` on fields marked `#[recallable(skip)]`

Enum support is intentionally split:

- assignment-only enums can use `#[recallable_model]` directly
- enums with skipped `PhantomData<_>` marker fields can also use it directly
- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields
should derive `Recallable` and implement `Recall` or `TryRecall` manually

Example:

```rust
Expand Down Expand Up @@ -356,6 +363,13 @@ Important distinction:
- `#[recallable_model]` mutates source-side serde behavior for the common case
- direct `#[derive(Recallable, Recall)]` does not

Direct derives are also the split point for complex enums:

- `#[derive(Recallable)]` supports enum-shaped mementos under the normal field rules
- `#[derive(Recall)]` works only for assignment-only enums
- enums with nested `#[recallable]` or skipped variant fields should derive
`Recallable` and implement `Recall` or `TryRecall` manually

If you use direct derives and want the source struct to serialize in the same
shape as the generated memento, you must add the serde derives and
`#[serde(skip)]` attributes yourself.
Expand All @@ -381,6 +395,66 @@ Generated mementos are intentionally somewhat opaque:
This design pushes callers toward "construct or deserialize a memento, then
apply it" instead of depending on widened field visibility.

### Skipped `PhantomData` and retained generics

Most skipped fields simply disappear from the generated memento. The tricky case
is when a skipped `PhantomData<_>` is the only field mentioning a generic that
still must remain part of the memento type.

```rust
use core::any::TypeId;
use core::marker::PhantomData;
use recallable::Recallable;

#[derive(Recallable)]
struct BoundDependent<T: From<U>, U> {
value: T,
#[recallable(skip)]
marker: PhantomData<U>,
}

type Left = <BoundDependent<String, &'static str> as Recallable>::Memento;
type Right = <BoundDependent<String, String> as Recallable>::Memento;

assert_ne!(TypeId::of::<Left>(), TypeId::of::<Right>());
```

Why this needs a hidden marker:

- the skipped field means there is no visible memento field of type `U`
- `U` still matters, because the retained generic `T` depends on it through
`T: From<U>`
- the generated memento type therefore needs to keep `U` alive internally

The derive handles that by synthesizing an internal `PhantomData` marker on the
generated memento.

If a skipped generic is otherwise unused, the derive prunes it instead of
preserving it:

```rust
use core::any::TypeId;
use core::marker::PhantomData;
use recallable::Recallable;

#[derive(Recallable)]
enum SkippedGenericEnum<T, U> {
Value(T),
Marker(#[recallable(skip)] PhantomData<U>),
}

type Left = <SkippedGenericEnum<u8, u16> as Recallable>::Memento;
type Right = <SkippedGenericEnum<u8, u32> as Recallable>::Memento;

assert_eq!(TypeId::of::<Left>(), TypeId::of::<Right>());
```

So the rule is:

- if a skipped generic is no longer needed, the memento drops it
- if it is still needed by retained generics or bounds, the derive keeps it via
an internal hidden marker

## Recursive fields and container-defined semantics

Mark a field with `#[recallable]` when that field should use its own
Expand Down Expand Up @@ -476,7 +550,7 @@ the generated memento.

```toml
[dependencies]
recallable = { version = "0.1.0", features = ["impl_from"] }
recallable = { version = "0.2.0", features = ["impl_from"] }
```

```rust
Expand Down Expand Up @@ -541,7 +615,7 @@ serde support by default:

```toml
[dependencies]
recallable = { version = "0.1.0", default-features = false }
recallable = { version = "0.2.0", default-features = false }
```

Then define the memento and recall behavior manually:
Expand Down Expand Up @@ -581,11 +655,16 @@ This manual style pairs naturally with:

The derive macros support more than just simple named structs.

### Supported struct shapes
### Supported item shapes

- named structs
- tuple structs
- unit structs
- enums for `Recallable`
- enums for `Recall` and `recallable_model` only when every variant field is
assignment-only
- complex enums should derive `Recallable` only and supply manual `Recall` or
`TryRecall`

### Supported generic forms

Expand Down Expand Up @@ -637,7 +716,7 @@ write-side output format by default.

### `#[recallable_model]`

Convenience attribute for the common struct model path.
Convenience attribute for the common struct or assignment-only enum model path.
It is the recommended default whether or not `serde` is enabled; with `serde`
enabled it also removes extra derive boilerplate.

Expand All @@ -646,6 +725,8 @@ Behavior:
- injects `Recallable` and `Recall`
- injects `serde::Serialize` when the `serde` feature is enabled
- injects `#[serde(skip)]` onto fields marked `#[recallable(skip)]`
- rejects complex enums where generated `Recall` would be ambiguous; those
should derive `Recallable` and implement `Recall` or `TryRecall` manually

### `#[derive(Recallable)]`

Expand All @@ -654,16 +735,19 @@ Generates:
- the companion memento type
- the `Recallable` implementation
- `From<Struct>` for the memento when `impl_from` is enabled
- enum-shaped mementos for enums, even when `Recall` must stay manual

### `#[derive(Recall)]`

Generates the `Recall` implementation.

Behavior:

- plain fields are assigned directly
- `#[recallable]` fields call nested `recall`
- `#[recallable(skip)]` fields are left untouched
- struct fields are handled as before
- enum derives are supported only for assignment-only variants, plus skipped
`PhantomData<_>` marker fields
- enums with nested `#[recallable]` or other skipped fields should derive
`Recallable` and implement `Recall` or `TryRecall` manually

### `#[recallable]`

Expand Down Expand Up @@ -718,7 +802,11 @@ pub trait TryRecall: Recallable {

## Current limitations

- Derive macros currently support structs only; enum support is not implemented
- `#[derive(Recallable)]` supports enums under the normal field rules
- `#[derive(Recall)]` and `#[recallable_model]` support enums only for
assignment-only variants
- complex enums should derive `Recallable` and implement `Recall` or
`TryRecall` manually
- Borrowed non-skipped state fields are rejected
- `#[recallable]` is path-only and does not accept tuple/reference/slice/function
syntax directly
Expand Down
42 changes: 39 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,75 @@

CARGO ?= cargo
TEST_ARGS ?= --workspace
EXAMPLE_ARGS ?= --package recallable
CARGO_LLVM_COV ?= cargo llvm-cov
COVERAGE_ARGS ?= --workspace
COVERAGE_DIR ?= target/llvm-cov
COVERAGE_HTML_INDEX := $(COVERAGE_DIR)/html/index.html
BROWSER_OPEN ?= open

.PHONY: help test test-default test-no-default test-impl-from test-all-features coverage coverage-open
.PHONY: help regression test validate-examples \
test-default test-no-default test-impl-from test-all-features \
examples-default examples-no-default examples-impl-from examples-all-features \
coverage coverage-open

help:
@printf '%s\n' \
'Available targets:' \
' make test Run the full workspace test feature matrix' \
' make regression Run the full workspace test and example matrix' \
' make test Run the original workspace test feature matrix' \
' make validate-examples Run the example validation matrix' \
' make test-default Run tests with default features' \
' make examples-default Run default-feature examples' \
' make test-no-default Run tests with no default features' \
' make examples-no-default Run examples with no default features' \
' make test-impl-from Run tests with impl_from enabled' \
' make examples-impl-from Run impl_from examples with no default features' \
' make test-all-features Run tests with all features enabled' \
' make examples-all-features Check examples with all features enabled' \
' make coverage Generate merged llvm-cov HTML and JSON reports' \
' make coverage-open Generate coverage reports and open the HTML report'

test: test-default test-no-default test-impl-from test-all-features
regression: test validate-examples

test: \
test-default \
test-no-default \
test-impl-from \
test-all-features

validate-examples: \
examples-default \
examples-no-default \
examples-impl-from \
examples-all-features

test-default:
$(CARGO) test $(TEST_ARGS)

examples-default:
$(CARGO) run $(EXAMPLE_ARGS) --example basic_model
$(CARGO) run $(EXAMPLE_ARGS) --example nested_generic
$(CARGO) run $(EXAMPLE_ARGS) --example postcard_roundtrip

test-no-default:
$(CARGO) test $(TEST_ARGS) --no-default-features

examples-no-default:
$(CARGO) run $(EXAMPLE_ARGS) --no-default-features --example manual_no_serde

test-impl-from:
$(CARGO) test $(TEST_ARGS) --features impl_from

examples-impl-from:
$(CARGO) run $(EXAMPLE_ARGS) --no-default-features --features impl_from --example impl_from_roundtrip

test-all-features:
$(CARGO) test $(TEST_ARGS) --all-features

examples-all-features:
$(CARGO) check $(EXAMPLE_ARGS) --examples --all-features

coverage:
$(CARGO_LLVM_COV) clean $(COVERAGE_ARGS)
$(CARGO_LLVM_COV) $(COVERAGE_ARGS) --no-default-features --tests --no-report
Expand Down
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ input.
- `Recall`: applies that memento infallibly
- `TryRecall`: applies it fallibly with validation
- `#[derive(Recallable)]`: generates the companion memento type
- `#[derive(Recall)]`: generates recall logic
- `#[recallable_model]`: convenience attribute for the common model path; with `serde` enabled it also removes extra
derive boilerplate
- `#[derive(Recall)]`: generates recall logic for structs and assignment-only enums
- `#[recallable_model]`: convenience attribute for the common struct or assignment-only enum path; with
`serde` enabled it also removes extra derive boilerplate

The crate intentionally does not force one universal memento shape for
container-like field types. A field type can choose whole-value replacement,
Expand Down Expand Up @@ -97,6 +97,19 @@ For `no_std + serde` deployments, prefer a `no_std`-compatible format such as
- `#[derive(serde::Serialize)]` when the default `serde` feature is enabled
- `#[serde(skip)]` for fields marked `#[recallable(skip)]`

For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recallable)]`:

- assignment-only enums are supported directly
- enums with skipped `PhantomData<_>` marker fields are still supported directly
- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should
derive `Recallable` and implement `Recall` or `TryRecall` manually

Skipped `PhantomData<_>` can also affect generic retention on generated
mementos. When a skipped marker is the only field mentioning a generic that
still must remain part of the memento type, the derive keeps that generic alive
with an internal hidden marker. The user-facing examples for that corner case
live in the API docs and [GUIDE.md](GUIDE.md).

## Features

- `serde` (default): enables macro-generated serde support; generated mementos derive
Expand All @@ -113,15 +126,15 @@ Example dependency sets:
```toml
[dependencies]
# Readable std example
recallable = "0.1"
recallable = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```

```toml
[dependencies]
# no_std + serde example
recallable = { version = "0.1", default-features = false, features = ["serde"] }
recallable = { version = "0.2", default-features = false, features = ["serde"] }
serde = { version = "1", default-features = false, features = ["derive"] }
postcard = { version = "1", default-features = false, features = ["heapless"] }
heapless = { version = "0.9.2", default-features = false }
Expand All @@ -130,7 +143,7 @@ heapless = { version = "0.9.2", default-features = false }
```toml
[dependencies]
# In-memory snapshots
recallable = { version = "0.1", features = ["impl_from"] }
recallable = { version = "0.2", features = ["impl_from"] }
```

## Two Common Workflows
Expand Down Expand Up @@ -183,7 +196,7 @@ default:

```toml
[dependencies]
recallable = { version = "0.1", default-features = false }
recallable = { version = "0.2", default-features = false }
```

Define the memento type and recall behavior manually:
Expand Down Expand Up @@ -223,7 +236,13 @@ impl Recall for EngineState {

## Current Limitations

- Derive macros currently support structs only: named, tuple, and unit structs
- Derive macros support structs and enums
- `#[derive(Recallable)]` supports enums under the normal field rules
- `#[derive(Recall)]` and `#[recallable_model]` support enums only when every
non-marker variant field is assignment-only
- Enums with skipped `PhantomData<_>` marker fields are still supported
- Enums with nested `#[recallable]` or other `#[recallable(skip)]` fields
should derive `Recallable` and implement `Recall` or `TryRecall` manually
- Borrowed state fields are rejected unless they are skipped
- `#[recallable]` is path-only: it supports type parameters, path types, and
associated types, but not tuple/reference/slice/function syntax directly
Expand Down
Loading