From 20a0b0f94c0405165dc173e7446d764ae9a277ff Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Thu, 6 Aug 2026 23:46:11 +0800 Subject: [PATCH 1/6] add apply_with_log for per-field patch logging --- README.md | 30 +++++++++++++++++++++++- derive/src/catalyst.rs | 8 ++++++- derive/src/patch.rs | 49 +++++++++++++++++++++++++++++++++++++--- lib/examples/instance.rs | 6 ++++- lib/src/traits.rs | 26 +++++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 92504a7..cb26b87 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A library to help you modify config structs. It provides: This crate provides the `Patch`, `Filler`, `Substrate`, `Catalyst` and `Complex` traits with accompanying derive macros in the following three use cases. -- If any field in a `Patch` is `Some`, it overwrites the corresponding field when applied. +- If any field in a `Patch` is `Some`, it overwrites the corresponding field when applied. Use `apply_with_log` to receive a callback for each field that is actually changed. - If any field in the instance is empty (`None` or an empty collection), `Filler` will try to fill it. It supports `Option`, `Vec`, `VecDeque`, `LinkedList`, `HashMap`, `BTreeMap`, `HashSet`, `BTreeSet`, `BinaryHeap` fields, as well as custom types via `#[filler(extendable)]` and any type via `#[filler(empty_value = ...)]`. - With the `catalyst` feature, `Substrate`, `Catalyst` and `Complex` traits with accompanying derive macros help you extend a struct with extra fields from another crate. @@ -156,6 +156,34 @@ struct Amyloid { // } ``` +#### Case 5 - Log which fields were patched + +`apply_with_log` works like `apply` but calls a closure with the name of each +field that is actually changed. This lets you print or record which fields were +updated, in whatever format you need. + +```rust +use struct_patch::Patch; + +#[derive(Default, Patch)] +struct Item { + field_int: usize, + field_string: String, +} + +let mut item = Item::default(); +let patch = ItemPatch { field_int: Some(42), field_string: None }; + +let mut patched_fields = Vec::new(); +item.apply_with_log(patch, |field| patched_fields.push(field.to_string())); + +assert_eq!(patched_fields, vec!["field_int"]); +assert_eq!(item.field_int, 42); +``` + +For structs using `#[patch(nesting)]`, the log closure is threaded into nested patches +so you receive field names from all levels of nesting. + #### Case 4 - Avoid double-`Option` for `Option>` fields By default, deriving `Patch` wraps every field in an `Option`, so a field typed `Option>` becomes `Option>>` in the generated patch. When diff --git a/derive/src/catalyst.rs b/derive/src/catalyst.rs index 87cfc3f..2c3b043 100644 --- a/derive/src/catalyst.rs +++ b/derive/src/catalyst.rs @@ -101,7 +101,13 @@ impl Catalyst { let complex_fields = raw_complex_fields .iter() - .map(|f| f.to_token_stream(*keep_field_attribute, override_field_attributes, exclude_field_attributes)) + .map(|f| { + f.to_token_stream( + *keep_field_attribute, + override_field_attributes, + exclude_field_attributes, + ) + }) .collect::>>()?; #[cfg(not(feature = "unsafe"))] diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 824c456..2d7df26 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -146,7 +146,9 @@ impl Patch { #[cfg(feature = "nesting")] let renamed_field_names_by_empty_value = fields .iter() - .filter(|f| f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) + .filter(|f| { + f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) + }) .map(|f| f.ident.as_ref()) .collect::>(); let renamed_field_name_empty_values = fields @@ -177,7 +179,9 @@ impl Patch { #[cfg(feature = "nesting")] let original_field_names_by_empty_value = fields .iter() - .filter(|f| !f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_))) + .filter(|f| { + !f.retyped && !f.nesting && matches!(f.special_attr, SpecialAttr::EmptyValue(_)) + }) .map(|f| f.ident.as_ref()) .collect::>(); #[cfg(not(feature = "nesting"))] @@ -541,6 +545,42 @@ impl Patch { )* } + fn apply_with_log(&mut self, patch: #name #generics, mut log: F) { + #( + if let Some(v) = patch.#renamed_field_names { + log(stringify!(#renamed_field_names)); + self.#renamed_field_names.apply(v); + } + )* + #( + if patch.#renamed_field_names_by_empty_value != #renamed_field_name_empty_values { + log(stringify!(#renamed_field_names_by_empty_value)); + self.#renamed_field_names_by_empty_value.apply(patch.#renamed_field_names_by_empty_value); + } + )* + #( + if let Some(v) = patch.#original_field_names { + log(stringify!(#original_field_names)); + self.#original_field_names = v; + } + )* + #( + if patch.#original_field_names_by_empty_value != #original_field_name_empty_values { + log(stringify!(#original_field_names_by_empty_value)); + self.#original_field_names_by_empty_value = patch.#original_field_names_by_empty_value; + } + )* + #( + if let Some(v) = patch.#skip_wrap_field_names { + log(stringify!(#skip_wrap_field_names)); + self.#skip_wrap_field_names = Some(v); + } + )* + #( + self.#nesting_field_names.apply_with_log(patch.#nesting_field_names, &mut log); + )* + } + fn into_patch(self) -> #name #generics { #name { #( @@ -1024,7 +1064,10 @@ mod tests { retyped: false, #[cfg(feature = "op")] addable: Addable::Disable, - special_attr: SpecialAttr::EmptyValue(Lit::Bool(syn::LitBool::new(false, Span::call_site()))), + special_attr: SpecialAttr::EmptyValue(Lit::Bool(syn::LitBool::new( + false, + Span::call_site(), + ))), }, ], }; diff --git a/lib/examples/instance.rs b/lib/examples/instance.rs index 147ef0b..dbee17e 100644 --- a/lib/examples/instance.rs +++ b/lib/examples/instance.rs @@ -22,6 +22,10 @@ struct Item { // } fn main() { + fn log(field: &str) { + println!("TRACE: {field} patched") + } + let mut item = Item::default(); let mut patch: ItemPatch = Item::new_empty_patch(); @@ -33,7 +37,7 @@ fn main() { "ItemPatch { field_complete: None, field_int: Some(7), field_string: None, field_option_vec: None }" ); - item.apply(patch); + item.apply_with_log(patch, log); assert!(!item.field_complete); assert_eq!(item.field_int, 7); diff --git a/lib/src/traits.rs b/lib/src/traits.rs index 3223e37..b026f06 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -112,6 +112,32 @@ pub trait Patch

{ /// Apply a patch fn apply(&mut self, patch: P); + /// Apply a patch, calling `log` with each patched field name. + /// + /// The default implementation ignores `log` and delegates to [`apply`](Patch::apply). + /// The derive macro generates an override that calls `log` once per field that is + /// actually changed. + /// + /// ```rust + /// # use struct_patch::Patch; + /// #[derive(Default, Patch)] + /// struct Item { + /// field_int: usize, + /// field_string: String, + /// } + /// + /// let mut item = Item::default(); + /// let patch = ItemPatch { field_int: Some(42), field_string: None }; + /// + /// let mut patched_fields = Vec::new(); + /// item.apply_with_log(patch, |field| patched_fields.push(field.to_string())); + /// + /// assert_eq!(patched_fields, vec!["field_int"]); + /// ``` + fn apply_with_log(&mut self, patch: P, _log: F) { + self.apply(patch); + } + /// Returns a patch that when applied turns any struct of the same type into `Self` fn into_patch(self) -> P; From 6016c0af838685afbfd62ec7bfc9579b3e6874d0 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Fri, 7 Aug 2026 00:04:43 +0800 Subject: [PATCH 2/6] add `#[patch(default_log(..))]` --- README.md | 39 ++++++++++++++++++++++++++++++++++----- derive/src/patch.rs | 36 ++++++++++++++++++++++++++++++++++++ lib/src/traits.rs | 20 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cb26b87..357aba6 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,10 @@ struct Amyloid { #### Case 5 - Log which fields were patched -`apply_with_log` works like `apply` but calls a closure with the name of each -field that is actually changed. This lets you print or record which fields were -updated, in whatever format you need. +There are two ways to observe which fields are patched: + +**Ad-hoc at the call site** — use `apply_with_log`, which takes a closure that +is called with each patched field name: ```rust use struct_patch::Patch; @@ -181,8 +182,35 @@ assert_eq!(patched_fields, vec!["field_int"]); assert_eq!(item.field_int, 42); ``` -For structs using `#[patch(nesting)]`, the log closure is threaded into nested patches -so you receive field names from all levels of nesting. +For structs using `#[patch(nesting)]`, the log closure is threaded into nested +patches so you receive field names from all levels of nesting. + +**Always-on via struct attribute** — use `#[patch(default_log(fn_path))]` to wire a +specific function into `apply` itself. Every call to `apply` on that struct will +automatically invoke the function for each field that is changed, with no +extra effort at call sites. Has no effect on `apply_with_log`. + +```rust +use struct_patch::Patch; + +fn my_log(field: &str) { + println!("patched: {field}"); +} + +#[derive(Default, Patch)] +#[patch(default_log(my_log))] +struct Config { + retries: usize, + timeout: u64, +} + +let mut cfg = Config::default(); +cfg.apply(ConfigPatch { retries: Some(3), timeout: None }); +// prints: patched: retries +``` + +The path may be any item path (`crate::logging::log_patch_field`, +`tracing::debug!` wrapped in a thin function, etc.). #### Case 4 - Avoid double-`Option` for `Option>` fields By default, deriving `Patch` wraps every field in an `Option`, so a field typed @@ -227,6 +255,7 @@ Two attribute namespaces are provided for the catalyst feature because we need t - `#[patch(name = "...")]`: change the name of the generated patch struct. - `#[patch(attribute(...))]`: add attributes to the generated patch struct. - `#[patch(attribute(derive(...)))]`: add derives to the generated patch struct. +- `#[patch(default_log(fn_path))]`: call `fn_path` with each patched field name on every `apply` call. Has no effect on `apply_with_log`. The function must accept `&str`. - `#[filler(attribute(...))]`: add attributes to the generated filler struct. - `#[catalyst(bind = "...")]`: specify the base (substrate) structure. (catalyst feature) - `#[catalyst(keep_field_attribute)]`: pass all field attributes from a substrate or catalyst through to the complex, unless an override is explicitly specified for that field. (catalyst feature) diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 2d7df26..2f2bef8 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -18,6 +18,7 @@ const ADD: &str = "add"; const NESTING: &str = "nesting"; const EMPTY_VALUE: &str = "empty_value"; const SKIP_WRAP: &str = "skip_wrap"; +const DEFAULT_LOG: &str = "default_log"; pub(crate) struct Patch { visibility: syn::Visibility, @@ -26,6 +27,7 @@ pub(crate) struct Patch { generics: syn::Generics, attributes: Vec, fields: Vec, + default_log_fn: Option, } enum SpecialAttr { @@ -72,6 +74,7 @@ impl Patch { generics, attributes, fields, + default_log_fn, } = self; let patch_struct_fields = fields @@ -511,32 +514,56 @@ impl Patch { #[cfg(not(feature = "op"))] let op_impl = quote!(); + // Per-field log-call token streams, parallel with each field-name vec. + // Emit `default_log_fn(stringify!(field));` when a struct-level log is configured, + // or an empty token stream otherwise. + let make_log_calls = |names: &[Option<&Ident>]| -> Vec { + if let Some(f) = default_log_fn { + names + .iter() + .map(|n| quote! { #f(stringify!(#n)); }) + .collect() + } else { + names.iter().map(|_| quote! {}).collect() + } + }; + let renamed_log_calls = make_log_calls(&renamed_field_names); + let renamed_by_ev_log_calls = make_log_calls(&renamed_field_names_by_empty_value); + 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 patch_impl = quote! { #[automatically_derived] impl #generics struct_patch::traits::Patch< #name #generics > for #struct_name #generics #where_clause { fn apply(&mut self, patch: #name #generics) { #( if let Some(v) = patch.#renamed_field_names { + #renamed_log_calls self.#renamed_field_names.apply(v); } )* #( if patch.#renamed_field_names_by_empty_value != #renamed_field_name_empty_values { + #renamed_by_ev_log_calls self.#renamed_field_names_by_empty_value.apply(patch.#renamed_field_names_by_empty_value); } )* #( if let Some(v) = patch.#original_field_names { + #original_log_calls self.#original_field_names = v; } )* #( if patch.#original_field_names_by_empty_value != #original_field_name_empty_values { + #original_by_ev_log_calls self.#original_field_names_by_empty_value = patch.#original_field_names_by_empty_value ; } )* #( if let Some(v) = patch.#skip_wrap_field_names { + #skip_wrap_log_calls self.#skip_wrap_field_names = Some(v); } )* @@ -706,6 +733,7 @@ impl Patch { let mut name = None; let mut attributes = vec![]; let mut fields = vec![]; + let mut default_log_fn: Option = None; for attr in attrs { if attr.path().to_string().as_str() != PATCH { @@ -739,6 +767,12 @@ impl Patch { let attribute: TokenStream = content.parse()?; attributes.push(attribute); } + DEFAULT_LOG => { + // #[patch(default_log(path::to::fn))] + let content; + parenthesized!(content in meta.input); + default_log_fn = Some(content.parse()?); + } _ => { return Err(meta.error(format_args!( "unknown patch container attribute `{}`", @@ -767,6 +801,7 @@ impl Patch { generics, attributes, fields, + default_log_fn, }) } } @@ -1045,6 +1080,7 @@ mod tests { patch_struct_name: syn::Ident::new("MyPatch", Span::call_site()), generics: syn::Generics::default(), attributes: vec![quote! { derive(Debug, PartialEq, Clone, Serialize, Deserialize) }], + default_log_fn: None, fields: vec![ Field { ident: Some(syn::Ident::new("field1", Span::call_site())), diff --git a/lib/src/traits.rs b/lib/src/traits.rs index b026f06..e353c54 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -60,6 +60,26 @@ /// // struct ItemOverlay {} /// ``` /// +/// ### `#[patch(default_log(fn_path))]` +/// Automatically call `fn_path(&str)` with each patched field name inside +/// every generated `apply` call. Has no effect on `apply_with_log`. The path +/// may be any function path visible at the call site. +/// ```rust +/// # use struct_patch::Patch; +/// fn log_field(field: &str) { let _ = field; } +/// +/// #[derive(Default, Patch)] +/// #[patch(default_log(log_field))] +/// struct Item { +/// field_int: usize, +/// field_string: String, +/// } +/// +/// let mut item = Item::default(); +/// item.apply(ItemPatch { field_int: Some(1), field_string: None }); +/// // log_field("field_int") is called automatically +/// ``` +/// /// ## Field attributes /// ### `#[patch(skip)]` /// If you want certain fields to be unpatchable, you can let the derive macro skip certain fields when creating the patch struct From 53278d8d9fc8aa831c41e73bfd7fbca8800aa934 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Fri, 7 Aug 2026 00:10:42 +0800 Subject: [PATCH 3/6] add example for log feature --- .github/workflows/test.yml | 2 ++ lib/examples/log.rs | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 lib/examples/log.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9525374..96d525b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ jobs: nix develop .#ci -c cargo run --no-default-features --example clap nix develop .#ci -c cargo run --no-default-features --features=nesting --example nesting nix develop .#ci -c cargo run --no-default-features --features=option --example option + nix develop .#ci -c cargo run --no-default-features --example log nix develop .#ci -c cargo test --no-default-features - name: Test with std features @@ -66,6 +67,7 @@ jobs: nix develop .#ci -c cargo run --example clap nix develop .#ci -c cargo run --features=nesting --example nesting nix develop .#ci -c cargo run --features=nesting --example clap + nix develop .#ci -c cargo run --example log nix develop .#ci -c cargo test - name: Test in no std diff --git a/lib/examples/log.rs b/lib/examples/log.rs new file mode 100644 index 0000000..a2e550f --- /dev/null +++ b/lib/examples/log.rs @@ -0,0 +1,52 @@ +use struct_patch::Patch; + +fn log_field(field: &str) { + println!("[default_log] patched field: {field}"); +} + +#[derive(Default, Patch)] +#[patch(attribute(derive(Debug, Default)))] +#[patch(default_log(log_field))] +struct Config { + host: String, + port: u16, + debug: bool, +} + +fn main() { + let mut config = Config::default(); + + // apply() calls log_field automatically via default_log + println!("--- apply() with default_log ---"); + config.apply(ConfigPatch { + host: Some("localhost".into()), + port: Some(8080), + debug: None, + }); + // Prints: + // [default_log] patched field: host + // [default_log] patched field: port + + println!( + "host={}, port={}, debug={}", + config.host, config.port, config.debug + ); + + // apply_with_log() overrides the log callback with a custom format + println!("\n--- apply_with_log() with custom format ---"); + config.apply_with_log( + ConfigPatch { + host: None, + port: None, + debug: Some(true), + }, + |field| println!("[custom_log] field '{}' was updated", field), + ); + // Prints: + // [custom_log] field 'debug' was updated + + println!( + "host={}, port={}, debug={}", + config.host, config.port, config.debug + ); +} From 7c6daf95714fb26bf0f6aa924a36c82da7f48a84 Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Fri, 7 Aug 2026 00:16:04 +0800 Subject: [PATCH 4/6] add log feature on Filler --- README.md | 50 ++++++++++++++++++++------ derive/src/filler.rs | 51 ++++++++++++++++++++++++++ lib/examples/log.rs | 86 +++++++++++++++++++++++++++++++++++++------- lib/src/traits.rs | 25 +++++++++++++ 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 357aba6..cc44b1e 100644 --- a/README.md +++ b/README.md @@ -156,15 +156,15 @@ struct Amyloid { // } ``` -#### Case 5 - Log which fields were patched +#### Case 5 - Log which fields were patched or filled -There are two ways to observe which fields are patched: +Both `Patch` and `Filler` support two ways to observe which fields are changed: **Ad-hoc at the call site** — use `apply_with_log`, which takes a closure that -is called with each patched field name: +is called with each patched/filled field name: ```rust -use struct_patch::Patch; +use struct_patch::{Filler, Patch}; #[derive(Default, Patch)] struct Item { @@ -180,18 +180,32 @@ item.apply_with_log(patch, |field| patched_fields.push(field.to_string())); assert_eq!(patched_fields, vec!["field_int"]); assert_eq!(item.field_int, 42); + +#[derive(Default, Filler)] +struct Settings { + theme: Option, +} + +let mut settings = Settings::default(); +let mut filled_fields = Vec::new(); +settings.apply_with_log( + SettingsFiller { theme: Some("dark".into()) }, + |field| filled_fields.push(field.to_string()), +); +assert_eq!(filled_fields, vec!["theme"]); ``` For structs using `#[patch(nesting)]`, the log closure is threaded into nested patches so you receive field names from all levels of nesting. -**Always-on via struct attribute** — use `#[patch(default_log(fn_path))]` to wire a -specific function into `apply` itself. Every call to `apply` on that struct will -automatically invoke the function for each field that is changed, with no -extra effort at call sites. Has no effect on `apply_with_log`. +**Always-on via struct attribute** — use `#[patch(default_log(fn_path))]` or +`#[filler(default_log(fn_path))]` to wire a specific function into `apply` +itself. Every call to `apply` on that struct will automatically invoke the +function for each field that is changed, with no extra effort at call sites. +Has no effect on `apply_with_log`. ```rust -use struct_patch::Patch; +use struct_patch::{Filler, Patch}; fn my_log(field: &str) { println!("patched: {field}"); @@ -207,9 +221,23 @@ struct Config { let mut cfg = Config::default(); cfg.apply(ConfigPatch { retries: Some(3), timeout: None }); // prints: patched: retries + +fn my_filler_log(field: &str) { + println!("filled: {field}"); +} + +#[derive(Default, Filler)] +#[filler(default_log(my_filler_log))] +struct Settings { + theme: Option, +} + +let mut settings = Settings::default(); +settings.apply(SettingsFiller { theme: Some("dark".into()) }); +// prints: filled: theme ``` -The path may be any item path (`crate::logging::log_patch_field`, +The path may be any item path (`crate::logging::log_field`, `tracing::debug!` wrapped in a thin function, etc.). #### Case 4 - Avoid double-`Option` for `Option>` fields @@ -257,6 +285,7 @@ Two attribute namespaces are provided for the catalyst feature because we need t - `#[patch(attribute(derive(...)))]`: add derives to the generated patch struct. - `#[patch(default_log(fn_path))]`: call `fn_path` with each patched field name on every `apply` call. Has no effect on `apply_with_log`. The function must accept `&str`. - `#[filler(attribute(...))]`: add attributes to the generated filler struct. +- `#[filler(default_log(fn_path))]`: call `fn_path` with each filled field name on every `apply` call. Has no effect on `apply_with_log`. The function must accept `&str`. - `#[catalyst(bind = "...")]`: specify the base (substrate) structure. (catalyst feature) - `#[catalyst(keep_field_attribute)]`: pass all field attributes from a substrate or catalyst through to the complex, unless an override is explicitly specified for that field. (catalyst feature) - `#[catalyst(exclude_field_attributes = ["..."])]`: when `keep_field_attribute` is used, specifies attribute names to exclude from being passed through to the complex struct fields. For example, `exclude_field_attributes = ["serde"]` strips all `#[serde(...)]` field attributes from the substrate before they reach the complex. (catalyst feature) @@ -298,6 +327,7 @@ The [examples][examples] demonstrate the following scenarios: - show operators on fillers (`filler-op.rs`) - show `skip_wrap` field behavior (`instance.rs`) - use `Patch` with `clap` for command-line config (`clap.rs`) +- demonstrate `default_log` and `apply_with_log` for both `Patch` and `Filler` (`log.rs`) ## Features diff --git a/derive/src/filler.rs b/derive/src/filler.rs index 0c76d1f..3489578 100644 --- a/derive/src/filler.rs +++ b/derive/src/filler.rs @@ -11,6 +11,7 @@ const ATTRIBUTE: &str = "attribute"; const EXTENDABLE: &str = "extendable"; const EMPTY_VALUE: &str = "empty_value"; const ADDABLE: &str = "addable"; +const DEFAULT_LOG: &str = "default_log"; pub(crate) struct Filler { visibility: syn::Visibility, @@ -19,6 +20,7 @@ pub(crate) struct Filler { generics: syn::Generics, attributes: Vec, fields: Vec, + default_log_fn: Option, } enum FillerType { @@ -65,6 +67,7 @@ impl Filler { generics, attributes, fields, + default_log_fn, } = self; let filler_struct_fields = fields @@ -225,23 +228,63 @@ impl Filler { #[cfg(not(feature = "op"))] let op_impl = quote!(); + let make_log_calls = |names: &[Option<&Ident>]| -> Vec { + if let Some(f) = default_log_fn { + names + .iter() + .map(|n| quote! { #f(stringify!(#n)); }) + .collect() + } else { + names.iter().map(|_| quote! {}).collect() + } + }; + let native_value_log_calls = make_log_calls(&native_value_field_names); + let extendable_log_calls = make_log_calls(&extendable_field_names); + let option_log_calls = make_log_calls(&option_field_names); + let filler_impl = quote! { #[automatically_derived] impl #generics struct_patch::traits::Filler< #name #generics > for #struct_name #generics #where_clause { fn apply(&mut self, filler: #name #generics) { #( if self.#native_value_field_names == #native_value_field_empty_values { + #native_value_log_calls self.#native_value_field_names = filler.#native_value_field_names; } )* #( if self.#extendable_field_names.is_empty() { + #extendable_log_calls self.#extendable_field_names.extend(filler.#extendable_field_names.into_iter()); } )* #( if let Some(v) = filler.#option_field_names { if self.#option_field_names.is_none() { + #option_log_calls + self.#option_field_names = Some(v); + } + } + )* + } + + fn apply_with_log<__L: FnMut(&str)>(&mut self, filler: #name #generics, mut log: __L) { + #( + if self.#native_value_field_names == #native_value_field_empty_values { + log(stringify!(#native_value_field_names)); + self.#native_value_field_names = filler.#native_value_field_names; + } + )* + #( + if self.#extendable_field_names.is_empty() { + log(stringify!(#extendable_field_names)); + self.#extendable_field_names.extend(filler.#extendable_field_names.into_iter()); + } + )* + #( + if let Some(v) = filler.#option_field_names { + if self.#option_field_names.is_none() { + log(stringify!(#option_field_names)); self.#option_field_names = Some(v); } } @@ -287,6 +330,7 @@ impl Filler { let mut attributes = vec![]; let mut fields = vec![]; + let mut default_log_fn: Option = None; for attr in attrs { if attr.path().to_string().as_str() != FILLER { @@ -310,6 +354,12 @@ impl Filler { let attribute: TokenStream = content.parse()?; attributes.push(attribute); } + DEFAULT_LOG => { + // #[filler(default_log(path::to::fn))] + let content; + parenthesized!(content in meta.input); + default_log_fn = Some(content.parse()?); + } _ => { return Err(meta.error(format_args!( "unknown filler container attribute `{}`", @@ -337,6 +387,7 @@ impl Filler { generics, attributes, fields, + default_log_fn, }) } } diff --git a/lib/examples/log.rs b/lib/examples/log.rs index a2e550f..a1223f4 100644 --- a/lib/examples/log.rs +++ b/lib/examples/log.rs @@ -1,52 +1,112 @@ -use struct_patch::Patch; +use struct_patch::{Filler, Patch}; -fn log_field(field: &str) { - println!("[default_log] patched field: {field}"); +fn log_patch_field(field: &str) { + println!("[default_log] patch field: {field}"); } +fn log_filler_field(field: &str) { + println!("[default_log] filler field: {field}"); +} + +// --- Patch example --- + #[derive(Default, Patch)] #[patch(attribute(derive(Debug, Default)))] -#[patch(default_log(log_field))] +#[patch(default_log(log_patch_field))] struct Config { host: String, port: u16, debug: bool, } +// --- Filler example --- + +#[derive(Default, Filler)] +#[filler(attribute(derive(Debug, Default)))] +#[filler(default_log(log_filler_field))] +struct Settings { + theme: Option, + max_connections: Option, +} + fn main() { + // --- Patch with default_log --- + println!("--- Patch: apply() with default_log ---"); let mut config = Config::default(); - - // apply() calls log_field automatically via default_log - println!("--- apply() with default_log ---"); config.apply(ConfigPatch { host: Some("localhost".into()), port: Some(8080), debug: None, }); // Prints: - // [default_log] patched field: host - // [default_log] patched field: port + // [default_log] patch field: host + // [default_log] patch field: port println!( "host={}, port={}, debug={}", config.host, config.port, config.debug ); - // apply_with_log() overrides the log callback with a custom format - println!("\n--- apply_with_log() with custom format ---"); + // --- Patch with apply_with_log (custom format) --- + println!("\n--- Patch: apply_with_log() with custom format ---"); config.apply_with_log( ConfigPatch { host: None, port: None, debug: Some(true), }, - |field| println!("[custom_log] field '{}' was updated", field), + |field| println!("[custom_log] patch field '{}' was updated", field), ); // Prints: - // [custom_log] field 'debug' was updated + // [custom_log] patch field 'debug' was updated println!( "host={}, port={}, debug={}", config.host, config.port, config.debug ); + + // --- Filler with default_log --- + println!("\n--- Filler: apply() with default_log ---"); + let mut settings = Settings::default(); + settings.apply(SettingsFiller { + theme: Some("dark".into()), + max_connections: Some(100), + }); + // Prints: + // [default_log] filler field: theme + // [default_log] filler field: max_connections + + println!( + "theme={:?}, max_connections={:?}", + settings.theme, settings.max_connections + ); + + // Applying again has no effect because the fields are already filled. + println!("\n--- Filler: apply() again (fields already filled, no log) ---"); + settings.apply(SettingsFiller { + theme: Some("light".into()), + max_connections: Some(999), + }); + println!( + "theme={:?}, max_connections={:?}", + settings.theme, settings.max_connections + ); + + // --- Filler with apply_with_log (custom format) --- + println!("\n--- Filler: apply_with_log() with custom format ---"); + let mut settings2 = Settings::default(); + settings2.apply_with_log( + SettingsFiller { + theme: Some("light".into()), + max_connections: None, + }, + |field| println!("[custom_log] filler field '{}' was filled", field), + ); + // Prints: + // [custom_log] filler field 'theme' was filled + + println!( + "theme={:?}, max_connections={:?}", + settings2.theme, settings2.max_connections + ); } diff --git a/lib/src/traits.rs b/lib/src/traits.rs index e353c54..fa70aad 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -172,6 +172,31 @@ pub trait Filler { /// Apply a filler fn apply(&mut self, filler: F); + /// Apply a filler, calling `log` with each field name that is actually filled. + /// + /// The default implementation ignores `log` and delegates to [`apply`](Filler::apply). + /// The derive macro generates an override that calls `log` once per field that is + /// actually filled (i.e. the field was empty and the filler supplied a value). + /// + /// ```rust + /// # use struct_patch::Filler; + /// #[derive(Default, Filler)] + /// struct Item { + /// value: Option, + /// } + /// + /// let mut item = Item::default(); + /// let filler = ItemFiller { value: Some(42) }; + /// + /// let mut filled_fields = Vec::new(); + /// item.apply_with_log(filler, |field| filled_fields.push(field.to_string())); + /// + /// assert_eq!(filled_fields, vec!["value"]); + /// ``` + fn apply_with_log(&mut self, filler: F, _log: L) { + self.apply(filler); + } + /// Get an empty filler instance fn new_empty_filler() -> F; } From 883f30fbc538adaf0df0aede986fec09dd4ab15b Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Fri, 7 Aug 2026 18:33:56 +0800 Subject: [PATCH 5/6] support log on nesting feature --- derive/src/patch.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 2f2bef8..bbb49eb 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -533,6 +533,27 @@ impl Patch { 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); + // 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 + // struct-level default log. When no default_log_fn is set, fall back to plain + // `.apply()` so nested structs use their own log config (if any). + #[cfg(feature = "nesting")] + let nesting_apply_section: TokenStream = if let Some(ref f) = default_log_fn { + quote! { + #( + self.#nesting_field_names.apply_with_log(patch.#nesting_field_names, #f); + )* + } + } else { + quote! { + #( + self.#nesting_field_names.apply(patch.#nesting_field_names); + )* + } + }; + #[cfg(not(feature = "nesting"))] + let nesting_apply_section: TokenStream = quote! {}; + let patch_impl = quote! { #[automatically_derived] impl #generics struct_patch::traits::Patch< #name #generics > for #struct_name #generics #where_clause { @@ -567,9 +588,7 @@ impl Patch { self.#skip_wrap_field_names = Some(v); } )* - #( - self.#nesting_field_names.apply(patch.#nesting_field_names); - )* + #nesting_apply_section } fn apply_with_log(&mut self, patch: #name #generics, mut log: F) { @@ -1091,6 +1110,8 @@ mod tests { retyped: true, #[cfg(feature = "op")] addable: Addable::Disable, + #[cfg(feature = "nesting")] + nesting: false, special_attr: SpecialAttr::None, }, Field { @@ -1100,6 +1121,8 @@ mod tests { retyped: false, #[cfg(feature = "op")] addable: Addable::Disable, + #[cfg(feature = "nesting")] + nesting: false, special_attr: SpecialAttr::EmptyValue(Lit::Bool(syn::LitBool::new( false, Span::call_site(), From c55ed4b0a52a895d23905e3daf0270f771b19a6a Mon Sep 17 00:00:00 2001 From: Antonio Yang Date: Fri, 7 Aug 2026 22:38:02 +0800 Subject: [PATCH 6/6] readme: incompatiable log with std feature --- README.md | 2 +- lib/Cargo.toml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc44b1e..0ee942e 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ This crate includes the following optional features: - By default, when there is a field conflict between patches/fillers, `+` will add them together if `#[patch(addable)]`, `#[patch(add = fn)]`, or `#[filler(addable)]` is provided; otherwise it will panic. - `merge` *(optional)*: implements the `Merge` trait for the patch struct, which provides the `merge` method, and `<<` (if `op` is enabled) between patches. - `alloc` *(optional)*: enables `alloc` support for `no_std` + alloc environments. -- `std` *(optional)*: enables `std`-dependent features (implies `box` and `option`). +- `std` *(optional)*: enables `std`-dependent features (implies `box` and `option`). Note: the `log` example is incompatible with this feature. - `box` *(optional)*: implements the `Patch>` trait for `T` where `T` implements `Patch

`. This lets you patch a boxed (or unboxed) struct with a boxed patch. - `option` *(optional)*: implements the `Patch>` trait for `Option` where `T` implements `Patch

`. Please take a look at the example to learn more. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index dfecae2..a09f4e1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -49,3 +49,10 @@ catalyst = [ unsafe = [ "struct-patch-derive/unsafe" ] + +# exmaple features +log = [] # avoid running std on log example + +[[example]] +name = "log" +required-features = ["log"]