ddd_toolkit_macro/lib.rs
1//! Derive macros for `ddd-toolkit-core`'s value-object-shaped traits:
2//! [`ValueObject`](derive_value_object), [`EntityId`](derive_entity_id),
3//! [`SecretVo`](derive_secret_vo), and [`EnumVo`](derive_enum_vo). Works
4//! alongside `ddd-toolkit-core` directly, or through the `ddd-toolkit`
5//! facade crate - the generated code resolves the right crate path either
6//! way, so it doesn't matter which one the caller's `Cargo.toml` names.
7//!
8//! Three of the four derives ([`ValueObject`](derive_value_object),
9//! [`EntityId`](derive_entity_id), [`SecretVo`](derive_secret_vo)) apply to
10//! a tuple struct with exactly one non-`pub` field, e.g. `struct
11//! Email(String);`. The fourth, [`EnumVo`](derive_enum_vo), applies to a
12//! fieldless enum instead. All four accept `#[vo(...)]` attributes to
13//! configure the generated code; each derive's own docs list which
14//! attributes it accepts and why the others are rejected.
15//!
16//! # Example
17//!
18//! ```
19//! use ddd_toolkit_core::domain::{ValidationError, Wrapped};
20//! use ddd_toolkit_macro::ValueObject;
21//!
22//! #[derive(ValueObject, Clone, PartialEq, Debug)]
23//! #[vo(validate = "validate_email")]
24//! struct Email(String);
25//!
26//! fn validate_email(s: &str) -> Result<(), ValidationError> {
27//! if s.contains('@') {
28//! Ok(())
29//! } else {
30//! Err(ValidationError::new("Email", "missing @"))
31//! }
32//! }
33//!
34//! let email = Email::try_from("a@b.com".to_string())?;
35//! assert_eq!(email.as_inner(), "a@b.com");
36//! assert!(Email::try_from("no-at-sign".to_string()).is_err());
37//! # Ok::<(), ValidationError>(())
38//! ```
39#![deny(missing_docs)]
40
41mod attributes;
42mod entity_id;
43mod enum_vo;
44mod krate;
45mod secret_vo;
46mod vo;
47
48use crate::enum_vo::expand_enum_vo;
49use crate::vo::expand_value_object;
50use crate::{entity_id::expand_entity_id, secret_vo::expand_secret_vo};
51use proc_macro::TokenStream;
52use syn::{DeriveInput, parse_macro_input};
53
54/// Derives [`ValueObject`], [`Wrapped`], `TryFrom<Inner>`, `From<Self> for
55/// Inner`, and `Display` for a single-field tuple struct.
56///
57/// [`ValueObject`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.ValueObject.html
58/// [`Wrapped`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.Wrapped.html
59///
60/// # Requirements
61///
62/// The target must be a tuple struct with exactly one field, and that
63/// field must not be `pub`. A `pub` field would let callers construct the
64/// type directly (`Email("anything".into())`), bypassing the validation
65/// this derive puts into the generated `TryFrom` impl - so both violations
66/// are rejected at compile time rather than left as a foot-gun.
67///
68/// # Attributes
69///
70/// All are passed as `#[vo(...)]` on the struct itself:
71///
72/// - `validate = "path::to::fn"` - runs `fn(&Inner) -> Result<(),
73/// ValidationError>` inside the generated `TryFrom` before the value is
74/// constructed. Without it, `TryFrom::Error` is
75/// [`Infallible`](std::convert::Infallible).
76/// - `no_display` - skips the generated `Display` impl, so a hand-written
77/// one can be provided instead.
78///
79/// `rename_all` and `zeroize` are rejected here: `rename_all` only affects
80/// [`EnumVo`](derive_enum_vo)'s variant names, and `zeroize` only makes
81/// sense on [`SecretVo`](derive_secret_vo), which owns a value worth
82/// scrubbing from memory on drop.
83///
84/// # Examples
85///
86/// ```
87/// use ddd_toolkit_core::domain::{ValidationError, Wrapped};
88/// use ddd_toolkit_macro::ValueObject;
89///
90/// #[derive(ValueObject, Clone, PartialEq, Debug)]
91/// #[vo(validate = "validate_email")]
92/// struct Email(String);
93///
94/// fn validate_email(s: &str) -> Result<(), ValidationError> {
95/// if s.contains('@') {
96/// Ok(())
97/// } else {
98/// Err(ValidationError::new("Email", "missing @"))
99/// }
100/// }
101///
102/// let email = Email::try_from("a@b.com".to_string())?;
103/// assert_eq!(email.as_inner(), "a@b.com");
104/// assert_eq!(email.to_string(), "a@b.com");
105/// assert!(Email::try_from("no-at-sign".to_string()).is_err());
106/// # Ok::<(), ValidationError>(())
107/// ```
108#[proc_macro_derive(ValueObject, attributes(vo))]
109pub fn derive_value_object(input: TokenStream) -> TokenStream {
110 let input = parse_macro_input!(input as DeriveInput);
111 expand_value_object(&input)
112 .unwrap_or_else(syn::Error::into_compile_error)
113 .into()
114}
115
116/// Derives [`EntityId`] - and everything
117/// [`#[derive(ValueObject)]`](derive_value_object) derives - for a
118/// single-field tuple struct.
119///
120/// [`EntityId`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.EntityId.html
121///
122/// On top of the `ValueObject` bundle, this adds `Eq`, `Hash`, `Ord`, and
123/// `PartialOrd`, all delegating to the wrapped field - two ids compare and
124/// hash equal exactly when their inner values do. That makes the result
125/// usable as a `HashMap`/`HashSet` key or in a sorted collection, which is
126/// the common case for an identifier type.
127///
128/// See [`derive_value_object`] for the shared requirements (single-field
129/// tuple struct, non-`pub` field) and `#[vo(...)]` attributes (`validate`,
130/// `no_display`) - both apply here unchanged, and `rename_all`/`zeroize`
131/// are rejected for the same reasons.
132///
133/// # Examples
134///
135/// ```
136/// use ddd_toolkit_core::domain::Wrapped;
137/// use ddd_toolkit_macro::EntityId;
138/// use std::collections::HashSet;
139///
140/// #[derive(EntityId, Clone, PartialEq, Debug)]
141/// struct UserId(u64);
142///
143/// let a = UserId::try_from(1)?;
144/// let b = UserId::try_from(2)?;
145/// assert!(a < b);
146///
147/// let ids: HashSet<UserId> = HashSet::from([UserId::try_from(1)?, UserId::try_from(1)?]);
148/// assert_eq!(ids.len(), 1, "equal inner values dedupe to one id");
149/// # Ok::<(), std::convert::Infallible>(())
150/// ```
151#[proc_macro_derive(EntityId, attributes(vo))]
152pub fn derive_entity_id(input: TokenStream) -> TokenStream {
153 let input = parse_macro_input!(input as DeriveInput);
154 expand_entity_id(&input)
155 .unwrap_or_else(syn::Error::into_compile_error)
156 .into()
157}
158
159/// Derives [`SecretVo`] for a single-field tuple struct, with a `Debug`
160/// impl that always redacts the wrapped value.
161///
162/// [`SecretVo`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.SecretVo.html
163///
164/// Unlike [`ValueObject`](derive_value_object), this derive generates its
165/// own `Debug` impl (printing `TypeName(***)`, never the wrapped value) -
166/// so the target must not derive or implement `Debug` itself, or the two
167/// impls collide (`E0119`). It never generates `Display` either, which is
168/// why `no_display` is rejected here: there is nothing for it to suppress.
169///
170/// See [`derive_value_object`] for the shared single-field-tuple-struct
171/// requirement and the `validate` attribute, which behaves identically
172/// here. `rename_all` is rejected for the same reason as on `ValueObject`.
173///
174/// # Attributes
175///
176/// - `validate = "path::to::fn"` - see [`derive_value_object`].
177/// - `zeroize` - generates a `Drop` impl that calls
178/// [`Zeroize::zeroize`](https://docs.rs/zeroize/latest/zeroize/trait.Zeroize.html#tymethod.zeroize)
179/// on the wrapped value when it goes out of scope. Requires *this*
180/// crate's own `zeroize` Cargo feature to be enabled, and the *calling*
181/// crate to depend on the `zeroize` crate directly (the generated `Drop`
182/// impl references it by a bare path, which only resolves at the call
183/// site). Without the feature enabled, `#[vo(zeroize)]` is rejected at
184/// compile time with a message naming both requirements, rather than
185/// failing later with a confusing unresolved-crate error.
186///
187/// # Examples
188///
189/// ```
190/// use ddd_toolkit_core::domain::SecretVo;
191/// use ddd_toolkit_macro::SecretVo as DeriveSecretVo;
192///
193/// #[derive(Clone, PartialEq, DeriveSecretVo)]
194/// struct ApiKey(String);
195///
196/// let secret = ApiKey::try_new("sekret".to_string())?;
197/// assert_eq!(secret.expose_secret(), "sekret");
198///
199/// let debug = format!("{secret:?}");
200/// assert_eq!(debug, "ApiKey(***)");
201/// assert!(!debug.contains("sekret"));
202/// # Ok::<(), std::convert::Infallible>(())
203/// ```
204///
205/// With the `zeroize` feature enabled on this crate, and `zeroize` in the
206/// caller's own dependencies:
207///
208/// ```ignore
209/// use ddd_toolkit_core::domain::SecretVo;
210/// use ddd_toolkit_macro::SecretVo as DeriveSecretVo;
211///
212/// #[derive(Clone, PartialEq, DeriveSecretVo)]
213/// #[vo(zeroize)]
214/// struct ZeroizingSecret([u8; 4]);
215///
216/// let secret = ZeroizingSecret::try_new([1, 2, 3, 4])?;
217/// drop(secret); // wipes the inner bytes on the way out
218/// # Ok::<(), std::convert::Infallible>(())
219/// ```
220#[proc_macro_derive(SecretVo, attributes(vo))]
221pub fn derive_secret_vo(input: TokenStream) -> TokenStream {
222 let input = parse_macro_input!(input as DeriveInput);
223 expand_secret_vo(&input)
224 .unwrap_or_else(syn::Error::into_compile_error)
225 .into()
226}
227
228/// Derives [`EnumVo`], `Display`, and `FromStr` for a fieldless enum.
229///
230/// [`EnumVo`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.EnumVo.html
231///
232/// `EnumVo::variants()`, `Display`, and `FromStr` are all generated from
233/// the same variant list, so they can't drift out of sync with each other
234/// or with the enum's actual variants. Every variant must be a unit
235/// variant (no fields) - a variant carrying data isn't a simple
236/// enumeration, so the derive rejects it at compile time instead of
237/// silently ignoring the payload.
238///
239/// `validate` is rejected here: `FromStr` already exhaustively checks
240/// variant membership, so there is no "invalid but parseable" value left
241/// for a validator to catch. `zeroize` is rejected for the same reason as
242/// on [`ValueObject`](derive_value_object) - a fieldless enum owns nothing
243/// worth scrubbing.
244///
245/// # Attributes
246///
247/// - `no_display` - see [`derive_value_object`].
248/// - `rename_all = "..."` - renames every variant for `Display`/`FromStr`
249/// according to one of: `PascalCase` (the default - unchanged), `snake_case`,
250/// `SCREAMING_SNAKE_CASE`, `kebab-case`, `lowercase`, `UPPERCASE`, or
251/// `camelCase`. An unrecognized rule name is rejected at compile time,
252/// listing the valid options.
253///
254/// # Examples
255///
256/// ```
257/// use ddd_toolkit_core::domain::EnumVo;
258/// use ddd_toolkit_macro::EnumVo as DeriveEnumVo;
259/// use std::str::FromStr;
260///
261/// #[derive(Debug, Clone, Copy, PartialEq, DeriveEnumVo)]
262/// enum Status {
263/// Active,
264/// Inactive,
265/// }
266///
267/// assert_eq!(Status::variants(), &[Status::Active, Status::Inactive]);
268/// assert_eq!(Status::Active.to_string(), "Active");
269/// assert_eq!(Status::from_str("Active"), Ok(Status::Active));
270/// assert!(Status::from_str("bogus").is_err());
271/// ```
272///
273/// With `rename_all`, `Display` and `FromStr` agree on the renamed form:
274///
275/// ```
276/// use ddd_toolkit_macro::EnumVo as DeriveEnumVo;
277/// use std::str::FromStr;
278///
279/// #[derive(Debug, Clone, Copy, PartialEq, DeriveEnumVo)]
280/// #[vo(rename_all = "snake_case")]
281/// enum HttpMethod {
282/// Get,
283/// PostRequest,
284/// }
285///
286/// assert_eq!(HttpMethod::PostRequest.to_string(), "post_request");
287/// assert_eq!(HttpMethod::from_str("post_request"), Ok(HttpMethod::PostRequest));
288/// assert!(HttpMethod::from_str("PostRequest").is_err());
289/// ```
290#[proc_macro_derive(EnumVo, attributes(vo))]
291pub fn derive_enum_vo(input: TokenStream) -> TokenStream {
292 let input = parse_macro_input!(input as DeriveInput);
293 expand_enum_vo(&input)
294 .unwrap_or_else(syn::Error::into_compile_error)
295 .into()
296}