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 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/derive/src/patch.rs b/derive/src/patch.rs index 1b35e8e..2cad796 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: &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::>(); + #[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(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, )* @@ -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, )* @@ -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,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 } @@ -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); )* @@ -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(), )* @@ -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), )* @@ -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 = None; #[cfg(feature = "op")] let mut addable = Addable::Disable; @@ -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 `{}`", @@ -1107,6 +1188,7 @@ impl Field { #[cfg(feature = "nesting")] nesting, special_attr, + apply_by, })) } } @@ -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())), @@ -1177,6 +1260,7 @@ mod tests { false, Span::call_site(), ))), + apply_by: None, }, ], }; 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. diff --git a/lib/examples/apply-by.rs b/lib/examples/apply-by.rs new file mode 100644 index 0000000..4521c39 --- /dev/null +++ b/lib/examples/apply-by.rs @@ -0,0 +1,45 @@ +use struct_patch::Patch; + +fn concat_list(original: &mut Vec, additional: Vec) { + original.extend(additional); +} + +#[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); +}