Configuration

Configuration

On this page

Two layers:

  • .env — deployment values, one per environment, never committed
  • the config repository — a dotted tree the application reads
Config::instance().get(keys::APP_NAME);
Config::instance().get_or(POSTS_PER_PAGE, 15);
Config::instance().setting(keys::CACHE_DRIVER)?;

Plus one thing a stringly-typed config API can never give you: the keys and the driver names are types, not strings. config("cache.drivers") is a typo a dynamic language cannot catch and Rust can, so it does.

flowchart LR
    ENV[".env<br/><i>CACHE_DRIVER=redis</i>"] -->|"env.setting::&lt;CacheDriver&gt;()"| PARSE{in the set?}
    PARSE -->|no| FAIL["boot fails, listing<br/>the valid values"]
    PARSE -->|yes| TREE["the config tree<br/><i>cache.driver = &quot;redis&quot;</i>"]
    TREE -->|"config.setting(CACHE_DRIVER)"| ENUM["CacheDriver::Redis"]
    ENUM --> MATCH["an exhaustive match<br/>in bootstrap.rs"]

    style FAIL fill:#633,stroke:#a66,color:#fff
    style ENUM fill:#353,stroke:#6a6,color:#fff

.env

APP_NAME="Rainier Sample"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000

SERVER_HOST=127.0.0.1
SERVER_PORT=8000

DATABASE_URL=sqlite::memory:
QUEUE_DRIVER=sync
MAIL_DRIVER=log
MAIL_FROM=[email protected]

RUST_LOG=info

Rainier::new(base_path) reads .env from that path if it is there.

Real environment variables always win over the file. A container orchestrator, a CI secret, or a DATABASE_URL=… cargo run beats a checked-out copy — which is what makes the same binary deployable everywhere.

.env is git-ignored; .env.example is committed and documents every key.

use rainier_framework::config::Env;

let env = Env::load_or_default(".env");     // missing file is fine
let env = Env::load(".env")?;               // missing file is an error
let env = Env::parse("APP_NAME=Test");      // from a string, for tests

env.string("APP_NAME", "Rainier");
env.int("SERVER_PORT", 8000);
env.bool("APP_DEBUG", false);
env.get("ANYTHING");                        // Option<String>
env.require("DATABASE_URL")?;               // Err if absent

require is worth using for anything with no safe default. Failing at boot with "DATABASE_URL is not set" beats failing on the first query with a connection error to localhost.

Reading a driver

env.setting::<CacheDriver>("CACHE_DRIVER")?;      // default if unset, Err if unknown
env.setting_or("QUEUE_DRIVER", QueueDriver::Sync)?;   // a different default

Note that this does not follow bool and int in falling back:

Error: `CACHE_DRIVER`: `redys` is not a valid cache driver; expected one of
`memory`, `redis`, `redis-cluster`, `memcached`, `dynamodb`

A default is for a value nobody set. It is not for a value somebody set wrong — substituting one there means the deployment that typed redys runs on an in-process cache, and the first symptom is a rate limiter letting through N × its limit across N instances, weeks later. bool and int still fall back because their whole range is obvious from the type and a bad one is usually a stray quote; a driver name selects code, and running different code than was asked for is not a recovery.

An empty value is not an unset one

CACHE_DRIVER= is an error, not the default. Blanking a line is a common way to mean "disable this", so the message says which mistake it is:

Error: `CACHE_DRIVER` is set to an empty value, which is not the same as leaving
it unset: remove the line to take the default cache driver, or name one of
`memory`, `redis`, `redis-cluster`, `memcached`, `dynamodb`, `kv`

The behaviour does not bend to match the friendlier message, and the reason is the other way an empty value arises: CACHE_DRIVER=$SOMETHING_UNSET in a compose file or a shell wrapper expands to empty with nobody having written it down. Reading that as "unset" hands the deployment an in-process cache — the failure a closed set exists to prevent. Delete the line; do not blank it.

The config repository

config.set("posts.per_page", 15)?;
config.set_default("posts.per_page", 15)?;      // only if unset; returns whether it set
config.merge("mail", json!({ "retries": 3 }))?; // deep-merge into a subtree
config.forget("posts.per_page");

Reading:

config.get::<u64>("posts.per_page");            // Option<T>
config.get_or("posts.per_page", 15u64);         // T
config.require::<String>("database.url")?;      // Result<T>
config.value("posts");                          // Option<Value> — a whole subtree
config.has("posts.per_page");
config.all();                                   // the whole tree

config.string("app.name");                      // typed shorthands
config.int("server.port");
config.bool("app.debug");
config.float("billing.rate");

Keys are dotted paths into a JSON tree, so a section is a subtree and config.value("mail") gives you all of it.

set takes &self — the repository is interior-mutable and Send + Sync, so it lives in the container and is read from every thread.

Typed keys

Every call above has a magic string in it, and two of them have a magic string on both sides. A Key<T> is a dotted path that knows what type lives there:

use rainier_framework::config::config_keys;

config_keys! {
    /// How many posts a listing shows.
    pub POSTS_PER_PAGE: u64 = "posts.per_page";
    /// The largest page a client may ask for.
    pub POSTS_MAX_PER_PAGE: u64 = "posts.max_per_page";
}
config.set(POSTS_PER_PAGE, 15)?;
let per_page = config.get(POSTS_PER_PAGE);      // Option<u64> — no turbofish

The type comes from the key, and the mistakes stop compiling:

config.set(POSTS_PER_PAGE, "fifteen")?;         // error: expected u64, found &str
let name: Option<String> = config.get(POSTS_PER_PAGE);   // error: not ConfigKey<String>

A &str still works everywhere — ConfigKey<T> is implemented for it for every T, because a path built at runtime cannot be a Key. The typed form is for the keys an application names in its own source, which is nearly all of them.

Where to declare them

In one module, next to the sections that write them. The sample project has src/config/keys.rs, which re-exports the framework's:

use rainier_framework::config::config_keys;

config_keys! {
    pub POSTS_PER_PAGE: u64 = "posts.per_page";
}

// APP_NAME, CACHE_DRIVER, SESSION_LIFETIME, and the rest.
pub use rainier_framework::keys::*;

One import, both sets, and a name that collided would be a compile error in the file where you can see both.

The framework's own are in rainier_framework::keys — that module is the index of what the framework reads, one page to look at instead of a directory of config files to leaf through.

Settings: closed sets of values

A driver name is not a string. It is one of a handful of values the code knows how to build, and every other string is a mistake.

use rainier_framework::support::setting_enum;

setting_enum! {
    /// Which search backend to query.
    pub enum SearchDriver: "search driver" {
        #[default]
        Database = "database",
        Meilisearch = "meilisearch",
    }
}

That one declaration gives you Display, FromStr, Serialize, Deserialize, Default, and a parse error that lists the alternatives. The wire spelling is written once and everything uses it, so a value written by config.set reads back through config.setting with no second mapping to keep in step.

config.set(SEARCH_DRIVER, SearchDriver::Meilisearch)?;
config.all();     // { "search": { "driver": "meilisearch" } }

Reading one

config.setting(keys::CACHE_DRIVER)?;    // Result<CacheDriver>
The key is…getsetting
unsetNonethe setting's Default
a valid spellingSome(driver)Ok(driver)
"redys"NoneErr, listing the options
an objectNoneErr, saying it should be a string

Use setting for anything that selects code. The difference is entirely in the third row, and the third row is the one that costs you a weekend.

The drivers Rainier ships

Each lives in the crate that owns the concept, and each carries the predicates worth asking about it:

EnumValuesAsk it
CacheDrivermemory redis redis-cluster memcached dynamodbis_shared(), feature()
SessionDrivermemory database cache cookieis_revocable(), is_durable(), is_shared()
QueueDriversync memory database sqsis_deferred(), needs_a_worker(), survives_a_restart()
MailDriverlog file memory smtp ses postmark mailgun sendgrid resenddelivers(), is_inspectable()
FilesystemDriverlocal memory s3is_shared(), is_durable()
AppEnvproduction staging local testingis_developing(), is_serving_users()

FilesystemDriver is the one whose set a declaration may exceed: an application can register a driver the framework does not ship, and a disk may then name it. The enum stays closed because it is the set the framework knows how to build — which is what lets a caller match on FilesystemDriver::S3 without handling an open-ended string to do it. A name that is neither built in nor registered is still a boot failure, never a silent local.

The predicates are the point. driver.is_revocable() is a question with one answer; driver == "cookie" is the same question, spelled so that adding a second non-revocable store silently gets it wrong.

Note what SessionDriver does not have: redis. Sessions in Redis are the cache driver pointed at Redis — one store to configure, one pool to open. A closed set makes that visible, where a free string would leave someone to discover that SESSION_DRIVER=redis does nothing.

Matching on one

let store: Arc<dyn SessionStore> = match env.setting::<SessionDriver>("SESSION_DRIVER")? {
    SessionDriver::Memory => Arc::new(MemorySessionStore::new(lifetime)),
    SessionDriver::Database => Arc::new(DatabaseSessionStore::new(db).with_lifetime(lifetime)),
    SessionDriver::Cache => Arc::new(CacheSessionStore::new(cache).with_lifetime(lifetime)),
    SessionDriver::Cookie => Arc::new(CookieSessionStore::new(crypt)),
};

No _ => arm, and that is deliberate. Adding a store to the framework makes this a compile error — which is exactly the list of places that need to learn about it.

Sections: when one of something is not enough

A driver name is one setting, and for a single backend that is the whole configuration. It stops being enough the moment there are two, because the second one shares nothing with the first: its own driver, its own host, its own credentials, its own timeouts.

Four things come in more than one, and all four are declared the same way — a default naming one entry, and each entry naming its own driver and settings:

SectionEntries are calledAlso declarable as one, byType
filesystemsdisksFILESYSTEM_DISK naming a declared diskDisks
databasesconnectionsDATABASE_URLDatabases
queuesconnectionsQUEUE_DRIVERConnections
cache.storesstoresCACHE_DRIVERStores

The disks / connections / stores inconsistency is inherited rather than invented. The wire shapes mirror the framework these are ported from, including its own asymmetry, so a section carried across from an existing application does not have to be re-learned to be brought over.

{
  "queues": {
    "default": "primary",
    "connections": {
      "primary": { "driver": "database", "reservation": 90 },
      "bulk": { "driver": "sqs", "queue_url": "https://sqs…/bulk", "region": "us-east-1" }
    }
  }
}

Each is also a typed key, so a section can be built in code and handed to the builder rather than assembled as JSON:

Rainier::new(".")
    .with_disks(Disks::new("uploads").with("uploads", DiskConfig::local("storage/app")))
    .with_databases(Databases::from_url(&url)?.with("replica", replica))
    .with_queues(Connections::new("primary").with("primary", ConnectionConfig::database()))

One or the other, never both

Each section's scalar counterpart names the default entry, so declaring both is two answers to one question — and that is a boot failure rather than a precedence rule:

Error: `DATABASE_URL` is set and a `databases` section is declared, and both name
the default database connection. Rainier will not choose between them: …

The reason precedence is the wrong answer is what happens after it resolves. Whichever declaration lost would still be sitting in the configuration, read by whoever changes it next — so repointing the database by editing the visible one would review cleanly, deploy cleanly and change nothing. Then the query runs against the other one and answers: rows come back, the types match, the page renders. There is no failure to notice.

Handing one over skips the check entirely

with_cache, with_queue, with_database and with_storage each win over both the variable and the section — that is the point, since they are the escape hatch for a backend no configuration file can describe. What is easy to miss is that the refusal above lives in the branch they replace, so an application that hands one over never sees it.

That is a trap rather than a nuance, and it has caught a real application. Hand over a CacheManager and CACHE_DRIVER is then read by nothing, so setting it to redis boots cleanly on whatever store was passed in — which is the unshared-cache failure the refusal exists to prevent, arriving through the wiring that suppressed the refusal.

If your application hands a backend over and still reads the variable for its own purposes, restate the refusal in your own configure:

if env.get("CACHE_DRIVER").is_some_and(|d| !d.trim().is_empty()) {
    return Err(Error::internal(
        "this application declares its cache stores; CACHE_DRIVER is not read — set CACHE_STORE",
    ));
}

That failure gets quieter as you go down the list, which is why all four refuse rather than only the loud ones:

  • a disk on the wrong bucket reads back empty, and somebody notices a missing file;
  • a job pushed to the wrong backend is accepted, returns an id, and then waits in a store no worker drains — no error, no retry, no failed-job row, because it never failed;
  • a cache store on the wrong server is not an outage at all, because everything downstream of a cache treats a miss as normal. It reads as a slow application — and when what was cached was a lock or a rate-limit counter, it is not slow, it is wrong;
  • a database query against the wrong connection simply answers.

An entry declares its own settings, and is built from those alone

This is the property all four share and the reason they are sections at all. The version of each that built every entry from one shared client produced a second entry with the right name pointed at the wrong place.

A setting an entry's driver cannot honour is refused when the section is read, not accepted and dropped. That includes settings a section ported from elsewhere carries that Rainier has no equivalent for — after_commit on a queue connection is refused because there is no transaction API for it to wait on, and the refusal names what to do instead. An ignored setting is worse than a rejected one: a rejected after_commit is a boot failure and a five-minute conversation, while an accepted one is a configuration file that states, in writing, that jobs wait for their transaction — while they do not.

Where it is set

// src/config/mod.rs — config/*.php
pub fn configure(config: &Config, env: &Env) -> Result<()> {
    app::configure(config, env)?;
    cache::configure(config, env)?;
    posts::configure(config, env)?;
    Ok(())
}
// src/config/app.rs — config/app.php
use crate::config::keys::{APP_LOCALE, APP_NAME};

pub fn configure(config: &Config, env: &Env) -> Result<()> {
    config.set(APP_NAME, env.string("APP_NAME", "Rainier Sample"))?;
    config.set(APP_LOCALE, env.string("APP_LOCALE", "en"))?;
    Ok(())
}
// src/config/cache.rs — config/cache.php
use crate::config::keys::{CACHE_DRIVER, CACHE_PREFIX, CACHE_REDIS_URL};

pub fn configure(config: &Config, env: &Env) -> Result<()> {
    config.set(CACHE_DRIVER, env.setting::<CacheDriver>("CACHE_DRIVER")?)?;
    config.set(CACHE_REDIS_URL, env.string("REDIS_URL", "redis://127.0.0.1:6379/"))?;

    // A literal: it namespaces our keys on a shared server, which is a
    // property of the application rather than of a deployment.
    config.set(CACHE_PREFIX, "rainier_sample".to_string())?;
    Ok(())
}
// src/config/posts.rs — an application's own section
use crate::config::keys::{POSTS_MAX_PER_PAGE, POSTS_PER_PAGE};

pub fn configure(config: &Config, env: &Env) -> Result<()> {
    config.set(POSTS_PER_PAGE, env.int("POSTS_PER_PAGE", 15) as u64)?;
    // Bounded, so a client cannot ask for every row in one request.
    config.set(POSTS_MAX_PER_PAGE, 100)?;
    Ok(())
}
Rainier::new(".").configure(|c| {
    config::configure(c, &Env::load_or_default(".env")).ok();
})

One function per concern. Every value comes from the environment with a fallback, so a fresh clone runs with no .env at all.

What the framework sets for you

Rainier::new fills these in before your configure runs, so you only set what is specific to your application — and override anything you want to differ:

KeyTypeFromDefault
keys::APP_NAMEStringAPP_NAMERainier
keys::APP_ENVAppEnvAPP_ENVproduction
keys::APP_DEBUGboolAPP_DEBUGfalse
keys::APP_URLStringAPP_URLhttp://localhost:8000
keys::APP_BASE_PATHStringthe builder's argument
keys::APP_CIPHERCryptSchemeAPP_CIPHERnative
keys::SERVER_HOSTStringSERVER_HOST127.0.0.1
keys::SERVER_PORTu16SERVER_PORT8000
keys::SERVER_MAX_BODY_BYTESu64SERVER_MAX_BODY2097152 (2 MiB)
keys::SERVER_REQUEST_TIMEOUT_SECSu64SERVER_REQUEST_TIMEOUT0 (off)
keys::SERVER_COMPRESSIONboolSERVER_COMPRESSIONfalse
keys::LOG_FORMATLogFormatLOG_FORMATauto
keys::DATABASE_URLStringDATABASE_URLsqlite::memory:
keys::FILESYSTEMSDisksFILESYSTEM_DISK names the defaultone local disk at storage/app
keys::CACHE_DRIVERCacheDriverCACHE_DRIVERmemory
keys::CACHE_REDIS_URLStringREDIS_URLredis://127.0.0.1:6379/
keys::CACHE_MEMCACHED_URLStringMEMCACHED_URL127.0.0.1:11211
keys::CACHE_PREFIXString—
keys::SESSION_DRIVERSessionDriverSESSION_DRIVERmemory
keys::SESSION_LIFETIMEi64SESSION_LIFETIME7200
keys::SESSION_COOKIEStringSESSION_COOKIErainier_session
keys::SESSION_SECUREboolSESSION_SECUREfalse
keys::QUEUE_DRIVERQueueDriverQUEUE_DRIVERsync
keys::QUEUE_DEFAULTStringQUEUE_DEFAULTdefault
keys::KAFKA_BROKERSStringKAFKA_BROKERS(empty)
keys::KAFKA_GROUPStringKAFKA_GROUPrainier
keys::KAFKA_TOPIC_PREFIXStringKAFKA_TOPIC_PREFIX(empty)
keys::KAFKA_BROADCAST_TOPICStringKAFKA_BROADCAST_TOPICbroadcasts
keys::KAFKA_TLSboolKAFKA_TLSfalse
keys::KAFKA_USERNAMEStringKAFKA_USERNAME(empty)
keys::KAFKA_PASSWORDStringKAFKA_PASSWORD(empty)
keys::KAFKA_SASL_MECHANISMStringKAFKA_SASL_MECHANISMplain
keys::MAIL_DRIVERMailDriverMAIL_DRIVERlog
keys::MAIL_FROM_ADDRESSStringMAIL_FROM[email protected]
keys::MAIL_FROM_NAMEStringMAIL_FROM_NAMERainier
keys::MAIL_ALWAYS_TOStringMAIL_ALWAYS_TO(empty)
keys::MAIL_FILE_PATHStringMAIL_FILE_PATHstorage/mail
keys::MAIL_HOSTStringMAIL_HOST(empty)
keys::MAIL_PORTi64MAIL_PORT0 — by encryption
keys::MAIL_USERNAMEStringMAIL_USERNAME(empty)
keys::MAIL_PASSWORDStringMAIL_PASSWORD(empty)
keys::MAIL_ENCRYPTIONMailEncryptionMAIL_ENCRYPTIONstarttls
keys::MAIL_TIMEOUTi64MAIL_TIMEOUT30
keys::MAIL_POSTMARK_TOKENStringMAIL_POSTMARK_TOKEN(empty)
keys::MAIL_MAILGUN_DOMAINStringMAIL_MAILGUN_DOMAIN(empty)
keys::MAIL_MAILGUN_SECRETStringMAIL_MAILGUN_SECRET(empty)
keys::MAIL_MAILGUN_ENDPOINTStringMAIL_MAILGUN_ENDPOINT(empty) — the US endpoint
keys::MAIL_SENDGRID_KEYStringMAIL_SENDGRID_KEY(empty)
keys::MAIL_RESEND_KEYStringMAIL_RESEND_KEY(empty)

filesystems is the only section the framework seeds, and it seeds it because a seeded disk costs a directory nobody writes to. databases, queues and cache.stores are seeded with nothing: a seeded connection opens a pool at boot against a database the application never asked for, and a seeded queue accepts jobs no worker drains. Declare nothing and you get no database and no queue at all, which is what an application that has neither should get.

The three new in 1.0.1 are worth a word each:

  • server.request_timeout_secs installs the Timeout middleware globally, answering 408 when a handler overruns. 0 is off, and it is off by default because the right ceiling is a fact about your application — a wrong one cancels work that was going to succeed.
  • server.compression installs Compress. Off by default because the usual deployment has nginx or a CDN in front, and compressing twice is CPU spent to produce the same bytes.
  • telemetry.log_format decides whether log lines are for a human or an aggregator. auto means JSON in production and staging — see Observability.
  • app.cipher is native or php, and chooses the envelope encrypted columns are written in. A closed set for the same reason a driver name is: writing the wrong one is not a preference that degrades, it is a column nothing can read. See Encryption.

Note the defaults are the safe ones. app.env is production and app.debug is false, so a misconfigured deployment fails closed rather than disclosing internal error messages. Every driver default needs no infrastructure and, in MailDriver::Log's case, cannot mail a real person — which is what a deployment that forgot to set one should do.

Settings that are not environment variables

Not every setting has an env var behind it, and it does not need one. .env is for values that differ per deployment. Anything else is a literal in config.rs.

fn server(config: &Config) -> Result<()> {
    // No env var: this is a property of your topology, not of a deployment,
    // and getting it wrong is a security bug rather than an inconvenience.
    config.set("server.trust_proxy", true)?;
    Ok(())
}

server.trust_proxy is the case that ships this way. It is read by serve, it is false unless you set it, and no environment variable sets it — see Deployment for why it should not be easy to turn on by accident.

The same applies to your own settings. A rule that holds up:

ValueWhere
differs per environment (URLs, credentials, driver choice).env, read with env.string(…)
differs per deployment but has a safe default.env with a fallback
the same everywhere in this applicationa literal in config.rs
the same for every applicationnot configuration — a constant
fn posts(config: &Config, env: &Env) -> Result<()> {
    // Tunable per environment.
    config.set("posts.per_page", env.int("POSTS_PER_PAGE", 15))?;

    // A property of the API contract. Same everywhere; no env var.
    config.set("posts.max_per_page", 100)?;
    Ok(())
}

Putting a literal in config.rs rather than inlining it at the use site still buys you something: it is discoverable in one place, readable through Config::instance() from anywhere, and overridable in a test with config.set(…) without touching the environment.

Overriding a framework default

Your configure runs after the framework has filled in its own defaults, so setting a key again simply wins:

pub fn configure(config: &Config, env: &Env) -> Result<()> {
    // The framework read SERVER_MAX_BODY; this application accepts larger
    // uploads regardless of what the environment says.
    config.set(keys::SERVER_MAX_BODY_BYTES, 20 * 1024 * 1024)?;
    Ok(())
}

Use set_default instead when you want to fill a gap without overriding something already set:

config.set_default(POSTS_PER_PAGE, 15)?;   // returns whether it set anything

Reading it

Anywhere, through the facade:

let per_page = Config::instance().get_or(POSTS_PER_PAGE, 15);

Or from the container, which is what a service should do:

app.singleton(|c: &Container| {
    let config = c.resolve::<Config>()?;
    Ok(SearchClient::new(config.require(SEARCH_URL)?))
});

Per-environment values

There is no config/local/. Branch on the environment where it matters:

let engine = if config.setting(keys::APP_ENV)?.is_developing() {
    TemplateEngine::new("resources/views").without_cache()
} else {
    TemplateEngine::new("resources/views")
};

is_developing() rather than == AppEnv::Local, because staging is the value everybody forgets and a predicate has one place to get it right.

or, as the sample project does, on an explicit mode:

pub enum Mode { Running, Testing }

pub async fn boot(mode: Mode) -> Result<Arc<Application>> { … }

A parameter rather than a global, because a test wants captured mail and an in-memory queue while a running app wants neither — and because reading APP_ENV inside a test suite makes the suite depend on the developer's .env.

app.environment();
app.environment_is(&["local", "testing"]);
app.is_local();
app.is_testing();
app.is_production();

Configuration is not a secret store

.env is fine for a development database URL. It is not fine for a production credential — it lands on disk, in a backup, in a container image layer.

For anything that matters, read the real environment (which wins over the file) and let your platform inject it: Kubernetes secrets, AWS Parameter Store, Vault, systemd EnvironmentFile with the right mode. Rainier does not need to know the difference — env.require("…") reads either.

Testing configuration

Env::parse builds one from a string, so a test needs no file:

#[test]
fn the_environment_overrides_a_default() {
    let config = Config::new();
    configure(&config, &Env::parse("APP_NAME=Custom\nPOSTS_PER_PAGE=50")).unwrap();

    assert_eq!(config.get(keys::APP_NAME).as_deref(), Some("Custom"));
    assert_eq!(config.get(POSTS_PER_PAGE), Some(50));
}

Worth writing for any section with real defaults in it — it is the cheapest test in the suite.

The process environment wins, unless you say otherwise

Env::get consults the real environment before its own map. That is the right rule in production — a variable set on the box beats a .env file committed months ago — and it makes a test that sets MAIL_DRIVER=log in a map still see whatever the machine exports.

let env = Env::parse("MAIL_DRIVER=log").isolated();     // nothing but this
let env = Env::from_map([("MAIL_DRIVER", "log")]);      // implies isolated

from_map implies isolation because a map is a complete statement of intent by definition; is_isolated() answers which mode an Env is in. See Testing.

It no longer catches a misspelled key, though, and that is the improvement: with typed keys a bad path does not compile, so the test is free to be about behaviour. The one worth adding instead is that a bad driver is refused:

#[test]
fn a_driver_outside_its_set_stops_the_boot() {
    let err = configure(&Config::new(), &Env::parse("CACHE_DRIVER=redys")).unwrap_err();

    assert!(err.message().contains("CACHE_DRIVER"));
    assert!(err.message().contains("`memcached`"), "the message should list the options");
}

This page is the framework’s docs/configuration.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.