diff --git a/README.md b/README.md index 5bff95e..82bdb4f 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,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(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. Can be combined with `skip_wrap` as `#[patch(skip_wrap, apply_by(fn))]`: the field type is kept as-is (no extra `Option` wrapping) and `fn` is called to apply the change. - `#[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. diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 2cad796..8523c1b 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -37,11 +37,15 @@ enum SpecialAttr { EmptyValue(Lit), /// Field type is already `Option`; `None` means "no change", `Some(v)` applies the value. SkipWrap, + /// Field is Option-wrapped in the patch struct; applied via a user-supplied function. + ApplyBy(syn::Path), + /// Combines `skip_wrap` and `apply_by`: field keeps its original type, fn applied on change. + SkipWrapApplyBy(syn::Path), } impl SpecialAttr { - fn is_empty(&self) -> bool { - matches!(self, SpecialAttr::None) + fn is_wrapped(&self) -> bool { + matches!(self, SpecialAttr::None | SpecialAttr::ApplyBy(_)) } fn empty_value(&self) -> Option<&Lit> { @@ -51,19 +55,47 @@ impl SpecialAttr { None } } + + fn apply_by_fn(&self) -> Option<&syn::Path> { + match self { + SpecialAttr::ApplyBy(p) | SpecialAttr::SkipWrapApplyBy(p) => Some(p), + _ => None, + } + } +} + +enum FieldType { + Original(Type), + Retyped(Type), +} + +impl FieldType { + fn is_retyped(&self) -> bool { + matches!(self, FieldType::Retyped(_)) + } + + fn inner(&self) -> &Type { + match self { + FieldType::Original(ty) | FieldType::Retyped(ty) => ty, + } + } +} + +impl ToTokens for FieldType { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.inner().to_tokens(tokens) + } } struct Field { ident: Option, - ty: Type, + ty: FieldType, attributes: Vec, - retyped: bool, #[cfg(feature = "op")] addable: Addable, #[cfg(feature = "nesting")] nesting: bool, special_attr: SpecialAttr, - apply_by: Option, } impl Patch { @@ -88,7 +120,7 @@ impl Patch { #[cfg(not(feature = "nesting"))] let field_names = fields .iter() - .filter(|f| f.special_attr.is_empty()) + .filter(|f| f.special_attr.is_wrapped()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] @@ -100,7 +132,7 @@ impl Patch { #[cfg(feature = "nesting")] let field_names = fields .iter() - .filter(|f| !f.nesting && f.special_attr.is_empty()) + .filter(|f| !f.nesting && f.special_attr.is_wrapped()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] @@ -129,36 +161,144 @@ impl Patch { .map(|f| f.ident.as_ref()) .collect::>(); + // Fields with `SkipWrapApplyBy` (i.e. both `#[patch(skip_wrap)]` and + // `#[patch(apply_by(fn))]`). Two sub-groups based on whether the original + // field type is `Option`: + // + // • Option sub-group: patch field stays `Option`; fn is called with the + // unwrapped inner value when the patch is `Some`. Function signature: + // `fn(original: &mut T, new_value: T)`. + // + // • Plain sub-group: patch field stays `T` (no Option wrap at all); fn is + // always called unconditionally. Same function signature. + #[cfg(not(feature = "nesting"))] + let skip_wrap_apply_by_option_field_names = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && is_option_type(f.ty.inner()) + }) + .map(|f| f.ident.as_ref()) + .collect::>(); + #[cfg(not(feature = "nesting"))] + let skip_wrap_apply_by_option_fns = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && is_option_type(f.ty.inner()) + }) + .filter_map(|f| f.special_attr.apply_by_fn()) + .collect::>(); + #[cfg(not(feature = "nesting"))] + let skip_wrap_apply_by_plain_field_names = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !is_option_type(f.ty.inner()) + }) + .map(|f| f.ident.as_ref()) + .collect::>(); + #[cfg(not(feature = "nesting"))] + let skip_wrap_apply_by_plain_field_types = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !is_option_type(f.ty.inner()) + }) + .map(|f| f.ty.inner()) + .collect::>(); + #[cfg(not(feature = "nesting"))] + let skip_wrap_apply_by_plain_fns = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !is_option_type(f.ty.inner()) + }) + .filter_map(|f| f.special_attr.apply_by_fn()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_apply_by_option_field_names = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !f.nesting + && is_option_type(f.ty.inner()) + }) + .map(|f| f.ident.as_ref()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_apply_by_option_fns = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !f.nesting + && is_option_type(f.ty.inner()) + }) + .filter_map(|f| f.special_attr.apply_by_fn()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_apply_by_plain_field_names = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !f.nesting + && !is_option_type(f.ty.inner()) + }) + .map(|f| f.ident.as_ref()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_apply_by_plain_field_types = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !f.nesting + && !is_option_type(f.ty.inner()) + }) + .map(|f| f.ty.inner()) + .collect::>(); + #[cfg(feature = "nesting")] + let skip_wrap_apply_by_plain_fns = fields + .iter() + .filter(|f| { + matches!(f.special_attr, SpecialAttr::SkipWrapApplyBy(_)) + && !f.nesting + && !is_option_type(f.ty.inner()) + }) + .filter_map(|f| f.special_attr.apply_by_fn()) + .collect::>(); + // Rename fields #[cfg(not(feature = "nesting"))] let renamed_field_names = fields .iter() - .filter(|f| f.retyped && f.special_attr.is_empty()) + .filter(|f| f.ty.is_retyped() && f.special_attr.is_wrapped()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let renamed_field_names_by_empty_value = fields .iter() - .filter(|f| f.retyped && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) + .filter(|f| f.ty.is_retyped() && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let renamed_field_names = fields .iter() - .filter(|f| f.retyped && !f.nesting && f.special_attr.is_empty()) + .filter(|f| f.ty.is_retyped() && !f.nesting && f.special_attr.is_wrapped()) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let renamed_field_names_by_empty_value = fields .iter() .filter(|f| { - f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) + f.ty.is_retyped() + && !f.nesting + && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) }) .map(|f| f.ident.as_ref()) .collect::>(); let renamed_field_name_empty_values = fields .iter() - .filter(|f| f.retyped) + .filter(|f| f.ty.is_retyped()) .filter_map(|f| f.special_attr.empty_value()) .collect::>(); @@ -166,66 +306,78 @@ impl Patch { #[cfg(not(feature = "nesting"))] let original_field_names = fields .iter() - .filter(|f| !f.retyped && f.special_attr.is_empty() && f.apply_by.is_none()) + .filter(|f| !f.ty.is_retyped() && matches!(f.special_attr, SpecialAttr::None)) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] let original_field_names_by_empty_value = fields .iter() - .filter(|f| !f.retyped && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) + .filter(|f| !f.ty.is_retyped() && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let original_field_names = fields .iter() - .filter(|f| !f.retyped && !f.nesting && f.special_attr.is_empty() && f.apply_by.is_none()) + .filter(|f| { + !f.ty.is_retyped() && !f.nesting && matches!(f.special_attr, SpecialAttr::None) + }) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(feature = "nesting")] let original_field_names_by_empty_value = fields .iter() .filter(|f| { - !f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) + !f.ty.is_retyped() + && !f.nesting + && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) }) .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. + // `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()) + .filter(|f| !f.ty.is_retyped() && matches!(f.special_attr, SpecialAttr::ApplyBy(_))) .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()) + .filter(|f| !f.ty.is_retyped() && matches!(f.special_attr, SpecialAttr::ApplyBy(_))) + .filter_map(|f| f.special_attr.apply_by_fn()) .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()) + .filter(|f| { + !f.ty.is_retyped() + && !f.nesting + && matches!(f.special_attr, SpecialAttr::ApplyBy(_)) + }) .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()) + .filter(|f| { + !f.ty.is_retyped() + && !f.nesting + && matches!(f.special_attr, SpecialAttr::ApplyBy(_)) + }) + .filter_map(|f| f.special_attr.apply_by_fn()) .collect::>(); #[cfg(not(feature = "nesting"))] let original_field_name_empty_values = fields .iter() - .filter(|f| !f.retyped) + .filter(|f| !f.ty.is_retyped()) .filter_map(|f| f.special_attr.empty_value()) .collect::>(); #[cfg(feature = "nesting")] let original_field_name_empty_values = fields .iter() - .filter(|f| !f.retyped && !f.nesting) + .filter(|f| !f.ty.is_retyped() && !f.nesting) .filter_map(|f| f.special_attr.empty_value()) .collect::>(); @@ -245,7 +397,7 @@ impl Patch { let nesting_field_types = fields .iter() .filter(|f| f.nesting) - .map(|f| f.ty.clone()) + .map(|f| f.ty.inner().clone()) .collect::>(); let mapped_attributes = attributes @@ -285,6 +437,16 @@ impl Patch { return false } )* + #( + if self.#skip_wrap_apply_by_option_field_names.is_some() { + return false + } + )* + #( + if self.#skip_wrap_apply_by_plain_field_names != <#skip_wrap_apply_by_plain_field_types as Default>::default() { + return false + } + )* #( if !self.#nesting_field_names.is_empty() { return false @@ -333,6 +495,16 @@ impl Patch { #( #skip_wrap_field_names: other.#skip_wrap_field_names.or(self.#skip_wrap_field_names), )* + #( + #skip_wrap_apply_by_option_field_names: other.#skip_wrap_apply_by_option_field_names.or(self.#skip_wrap_apply_by_option_field_names), + )* + #( + #skip_wrap_apply_by_plain_field_names: { + let mut merged = self.#skip_wrap_apply_by_plain_field_names; + #skip_wrap_apply_by_plain_fns(&mut merged, other.#skip_wrap_apply_by_plain_field_names); + merged + }, + )* #( #apply_by_field_names: other.#apply_by_field_names.or(self.#apply_by_field_names), )* @@ -446,6 +618,21 @@ impl Patch { (None, None) => None, }, )* + #( + #skip_wrap_apply_by_option_field_names: match (self.#skip_wrap_apply_by_option_field_names, rhs.#skip_wrap_apply_by_option_field_names) { + (Some(mut a), Some(b)) => { #skip_wrap_apply_by_option_fns(&mut a, b); Some(a) }, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* + #( + #skip_wrap_apply_by_plain_field_names: { + let mut a = self.#skip_wrap_apply_by_plain_field_names; + #skip_wrap_apply_by_plain_fns(&mut a, rhs.#skip_wrap_apply_by_plain_field_names); + a + }, + )* #( #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) }, @@ -543,6 +730,21 @@ impl Patch { (None, None) => None, }, )* + #( + #skip_wrap_apply_by_option_field_names: match (self.#skip_wrap_apply_by_option_field_names, rhs.#skip_wrap_apply_by_option_field_names) { + (Some(mut a), Some(b)) => { #skip_wrap_apply_by_option_fns(&mut a, b); Some(a) }, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }, + )* + #( + #skip_wrap_apply_by_plain_field_names: { + let mut a = self.#skip_wrap_apply_by_plain_field_names; + #skip_wrap_apply_by_plain_fns(&mut a, rhs.#skip_wrap_apply_by_plain_field_names); + a + }, + )* #( #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) }, @@ -580,6 +782,10 @@ 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 skip_wrap_apply_by_option_log_calls = + make_log_calls(&skip_wrap_apply_by_option_field_names); + let skip_wrap_apply_by_plain_log_calls = + make_log_calls(&skip_wrap_apply_by_plain_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 @@ -637,6 +843,20 @@ impl Patch { self.#skip_wrap_field_names = Some(v); } )* + #( + if let Some(v) = patch.#skip_wrap_apply_by_option_field_names { + #skip_wrap_apply_by_option_log_calls + if let Some(ref mut orig) = self.#skip_wrap_apply_by_option_field_names { + #skip_wrap_apply_by_option_fns(orig, v); + } + } + )* + #( + { + #skip_wrap_apply_by_plain_log_calls + #skip_wrap_apply_by_plain_fns(&mut self.#skip_wrap_apply_by_plain_field_names, patch.#skip_wrap_apply_by_plain_field_names); + } + )* #( if let Some(v) = patch.#apply_by_field_names { #apply_by_log_calls @@ -677,6 +897,20 @@ impl Patch { self.#skip_wrap_field_names = Some(v); } )* + #( + if let Some(v) = patch.#skip_wrap_apply_by_option_field_names { + log(stringify!(#skip_wrap_apply_by_option_field_names)); + if let Some(ref mut orig) = self.#skip_wrap_apply_by_option_field_names { + #skip_wrap_apply_by_option_fns(orig, v); + } + } + )* + #( + { + log(stringify!(#skip_wrap_apply_by_plain_field_names)); + #skip_wrap_apply_by_plain_fns(&mut self.#skip_wrap_apply_by_plain_field_names, patch.#skip_wrap_apply_by_plain_field_names); + } + )* #( if let Some(v) = patch.#apply_by_field_names { log(stringify!(#apply_by_field_names)); @@ -705,6 +939,12 @@ impl Patch { #( #skip_wrap_field_names: self.#skip_wrap_field_names, )* + #( + #skip_wrap_apply_by_option_field_names: self.#skip_wrap_apply_by_option_field_names, + )* + #( + #skip_wrap_apply_by_plain_field_names: self.#skip_wrap_apply_by_plain_field_names, + )* #( #apply_by_field_names: Some(self.#apply_by_field_names), )* @@ -756,6 +996,22 @@ impl Patch { None }, )* + #( + #skip_wrap_apply_by_option_field_names: if self.#skip_wrap_apply_by_option_field_names != previous_struct.#skip_wrap_apply_by_option_field_names { + self.#skip_wrap_apply_by_option_field_names + } + else { + None + }, + )* + #( + #skip_wrap_apply_by_plain_field_names: if self.#skip_wrap_apply_by_plain_field_names != previous_struct.#skip_wrap_apply_by_plain_field_names { + self.#skip_wrap_apply_by_plain_field_names + } + else { + <#skip_wrap_apply_by_plain_field_types as Default>::default() + }, + )* #( #apply_by_field_names: if self.#apply_by_field_names != previous_struct.#apply_by_field_names { Some(self.#apply_by_field_names) @@ -781,6 +1037,12 @@ impl Patch { #( #skip_wrap_field_names: None, )* + #( + #skip_wrap_apply_by_option_field_names: None, + )* + #( + #skip_wrap_apply_by_plain_field_names: <#skip_wrap_apply_by_plain_field_types as Default>::default(), + )* #( #nesting_field_names: #nesting_field_types::new_empty_patch(), )* @@ -971,7 +1233,7 @@ impl Field { match ident { #[cfg(not(feature = "nesting"))] Some(ident) => { - if !special_attr.is_empty() { + if !special_attr.is_wrapped() { Ok(quote! { #(#attributes)* pub #ident: #ty, @@ -995,7 +1257,7 @@ impl Field { #(#attributes)* pub #ident: #patch_type, }) - } else if !special_attr.is_empty() { + } else if !special_attr.is_wrapped() { Ok(quote! { #(#attributes)* pub #ident: #ty, @@ -1009,7 +1271,7 @@ impl Field { } #[cfg(not(feature = "nesting"))] None => { - if !special_attr.is_empty() { + if !special_attr.is_wrapped() { Ok(quote! { #(#attributes)* pub #ty, @@ -1033,7 +1295,7 @@ impl Field { #(#attributes)* pub #patch_type, }) - } else if !special_attr.is_empty() { + } else if !special_attr.is_wrapped() { Ok(quote! { #(#attributes)* pub #ty, @@ -1058,7 +1320,6 @@ 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; @@ -1149,20 +1410,43 @@ impl Field { } SKIP_WRAP => { // #[patch(skip_wrap)] + // Upgrades ApplyBy → SkipWrapApplyBy when apply_by was already parsed. if matches!(special_attr, SpecialAttr::EmptyValue(_)) { return Err(meta.error( "`skip_wrap` and `empty_value` cannot be combined on the same field", )); } - special_attr = SpecialAttr::SkipWrap; + // Use mem::replace so we move the contained Path without + // moving `special_attr` itself (required by FnMut closure). + let prev = std::mem::replace(&mut special_attr, SpecialAttr::None); + special_attr = match prev { + SpecialAttr::ApplyBy(p) | SpecialAttr::SkipWrapApplyBy(p) => { + SpecialAttr::SkipWrapApplyBy(p) + } + _ => 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. + // Upgrades SkipWrap → SkipWrapApplyBy when skip_wrap was already parsed. let content; parenthesized!(content in meta.input); - apply_by = Some(content.parse()?); + let path: syn::Path = content.parse()?; + let prev = std::mem::replace(&mut special_attr, SpecialAttr::None); + special_attr = match prev { + SpecialAttr::SkipWrap => SpecialAttr::SkipWrapApplyBy(path), + SpecialAttr::None => SpecialAttr::ApplyBy(path), + SpecialAttr::ApplyBy(_) | SpecialAttr::SkipWrapApplyBy(_) => { + return Err(meta.error("apply_by can only be specified once")); + } + SpecialAttr::EmptyValue(_) => { + return Err(meta.error( + "`apply_by` and `empty_value` cannot be combined on the same field", + )); + } + }; } _ => { return Err(meta.error(format_args!( @@ -1180,19 +1464,28 @@ impl Field { Ok(Some(Field { ident, - retyped: field_type.is_some(), - ty: field_type.unwrap_or(ty), + ty: field_type + .map(FieldType::Retyped) + .unwrap_or(FieldType::Original(ty)), attributes, #[cfg(feature = "op")] addable, #[cfg(feature = "nesting")] nesting, special_attr, - apply_by, })) } } +fn is_option_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "Option"; + } + } + false +} + trait ToStr { fn to_string(&self) -> String; } @@ -1235,23 +1528,24 @@ mod tests { fields: vec![ Field { ident: Some(syn::Ident::new("field1", Span::call_site())), - ty: LitStr::new("SubItemPatch", Span::call_site()) - .parse() - .unwrap(), + ty: FieldType::Retyped( + LitStr::new("SubItemPatch", Span::call_site()) + .parse() + .unwrap(), + ), attributes: vec![], - retyped: true, #[cfg(feature = "op")] addable: Addable::Disable, #[cfg(feature = "nesting")] nesting: false, special_attr: SpecialAttr::None, - apply_by: None, }, Field { ident: Some(syn::Ident::new("field3", Span::call_site())), - ty: LitStr::new("bool", Span::call_site()).parse().unwrap(), + ty: FieldType::Original( + LitStr::new("bool", Span::call_site()).parse().unwrap(), + ), attributes: vec![], - retyped: false, #[cfg(feature = "op")] addable: Addable::Disable, #[cfg(feature = "nesting")] @@ -1260,7 +1554,6 @@ mod tests { false, Span::call_site(), ))), - apply_by: None, }, ], }; diff --git a/docs/custom-apply.md b/docs/custom-apply.md index efbdcb6..fbaf174 100644 --- a/docs/custom-apply.md +++ b/docs/custom-apply.md @@ -49,3 +49,47 @@ 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. + +## Combining `apply_by` with `skip_wrap` + +Add `#[patch(skip_wrap)]` alongside `apply_by` to keep the patch field as the +same type as the original field, with **no `Option` wrapping at all**. The +`apply_by` function is then called unconditionally on every `apply` — there is +no `None`/"no change" state for this field. The function signature stays the +clean `fn(original: &mut T, new_value: T)` form. + +This is useful when you always want the function to run, for example to merge +rather than replace: + +```rust +use struct_patch::Patch; + +fn merge_tags(original: &mut String, additional: String) { + for c in additional.chars() { + if !original.contains(c) { + original.push(c); + } + } +} + +#[derive(Default, Patch)] +struct Config { + name: String, + // Patch field is `String`, not `Option`. + // merge_tags is always called on every apply. + #[patch(skip_wrap, apply_by(merge_tags))] + tags: String, +} + +let mut config = Config { name: "base".to_string(), tags: "a".to_string() }; + +config.apply(ConfigPatch { name: None, tags: "ab".to_string() }); +assert_eq!(config.tags, "ab"); + +config.apply(ConfigPatch { name: None, tags: "c".to_string() }); +assert_eq!(config.tags, "abc"); +``` + +When the field type is `Option`, `skip_wrap + apply_by` keeps the patch +field as `Option` (avoiding a double-wrap to `Option>`), and the +function is called only when the patch is `Some`. diff --git a/lib/examples/apply-by.rs b/lib/examples/apply-by.rs index 4521c39..5c1fa19 100644 --- a/lib/examples/apply-by.rs +++ b/lib/examples/apply-by.rs @@ -4,42 +4,54 @@ fn concat_list(original: &mut Vec, additional: Vec) { original.extend(additional); } +fn merge_tags(original: &mut String, additional: String) { + for c in additional.chars() { + if !original.contains(c) { + original.push(c); + } + } +} + #[derive(Debug, Default, Patch)] #[patch(attribute(derive(Debug, Default)))] struct Config { #[patch(apply_by(concat_list))] items: Vec, name: String, + // skip_wrap keeps the patch field as `String` (no Option wrapping). + // merge_tags is always called unconditionally on every apply. + #[patch(skip_wrap, apply_by(merge_tags))] + tags: String, } fn main() { let mut config = Config { items: vec![1, 2, 3], name: "base".to_string(), + tags: "a".to_string(), }; // Patch with apply_by: items are concatenated instead of replaced. + // tags patch field is plain String (not Option) due to skip_wrap. config.apply(ConfigPatch { items: Some(vec![4, 5, 6]), name: None, + tags: "ab".to_string(), }); assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6]); + assert_eq!(config.tags, "ab"); println!("After first patch: {:?}", config.items); + println!("Tags after merge: {:?}", config.tags); - // A second patch appends more items. + // A second patch appends more items; tags is always applied. config.apply(ConfigPatch { items: Some(vec![7, 8]), name: Some("updated".to_string()), + tags: "c".to_string(), }); assert_eq!(config.items, vec![1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(config.tags, "abc"); println!("After second patch: {:?}", config.items); + println!("Tags after second merge: {:?}", config.tags); 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); }