Skip to content

feat(environments)!: declarative, attribute-driven per-environment config - #69

Merged
jpage-godaddy merged 7 commits into
mainfrom
typed-environment-config
Jul 31, 2026
Merged

feat(environments)!: declarative, attribute-driven per-environment config#69
jpage-godaddy merged 7 commits into
mainfrom
typed-environment-config

Conversation

@jpage-godaddy

@jpage-godaddy jpage-godaddy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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, and Environments threads a chain of sources through the struct's generated assemble function to build it.

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 here. #66 should stay closed unmerged.

  • 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 typed toml::Value data instead of stringified fields, with_environment handles compiled-in environment registrations, and resolve::<T>() assembles any EnvConfig struct from the config file and environment variables.
  • PkceAuthProvider's OAuth config is now an ordinary EnvConfig struct (OAuthSection).
  • Feature-flags also use this configuration system

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:

// BEFORE — every field is a plain string in a shared map. A typo in a key
// name, or a field that should really be a number or a list, is invisible
// to the compiler and only shows up as a bug when something runs.
let environments = Environments::new("prod").with_environment(
    "prod",
    EnvironmentDef::new()
        .with_client_id("prod-client-id")
        .with_field("api_url", "https://api.example.com")
        .with_field_validator("auth_url", looks_like_a_url)
        .with_field_default("account_url", |env| derive_account_url(env)),
);

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:

#[derive(EnvConfig)]
struct ApiConfig {
    pub api_url: String,
    pub client_id: String,

    // "GDDY_AUTH_URL" (or whatever the app is called) now overrides this
    // field automatically at runtime — no extra code anywhere for that.
    #[env_config(env = "AUTH_URL", default_fn = default_auth_url)]
    pub auth_url: String,

    // A real list, not a comma-joined string someone has to split by hand.
    #[env_config(default = Vec::new())]
    pub scopes: Vec<String>,
}

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:

let config: ApiConfig = environments.resolve("prod")?;

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 --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features
  • cargo test --all-targets --all-features (236 lib tests + 282 integration tests, all passing)
  • cargo test --doc
  • Confirmed debug_transport_logger_for (fix(test): remove two process-global races causing flaky CI panics #61), the ENV_LOCK-guarded persist_active_without_app_id_errors_clearly test (fix(test): remove two process-global races causing flaky CI panics #61), and the Fix/with_fix/error_fix machinery (feat(output): add optional top-level fix on error envelopes [DEVEX-945] #65) all survived the merge from origin/main intact and still pass.
  • Merged main a 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 (a tests/foundation.rs test still using the old EnvironmentDef API) and re-verified the full suite.
  • gddy (path-patched consumer) builds and passes its own full suite against this branch.
  • Addressed both Copilot review findings: a docs typo, and a real one — derive-generated code referenced toml::Value in a way that forced every consumer to add their own direct toml dependency even for plain fields with no custom conversion. Fixed by re-exporting toml from cli_engine::env_config and routing generated code through it.
  • cargo publish --dry-run in CI now tolerates the one expected gap: cli-engine-macros isn't on crates.io until its first release (wired into release-please-config.json/release.yml to publish it before cli-engine, in dependency order) — any other dry-run failure still fails the check.

🤖 Generated with Claude Code

…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_config runtime + new cli-engine-macros proc-macro crate to derive typed per-environment config structs from a SourceChain of TOML/env-var sources.
  • Reworks Environments to store/merge raw TOML tables (EnvTable), support a fallback resolver for unknown names, and resolve any EnvConfig section type.
  • Updates feature-flag layering, env info output, and PkceAuthProvider OAuth 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.

Comment thread docs/environments.md Outdated
Comment thread cli-engine-macros/src/lib.rs
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; see flags::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::resolve builds the app-scoped EnvVarSource prefix via self.app_id.to_uppercase(), which leaves non-alphanumeric characters (notably -) intact. This is inconsistent with the existing ${APP_ID}_... env var convention (sanitized via flags::app_id_env_prefix, e.g. my-cli -> MY_CLI) and makes overrides hard/impossible to set from typical shells when app_id contains -. 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 (see flags::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 |

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_tables silently 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 (see flags::app_id_env_prefix). Documenting the sanitized form avoids suggesting shell-hostile names like MY-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::resolve builds the app-scoped env-var prefix with self.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 in app_id would 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_tables silently ignores a top-level environment entry if its TOML value isn’t a table. This makes typos/mis-shapes in environments.toml hard 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 (see flags::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 |

Comment thread .github/workflows/release.yml
Comment thread src/auth/pkce.rs
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • EnvVarSource derives its prefix with self.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 established flags::app_id_env_prefix behavior (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 env attribute 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 (see flags::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 like my-cli need 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_url has 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") };

@jpage-godaddy
jpage-godaddy enabled auto-merge (squash) July 31, 2026 18:29
@jpage-godaddy
jpage-godaddy disabled auto-merge July 31, 2026 18:29
@jpage-godaddy
jpage-godaddy merged commit bdf256d into main Jul 31, 2026
3 checks passed
@jpage-godaddy
jpage-godaddy deleted the typed-environment-config branch July 31, 2026 18:30
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
jpage-godaddy pushed a commit that referenced this pull request Jul 31, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants