rskit-config — General-Purpose Config Loading
One config crate for every consumer class: services, apps, CLIs, tools, libraries, and schema-strict consumers — with a pluggable provider model (read / write / watch).
Capability layers
Each layer is opt-in, so a consumer links only what it needs:
| Layer | Module | For |
|---|---|---|
| Source pipeline (read) | source |
Layered load: defaults → TOML → dotenv → adapters → env → overrides |
| Typed decode | typed (via ConfigLoader::load) |
Plain Deserialize types; validation optional |
| Service shape | service + app |
ServiceConfig / AppConfig convenience for network services |
| Strict TOML | strict |
deny_unknown_fields, raw-subtree passthrough, identity-aware include-merge |
| Write (sink) | sink |
Persist/patch config back to a backend (ConfigSink) |
| Watch (reload) | watch |
Dynamic-reload change stream (ConfigWatch, feature-gated) |
Features
| Feature | Default | Enables |
|---|---|---|
validate |
on | ServiceConfig, AppConfig, load_validated*, load_app, load_config (pulls validator) |
watch |
off | ConfigWatch dynamic-reload contract + in-memory watch source (pulls rskit-stream for the bounded fan-out Broadcaster, tokio-util for cancellation) |
Apps, CLIs, tools, and libraries that load plain serde types should set default-features = false to drop the validator dependency.
Consumer stories
1. Service — ServiceConfig / AppConfig
Services want layered loading plus service identity (name/address/port/logging) and validation. Requires the default validate feature.
use ;
use Deserialize;
use Validate;
let cfg: Config = app
.with_default
.with_config_file
.with_env_prefix
.load_app?;
2. App / CLI / tool / library — plain typed load, no service identity
No service shape, no validator. Set default-features = false. ConfigLoader::toml(path) is the deterministic entry point (file only — no dotenv, no process env); ConfigLoader::custom() reads only explicitly added sources.
use ConfigLoader;
use Deserialize;
// File-only, deterministic: ideal for CLIs and one-shot tools.
let cfg: ToolConfig = toml.load?;
Opt into validation per-call without making it the default by implementing Validate and calling load_validated() instead of load().
3. Strict — deny_unknown_fields + raw passthrough + include-merge
For plugin hosts and multi-file schemas (e.g. Toven's Document) that must reject unknown keys, keep verbatim sub-trees for downstream owner-specific parsing, and merge includes by identity. Parses through the toml crate so serde's unknown-field rejection actually fires. Independent of the validate feature.
use BTreeMap;
use ;
use Deserialize;
let manifest: Manifest = new
.with_include
.load?;
// Each owner parses its own sub-tree, with its own `deny_unknown_fields`.
for in manifest.plugins
Identity-aware include-merge hard-errors on duplicate identities instead of silently overriding:
use ;
let merge = new
// Arrays-of-tables keyed by a single `name` field: a duplicate `name`
// across merged documents is a hard error, not a silent last-wins override.
.with_identity
// Arrays-of-tables with no single identifying field: identity is the
// joined value of several (optionally nested) fields.
.with_identity
// Tables-of-tables keyed by name (`[domains.<name>]`): a key contributed by
// two documents is a duplicate identity, rejected rather than merged.
.with_unique_keys;
4. Pluggable providers — read / write / watch
Add a custom backend without changing core. rskit-config owns ordering/merge/decoding; backends implement the contracts and are injected explicitly. A future rskit-vault (or your own adapter) is a pure consumer of these traits.
Read — ConfigSource slots into the ordered pipeline:
use ;
;
let loader = custom.with_source;
Write — ConfigSink persists/patches values back. Values flow as SecretString and are never logged. InMemoryConfigSink and FileConfigSink ship as reference impls; FileConfigSink reads and writes through a pluggable rskit-codec Codec (TOML by default, any format via with_codec):
use ;
let sink = new;
sink.set?;
sink.remove?;
Watch — ConfigWatch yields a bounded change stream (the rskit-stream Broadcaster specialized to typed change events) so a long-running consumer re-runs the pipeline and re-decodes. Behind the watch feature; cancellation via CancellationToken:
use StreamExt;
use ;
use CancellationToken;
let sink = new;
let cancel = new;
let mut changes = sink.watch?;
sink.set?;
if let Some = changes.next.await
cancel.cancel;
Loading order (lowest → highest priority)
- Programmatic defaults (
with_default) - TOML file (
with_config_file) - Profile env file (
config/profiles/{profile}.env, viawith_profile) .envfile- Adapter sources (
with_source) - Environment variables (
__separator, optional prefix viawith_env_prefix) - Programmatic overrides (
with_override)
Profile files requested through with_profile and explicit .env files requested through with_env_file are fail-closed: missing or malformed files return an AppError during load() instead of being silently ignored.
Dotenv values are loaded into the ConfigLoader source chain only; they do not mutate the process environment. Read dotenv-backed values from the typed config returned by load(), not from std::env. Malformed auto-discovered .env files are logged and skipped so optional local developer files do not prevent startup.
Secrets
Use SecretString for credentials, tokens, private keys, and other secret fields. It deserializes from config sources but masks Debug, Display, and serialization output; call expose() only at the boundary that needs the plaintext. Secrets keep this redaction across read, write, and watch.
ServiceConfig fields
| Field | Type | Default | Description |
|---|---|---|---|
name |
String |
"service" |
Service name |
environment |
Environment |
Development |
Deployment environment |
version |
String |
package version | Service version |
address |
String |
"0.0.0.0" |
Service bind address |
port |
u16 |
50051 |
Service port |
debug |
bool |
false |
Debug mode |
logging |
LoggingConfig |
Logging (level, format, output) |
LoggingConfig,LogFormat, andLogOutputare owned byrskit-loggingand re-exported here under thevalidatefeature for convenience. They are plainserdedata and carry notracingdependency, so loading config never links the logging subscriber stack.