Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ ena = { version = "0.14.3", default-features = fa
enum-iterator = { version = "2.1.0", default-features = false }
enumflags2 = { version = "0.7.12", default-features = false }
expect-test = { version = "1.5.1", default-features = false }
figment = { version = "0.10.19", default-features = false }
foldhash = { version = "0.2.0", default-features = false }
frunk = { version = "0.4.4", default-features = false }
frunk_core = { version = "0.4.4", default-features = false }
Expand Down
16 changes: 16 additions & 0 deletions libs/@local/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,24 @@ publish.workspace = true
version.workspace = true

[dependencies]
# Public workspace dependencies
error-stack = { workspace = true, public = true, features = ["std"] }

# Public third-party dependencies
serde_core = { workspace = true, public = true }

# Private third-party dependencies
figment = { workspace = true }
simple-mermaid = { workspace = true }

[dev-dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

[[example]]
doc-scrape-examples = true
name = "defaults"
test = true

[lints]
workspace = true
2 changes: 2 additions & 0 deletions libs/@local/config/docs/dependency-diagram.mmd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions libs/@local/config/examples/defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#![expect(clippy::print_stdout, clippy::use_debug)]
//! Builds a store configuration from two layers of programmatic defaults.

use error_stack::Report;
use hash_config::{LoadError, Loader};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug, Deserialize)]
struct Config {
store: Store,
routes: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct Store {
host: String,
port: u16,
}

#[derive(Serialize)]
struct StoreDefaults {
host: &'static str,
port: u16,
}

#[derive(Serialize)]
struct Defaults {
store: StoreDefaults,
routes: [&'static str; 2],
}

/// The values the binary ships with.
const SHIPPED: Defaults = Defaults {
store: StoreDefaults {
host: "localhost",
port: 5432,
},
routes: ["api", "health"],
};

/// A deployment moves the store to another port and leaves the rest alone.
fn deployed() -> Result<Config, Report<LoadError>> {
Loader::new()
.with_defaults(SHIPPED)
.with_defaults(json!({ "store": { "port": 6543 } }))
.load()
}

/// A deployment sets the port to a password by mistake.
fn misconfigured() -> Result<Config, Report<LoadError>> {
Loader::new()
.with_defaults(SHIPPED)
.with_defaults(json!({ "store": { "port": "hunter2" } }))
.load()
}

fn main() {
let config = deployed().expect("the deployed defaults should load");
println!(
"{}:{} serving {:?}",
config.store.host, config.store.port, config.routes
);

let report = misconfigured().expect_err("a password should not load as a port");
println!("\n{report:?}");
}

#[test]
fn deployment_overrides_shipped_port() {
let config = deployed().expect("the deployed defaults should load");

assert_eq!(
config.store.host, "localhost",
"the shipped host should survive the deployment layer"
);
assert_eq!(
config.store.port, 6543,
"the deployment layer should replace the shipped port"
);
}

#[test]
fn report_names_key_not_password() {
let report = misconfigured().expect_err("a password should not load as a port");
let rendered = format!("{report:?}");

assert!(
rendered.contains("store.port"),
"the report should name the key: {report:?}"
);
assert!(
!rendered.contains("hunter2"),
"the report should omit the value: {report:?}"
);
}
3 changes: 3 additions & 0 deletions libs/@local/config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
"fix:clippy": "just clippy --fix",
"lint:clippy": "just clippy",
"test:unit": "mise run test:unit @rust/hash-config"
},
"dependencies": {
"@rust/error-stack": "workspace:*"
}
}
36 changes: 36 additions & 0 deletions libs/@local/config/src/defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use figment::{
Metadata, Profile, Provider,
error::Error as FigmentError,
providers::Serialized,
value::{Dict, Map},
};
use serde_core::Serialize;

/// The programmatic default layer.
pub(crate) struct Defaults<T>(Serialized<T>);

impl<T> Defaults<T> {
/// Records the caller as the layer's source, so an error points at the values it rejected.
#[track_caller]
pub(crate) fn new(values: T) -> Self {
Self(Serialized::defaults(values))
}
}

impl<T> Provider for Defaults<T>
where
T: Serialize,
{
fn metadata(&self) -> Metadata {
let mut metadata = self.0.metadata();
// `Serialized` names itself after the Rust type it was handed, which says nothing to the
// reader of an error. The recorded call site survives on `source`.
metadata.name = "defaults".into();
// Figment's default notation prefixes the profile a key was found under.
metadata.interpolater(|_profile, keys| keys.join("."))
}

fn data(&self) -> Result<Map<Profile, Dict>, FigmentError> {
self.0.data()
}
}
Loading
Loading