# koruma > koruma is a per-field validation framework for Rust focused on type safety, ergonomics, and developer experience. It generates strongly typed validation error structs at compile time using derive macros. Key features: - Define reusable validator structs with `#[validator]` - Attach validators to fields with `#[koruma(...)]` - Derive `Koruma` on data types for typed error accessors - Optional i18n support via Project Fluent and `es-fluent` - Nested validation and newtype pattern support - Built-in validator collection via `koruma-collection` crate ## Docs # Introduction `koruma` is a Rust validation framework built around derive macros and explicit validator types. Instead of hiding validation logic behind opaque attributes, it lets you define reusable validator structs, attach them to fields, and derive strongly typed error accessors. This book is a comprehensive guide to understanding and applying the `koruma` validation workflow from the ground up: - define validators with `#[validator]`, - attach them with `#[koruma(...)]`, - derive `Koruma` on your data type, - and inspect failures through generated typed error accessors. The next chapter walks through the basic setup and the overall flow before going deeper into custom validators, error handling, localisation, nested validation, and newtypes. --- # Getting Started Add `koruma` to your dependencies: ```toml [dependencies] koruma = { version = "*" } ``` ## Feature flags - `derive` (default): enables derive/attribute macros (`Koruma`, `KorumaAllDisplay`, `#[validator]`). - `fluent`: enables localized error support for `KorumaAllFluent` (use with `es-fluent`). - `internal-showcase`: enables internal validator showcase registry hooks used by workspace demos. A typical `koruma` workflow looks like this: 1. Define one or more validator types. 2. Implement `Validate` for each validator. 3. Attach validators to fields with `#[koruma(...)]`. 4. Derive `Koruma` on the struct you want to validate. 5. Call `validate()` and inspect the generated error accessors. Validators in `#[koruma(...)]` can use either form and be mixed across fields: ```rust #[koruma(NumberRangeValidation<_>(min = 0, max = 100))] #[koruma(NumberRangeValidation::<_>::builder().min(0).max(100))] ``` A small end-to-end example, using the validator definitions from the next chapter: ```rust use koruma::{Koruma, KorumaAllDisplay}; // Assume NumberRangeValidation and StringLengthValidation are defined as in the next chapter. #[derive(Koruma, KorumaAllDisplay)] pub struct Item { #[koruma(NumberRangeValidation::<_>::builder().min(0).max(100))] pub age: i32, #[koruma(StringLengthValidation(min = 1, max = 67))] pub name: String, // No #[koruma(...)] attribute -> not validated pub internal_id: u64, } let item = Item { age: 150, name: "".to_string(), internal_id: 1, }; if let Err(errors) = item.validate() { if let Some(age_err) = errors.age().number_range_validation() { println!("age failed: {}", age_err); } if let Some(name_err) = errors.name().string_length_validation() { println!("name failed: {}", name_err); } } ``` The validator definitions themselves come next. If you want to inspect the captured input on a validator error, call the generated getter that matches the `#[koruma(value)]` field name. For validators that only care about presence and do not need to store the input, an `Option` value field can use `#[koruma(value, skip_capture)]` to avoid derive-generated capture clones. If that field would still impose `Clone` or `Debug` bounds on the validator type, use manual impls like `RequiredValidation` does. The following chapters expand this pattern and show how to build richer validators and more useful error reporting. --- # Declaring Validators Validators are regular Rust types that describe a validation rule. To define one, annotate the struct with `#[validator]` and implement `Validate` for the input type you want to check. The `#[koruma(value)]` attribute marks the field that stores the actual input value used for error reporting, and `#[validator]` generates a getter with the same name. Keep that field private and use the generated getter for external reads. For example, a generic range validator: ```rust use koruma::{Validate, validator}; use std::fmt; #[validator] #[derive(Clone, Debug)] pub struct NumberRangeValidation { min: T, max: T, #[koruma(value)] actual: T, } impl Validate for NumberRangeValidation { fn validate(&self, value: &T) -> bool { *value >= self.min && *value <= self.max } } impl fmt::Display for NumberRangeValidation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "Value {} must be between {} and {}", self.actual, self.min, self.max ) } } ``` You can also write type-specific validators. For example, a validator for string length: ```rust use koruma::{Validate, validator}; use std::fmt; #[validator] #[derive(Clone, Debug)] pub struct StringLengthValidation { min: usize, max: usize, #[koruma(value)] input: String, } impl Validate for StringLengthValidation { fn validate(&self, value: &String) -> bool { let len = value.chars().count(); len >= self.min && len <= self.max } } impl fmt::Display for StringLengthValidation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "String length {} must be between {} and {} characters", self.input.chars().count(), self.min, self.max ) } } ``` The core pattern stays the same: define any configuration fields you need, mark the validated value with `#[koruma(value)]`, implement `Validate`, and optionally implement `Display` for friendly error messages. External callers can read the captured value through the generated getter (`validator.actual()`, `validator.input()`, and so on). For presence-only validators that do not need to retain the failing input, use `#[koruma(value, skip_capture)]` on an `Option` field. During derived validation, koruma leaves that field at `None` instead of cloning the input into the error value. If the validator still needs `Clone` or `Debug`, implement those manually so the skipped field does not reintroduce type bounds. --- # Using Validators Once validators are defined, attach them to fields with `#[koruma(...)]` and derive `Koruma` on your data type. The derive macro generates a `validate()` method that runs the configured validators and returns `Result<(), Errors>`. Validators in `#[koruma(...)]` can use either form and be mixed across fields: ```rust #[koruma(TypeName<_>(min = 0, max = 100))] #[koruma(TypeName::<_>::builder().min(0).max(100))] ``` Use `TypeName<_>(...)` or `TypeName::<_>::builder()...` when the validator is generic and Rust can infer the missing type parameter. ```rust use koruma::{Koruma, KorumaAllDisplay}; #[derive(Koruma, KorumaAllDisplay)] pub struct Item { #[koruma(NumberRangeValidation::<_>::builder().min(0).max(100))] pub age: i32, #[koruma(StringLengthValidation(min = 1, max = 67))] pub name: String, // Fields without #[koruma(...)] are ignored by validation. pub internal_id: u64, } let item = Item { age: 150, name: "".to_string(), internal_id: 1, }; match item.validate() { Ok(()) => println!("Item is valid"), Err(errors) => { if let Some(age_err) = errors.age().number_range_validation() { println!("age failed: {}", age_err); } if let Some(name_err) = errors.name().string_length_validation() { println!("name failed: {}", name_err); } } } ``` If a field has no `#[koruma(...)]` attribute, `koruma` does not validate it. For per-element validation, `each(...)` supports `Vec`, borrowed slices like `&[T]`, arrays like `[T; N]`, and optional variants of those: ```rust #[derive(Koruma)] pub struct Order { #[koruma(each(NumberRangeValidation<_>(min = 1, max = 5)))] pub quantities: Vec, } #[derive(Koruma)] pub struct BorrowedOrder<'a> { #[koruma(each(NumberRangeValidation<_>(min = 1, max = 5)))] pub quantities: &'a [i32], } ``` For fields with more than one validator, `koruma` generates accessors for each validator as well as an `all()` iterator when you derive `KorumaAllDisplay` or `KorumaAllFluent`. The next chapter covers that multi-validator case in more detail. --- # Multiple Validators & Error Handling A field can have more than one validator. After deriving `Koruma`, calling `validate()` runs all configured validators and returns `Result<(), Errors>`. On failure, the generated `Errors` type provides strongly typed field accessors, and each field error group exposes accessors for the individual validators that failed. ```rust if let Err(errors) = item.validate() { for failed in errors.age().all() { println!("age validator: {}", failed); } for failed in errors.name().all() { println!("name validator: {}", failed); } } ``` This pattern is useful when a field has multiple rules and you want to show every failure instead of only the first one. The order of execution follows the order in which validators are listed in the `#[koruma(...)]` attribute, and all configured validators are evaluated. You can customise error rendering by implementing `Display` for validators, or localise errors with [es-fluent](https://github.com/stayhydated/es-fluent) and `KorumaAllFluent`, which is covered in the next chapter. --- # i18n Integration with [es-fluent](https://github.com/stayhydated/es-fluent) `koruma` can integrate with [es-fluent](https://github.com/stayhydated/es-fluent) to localise validation errors. Enable the `fluent` feature and add the matching [es-fluent](https://github.com/stayhydated/es-fluent) dependency: ```toml [dependencies] koruma = { version = "*", features = ["derive", "fluent"] } es-fluent = { version = "*", features = ["derive"] } ``` This setup assumes: - `koruma` is built with `derive` + `fluent`. - your `es-fluent` manager is initialized. - a locale is selected before rendering messages. Validators intended for localisation derive `EsFluent`. When the validated value needs custom conversion, annotate it with `#[fluent(value(|x| ...))]`. Then derive `KorumaAllFluent` on the consumer type. ```rust use es_fluent::{EsFluent, ToFluentString as _}; use koruma::{Koruma, KorumaAllFluent, Validate, validator}; #[validator] #[derive(Clone, Debug, EsFluent)] pub struct IsEvenNumberValidation< T: Clone + Copy + std::fmt::Display + std::ops::Rem + From + PartialEq, > { #[koruma(value)] #[fluent(value(|x: &T| x.to_string()))] actual: T, } impl + From + PartialEq> Validate for IsEvenNumberValidation { fn validate(&self, value: &T) -> bool { *value % T::from(2u8) == T::from(0u8) } } #[validator] #[derive(Clone, Debug, EsFluent)] pub struct NonEmptyStringValidation { #[koruma(value)] input: String, } impl Validate for NonEmptyStringValidation { fn validate(&self, value: &String) -> bool { !value.is_empty() } } #[derive(Koruma, KorumaAllFluent)] pub struct User { #[koruma(IsEvenNumberValidation<_>)] pub id: i32, #[koruma(NonEmptyStringValidation)] pub username: String, } let user = User { id: 3, username: "".to_string() }; if let Err(errors) = user.validate() { if let Some(id_err) = errors.id().is_even_number_validation() { println!("{}", id_err.to_fluent_string()); } if let Some(username_err) = errors.username().non_empty_string_validation() { println!("{}", username_err.to_fluent_string()); } for failed in errors.id().all() { println!("{}", failed.to_fluent_string()); } for failed in errors.username().all() { println!("{}", failed.to_fluent_string()); } } ``` `KorumaAllFluent` gives you an `all()` iterator whose elements can be converted with `ToFluentString`. Initialise your i18n manager and select a locale before rendering localised messages. --- # Nested Validation When your data model contains structs inside other structs, you can use the `#[koruma(nested)]` attribute to validate them hierarchically. This attribute tells `koruma` to call `validate()` on the nested field and include its errors in the parent's error type if any occur. This allows the parent struct's `Errors` struct to provide strongly typed access not just to its own fields, but also to the nested struct's fields. ```rust use koruma::Koruma; #[derive(Clone, Koruma)] pub struct Address { #[koruma(StringLengthValidation(min = 1, max = 100))] pub street: String, #[koruma(StringLengthValidation(min = 1, max = 50))] pub city: String, #[koruma(ZipCodeValidation)] pub zip_code: String, } #[derive(Koruma)] pub struct Customer { #[koruma(StringLengthValidation(min = 1, max = 100))] pub name: String, #[koruma(NumberRangeValidation<_>(min = 18, max = 120))] pub age: i32, // Nested struct - validation cascades automatically #[koruma(nested)] pub address: Address, } let customer = Customer { name: "".to_string(), // Invalid: empty name age: 15, // Invalid: too young (min 18) address: Address { street: "123 Main St".to_string(), city: "".to_string(), // Invalid: empty city zip_code: "ABC".to_string(), // Invalid: not 5 digits }, }; match customer.validate() { Ok(()) => println!("Customer is valid!"), Err(errors) => { // Access top-level field errors if let Some(name_err) = errors.name().string_length_validation() { println!("name: {}", name_err); } if let Some(age_err) = errors.age().number_range_validation() { println!("age: {}", age_err); } // Access nested struct errors if let Some(address_err) = errors.address() { if let Some(street_err) = address_err.street().string_length_validation() { println!("street: {}", street_err); } if let Some(city_err) = address_err.city().string_length_validation() { println!("city: {}", city_err); } if let Some(zip_err) = address_err.zip_code().zip_code_validation() { println!("zip_code: {}", zip_err); } } } } ``` ## How It Works 1. Both the parent (`Customer`) and nested (`Address`) structs must derive `Koruma`. 2. When `customer.validate()` is called, it verifies `name` and `age` normally and also calls `address.validate()`. 3. If `address.validate()` fails, the resulting errors are wrapped inside `customer`'s overall `Errors` struct. 4. You access the nested errors using the corresponding accessor (`errors.address()`), which returns an `Option`. If there are any errors in the `address`, this returns `Some`, containing the exact error tree of the nested type. This nested pattern seamlessly integrates with all `koruma` features including [es-fluent](https://github.com/stayhydated/es-fluent) localisation and `newtype` validation. --- # Newtype Pattern & TryFrom Use `#[koruma(newtype)]`, adding `try_new` and `newtype(try_from)` as needed, when you want: - `newtype` - transparent error access to the inner field's error (`Deref` for non-optional fields, `Option<&InnerError>` accessors for `Option` fields) - `try_new` - a checked constructor function (`fn try_new(value: Inner) -> Result`) - `newtype(try_from)` - a `TryFrom` impl for checked conversions from the inner type You can layer `derive_more` traits on top for additional wrapper ergonomics (for example, `Deref` to inner value). ```rust use es_fluent::ToFluentString as _; use koruma::{Koruma, KorumaAllFluent, Validate}; #[derive(Clone, Koruma, KorumaAllFluent)] #[koruma(try_new, newtype)] pub struct Email { #[koruma(NonEmptyStringValidation)] pub value: String, } #[derive(Koruma, KorumaAllFluent)] pub struct SignupForm { #[koruma(NonEmptyStringValidation)] pub username: String, #[koruma(newtype)] pub email: Email, } #[derive(Koruma, KorumaAllFluent)] pub struct OptionalSignupForm { #[koruma(newtype)] pub email: Option, } let form = SignupForm { username: "".to_string(), email: Email { value: "".to_string(), }, }; if let Err(errors) = form.validate() { if let Some(username_err) = errors.username().non_empty_string_validation() { println!("username failed: {}", username_err.to_fluent_string()); } if let Some(email_err) = errors.email().non_empty_string_validation() { println!("email failed: {}", email_err.to_fluent_string()); } for failed in errors.email().all() { println!("email validator: {}", failed.to_fluent_string()); } } let optional_form = OptionalSignupForm { email: None }; assert!(optional_form.validate().is_ok()); let invalid_optional_form = OptionalSignupForm { email: Some(Email { value: "".to_string(), }), }; if let Err(errors) = invalid_optional_form.validate() && let Some(email_errors) = errors.email() && let Some(email_err) = email_errors.non_empty_string_validation() { println!("optional email failed: {}", email_err.to_fluent_string()); } if let Err(errors) = Email::try_new("".to_string()) { if let Some(email_err) = errors.non_empty_string_validation() { println!("email::try_new failed: {}", email_err.to_fluent_string()); } for failed in errors.all() { println!("email::try_new validator: {}", failed.to_fluent_string()); } } ``` ## Unnamed newtype (tuple struct) The same pattern works with tuple structs: ```rust use es_fluent::ToFluentString as _; use koruma::{Koruma, KorumaAllFluent, Validate}; #[derive(Clone, Koruma, KorumaAllFluent)] #[koruma(try_new, newtype)] pub struct Username(#[koruma(NonEmptyStringValidation)] pub String); #[derive(Koruma, KorumaAllFluent)] pub struct LoginForm { #[koruma(newtype)] pub username: Username, } let login = LoginForm { username: Username("".to_string()), }; if let Err(errors) = login.validate() { if let Some(username_err) = errors.username().non_empty_string_validation() { println!("username failed: {}", username_err.to_fluent_string()); } } if let Ok(username) = Username::try_new("alice".to_string()) { println!("username created: {}", username.0); } ``` ## TryFrom integration (`#[koruma(newtype(try_from))]`) Add `try_from` inside `newtype(...)` to generate a `TryFrom` impl: ```rust use std::convert::TryFrom; use es_fluent::ToFluentString as _; use koruma::{Koruma, KorumaAllFluent, Validate}; #[derive(Clone, Koruma, KorumaAllFluent)] #[koruma(newtype(try_from))] pub struct Only67u8(#[koruma(Only67Validation<_>)] u8); match Only67u8::try_from(69) { Ok(n) => println!("{}!", n.0), Err(errors) => { for failed in errors.all() { println!("validation failed: {}", failed.to_fluent_string()); } } } ``` --- # koruma-collection A curated set of validators built on top of `koruma`, organized by domain. ## Installation ```toml [dependencies] koruma-collection = { version = "*", features = ["full"] } ``` ## Modules at a glance ```rust use koruma_collection::{collection, format, general, numeric, string}; ``` ## Feature flags - `fmt` (default): `Display` messages for validators. - `full`: enables optional validator dependencies (`url`, `credit-card`, `phone-number`, `email`, `regex`, `smallvec`). - `fluent`: enables i18n integration with [es-fluent](https://github.com/stayhydated/es-fluent). - `full-fluent`: `full` + `fluent`. Validator-specific optional flags: - `credit-card` for `format::CreditCardValidation` - `email` for `format::EmailValidation` - `phone-number` for `format::PhoneNumberValidation` - `url` for `format::UrlValidation` - `regex` for `string::PatternValidation` - `smallvec` for `collection::HasLen` support on `SmallVec` ## Complete validator catalog ### String validators (`koruma_collection::string`) | Validator | Rule | Example attribute | Feature | | --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ | ------- | | `AlphanumericValidation` | Only letters and numbers | `#[koruma(string::AlphanumericValidation<_>)]` | always | | `AsciiValidation` | ASCII-only input | `#[koruma(string::AsciiValidation<_>)]` | always | | `ContainsValidation` | Contains substring | `#[koruma(string::ContainsValidation<_>(substring = "abc"))]` | always | | `MatchesValidation` | Equals expected value | `#[koruma(string::MatchesValidation<_>(other = "secret".to_string()))]` | always | | `PatternValidation` | Matches regex pattern | `#[koruma(string::PatternValidation<_>(pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap()))]` | `regex` | | `PrefixValidation` | Starts with prefix | `#[koruma(string::PrefixValidation<_>(prefix = "usr_"))]` | always | | `SuffixValidation` | Ends with suffix | `#[koruma(string::SuffixValidation<_>(suffix = ".rs"))]` | always | `MatchesValidation` and `PatternValidation` use generic error messages and do not echo the compared value or regex pattern. `PatternValidation` stores a compiled `regex::Regex`, so invalid patterns fail during construction instead of during validation. ### Format validators (`koruma_collection::format`) | Validator | Rule | Example attribute | Feature | | -------------------------- | ---------------------------- | --------------------------------------------------------------- | -------------- | | `IpValidation` | Valid IP (`Any`, `V4`, `V6`) | `#[koruma(format::IpValidation<_>(kind = format::IpKind::V4))]` | always | | `EmailValidation` | Valid email address | `#[koruma(format::EmailValidation<_>)]` | `email` | | `PhoneNumberValidation` | Valid phone number | `#[koruma(format::PhoneNumberValidation<_>)]` | `phone-number` | | `UrlValidation` | Valid URL | `#[koruma(format::UrlValidation<_>)]` | `url` | | `CreditCardValidation` | Valid credit card number | `#[koruma(format::CreditCardValidation<_>)]` | `credit-card` | ### Numeric validators (`koruma_collection::numeric`) | Validator | Rule | Example attribute | Feature | | -------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------- | | `PositiveValidation` | `value > 0` | `#[koruma(numeric::PositiveValidation<_>)]` | always | | `NonNegativeValidation` | `value >= 0` | `#[koruma(numeric::NonNegativeValidation<_>)]` | always | | `NonPositiveValidation` | `value <= 0` | `#[koruma(numeric::NonPositiveValidation<_>)]` | always | | `NegativeValidation` | `value < 0` | `#[koruma(numeric::NegativeValidation<_>)]` | always | | `RangeValidation` | Between `min` and `max` (inclusive by default) | `#[koruma(numeric::RangeValidation<_>(min = 0, max = 100, exclusive_max = true))]` | always | Primitive integers and floats implement `numeric::Numeric` out of the box. Enable the `decimal` feature to add `rust_decimal::Decimal`. Custom numeric-like types can opt in by implementing `numeric::Numeric::zero()`. `RangeValidation` messages use interval notation such as `[min, max]` or `(min, max]` so exclusive bounds are reflected directly in the rendered error. ### Collection validators (`koruma_collection::collection`) | Validator | Rule | Example attribute | Feature | | ----------------------- | ------------------------------ | ------------------------------------------------------------ | ------- | | `LenValidation` | Length within `[min, max]` | `#[koruma(collection::LenValidation<_>(min = 1, max = 10))]` | always | | `NonEmptyValidation` | Collection/string is not empty | `#[koruma(collection::NonEmptyValidation<_>)]` | always | `collection::HasLen` is implemented for common standard types (`String`, `str`, arrays/slices, `Vec`, sets/maps, etc.) and optionally for `SmallVec` with the `smallvec` feature. For `String` and `str`, `LenValidation` counts Unicode scalar values (`char`s), not UTF-8 bytes. ### General validators (`koruma_collection::general`) | Validator | Rule | Example attribute | Feature | | ------------------------------- | --------------------- | --------------------------------------------------- | ------- | | `RequiredValidation>` | Option must be `Some` | `#[koruma(general::RequiredValidation>)]` | always | `RequiredValidation` reports missing values, not empty strings or empty collections. Use `collection::NonEmptyValidation<_>` when you need an emptiness check. It uses `#[koruma(value, skip_capture)]` internally, so `Option` fields do not need `Clone` just to report a missing-value error. ## Example ```rust use koruma::{Koruma, KorumaAllDisplay}; use koruma_collection::{collection, general, numeric, string}; #[derive(Koruma, KorumaAllDisplay)] struct SignupInput { #[koruma(collection::NonEmptyValidation<_>)] username: String, #[koruma(string::AsciiValidation<_>, string::AlphanumericValidation<_>)] handle: String, #[koruma(numeric::RangeValidation::<_>::builder().min(13_u8).max(120_u8))] age: u8, #[koruma(general::RequiredValidation>)] display_name: Option, } let input = SignupInput { username: "".to_string(), handle: "bad-handle".to_string(), age: 8, display_name: None, }; if let Err(errors) = input.validate() { if let Some(err) = errors.username().non_empty_validation() { println!("username: {err}"); } if let Some(err) = errors.handle().ascii_validation() { println!("handle(ascii): {err}"); } for err in errors.handle().all() { println!("handle(any): {err}"); } } ``` The `RangeValidation` example above uses the standard Rust builder-chain form. The shorthand `numeric::RangeValidation<_>(min = 13_u8, max = 120_u8)` remains supported and is equivalent. ---