feat(environments)!: declarative, attribute-driven per-environment config - #69
Conversation
…nfig Replaces the bag-based EnvironmentDef/Environment/OAuthConfig system (DEVEX-947, more-flexible-envs / #66) with a general #[derive(EnvConfig)] mechanism: a typed struct declares, per field, where to find its value (a TOML key, an opt-in app-scoped env-var suffix, a literal or computed default) and how to convert it, and Environments threads a chain of sources through the struct's generated `assemble` function to build it. - New cli-engine-macros crate (required — proc-macro crates compile for the host, not the target) providing #[derive(EnvConfig)], and src/env_config.rs with the runtime pieces: the ConfigSource trait, EnvSource/EnvVarSource/ValueSource, and SourceChain. - Environments (src/environments.rs) rewritten around this: EnvTable stores real typed toml::Value data instead of stringified fields, with_environment merges (rather than replaces) a compiled-in registration, and resolve::<T>() assembles any EnvConfig struct from the compiled+file table plus an app-scoped env-var tier. Per-key env-var overrides are app-scoped only (`<APP_ID>_<SUFFIX>`), not environment-name-scoped — one override slot per app, not per env. - PkceAuthProvider's OAuth config is now an ordinary EnvConfig struct (OAuthSection) resolved the same way every other typed section is, with no bespoke bag-reading code. Its env-var override tiers (app-scoped and the legacy provider-scoped fallback) are removed — OAuth config comes from the resolved environment or the provider's own base config, full stop. client_id/auth_url/token_url have no default: a provider whose fields were never initialized fails loudly instead of assembling with an empty endpoint. - Feature-flag min_stage/feature_overrides resolution in Cli::new now goes through the same resolve_field primitive #[derive(EnvConfig)] itself calls, rather than a hand-rolled toml_value lookup, so a malformed value is logged (not silently dropped) and blank values are treated as absent consistently with every other field. - docs/environments.md rewritten for the new mechanism end to end. Also includes the (unrelated, already-independently-fixed-on-main) flaky-test fixes from #61 and the error-envelope `fix` field from #65 (DEVEX-945), both merged forward from origin/main during this rework. Supersedes more-flexible-envs (#66) outright rather than building on top of it — none of that branch's EnvironmentDef/with_init/ field_defaults/field_validators design survives; #66 should be closed unmerged.
There was a problem hiding this comment.
Pull request overview
This PR replaces the previous bag-based environment configuration (EnvironmentDef/Environment/OAuthConfig) with a typed, declarative #[derive(EnvConfig)] system backed by TOML tables and a configurable source chain, and rewires environment resolution, env introspection commands, and PKCE OAuth configuration to use the new mechanism.
Changes:
- Introduces
env_configruntime + newcli-engine-macrosproc-macro crate to derive typed per-environment config structs from aSourceChainof TOML/env-var sources. - Reworks
Environmentsto store/merge raw TOML tables (EnvTable), support a fallback resolver for unknown names, and resolve anyEnvConfigsection type. - Updates feature-flag layering,
env infooutput, andPkceAuthProviderOAuth config resolution to rely on the new typed config pipeline (removing OAuth env-var override behavior).
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/foundation.rs | Updates tests to use EnvTable instead of EnvironmentDef for environment fixtures. |
| tests/feature_flags.rs | Switches feature-flag layering tests to TOML-table inputs and removes the env-var override test consistent with the new model. |
| src/output/envelope.rs | Treats CliCoreError::EnvConfig as a non-detailed error in envelope rendering. |
| src/lib.rs | Exposes the new env_config module and re-exports new public types; adds extern crate self as cli_engine for macro path stability. |
| src/error.rs | Adds CliCoreError::EnvConfig and threads it through detailed-error and exit-code walking. |
| src/environments.rs | Refactors environment storage/resolution around merged TOML tables + typed EnvConfig assembly; adds opt-in fallback resolution and table overlay merge behavior. |
| src/env_config.rs | Adds the new runtime abstraction layer: ConfigSource, EnvSource, EnvVarSource, ValueSource, SourceChain, EnvConfigError, and resolve_field. |
| src/env_commands.rs | Changes env info to emit environment name + full merged TOML config table (serialized to JSON). |
| src/command.rs | Changes CommandContext::environment() to return EnvSource and adds environment_config::<T>() for typed resolution. |
| src/cli.rs | Adds startup --env prescan + CliConfig::with_startup_args; rewires feature-flag environment layer reads through resolve_field; updates env validation to use source(). |
| src/auth/pkce.rs | Reworks per-environment OAuth config as a typed EnvConfig section with SourceChain (env table → base config), removing legacy env-var OAuth overrides. |
| docs/environments.md | Rewrites documentation to describe the new EnvConfig/SourceChain model, new precedence rules, and updated env/OAuth behavior. |
| cli-engine-macros/src/lib.rs | Implements #[derive(EnvConfig)] expansion and From<T> for EnvTable generation from struct values. |
| cli-engine-macros/Cargo.toml | Adds the proc-macro crate manifest (workspace member, non-published). |
| Cargo.toml | Adds workspace member + dependencies on cli-engine-macros and toml. |
| Cargo.lock | Updates lockfile for the new crates and toml dependency graph. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
cli-engine now depends on the new cli-engine-macros crate, so it needs to be independently versioned and published, not just carried along as an internal path dependency. - release-please-config.json: add cli-engine-macros as a second tracked package, with the cargo-workspace plugin enabled so its version bumps propagate into cli-engine's dependency requirement. - .release-please-manifest.json: seed its starting version (0.1.0, matching Cargo.toml). - cli-engine-macros/Cargo.toml: drop `publish = false` — it needs to actually reach crates.io for cli-engine's own `cargo publish` to resolve it as a dependency. - release.yml: publish cli-engine-macros before cli-engine (a dependency must already be live before its dependent can publish), tolerating "already published" for a release where only cli-engine itself bumped. Note: `cargo publish --dry-run` in ci.yml will stay red on this PR until cli-engine-macros actually exists on crates.io (a one-time chicken-and-egg gap for a brand-new sibling crate) — left as-is per discussion; not a code correctness issue.
#[derive(EnvConfig)]-generated code referenced bare toml::Value / ::toml::Value, which resolves through the consuming crate's own extern prelude — forcing every consumer to add a direct toml dependency (on a matching major version) even for the common case of plain fields with no custom from_toml/to_toml at all. Re-export toml from cli_engine::env_config and have every generated path go through it (::cli_engine::env_config::toml::Value) instead. The common case now needs no direct toml dependency in the consumer; a consumer that does write a custom from_toml/to_toml can also name the type through this re-export instead of tracking cli-engine's toml version itself. A consumer already depending on toml directly (e.g. gddy) is unaffected — cargo unifies matching versions to the same type either way. Also fixes a duplicated-word typo and a missing blank line before a heading in docs/environments.md. Addresses Copilot review feedback on #69.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
docs/environments.md:172
- This section describes the override env var name as
<APP_ID_UPPER>_<SUFFIX>, but the crate’s established convention is to sanitize the app id into an env-var prefix (non-alphanumerics become_, e.g.my-cli->MY_CLI; seeflags::app_id_env_prefix). Clarifying the exact prefix computation helps users set the override variables correctly.
Environment variables can override environment config fields when you have configured the fields with `env = "SUFFIX"`. The engine will look for a variable of the format `<APP_ID_UPPER>_<SUFFIX>`.
src/environments.rs:328
Environments::resolvebuilds the app-scopedEnvVarSourceprefix viaself.app_id.to_uppercase(), which leaves non-alphanumeric characters (notably-) intact. This is inconsistent with the existing${APP_ID}_...env var convention (sanitized viaflags::app_id_env_prefix, e.g.my-cli->MY_CLI) and makes overrides hard/impossible to set from typical shells whenapp_idcontains-. Use the same sanitization helper here so app-scoped env-var overrides are usable and consistent across the crate.
let app_scoped = EnvVarSource {
prefix: self.app_id.to_uppercase(),
};
docs/environments.md:46
- The docs refer to an anchor
#environment-variable-overrides-are-app-scoped, but the actual heading is## Environment-variable overrides(so the link is broken). Also,${APP_ID}_...env vars in this crate use a sanitized prefix (seeflags::app_id_env_prefix, e.g.my-cli->MY_CLI), not a simple uppercase of the raw app id; this row currently suggests<APP_ID_UPPER>.
This issue also appears on line 172 of the same file.
| `env = "SUFFIX"` | opt-in; final env var checked is `<APP_ID_UPPER>_<SUFFIX>` (see [Environment-Variable Overrides](#environment-variable-overrides-are-app-scoped)) | not environment-variable overridable at all |
# Conflicts: # .release-please-manifest.json
cargo publish --dry-run for cli-engine fails to resolve the new cli-engine-macros path+version dependency until that crate has an actual first release on crates.io (see the release.yml publish-order fix). That's an expected, one-time bootstrapping gap, not a real publish problem, so tolerate specifically "no matching package named `cli-engine-macros` found" — any other dry-run failure still fails the check.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 20 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
src/environments.rs:389
file_tablessilently ignores a nested[environments.<name>]entry when it isn’t a table. This can hide config mistakes; returning an error here (like other parse failures) would be clearer to users.
let Some(table) = value.as_table() else {
continue;
};
docs/environments.md:128
- This reference to
<APP_ID_UPPER>should use the same sanitized env-var prefix convention as the rest of the crate (e.g.my-cli->MY_CLI) so users can set the variable from a shell consistently.
1. **App-scoped environment variable** — `<APP_ID_UPPER>_<SUFFIX>`.
docs/environments.md:172
- The env-var override format is documented as
<APP_ID_UPPER>_<SUFFIX>, but other app-scoped env vars use a sanitized prefix (seeflags::app_id_env_prefix). Documenting the sanitized form avoids suggesting shell-hostile names likeMY-CLI_SUFFIX.
Environment variables can override environment config fields when you have configured the fields with `env = "SUFFIX"`. The engine will look for a variable of the format `<APP_ID_UPPER>_<SUFFIX>`.
src/environments.rs:328
Environments::resolvebuilds the app-scoped env-var prefix withself.app_id.to_uppercase(), but the rest of the crate (e.g.flags::min_stage_env_var) sanitizes app ids by uppercasing ASCII alphanumerics and replacing other chars with_(my-cli->MY_CLI). Without the same sanitization here, apps with hyphens/spaces inapp_idwould require awkward or shell-hostile env var names and won’t match the documented${APP_ID}_...conventions.
let app_scoped = EnvVarSource {
prefix: self.app_id.to_uppercase(),
};
src/environments.rs:383
file_tablessilently ignores a top-level environment entry if its TOML value isn’t a table. This makes typos/mis-shapes inenvironments.tomlhard to notice (previously they would surface as a parse error). Consider failing with a clear error when[name]exists but is not a table.
This issue also appears on line 387 of the same file.
if let Some(table) = value.as_table() {
tables.insert(name.clone(), table.clone());
}
docs/environments.md:46
- Docs describe env-var overrides as
<APP_ID_UPPER>_<SUFFIX>, but the crate’s established convention is a sanitized prefix (seeflags::app_id_env_prefix), e.g.my-cli->MY_CLI. The placeholder here should match the actual env-var name a user can set.
This issue also appears in the following locations of the same file:
- line 128
- line 172
| `env = "SUFFIX"` | opt-in; final env var checked is `<APP_ID_UPPER>_<SUFFIX>` (see [Environment-Variable Overrides](#environment-variable-overrides-are-app-scoped)) | not environment-variable overridable at all |
Two real findings from human review, both fixed with test coverage: - resolve_field's "blank is absent by default" collapsing only ever checked toml::Value::String, so an empty array (e.g. a wired environment's `scopes = []`) could never be treated as absent the way an empty string already is — it would silently win over a later tier's real value with no warning. Generalized the check to also treat an empty TOML array as blank, consistent with the existing rule for strings; `allow_blank` opts out of this for arrays too, same as it already does for strings. Added a generic resolve_field test plus a concrete PkceAuthProvider test (environment_with_empty_scopes_falls_back_to_base_scopes) covering the exact scenario flagged. - release.yml's "already published" tolerance for cli-engine-macros couldn't distinguish "expected, wasn't part of this release" from "release-please released it, but crates.io says already-exists" — the latter would mean its version bump didn't happen despite real changes, and cli-engine would get published against a stale dependency with no error. Wired release-please's paths_released output through so only the expected case is tolerated; the unexpected one now fails the job loudly.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/environments.rs:328
EnvVarSourcederives its prefix withself.app_id.to_uppercase(), which doesn’t sanitize non-alphanumerics. For an app id like "my-cli" this yields a prefix "MY-CLI", which is not settable in typical shells and is inconsistent with the establishedflags::app_id_env_prefixbehavior (e.g.MY_CLI_MIN_STAGE). Use the same sanitization here so all app-scoped env vars share a consistent, usable prefix.
let app_scoped = EnvVarSource {
prefix: self.app_id.to_uppercase(),
};
docs/environments.md:47
- The
envattribute row links to#environment-variable-overrides-are-app-scoped, but the actual heading is## Environment-variable overrides, so the link is broken. Also, the env-var format should use the sanitized app-id prefix (non-alphanumerics replaced with_) rather than a raw<APP_ID_UPPER>placeholder.
| `key = "..."` | TOML key to look up in the environment's merged table | the field's Rust name |
| `env = "SUFFIX"` | opt-in; final env var checked is `<APP_ID_UPPER>_<SUFFIX>` (see [Environment-Variable Overrides](#environment-variable-overrides-are-app-scoped)) | not environment-variable overridable at all |
| `default = <expr>` | literal fallback of the field's own type | none |
src/environments.rs:300
- This rustdoc still documents the env-var name as
<APP_ID_UPPER>_<SUFFIX>, but the crate already defines a sanitized app-id env-var prefix (seeflags::app_id_env_prefix) that replaces non-alphanumerics with_. After fixing the prefix derivation, update this doc to match the real env-var format.
/// A field's `#[env_config(env = "SUFFIX")]` checks
/// `<APP_ID_UPPER>_<SUFFIX>` here — not `<NAME_UPPER>_<SUFFIX>`. At any
/// single resolution there is exactly one environment being asked about,
/// so scoping the override variable by environment name buys nothing an
docs/environments.md:130
- This section references
#environment-variable-overrides-are-app-scoped, but the document’s heading is## Environment-variable overrides, so the anchor is incorrect. Also, the env-var layer should be documented using the sanitized<APP_ID_ENV_PREFIX>(uppercased, non-alphanumerics replaced by_) to match other app-scoped env vars in the crate.
For a field that opts in via `env = "SUFFIX"` (see [Environment-Variable Overrides](#environment-variable-overrides-are-app-scoped)), precedence is, highest first:
1. **App-scoped environment variable** — `<APP_ID_UPPER>_<SUFFIX>`.
2. **`environments.toml`** — the file at `<config-dir>/<app-id>/environments.toml`.
3. **Compiled-in defaults** — values registered with `Environments::with_environment` in application source code.
docs/environments.md:173
- The env-var format is documented as
<APP_ID_UPPER>_<SUFFIX>, but app ids likemy-clineed sanitizing to a shell-friendly env-var prefix (uppercased, non-alphanumerics replaced with_). Otherwise users can’t reliably set the override vars in common shells.
## Environment-variable overrides
Environment variables can override environment config fields when you have configured the fields with `env = "SUFFIX"`. The engine will look for a variable of the format `<APP_ID_UPPER>_<SUFFIX>`.
docs/environments.md:155
- The example
auth_urlhas a malformed hostname (api.test/example.com). This looks like a typo and would be confusing in documentation/examples.
[test]
client_id = "test-client-id"
auth_url = "https://api.test/example.com/v2/oauth2/authorize"
token_url = "https://api.test.example.com/v2/oauth2/token"
src/env_config.rs:463
- This test mutates a process-global environment variable (
GDDY_PORT) without restoring any prior value. Because Rust tests run concurrently, this can cause flakes (or clobber a developer’s/CI’s pre-set env var). Use a test-unique env var name/prefix and restore the prior value after the assertion.
// SAFETY: single-threaded test, no other test reads this var.
unsafe { std::env::set_var("GDDY_PORT", "9999") };
let app = EnvVarSource {
prefix: "GDDY".to_owned(),
};
let chain = SourceChain::new().push(&app).push(&env);
let section = Section::assemble(&chain).expect("assembles");
// SAFETY: matches the set_var above.
unsafe { std::env::remove_var("GDDY_PORT") };
🤖 I have created a release *beep* *boop* --- <details><summary>cli-engine: 0.6.0</summary> ## [0.6.0](cli-engine-v0.5.0...cli-engine-v0.6.0) (2026-07-31) ### ⚠ BREAKING CHANGES * **environments:** declarative, attribute-driven per-environment config ([#69](#69)) ### Features * **environments:** declarative, attribute-driven per-environment config ([#69](#69)) ([bdf256d](bdf256d)) ### Dependencies * The following workspace dependencies were updated * dependencies * cli-engine-macros bumped from 0.1.0 to 0.2.0 </details> <details><summary>cli-engine-macros: 0.2.0</summary> ## [0.2.0](cli-engine-macros-v0.1.0...cli-engine-macros-v0.2.0) (2026-07-31) ### ⚠ BREAKING CHANGES * **environments:** declarative, attribute-driven per-environment config ([#69](#69)) ### Features * **environments:** declarative, attribute-driven per-environment config ([#69](#69)) ([bdf256d](bdf256d)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Replaces the stringly-typed environment config system with a general
#[derive(EnvConfig)]mechanism: a typed struct declares, per field, where to find its value (a TOML key, an opt-in app-scoped env-var suffix, a literal or computed default) and how to convert it, andEnvironmentsthreads a chain of sources through the struct's generatedassemblefunction to build it.Supersedes
more-flexible-envs(#66) outright rather than building on top of it — none of that branch'sEnvironmentDef/with_init/field_defaults/field_validatorsdesign survives here. #66 should stay closed unmerged.cli-engine-macroscrate (required — proc-macro crates compile for the host, not the target) providing#[derive(EnvConfig)], andsrc/env_config.rswith the runtime pieces: theConfigSourcetrait,EnvSource/EnvVarSource/ValueSource, andSourceChain.Environments(src/environments.rs) rewritten around this:EnvTablestores typedtoml::Valuedata instead of stringified fields,with_environmenthandles compiled-in environment registrations, andresolve::<T>()assembles anyEnvConfigstruct from the config file and environment variables.PkceAuthProvider's OAuth config is now an ordinaryEnvConfigstruct (OAuthSection).Implements DEVEX-947 with a materially different design than #66; verified end-to-end against a real consumer (
gddy, patched via a path dependency) throughout development.What this actually looks like for a consumer
The old system described each config field as an entry in a shared, loosely-typed lookup table, with separate helper functions bolted on by key name for validation/computed defaults:
Now, a consumer just writes an ordinary Rust struct with real field types. A couple of small annotations on top say where to look for a value and what to do if nothing supplied one — the framework does the actual lookup and type-conversion:
Fetching a fully-resolved config for the active environment is one line, and you get back a normal, already-correct Rust value — no manual per-field merging, no "did every layer actually apply" bookkeeping:
And because every field's type is real (not a stringified detour), a required field that's genuinely missing everywhere — not configured in the file, no default, nothing — is a clear, immediate error instead of a silently wrong empty value making it into a live request.
Test plan
cargo fmt --all --checkcargo clippy --all-targets --all-features -- -D warningsRUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-featurescargo test --all-targets --all-features(236 lib tests + 282 integration tests, all passing)cargo test --docdebug_transport_logger_for(fix(test): remove two process-global races causing flaky CI panics #61), theENV_LOCK-guardedpersist_active_without_app_id_errors_clearlytest (fix(test): remove two process-global races causing flaky CI panics #61), and theFix/with_fix/error_fixmachinery (feat(output): add optional top-level fix on error envelopes [DEVEX-945] #65) all survived the merge fromorigin/mainintact and still pass.maina second time mid-review to pick up feat(cli)!: tidy --help output and make search a real command #68's--help/search changes and the 0.5.0 release; resolved the one real conflict (atests/foundation.rstest still using the oldEnvironmentDefAPI) and re-verified the full suite.gddy(path-patched consumer) builds and passes its own full suite against this branch.toml::Valuein a way that forced every consumer to add their own directtomldependency even for plain fields with no custom conversion. Fixed by re-exportingtomlfromcli_engine::env_configand routing generated code through it.cargo publish --dry-runin CI now tolerates the one expected gap:cli-engine-macrosisn't on crates.io until its first release (wired intorelease-please-config.json/release.ymlto publish it beforecli-engine, in dependency order) — any other dry-run failure still fails the check.🤖 Generated with Claude Code