From 6d772d4325878f8405820c8635ac5d584a950572 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Mon, 10 Aug 2026 23:41:30 +0800 Subject: [PATCH 1/6] add `#[patch(appy_by(fn))]` --- derive/src/patch.rs | 95 +++++++++++++++++++++++++++++++++++++++- lib/examples/apply-by.rs | 45 +++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 lib/examples/apply-by.rs diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 1b35e8e..cff3e5d 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -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, @@ -62,6 +63,7 @@ struct Field { #[cfg(feature = "nesting")] nesting: bool, special_attr: SpecialAttr, + apply_by: Option, } impl Patch { @@ -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::>(); #[cfg(not(feature = "nesting"))] @@ -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::>(); #[cfg(feature = "nesting")] @@ -187,6 +189,33 @@ impl Patch { }) .map(|f| f.ident.as_ref()) .collect::>(); + + // Fields with `#[patch(apply_by(fn))]` — applied via a user-supplied function + // `fn(original: T, new_value: T) -> 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::>(); + #[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::>(); + #[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::>(); + #[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::>(); #[cfg(not(feature = "nesting"))] let original_field_name_empty_values = fields .iter() @@ -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), )* @@ -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(_), Some(_)) => panic!("There are conflict patches on an apply_by field; only one patch may be Some."), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* #( #nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names, )* @@ -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(_), Some(_)) => panic!("There are conflict patches on an apply_by field; only one patch may be Some."), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* #( #nesting_field_names: self.#nesting_field_names + rhs.#nesting_field_names, )* @@ -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 @@ -588,6 +637,15 @@ impl Patch { self.#skip_wrap_field_names = Some(v); } )* + #( + if let Some(v) = patch.#apply_by_field_names { + #apply_by_log_calls + self.#apply_by_field_names = #apply_by_fns( + ::core::mem::take(&mut self.#apply_by_field_names), + v, + ); + } + )* #nesting_apply_section } @@ -622,6 +680,15 @@ impl Patch { self.#skip_wrap_field_names = Some(v); } )* + #( + if let Some(v) = patch.#apply_by_field_names { + log(stringify!(#apply_by_field_names)); + self.#apply_by_field_names = #apply_by_fns( + ::core::mem::take(&mut self.#apply_by_field_names), + v, + ); + } + )* #( self.#nesting_field_names.apply_with_log(patch.#nesting_field_names, &mut log); )* @@ -644,6 +711,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(), )* @@ -692,6 +762,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), )* @@ -986,6 +1064,7 @@ impl Field { let mut field_type = None; let mut skip = false; let mut special_attr = SpecialAttr::None; + let mut apply_by: Option = None; #[cfg(feature = "op")] let mut addable = Addable::Disable; @@ -1083,6 +1162,15 @@ impl Field { } special_attr = SpecialAttr::SkipWrap; } + APPLY_BY => { + // #[patch(apply_by(path::to::fn))] + // The function is called as `fn(original: T, new_value: T) -> T` + // when the patch field is Some. Requires T: Default to take ownership + // of the original value without a temporary placeholder. + let content; + parenthesized!(content in meta.input); + apply_by = Some(content.parse()?); + } _ => { return Err(meta.error(format_args!( "unknown patch field attribute `{}`", @@ -1107,6 +1195,7 @@ impl Field { #[cfg(feature = "nesting")] nesting, special_attr, + apply_by, })) } } @@ -1163,6 +1252,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())), @@ -1177,6 +1267,7 @@ mod tests { false, Span::call_site(), ))), + apply_by: None, }, ], }; diff --git a/lib/examples/apply-by.rs b/lib/examples/apply-by.rs new file mode 100644 index 0000000..49c1345 --- /dev/null +++ b/lib/examples/apply-by.rs @@ -0,0 +1,45 @@ +use struct_patch::Patch; + +fn concat_list(original: Vec, additional: Vec) -> Vec { + original.into_iter().chain(additional).collect() +} + +#[derive(Debug, Default, Patch)] +#[patch(attribute(derive(Debug, Default)))] +struct Config { + #[patch(apply_by(concat_list))] + items: Vec, + 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); +} From 18fd8b720fa8d0a625ef091082a466d87d978065 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Mon, 10 Aug 2026 23:47:22 +0800 Subject: [PATCH 2/6] auto-merge if #[patch(apply_by(fn))] set --- derive/src/patch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/derive/src/patch.rs b/derive/src/patch.rs index cff3e5d..22aa486 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -448,7 +448,7 @@ impl Patch { )* #( #apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) { - (Some(_), Some(_)) => panic!("There are conflict patches on an apply_by field; only one patch may be Some."), + (Some(a), Some(b)) => Some(#apply_by_fns(a, b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (None, None) => None, @@ -545,7 +545,7 @@ impl Patch { )* #( #apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) { - (Some(_), Some(_)) => panic!("There are conflict patches on an apply_by field; only one patch may be Some."), + (Some(a), Some(b)) => Some(#apply_by_fns(a, b)), (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (None, None) => None, From 57a6c9a0c3f0f9c1bc7ed0161b965f94c5764a01 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Mon, 10 Aug 2026 23:54:09 +0800 Subject: [PATCH 3/6] remove Default trait dependency --- derive/src/patch.rs | 19 ++++++------------- lib/examples/apply-by.rs | 4 ++-- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 22aa486..5eea5c6 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -448,7 +448,7 @@ impl Patch { )* #( #apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) { - (Some(a), Some(b)) => Some(#apply_by_fns(a, b)), + (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, @@ -545,7 +545,7 @@ impl Patch { )* #( #apply_by_field_names: match (self.#apply_by_field_names, rhs.#apply_by_field_names) { - (Some(a), Some(b)) => Some(#apply_by_fns(a, b)), + (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, @@ -640,10 +640,7 @@ impl Patch { #( if let Some(v) = patch.#apply_by_field_names { #apply_by_log_calls - self.#apply_by_field_names = #apply_by_fns( - ::core::mem::take(&mut self.#apply_by_field_names), - v, - ); + #apply_by_fns(&mut self.#apply_by_field_names, v); } )* #nesting_apply_section @@ -683,10 +680,7 @@ impl Patch { #( if let Some(v) = patch.#apply_by_field_names { log(stringify!(#apply_by_field_names)); - self.#apply_by_field_names = #apply_by_fns( - ::core::mem::take(&mut self.#apply_by_field_names), - v, - ); + #apply_by_fns(&mut self.#apply_by_field_names, v); } )* #( @@ -1164,9 +1158,8 @@ impl Field { } APPLY_BY => { // #[patch(apply_by(path::to::fn))] - // The function is called as `fn(original: T, new_value: T) -> T` - // when the patch field is Some. Requires T: Default to take ownership - // of the original value without a temporary placeholder. + // 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()?); diff --git a/lib/examples/apply-by.rs b/lib/examples/apply-by.rs index 49c1345..4521c39 100644 --- a/lib/examples/apply-by.rs +++ b/lib/examples/apply-by.rs @@ -1,7 +1,7 @@ use struct_patch::Patch; -fn concat_list(original: Vec, additional: Vec) -> Vec { - original.into_iter().chain(additional).collect() +fn concat_list(original: &mut Vec, additional: Vec) { + original.extend(additional); } #[derive(Debug, Default, Patch)] From 207ade88b2b56dcffc43ca67261a68b2947f6320 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Mon, 10 Aug 2026 23:54:37 +0800 Subject: [PATCH 4/6] ci: add apply-by example --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2226bdf..9bf0e4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 From bc9b50387c68fb9321b87975bb47afce0bba0b48 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Mon, 10 Aug 2026 23:54:50 +0800 Subject: [PATCH 5/6] update readme and docs --- README.md | 7 +++++- docs/custom-apply.md | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 docs/custom-apply.md diff --git a/README.md b/README.md index 9ad9abc..8ba253b 100644 --- a/README.md +++ b/README.md @@ -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>` 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>` 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}; @@ -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>`) 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. @@ -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 diff --git a/docs/custom-apply.md b/docs/custom-apply.md new file mode 100644 index 0000000..efbdcb6 --- /dev/null +++ b/docs/custom-apply.md @@ -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, additional: Vec) { + original.extend(additional); +} + +#[derive(Default, Patch)] +struct Config { + #[patch(apply_by(concat_list))] + items: Vec, + 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. From 26071e4d78c7fd44b0dcff51fd81bf1209480842 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Tue, 11 Aug 2026 21:08:31 +0800 Subject: [PATCH 6/6] revist document --- derive/src/patch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 5eea5c6..2cad796 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -191,7 +191,7 @@ impl Patch { .collect::>(); // Fields with `#[patch(apply_by(fn))]` — applied via a user-supplied function - // `fn(original: T, new_value: T) -> T` instead of a plain assignment. + // `fn(original: &mut T, new_value: T) ` instead of a plain assignment. #[cfg(not(feature = "nesting"))] let apply_by_field_names = fields .iter()