Repository: elastio/bon (language: rust)

Issue:
Support getters for already set members
Hi, Bon is my favorite crate and I'm using it as the backbone for a complex pipeline internal framework.

In essence, my pipeline works like this:

```rust
        pipeline
            .pass(the_first_step)
            .pass(another_step)
            .pass(some_other_step)
            .pass(the_last_step)
```

Each step of the pipeline is (simplified) implemented like this:

```rust
pub async fn the_first_step<S>(
    Payload(id): Payload,
    PgConn(pg_conn): PgConn,
    State(builder): State<NewDbRowBuilder<S>>,
) -> Result<NewDbRowBuilder<SetTheFirstStepValue<S>>, crate::Error>
where
    S: new_db_row_builder::State,
    S::TheFirstStepValue: IsUnset,
{
   // some work that connects to api, db, etc, and produces a value

    Ok(builder.the_first_step_value(my_awesome_value_i_fetched))
}
```

The types of the pipeline enforce that the state produced by a prior pass must be fed into the pass under it, so you can't accidentally order anything wrong. I _love_ using Bon to back this pipeline system and allow me to make each pass specify what the conditions of its state should be. 

Where I am struggling is that sometimes I run into a weird, limiting situation.

I have one step in my pipeline that scrapes some information off of a user in my DB, processes it, then sets it in the builder (because this value must go in the row that's being built). Then, in another pass of the pipeline, I must read processed data out and use it to compute another value that also goes on my row. Given the current implementation of Bon (while following the rules and not directly implementing unsafe methods on generated code), I have three solutions:

1. Take this processed value, clone it and set it into the state, then return a tuple of the state and the cloned value. This is bad to me because the passes of my pipeline become much less generic as they now must take a tuple of the built state and any previously obtained values and drill them through the entire way
2. Do what I'm doing currently, and maintain a cache that passes can use to set values and retrieve them later. This works (and is fairly generic), but I lose the ability to _safely_ do the guards like `IsSet` because this cache has to be completely detached from `bon`
3. Use `builder(field)` on these fields I want to cache. This also works, but it has the same problem as before where I lose the ability to do guards on the passes which is what I love so much about bon.

I noticed in 3.1.1 you removed the non-determinism of generated privates. After seeing this, being able to add methods that take in `&self` and return a reference to a value in the builder state would be incredible and make my pipeline work exactly how I want it to. This long winded context finally leads to my question (the one in the title)...

How fundamentally unsafe is this? Taking a look at the expanded macro from `builder` everything I can see indicates that adding read only accessor methods that do not move out of the tuple or clone the data would be completely safe, but I figured it would be well worth opening an issue for this because this will be running in production code where a mistake like this will not be acceptable. 

Thank you!

<!-- Please keep this note for the community at the end of the issue -->

### A note for the community from the maintainers

Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to help the maintainers with prioritizing it. You may add a comment describing your real use case related to this issue for us to better understand the problem domain.



Discussion / hints:
Hi, thank you for creating the issue! Accessing the private fields of the builder (except for `#[builder(field)]`) is definitely wrong, since those internals may change between patch versions. The only unsafe code generated by builder macros at the moment is `unwrap_unchecked` for required members in the finishing function. So the only way this may break is if you set the required field inside the builder to `None` by directly accessing its internals. Vending a readonly reference to the internal field is completely fine, however, there isn't an official API to do that in the current version of `bon` (3.1.1).

But.. I was actually planning to add an ability to generate getters for already set members as the next feature after `#[builder(field)]`. I imagine it like this. There will be a `#[builder(getter)]` annotation that will generate a simple by-readonly-reference getter with no magic, that has the name `get_{member}`. It will be possible to override its visibility, docs and name with the usual attributes `#[builder(getter(name = ..., vis = ..., doc { ... })]`. The default visibility will be the same as it would be for setters.

In future increments, I plan to provide the ability to generate getters for mutable references, the ability to do `AsRef` and `Deref` coercions, and custom conversions similar to `#[builder(with)]`, but it'll be `#[builder(getter(with))]`.
Fantastic to hear! Thank you. I am fairly familiar with `bon`'s internals, and if you haven't started I'd be happy to take on and implement this.
Yeah, I've been procrastinating on this a bit given the Stalker 2 release 😳. I haven't started with this feature yet, so feel free to take a stab at this 👍 
Haha, have fun. Feel free to assign me this. Will get started now.
At a high level, how do you feel about having the getters return an `impl Borrow<T>` instead of just `&T`? The main reason I'm suggesting this is because the ergonomics could be a bit better for `String`s and `Vec`s which would then return `&str` and `&[T]` accordingly.

Fix patch:
diff --git a/bon-macros/Cargo.toml b/bon-macros/Cargo.toml
index 98101b18..3c5570ed 100644
--- a/bon-macros/Cargo.toml
+++ b/bon-macros/Cargo.toml
@@ -63,6 +63,9 @@ rustversion  = "1.0"
 [features]
 default = []
 
+# See the docs on this feature in the `bon`'s crate `Cargo.toml`.
+experimental-getter = []
+
 # See the docs on this feature in the `bon`'s crate `Cargo.toml`
 experimental-overwritable = []
 
diff --git a/bon-macros/src/builder/builder_gen/getter.rs b/bon-macros/src/builder/builder_gen/getter.rs
new file mode 100644
index 00000000..291a9249
--- /dev/null
+++ b/bon-macros/src/builder/builder_gen/getter.rs
@@ -0,0 +1,92 @@
+use proc_macro2::TokenStream;
+use quote::quote;
+
+use super::{BuilderGenCtx, IdentExt, NamedMember};
+
+pub(crate) struct GetterCtx<'a> {
+    base: &'a BuilderGenCtx,
+    member: &'a NamedMember,
+}
+
+struct GetterItem {
+    name: syn::Ident,
+    vis: syn::Visibility,
+    docs: Vec<syn::Attribute>,
+}
+
+impl<'a> GetterCtx<'a> {
+    pub(crate) fn new(base: &'a BuilderGenCtx, member: &'a NamedMember) -> Self {
+        Self { base, member }
+    }
+
+    pub(crate) fn getter_method(&self) -> TokenStream {
+        let Some(GetterItem { name, vis, docs }) = GetterItem::new(self) else {
+            return quote! {};
+        };
+
+        let index = &self.member.index;
+        let ty = self.member.underlying_norm_ty();
+
+        let (return_type, body) = if self.member.is_required() {
+            (
+                quote! { &#ty },
+                quote! { unsafe { ::std::option::Option::unwrap_unchecked(self.__unsafe_private_named.#index.as_ref()) } },
+            )
+        } else {
+            (
+                quote! { ::core::option::Option<&#ty> },
+                quote! { self.__unsafe_private_named.#index.as_ref() },
+            )
+        };
+
+        let state_var = &self.base.state_var;
+        let member_pascal = &self.member.name.pascal;
+        let state_mod = &self.base.state_mod.ident;
+
+        quote! {
+            #( #docs )*
+            #[allow(
+                // This is intentional. We want the builder syntax to compile away
+                clippy::inline_always,
+                clippy::missing_const_for_fn,
+            )]
+            #[inline(always)]
+            #vis fn #name(&self) -> #return_type
+            where #state_var::#member_pascal: #state_mod::IsSet,
+            {
+                #body
+            }
+        }
+    }
+}
+
+impl GetterItem {
+    fn new(ctx: &GetterCtx<'_>) -> Option<Self> {
+        let GetterCtx { member, base } = ctx;
+
+        let spanned_keyed_config = member.config.getter.as_ref()?;
+
+        let common_name = spanned_keyed_config.name();
+        let common_vis = spanned_keyed_config.vis();
+        let common_docs = spanned_keyed_config.docs();
+
+        Some(Self {
+            name: common_name.cloned().unwrap_or_else(|| {
+                syn::Ident::new(
+                    &format!("get_{}", member.name.snake.raw_name()),
+                    member.name.snake.span(),
+                )
+            }),
+            vis: common_vis.unwrap_or(&base.builder_type.vis).clone(),
+            docs: common_docs
+                .map(<[syn::Attribute]>::to_vec)
+                .unwrap_or_else(|| {
+                    const HEADER: &str = "_**Getter.**_\n\n";
+
+                    std::iter::once(syn::parse_quote!(#[doc = #HEADER]))
+                        .chain(member.docs.iter().cloned())
+                        .collect()
+                }),
+        })
+    }
+}
diff --git a/bon-macros/src/builder/builder_gen/member/config/getter.rs b/bon-macros/src/builder/builder_gen/member/config/getter.rs
new file mode 100644
index 00000000..1e95ea56
--- /dev/null
+++ b/bon-macros/src/builder/builder_gen/member/config/getter.rs
@@ -0,0 +1,54 @@
+use darling::FromMeta;
+
+use super::{Result, SpannedKey};
+
+#[derive(Debug, Default)]
+pub(crate) struct GetterConfig {
+    name: Option<SpannedKey<syn::Ident>>,
+    vis: Option<SpannedKey<syn::Visibility>>,
+
+    docs: Option<SpannedKey<Vec<syn::Attribute>>>,
+}
+
+impl FromMeta for GetterConfig {
+    fn from_meta(meta: &syn::Meta) -> Result<Self> {
+        if let syn::Meta::Path(_) = meta {
+            return Ok(Self::default());
+        }
+
+        // Reject empty parens such as `#[builder(getter())]`
+        crate::parsing::require_non_empty_paren_meta_list_or_name_value(meta)?;
+
+        // Nested `Parsed` struct used as a helper for parsing the verbose form
+        #[derive(FromMeta)]
+        struct Parsed {
+            name: Option<SpannedKey<syn::Ident>>,
+            vis: Option<SpannedKey<syn::Visibility>>,
+
+            #[darling(rename = "doc", default, with = parse_docs, map = Some)]
+            docs: Option<SpannedKey<Vec<syn::Attribute>>>,
+        }
+
+        let Parsed { name, vis, docs } = Parsed::from_meta(meta)?;
+
+        Ok(Self { name, vis, docs })
+    }
+}
+
+impl GetterConfig {
+    pub(crate) fn name(&self) -> Option<&syn::Ident> {
+        self.name.as_ref().map(|n| &n.value)
+    }
+
+    pub(crate) fn vis(&self) -> Option<&syn::Visibility> {
+        self.vis.as_ref().map(|v| &v.value)
+    }
+
+    pub(crate) fn docs(&self) -> Option<&[syn::Attribute]> {
+        self.docs.as_ref().map(|a| &a.value).map(|a| &**a)
+    }
+}
+
+fn parse_docs(meta: &syn::Meta) -> Result<SpannedKey<Vec<syn::Attribute>>> {
+    crate::parsing::parse_docs_without_self_mentions("builder struct's impl block", meta)
+}
diff --git a/bon-macros/src/builder/builder_gen/member/config/mod.rs b/bon-macros/src/builder/builder_gen/member/config/mod.rs
index 02bfe590..a6e5606e 100644
--- a/bon-macros/src/builder/builder_gen/member/config/mod.rs
+++ b/bon-macros/src/builder/builder_gen/member/config/mod.rs
@@ -1,8 +1,10 @@
 mod blanket;
+mod getter;
 mod setters;
 mod with;
 
 pub(crate) use blanket::*;
+pub(crate) use getter::*;
 pub(crate) use setters::*;
 pub(crate) use with::*;
 
@@ -33,6 +35,12 @@ pub(crate) struct MemberConfig {
     #[darling(with = parse_optional_expr, map = Some)]
     pub(crate) field: Option<SpannedKey<Option<syn::Expr>>>,
 
+    /// Make the member gettable by reference.
+    ///
+    /// This takes the same attributes as the setter fns; `name`, `vis`, and `doc`
+    /// and produces a getter method that returns `&T` for the member.
+    pub(crate) getter: Option<SpannedKey<GetterConfig>>,
+
     /// Accept the value for the member in the finishing function parameters.
     pub(crate) finish_fn: darling::util::Flag,
 
@@ -82,6 +90,7 @@ pub(crate) struct MemberConfig {
 enum ParamName {
     Default,
     Field,
+    Getter,
     FinishFn,
     Into,
     Name,
@@ -98,6 +107,7 @@ impl fmt::Display for ParamName {
         let str = match self {
             Self::Default => "default",
             Self::Field => "field",
+            Self::Getter => "getter",
             Self::FinishFn => "finish_fn",
             Self::Into => "into",
             Self::Name => "name",
@@ -162,6 +172,7 @@ impl MemberConfig {
         let Self {
             default,
             field,
+            getter,
             finish_fn,
             into,
             name,
@@ -176,6 +187,7 @@ impl MemberConfig {
         let attrs = [
             (default.is_some(), ParamName::Default),
             (field.is_some(), ParamName::Field),
+            (getter.is_some(), ParamName::Getter),
             (finish_fn.is_present(), ParamName::FinishFn),
             (into.is_present(), ParamName::Into),
             (name.is_some(), ParamName::Name),
@@ -212,7 +224,7 @@ impl MemberConfig {
             self.validate_mutually_allowed(
                 ParamName::StartFn,
                 self.start_fn.span(),
-                &[ParamName::Into],
+                &[ParamName::Into, ParamName::Getter],
             )?;
         }
 
@@ -220,7 +232,34 @@ impl MemberConfig {
             self.validate_mutually_allowed(
                 ParamName::FinishFn,
                 self.finish_fn.span(),
-                &[ParamName::Into],
+                &[ParamName::Into, ParamName::Getter],
+            )?;
+        }
+
+        if let Some(getter) = &self.getter {
+            if !cfg!(feature = "experimental-getter") {
+                bail!(
+                    &getter.key.span(),
+                    "`getter` attribute is experimental and requires \
+                    \"experimental-getter\" cargo feature to be enabled; \
+                    we would be glad to make this attribute stable if you find it useful; \
+                    please leave a 👍 reaction under the issue https://github.com/elastio/bon/issues/221 \
+                    to help us measure the impact on this feature. If you have \
+                    a use case for this attribute, then open an issue/discussion on \
+                    https://github.com/elastio/bon/issues.",
+                );
+            }
+
+            self.validate_mutually_allowed(
+                ParamName::Getter,
+                getter.key.span(),
+                &[
+                    ParamName::With,
+                    ParamName::Into,
+                    ParamName::Name,
+                    ParamName::Setters,
+                    ParamName::Required,
+                ],
             )?;
         }
 
diff --git a/bon-macros/src/builder/builder_gen/mod.rs b/bon-macros/src/builder/builder_gen/mod.rs
index 9196c498..325fbd38 100644
--- a/bon-macros/src/builder/builder_gen/mod.rs
+++ b/bon-macros/src/builder/builder_gen/mod.rs
@@ -1,6 +1,7 @@
 mod builder_decl;
 mod builder_derives;
 mod finish_fn;
+mod getter;
 mod member;
 mod models;
 mod setters;
@@ -11,6 +12,7 @@ mod top_level_config;
 pub(crate) mod input_fn;
 pub(crate) mod input_struct;
 
+use getter::GetterCtx;
 pub(crate) use top_level_config::TopLevelConfig;
 
 use crate::util::prelude::*;
@@ -107,6 +109,10 @@ impl BuilderGenCtx {
             .map(|member| SettersCtx::new(self, member).setter_methods())
             .collect::<Result<Vec<_>>>()?;
 
+        let getter_methods = self
+            .named_members()
+            .map(|member| GetterCtx::new(self, member).getter_method());
+
         let generics_decl = &self.generics.decl_without_defaults;
         let generic_args = &self.generics.args;
         let where_clause = &self.generics.where_clause;
@@ -128,6 +134,7 @@ impl BuilderGenCtx {
             {
                 #finish_fn
                 #(#setter_methods)*
+                #(#getter_methods)*
             }
         })
     }
diff --git a/bon-sandbox/Cargo.toml b/bon-sandbox/Cargo.toml
index 12f0dc0f..8c5e56ca 100644
--- a/bon-sandbox/Cargo.toml
+++ b/bon-sandbox/Cargo.toml
@@ -29,7 +29,7 @@ targets = ["x86_64-unknown-linux-gnu"]
 workspace = true
 
 [dependencies]
-bon            = { path = "../bon", version = "=3.1.1", features = ["experimental-overwritable"] }
+bon            = { path = "../bon", version = "=3.1.1", features = ["experimental-overwritable", "experimental-getter"] }
 buildstructor  = "0.5"
 derive_builder = "0.20"
 typed-builder  = "0.20"
diff --git a/bon-sandbox/src/getter.rs b/bon-sandbox/src/getter.rs
new file mode 100644
index 00000000..287fd91e
--- /dev/null
+++ b/bon-sandbox/src/getter.rs
@@ -0,0 +1,17 @@
+use bon::{builder, Builder};
+
+#[builder]
+pub fn full_name_fn(#[builder(getter)] first_name: &str, last_name: &str) -> String {
+    format!("{first_name} {last_name}")
+}
+
+#[derive(Builder)]
+pub struct FullName {
+    #[builder(getter)]
+    pub first_name: String,
+    #[builder(getter(name = get_the_last_name, vis = "pub(crate)", doc {
+        /// Docs on the getter
+    }))]
+    pub last_name: String,
+    pub no_getter: String,
+}
diff --git a/bon-sandbox/src/lib.rs b/bon-sandbox/src/lib.rs
index 9cd816e8..49d95423 100644
--- a/bon-sandbox/src/lib.rs
+++ b/bon-sandbox/src/lib.rs
@@ -9,6 +9,7 @@ pub mod attr_default;
 pub mod attr_with;
 pub mod docs_comparison;
 pub mod functions;
+pub mod getter;
 pub mod macro_rules_wrapper_test;
 pub mod missing_docs_test;
 pub mod overrides;
diff --git a/bon/Cargo.toml b/bon/Cargo.toml
index 38eeff6a..fca6039e 100644
--- a/bon/Cargo.toml
+++ b/bon/Cargo.toml
@@ -82,3 +82,17 @@ implied-bounds = ["bon-macros/implied-bounds"]
 # this attribute. It would also be cool if you could leave a comment under that issue
 # describing your use case for it.
 experimental-overwritable = ["bon-macros/experimental-overwritable"]
+
+# 🔬 Experimental! There may be breaking changes to this feature between *minor* releases,
+# however, compatibility within patch releases is guaranteed though.
+#
+# This feature enables the #[builder(getter)] attribute that can be used to
+# allow getting references to already set fields in the builder.
+#
+# See more info at https://bon-rs.com/reference/builder/member/getter.
+#
+# We are considering stabilizing this attribute if you have a use for it. Please leave
+# a 👍 reaction under the issue https://github.com/elastio/bon/issues/221 if you need
+# this attribute. It would also be cool if you could leave a comment under that issue
+# describing your use case for it.
+experimental-getter = ["bon-macros/experimental-getter"]
diff --git a/rust-toolchain b/rust-toolchain
deleted file mode 100644
index 71fae54f..00000000
--- a/rust-toolchain
+++ /dev/null
@@ -1,1 +0,0 @@
-1.82.0
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 00000000..66788910
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel    = "1.82"
+components = ["cargo"]
diff --git a/scripts/init.sh b/scripts/init.sh
index 6df82a0b..bd9c3f9b 100755
--- a/scripts/init.sh
+++ b/scripts/init.sh
@@ -6,5 +6,8 @@ set -euxo pipefail
 # Install prettier
 npm ci
 
+# Install taplo
+cargo install taplo-cli --locked
+
 # Install the pre-commit hook
 ln -s ../../.githooks/pre-commit .git/hooks/pre-commit
diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts
index ffc757bf..3a6f3ae1 100644
--- a/website/.vitepress/config.mts
+++ b/website/.vitepress/config.mts
@@ -303,6 +303,10 @@ export default defineConfig({
                                     text: "finish_fn",
                                     link: "/reference/builder/member/finish_fn",
                                 },
+                                {
+                                    text: "getters 🔬",
+                                    link: "/reference/builder/member/getters",
+                                },
                                 {
                                     text: "into",
                                     link: "/reference/builder/member/into",
diff --git a/website/src/guide/contributing.md b/website/src/guide/contributing.md
index 6d9e9dce..ba31c906 100644
--- a/website/src/guide/contributing.md
+++ b/website/src/guide/contributing.md
@@ -14,6 +14,8 @@ However, even though desirable, creating an issue before making a pull request i
 
 This repository is a regular [`cargo` workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html). Just fork it and do the usual `cargo` business.
 
+Additionally, an init script is provided: `./scripts/init.sh`. This will install required dependencies and set up the commit hooks for our CI.
+
 ## Testing
 
 Test your changes with `cargo test`. You may add new tests to the `bon/tests/integration` folder.
diff --git a/website/src/reference/builder.md b/website/src/reference/builder.md
index 9f439428..742e8712 100644
--- a/website/src/reference/builder.md
+++ b/website/src/reference/builder.md
@@ -25,6 +25,7 @@ These attributes are placed on a `struct` field or `fn` argument.
 | [`default`](./builder/member/default)              | Makes the member optional with a default value                   |
 | [`field`](./builder/member/field)                  | Defines a private field on the builder without setters           |
 | [`finish_fn`](./builder/member/finish_fn)          | Makes the member a positional argument on the finishing function |
+| [`getter`](./builder/member/getter)                | Makes the member have getter method for `&T`                     |
 | [`into`](./builder/member/into)                    | Changes the signature of the setters to accept `impl Into<T>`    |
 | [`name`](./builder/member/name)                    | Overrides the name of the member used in the builder's API       |
 | [`overwritable` 🔬](./builder/member/overwritable) | Allows calling setters for the same member repeatedly            |
diff --git a/website/src/reference/builder/member/getter.md b/website/src/reference/builder/member/getter.md
new file mode 100644
index 00000000..57081ef3
--- /dev/null
+++ b/website/src/reference/builder/member/getter.md
@@ -0,0 +1,77 @@
+# `getter` :microscope:
+
+**Applies to:** <Badge type="warning" text="struct fields"/> <Badge type="warning" text="function arguments"/> <Badge type="warning" text="method arguments"/>
+
+Allows getting a reference to an already set member.
+
+::: danger 🔬 **Experimental**
+
+This attribute is available under the cargo feature `experimental-getter`. Breaking changes may occur between **minor** releases but not between patch releases.
+
+The fate of this feature depends on your feedback in the tracking issue [#149](https://github.com/elastio/bon/issues/221). Please, let us know if you have a use case for this attribute!
+
+:::
+
+This attribute now makes the following possible:
+
+```rust
+#[derive(bon::Builder)]
+struct Example {
+    #[builder(getter)] // [!code highlight]
+    x: u32,
+}
+
+let builder = Example::builder().x(1);
+
+let x_ref = builder.get_x(); // [!code highlight]
+
+builder.build();
+```
+
+## Config
+
+`getter` is configured quite similarly to [`setters`](./setters).
+
+You can specify any combination of a `name`, `vis`, and `doc` for the getter, or you can omit all of them and use it as a flag to inherit default values for your getters.
+
+### Example
+
+```rust
+#[derive(bon::Builder)]
+struct Example {
+    #[builder(getter(name = get_my_member, vis = "pub(crate)", doc { // [!code highlight]
+        /// `get_my_member` gets a ref to the member // [!code highlight]
+    }))] // [!code highlight]
+    member: u32, // [!code highlight]
+}
+
+// Name of `some_fn` that accepts the non-None value was overridden
+let builder = Example::builder().member(2);
+
+let my_member = builder.get_my_member(); // [!code highlight]
+```
+
+## `name`
+
+The default name for getters are chosen according to the following rules:
+
+| Member type | Default        |
+| ----------- | -------------- |
+| Required    | `get_{member}` |
+| Optional    | `get_{member}` |
+
+## `vis`
+
+The visibility must be enclosed with quotes. Use `""` or [`"pub(self)"`](https://doc.rust-lang.org/reference/visibility-and-privacy.html#pubin-path-pubcrate-pubsuper-and-pubself) for private visibility.
+
+The default visibility is the same as the visibility of the [`builder_type`](../top-level/builder_type#vis), which in turn, defaults to the visibility of the underlying `struct` or `fn`.
+
+## `doc`
+
+Simple documentation is generated by default. The syntax of this attribute expects a block with doc comments.
+
+```attr
+doc {
+    /// Doc comments
+}
+```


Test patch:
diff --git a/bon/tests/integration/builder/attr_getter.rs b/bon/tests/integration/builder/attr_getter.rs
new file mode 100644
index 00000000..90c8d995
--- /dev/null
+++ b/bon/tests/integration/builder/attr_getter.rs
@@ -0,0 +1,67 @@
+use crate::prelude::*;
+
+#[test]
+fn test_struct() {
+    #[derive(Debug, Builder)]
+    #[builder(derive(Debug, Clone))]
+    #[allow(dead_code)]
+    struct Sut<T> {
+        #[builder(start_fn)]
+        x1: u32,
+
+        #[builder(getter(name = x2_with_custom_name))]
+        x2: String,
+
+        #[builder(getter(vis = "pub(crate)", doc {
+            /// Docs on the getter
+        }))]
+        x3: u32,
+
+        #[builder(into, getter(name = x5, vis = "pub(crate)", doc {
+            /// The name is a lie
+        }))]
+        x4_but_its_actually_5: String,
+
+        not_a_getter: u32,
+
+        #[builder(getter)]
+        generic_option_getter: Option<T>,
+
+        x6: (),
+    }
+
+    #[allow(clippy::redundant_clone)]
+    let sut = Sut::<()>::builder(0u32).clone();
+
+    let actual = sut.x2("2".to_owned()).x3(3);
+
+    let actual = actual.x4_but_its_actually_5("4".to_owned());
+    let x5 = actual.x5();
+    assert_eq!(x5, "4");
+
+    let actual = actual.not_a_getter(5).x6(());
+
+    let x2 = actual.x2_with_custom_name();
+    assert_eq!(x2, "2");
+
+    let x3 = actual.get_x3();
+    assert_eq!(x3, &3);
+
+    let actual = actual.maybe_generic_option_getter(None);
+
+    let gen_opt_get = actual.get_generic_option_getter();
+    assert_eq!(gen_opt_get, None);
+
+    assert_debug_eq(
+        &actual,
+        expect![[r#"
+            SutBuilder {
+                x1: 0,
+                x2: "2",
+                x3: 3,
+                x4_but_its_actually_5: "4",
+                not_a_getter: 5,
+                x6: (),
+            }"#]],
+    );
+}
diff --git a/bon/tests/integration/builder/mod.rs b/bon/tests/integration/builder/mod.rs
index 8965f559..aa15c51c 100644
--- a/bon/tests/integration/builder/mod.rs
+++ b/bon/tests/integration/builder/mod.rs
@@ -3,6 +3,7 @@ mod attr_crate;
 mod attr_default;
 mod attr_derive;
 mod attr_field;
+mod attr_getter;
 mod attr_into;
 mod attr_on;
 mod attr_overwritable;