Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ These were deliberated and settled. If something looks wrong, it probably isn't.
`Result`, and silently returning wrong values is worse.
- **Validation error paths use serde names** (renames honored) for fields and variants; variants use
`PathSegment::Variant` + index, rendering `Many[0].inner`.
- **A `#[setting(default)]` path is told apart by its casing.** `LevelFilter::Debug` names a value,
`find_unused_port` names a handler function, and both arrive as a bare `Expr::Path` that nothing at
derive time can resolve. `path_names_a_value` goes by the last segment's first character, which
works because a name breaking Rust's conventions would already be earning a lint. Non-literal
defaults still never reach the schema — `impl_schema_type` only reads `Expr::Lit`.
- **Derive-time panics for wrong attribute usage are intentional.** The maintainer wants loud
failure over silent no-ops. `#[setting(nested)]` on a primitive panics.

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ while the schema types have been updated to be more flexible and composable.
required an explicit `#[setting(env)]` and panicked at derive time otherwise.
- Added support for `#[deprecated(since = "...", note = "...")]`, whose note is now used as the
deprecation message. Only `#[deprecated]` and `#[deprecated = "..."]` were recognized before.
- Added support for struct literals and paths that name a value in `#[setting(default)]`, so an enum
variant or constant can be written directly: `#[setting(default = LevelFilter::Debug)]`. A path
whose last segment starts uppercase is a value, and one starting lowercase is still a handler
function to call. ([#173](https://github.com/moonrepo/schematic/issues/173))
- Added a `validate::uuid` function, which validates a string is a UUID in the canonical hyphenated
form. It checks the shape only, so the nil UUID and unknown versions are both accepted, and it
needs no Cargo feature. ([#156](https://github.com/moonrepo/schematic/issues/156))
- Improved the parse, handling, and validation of container and field attributes.
- Updated `#[config(before_parse)]` on `ConfigEnum` to accept every case that `rename_all` does,
instead of only `lowercase` and `UPPERCASE`. Incoming values are normalized before being matched,
Expand Down
29 changes: 26 additions & 3 deletions book/src/config/struct/default.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ In Schematic, there are 2 forms of default values:
- The second is on the [final configuration](../index.md) itself, and uses the `Default` trait to
generate the final value if none was provided. This acts more like a fallback.

To define a default value, use the `#[setting(default)]` attribute. The `default` attribute field is
used for declaring primitive values, like numbers, strings, and booleans, but can also be used for
array and tuple literals, as well as function (mainly for `from()`) and macros calls.
To define a default value, use the `#[setting(default)]` attribute. The `default` attribute field
accepts primitive values, like numbers, strings, and booleans, as well as array, tuple, and struct
literals, paths that name a value, and function and macro calls.

```rust
#[derive(Config)]
Expand All @@ -25,9 +25,32 @@ struct AppConfig {

#[setting(default = vec!["localhost".into()])]
pub allowed_hosts: Vec<String>,

#[setting(default = LevelFilter::Debug)]
pub log_level: LevelFilter,

#[setting(default = Retry { attempts: 3, backoff: 500 })]
pub retry: Retry,
}
```

### Paths: value or function?

A bare path is ambiguous — `LevelFilter::Debug` names a value, while `find_unused_port` names a
[handler function](#handler-function) to call. Nothing at compile time can tell them apart, so
Schematic goes by Rust's naming conventions:

| Last segment of the path | Treated as | Example |
| ------------------------------- | ---------------- | ------------------------ |
| Starts uppercase | A value | `LevelFilter::Debug` |
| Starts lowercase | A handler function | `find_unused_port` |

That covers enum variants, unit structs, and constants on one side, and functions on the other,
since a name breaking those conventions would already be earning a compiler lint.

> A handler function that must keep a non-conventional name can be reached through a `snake_case`
> wrapper, or called directly with `default = my_wrapper()` when it needs no context.

For enums, the `default` field takes no value, and simply marks which variant to use as the default.

```rust
Expand Down
28 changes: 24 additions & 4 deletions crates/core/src/field_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,26 @@ use crate::value::{Layer, Value};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use std::ops::Deref;
use syn::{Expr, Lit, Type};
use syn::{Expr, Lit, Path, Type};

#[derive(Debug)]
pub struct FieldValue(Value);

/// Whether a path names a value -- an enum variant, unit struct, or constant --
/// rather than a function to hand the context to.
///
/// Both arrive as a bare `Expr::Path`, and nothing at derive time can resolve
/// which one it is, so this leans on Rust's naming conventions: a function is
/// `snake_case`, while everything that names a value is `PascalCase` or
/// `SCREAMING_SNAKE_CASE`. A handler that ignored that would already be earning
/// a `non_snake_case` warning of its own.
fn path_names_a_value(path: &Path) -> bool {
path.segments
.last()
.and_then(|segment| segment.ident.to_string().chars().next())
.is_some_and(|char| char.is_uppercase())
}

fn wrap_layer(layer: &Layer, value: TokenStream) -> TokenStream {
match layer {
Layer::Arc => quote! { Arc::new(#value) },
Expand Down Expand Up @@ -102,15 +117,20 @@ impl FieldValue {

match field_args.default.as_ref() {
// Handler functions return the entire value
Some(Expr::Path(func)) => {
Some(Expr::Path(func)) if !path_names_a_value(&func.path) => {
res.requires_internal = true;
res.value = quote! { handle_default_result(#func(context))? };
}
// Explicit defaults provide the value up to the outermost
// collection, so only wrap with the layers outside of it
Some(expr) => {
let mut value = match expr {
Expr::Array(_) | Expr::Call(_) | Expr::Macro(_) | Expr::Tuple(_) => {
Expr::Array(_)
| Expr::Call(_)
| Expr::Macro(_)
| Expr::Path(_)
| Expr::Struct(_)
| Expr::Tuple(_) => {
quote! { #expr }
}
Expr::Lit(lit) => match &lit.lit {
Expand All @@ -126,7 +146,7 @@ impl FieldValue {
},
invalid => {
panic!(
"Unsupported default value ({invalid:?}). May only provide literals, primitives, arrays, or tuples."
"Unsupported default value ({invalid:?}). May only provide literals, primitives, arrays, tuples, structs, paths, or calls."
);
}
};
Expand Down
55 changes: 55 additions & 0 deletions crates/core/tests/setting_default_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,61 @@ mod setting_default {
assert_snapshot!(pretty(container.impl_partial_default_values()));
}

// A path that names a value rather than a function, told apart by Rust's
// naming conventions, so that an enum variant does not compile as a call
#[test]
fn supports_value_paths() {
let container = Container::from(parse_quote! {
#[derive(Config)]
struct Example {
#[setting(default = LevelFilter::Debug)]
a: LevelFilter,
#[setting(default = MAX)]
b: usize,
#[setting(default = limits::MAX)]
c: usize,
#[setting(default = Point::ORIGIN)]
d: Point,
}
});

assert_snapshot!(pretty(container.impl_partial_default_values()));
}

#[test]
fn supports_struct_literals() {
let container = Container::from(parse_quote! {
#[derive(Config)]
struct Example {
#[setting(default = Point { x: 1, y: 2 })]
a: Point,
#[setting(default = Wrapper { inner: Point { x: 0, y: 0 } })]
b: Wrapper,
}
});

assert_snapshot!(pretty(container.impl_partial_default_values()));
}

// An `Option` setting still gets its value wrapped, and a collection still
// takes the whole literal
#[test]
fn wraps_value_paths_in_layers() {
let container = Container::from(parse_quote! {
#[derive(Config)]
struct Example {
#[setting(default = LevelFilter::Debug)]
a: Option<LevelFilter>,
#[setting(default = LevelFilter::Debug)]
b: Box<LevelFilter>,
#[setting(default = vec![LevelFilter::Debug])]
c: Vec<LevelFilter>,
}
});

assert_snapshot!(pretty(container.impl_partial_default_values()));
}

mod named_struct {
use super::*;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
source: crates/core/tests/setting_default_test.rs
expression: pretty(container.impl_partial_default_values())
---
fn default_values(
context: &Self::Context,
) -> std::result::Result<Option<Self>, schematic::ConfigError> {
Ok(
Some(Self {
a: Some(Point { x: 1, y: 2 }),
b: Some(Wrapper {
inner: Point { x: 0, y: 0 },
}),
}),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
source: crates/core/tests/setting_default_test.rs
expression: pretty(container.impl_partial_default_values())
---
fn default_values(
context: &Self::Context,
) -> std::result::Result<Option<Self>, schematic::ConfigError> {
Ok(
Some(Self {
a: Some(LevelFilter::Debug),
b: Some(MAX),
c: Some(limits::MAX),
d: Some(Point::ORIGIN),
}),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: crates/core/tests/setting_default_test.rs
expression: pretty(container.impl_partial_default_values())
---
fn default_values(
context: &Self::Context,
) -> std::result::Result<Option<Self>, schematic::ConfigError> {
Ok(
Some(Self {
a: Some(LevelFilter::Debug),
b: Some(LevelFilter::Debug),
c: Some(vec![LevelFilter::Debug]),
}),
)
}
108 changes: 108 additions & 0 deletions crates/macros/tests/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,114 @@ mod named_struct {
}
}

// https://github.com/moonrepo/schematic/issues/173
mod default_values {
use super::*;
use schematic::DefaultValueResult;
use serde::Deserialize;

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, schematic::Schematic)]
pub enum LevelFilter {
#[default]
Off,
Debug,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, schematic::Schematic)]
pub struct Point {
pub x: usize,
pub y: usize,
}

impl Point {
pub const ORIGIN: Point = Point { x: 0, y: 0 };
}

pub mod limits {
pub const MAX: usize = 99;
}

fn pick_level<C>(_: &C) -> DefaultValueResult<LevelFilter> {
Ok(Some(LevelFilter::Debug))
}

#[derive(Debug, Config)]
pub struct Values {
#[setting(default = LevelFilter::Debug)]
variant: LevelFilter,
#[setting(default = Point::ORIGIN)]
assoc_const: Point,
#[setting(default = limits::MAX)]
path_const: usize,
#[setting(default = Point { x: 1, y: 2 })]
literal: Point,
#[setting(default = Point { x: 3, ..Point::ORIGIN })]
literal_with_rest: Point,
}

#[test]
fn uses_a_path_that_names_a_value() {
let config = Values::default();

assert_eq!(config.variant, LevelFilter::Debug);
assert_eq!(config.assoc_const, Point { x: 0, y: 0 });
assert_eq!(config.path_const, 99);
}

#[test]
fn uses_a_struct_literal() {
let config = Values::default();

assert_eq!(config.literal, Point { x: 1, y: 2 });
assert_eq!(config.literal_with_rest, Point { x: 3, y: 0 });
}

#[derive(Debug, Config)]
pub struct Handlers {
#[setting(default = pick_level)]
via_fn: LevelFilter,
}

// A `snake_case` path is still called with the context, which is what
// tells the two apart
#[test]
fn still_calls_a_handler_function() {
assert_eq!(Handlers::default().via_fn, LevelFilter::Debug);
}

#[derive(Debug, Config)]
pub struct Layered {
#[setting(default = LevelFilter::Debug)]
optional: Option<LevelFilter>,
#[setting(default = LevelFilter::Debug)]
boxed: Box<LevelFilter>,
#[setting(default = vec![LevelFilter::Debug])]
list: Vec<LevelFilter>,
}

#[test]
fn wraps_a_value_in_its_layers() {
let config = Layered::default();

assert_eq!(config.optional, Some(LevelFilter::Debug));
assert_eq!(*config.boxed, LevelFilter::Debug);
assert_eq!(config.list, vec![LevelFilter::Debug]);
}

// A default is the lowest layer, so a configured value still wins
#[test]
fn a_source_still_overrides_it() {
let result = ConfigLoader::<Values>::new()
.code(r#"{"variant": "Off"}"#, "code.json")
.unwrap()
.load()
.unwrap();

assert_eq!(result.config.variant, LevelFilter::Off);
assert_eq!(result.config.literal, Point { x: 1, y: 2 });
}
}

mod merging {
use super::*;

Expand Down
2 changes: 2 additions & 0 deletions crates/schematic/src/validate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod number;
mod string;
#[cfg(feature = "validate_url")]
mod url;
mod uuid;

pub use crate::config::{ValidateError, ValidateResult, Validator};
#[cfg(feature = "validate_email")]
Expand All @@ -20,6 +21,7 @@ pub use number::*;
pub use string::*;
#[cfg(feature = "validate_url")]
pub use url::*;
pub use uuid::*;

pub(crate) fn map_err(error: garde::Error) -> ValidateError {
ValidateError::new(error.to_string())
Expand Down
Loading
Loading