Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
nix develop .#ci -c cargo run --quiet --no-default-features --features=nesting --example nesting
nix develop .#ci -c cargo run --quiet --no-default-features --features=option --example option
nix develop .#ci -c cargo run --quiet --no-default-features --example=log
nix develop .#ci -c cargo run --quiet --no-default-features --example apply-by
nix develop .#ci -c cargo run --quiet --no-default-features --features=box --example box
nix develop .#ci -c cargo test --quiet --no-default-features

Expand Down Expand Up @@ -70,6 +71,7 @@ jobs:
nix develop .#ci -c cargo run --quiet --features=nesting --example nesting
nix develop .#ci -c cargo run --quiet --features=nesting --example clap
nix develop .#ci -c cargo run --quiet --example=log
nix develop .#ci -c cargo run --quiet --example apply-by
nix develop .#ci -c cargo run --quiet --features=box --example box
nix develop .#ci -c cargo test --quiet

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ The following are more specific scenarios to help you learn the details:
#### Case 1 - Patch on a Config
Deriving `Patch` on a struct generates a struct similar to the original one, but with all fields wrapped in an `Option`.
An instance of such a patch struct can be applied onto the original struct, replacing values only if they are set to `Some`, leaving them unchanged otherwise.
See also: [Case Study: Avoid double-`Option` for `Option<Vec<_>>` fields](docs/already-optional-field.md) | [Case Study: Log which fields were patched or filled](docs/logs.md)
See also Case Studies:
- [Avoid double-`Option` for `Option<Vec<_>>` fields](docs/already-optional-field.md)
- [Log which fields were patched or filled](docs/logs.md)
- [Custom apply logic per field with `apply_by`](docs/custom-apply.md)
```rust
use struct_patch::Patch;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -185,6 +188,7 @@ Two attribute namespaces are provided for the catalyst feature because we need t
- `#[patch(attribute(derive(...)))]`: add derives to the field in the generated patch struct.
- `#[patch(empty_value = ...)]`: define a value as empty, so the corresponding field of the patch will not be wrapped by `Option`, and the patch is applied when the field differs from the empty value.
- `#[patch(skip_wrap)]`: keep the field type as-is in the patch struct (no extra `Option` wrapping). Useful when the field is already `Option<...>` (for example `Option<Vec<_>>`) and you do not want a double-`Option` in the patch. With `skip_wrap`, `None` in the patch means "no change" and `Some(v)` sets the field to `Some(v)` (including `Some(vec![])` to clear the vector). Cannot be combined with `empty_value`.
- `#[patch(apply_by(fn))]`: call `fn(original, new_value)` to update the field when the patch field is `Some`, instead of a plain assignment. The function signature must be `fn(original: &mut T, new_value: T)` — it mutates in place, so no `Default` bound is needed. When two patches are combined with `+` and both carry `Some` for this field, the same function merges the two patch values.
- `#[patch(nesting)]`: treat the field as a nested patchable struct. The inner struct must also derive `Patch`. Requires the `nesting` feature.
- `#[patch(addable)]`: allow conflicting patches to add their values together with the `+` operator instead of panicking. Requires the `op` feature.
- `#[patch(add = fn)]`: like `addable`, but use the specified function to combine values. Requires the `op` feature.
Expand Down Expand Up @@ -213,6 +217,7 @@ The [examples][examples] demonstrate the following scenarios:
- use `Patch` with `clap` for command-line config (`clap.rs`)
- demonstrate `default_log` and `apply_with_log` for both `Patch` and `Filler` (`log.rs`)
- apply a heap-allocated (boxed) patch and produce a boxed diff (`box.rs`)
- demonstrate `apply_by` for custom field-level apply logic, e.g. list concatenation (`apply-by.rs`)

## Features

Expand Down
88 changes: 86 additions & 2 deletions derive/src/patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const NESTING: &str = "nesting";
const EMPTY_VALUE: &str = "empty_value";
const SKIP_WRAP: &str = "skip_wrap";
const DEFAULT_LOG: &str = "default_log";
const APPLY_BY: &str = "apply_by";

pub(crate) struct Patch {
visibility: syn::Visibility,
Expand Down Expand Up @@ -62,6 +63,7 @@ struct Field {
#[cfg(feature = "nesting")]
nesting: bool,
special_attr: SpecialAttr,
apply_by: Option<syn::Path>,
}

impl Patch {
Expand Down Expand Up @@ -164,7 +166,7 @@ impl Patch {
#[cfg(not(feature = "nesting"))]
let original_field_names = fields
.iter()
.filter(|f| !f.retyped && f.special_attr.is_empty())
.filter(|f| !f.retyped && f.special_attr.is_empty() && f.apply_by.is_none())
.map(|f| f.ident.as_ref())
.collect::<Vec<_>>();
#[cfg(not(feature = "nesting"))]
Expand All @@ -176,7 +178,7 @@ impl Patch {
#[cfg(feature = "nesting")]
let original_field_names = fields
.iter()
.filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty())
.filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty() && f.apply_by.is_none())
.map(|f| f.ident.as_ref())
.collect::<Vec<_>>();
#[cfg(feature = "nesting")]
Expand All @@ -187,6 +189,33 @@ impl Patch {
})
.map(|f| f.ident.as_ref())
.collect::<Vec<_>>();

// Fields with `#[patch(apply_by(fn))]` — applied via a user-supplied function
// `fn(original: &mut T, new_value: T) ` instead of a plain assignment.
#[cfg(not(feature = "nesting"))]
let apply_by_field_names = fields
.iter()
.filter(|f| !f.retyped && f.special_attr.is_empty() && f.apply_by.is_some())
.map(|f| f.ident.as_ref())
.collect::<Vec<_>>();
#[cfg(not(feature = "nesting"))]
let apply_by_fns = fields
.iter()
.filter(|f| !f.retyped && f.special_attr.is_empty() && f.apply_by.is_some())
.filter_map(|f| f.apply_by.as_ref())
.collect::<Vec<_>>();
#[cfg(feature = "nesting")]
let apply_by_field_names = fields
.iter()
.filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty() && f.apply_by.is_some())
.map(|f| f.ident.as_ref())
.collect::<Vec<_>>();
#[cfg(feature = "nesting")]
let apply_by_fns = fields
.iter()
.filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty() && f.apply_by.is_some())
.filter_map(|f| f.apply_by.as_ref())
.collect::<Vec<_>>();
#[cfg(not(feature = "nesting"))]
let original_field_name_empty_values = fields
.iter()
Expand Down Expand Up @@ -304,6 +333,9 @@ impl Patch {
#(
#skip_wrap_field_names: other.#skip_wrap_field_names.or(self.#skip_wrap_field_names),
)*
#(
#apply_by_field_names: other.#apply_by_field_names.or(self.#apply_by_field_names),
)*
#(
#nesting_field_names: other.#nesting_field_names.merge(self.#nesting_field_names),
)*
Expand Down Expand Up @@ -414,6 +446,14 @@ impl Patch {
(None, None) => None,
},
)*
#(
#apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) {
(Some(mut a), Some(b)) => { #apply_by_fns(&mut a, b); Some(a) },
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
},
)*
#(
#nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names,
)*
Expand Down Expand Up @@ -503,6 +543,14 @@ impl Patch {
(None, None) => None,
},
)*
#(
#apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) {
(Some(mut a), Some(b)) => { #apply_by_fns(&mut a, b); Some(a) },
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
},
)*
#(
#nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names,
)*
Expand Down Expand Up @@ -532,6 +580,7 @@ impl Patch {
let original_log_calls = make_log_calls(&original_field_names);
let original_by_ev_log_calls = make_log_calls(&original_field_names_by_empty_value);
let skip_wrap_log_calls = make_log_calls(&skip_wrap_field_names);
let apply_by_log_calls = make_log_calls(&apply_by_field_names);

// For the `apply` method: propagate `default_log_fn` into nesting fields so
// that sub-fields of nested structs are also logged when applying with a
Expand Down Expand Up @@ -588,6 +637,12 @@ impl Patch {
self.#skip_wrap_field_names = Some(v);
}
)*
#(
if let Some(v) = patch.#apply_by_field_names {
#apply_by_log_calls
#apply_by_fns(&mut self.#apply_by_field_names, v);
}
)*
#nesting_apply_section
}

Expand Down Expand Up @@ -622,6 +677,12 @@ impl Patch {
self.#skip_wrap_field_names = Some(v);
}
)*
#(
if let Some(v) = patch.#apply_by_field_names {
log(stringify!(#apply_by_field_names));
#apply_by_fns(&mut self.#apply_by_field_names, v);
}
)*
#(
self.#nesting_field_names.apply_with_log(patch.#nesting_field_names, &mut log);
)*
Expand All @@ -644,6 +705,9 @@ impl Patch {
#(
#skip_wrap_field_names: self.#skip_wrap_field_names,
)*
#(
#apply_by_field_names: Some(self.#apply_by_field_names),
)*
#(
#nesting_field_names: self.#nesting_field_names.into_patch(),
)*
Expand Down Expand Up @@ -692,6 +756,14 @@ impl Patch {
None
},
)*
#(
#apply_by_field_names: if self.#apply_by_field_names != previous_struct.#apply_by_field_names {
Some(self.#apply_by_field_names)
}
else {
None
},
)*
#(
#nesting_field_names: self.#nesting_field_names.into_patch_by_diff(previous_struct.#nesting_field_names),
)*
Expand Down Expand Up @@ -986,6 +1058,7 @@ impl Field {
let mut field_type = None;
let mut skip = false;
let mut special_attr = SpecialAttr::None;
let mut apply_by: Option<syn::Path> = None;

#[cfg(feature = "op")]
let mut addable = Addable::Disable;
Expand Down Expand Up @@ -1083,6 +1156,14 @@ impl Field {
}
special_attr = SpecialAttr::SkipWrap;
}
APPLY_BY => {
// #[patch(apply_by(path::to::fn))]
// The function is called as `fn(original: &mut T, new_value: T)`
// when the patch field is Some.
let content;
parenthesized!(content in meta.input);
apply_by = Some(content.parse()?);
}
_ => {
return Err(meta.error(format_args!(
"unknown patch field attribute `{}`",
Expand All @@ -1107,6 +1188,7 @@ impl Field {
#[cfg(feature = "nesting")]
nesting,
special_attr,
apply_by,
}))
}
}
Expand Down Expand Up @@ -1163,6 +1245,7 @@ mod tests {
#[cfg(feature = "nesting")]
nesting: false,
special_attr: SpecialAttr::None,
apply_by: None,
},
Field {
ident: Some(syn::Ident::new("field3", Span::call_site())),
Expand All @@ -1177,6 +1260,7 @@ mod tests {
false,
Span::call_site(),
))),
apply_by: None,
},
],
};
Expand Down
51 changes: 51 additions & 0 deletions docs/custom-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Case Study: Custom apply logic per field with `apply_by`

Use `#[patch(apply_by(fn))]` on a field to call a user-supplied function instead
of a plain assignment when that patch field is `Some`. The function signature
must be `fn(original: &mut T, new_value: T)`, where `T` is the field type.

A typical use-case is a list field where patches should *append* rather than
*replace*:

```rust
use struct_patch::Patch;

fn concat_list(original: &mut Vec<i32>, additional: Vec<i32>) {
original.extend(additional);
}

#[derive(Default, Patch)]
struct Config {
#[patch(apply_by(concat_list))]
items: Vec<i32>,
name: String,
}

let mut config = Config { items: vec![1, 2, 3], name: "base".to_string() };

config.apply(ConfigPatch { items: Some(vec![4, 5, 6]), name: None });
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6]);

config.apply(ConfigPatch { items: Some(vec![7, 8]), name: None });
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6, 7, 8]);
```

## Auto-merge on patch combination

When two patches are combined with `+`, fields annotated with `apply_by` auto-merge
instead of panicking. The provided function is reused to combine the two `Some` values:

```rust
let patch_a = ConfigPatch { items: Some(vec![4, 5]), name: None };
let patch_b = ConfigPatch { items: Some(vec![6, 7]), name: None };

// Regular conflicting fields would panic here, but `apply_by` fields auto-merge.
let combined = patch_a + patch_b;
// combined.items == Some(vec![4, 5, 6, 7]) — concat_list was called to merge them

config.apply(combined);
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6, 7]);
```

This contrasts with ordinary patch fields, where combining two `Some` values with `+`
panics unless `#[patch(addable)]` or `#[patch(add = fn)]` is also set.
45 changes: 45 additions & 0 deletions lib/examples/apply-by.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use struct_patch::Patch;

fn concat_list(original: &mut Vec<i32>, additional: Vec<i32>) {
original.extend(additional);
}

#[derive(Debug, Default, Patch)]
#[patch(attribute(derive(Debug, Default)))]
struct Config {
#[patch(apply_by(concat_list))]
items: Vec<i32>,
name: String,
}

fn main() {
let mut config = Config {
items: vec![1, 2, 3],
name: "base".to_string(),
};

// Patch with apply_by: items are concatenated instead of replaced.
config.apply(ConfigPatch {
items: Some(vec![4, 5, 6]),
name: None,
});
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6]);
println!("After first patch: {:?}", config.items);

// A second patch appends more items.
config.apply(ConfigPatch {
items: Some(vec![7, 8]),
name: Some("updated".to_string()),
});
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6, 7, 8]);
println!("After second patch: {:?}", config.items);
println!("Name: {}", config.name);

// None patch leaves the field unchanged.
config.apply(ConfigPatch {
items: None,
name: None,
});
assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6, 7, 8]);
println!("After empty patch: {:?}", config.items);
}