diff --git a/.derived/codebase-index/by-package/stagecraft-cli.json b/.derived/codebase-index/by-package/stagecraft-cli.json new file mode 100644 index 0000000..436f3b3 --- /dev/null +++ b/.derived/codebase-index/by-package/stagecraft-cli.json @@ -0,0 +1,12 @@ +{ + "package": { + "edition": "2021", + "kind": "rust-bin", + "name": "stagecraft-cli", + "path": "", + "specRef": "002-crate-scaffold", + "version": "0.1.0" + }, + "schemaVersion": "1.1.0", + "shardHash": "5150ae59ab5b77d17d616c92d89619dc93ffab501fce31b711c357720a932467" +} diff --git a/.derived/codebase-index/by-spec/002-crate-scaffold.json b/.derived/codebase-index/by-spec/002-crate-scaffold.json index 5e17cbe..67f6f98 100644 --- a/.derived/codebase-index/by-spec/002-crate-scaffold.json +++ b/.derived/codebase-index/by-spec/002-crate-scaffold.json @@ -1,37 +1,37 @@ { - "diagnostics": { - "errors": [], - "warnings": [ + "mapping": { + "dependsOn": [ + "001-cli-mcp-thesis" + ], + "implementingPaths": [ + { + "path": "", + "source": "manifest-metadata" + }, { - "code": "W-001", - "message": "spec '002-crate-scaffold' file unit 'Cargo.toml' does not exist", - "path": "Cargo.toml" + "path": ".github/workflows/ci.yml", + "source": "spec-edge" }, { - "code": "W-001", - "message": "spec '002-crate-scaffold' file unit 'Cargo.lock' does not exist", - "path": "Cargo.lock" + "path": "Cargo.lock", + "source": "spec-edge" }, { - "code": "W-001", - "message": "spec '002-crate-scaffold' directory unit 'src/' is not a directory", - "path": "src/" + "path": "Cargo.toml", + "source": "spec-edge" }, { - "code": "W-001", - "message": "spec '002-crate-scaffold' file unit '.github/workflows/ci.yml' does not exist", - "path": ".github/workflows/ci.yml" + "path": "src/", + "source": "spec-edge" } - ] - }, - "mapping": { - "dependsOn": [ - "001-cli-mcp-thesis" ], - "implementingPaths": [], "resolvedUnits": [ { - "locations": [], + "locations": [ + { + "file": "src/" + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -40,7 +40,11 @@ } }, { - "locations": [], + "locations": [ + { + "file": ".github/workflows/ci.yml" + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -49,7 +53,11 @@ } }, { - "locations": [], + "locations": [ + { + "file": "Cargo.lock" + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -58,7 +66,11 @@ } }, { - "locations": [], + "locations": [ + { + "file": "Cargo.toml" + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -71,5 +83,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "84043e361eedbc733737e36d110624a6efa6677b48d3d5c778565e096d6d98be" + "shardHash": "c3dc2fca3ab2f6819289a8b5e27199169855bce3389d810dfd8b87d9fee7acc8" } diff --git a/.derived/spec-registry/by-spec/002-crate-scaffold.json b/.derived/spec-registry/by-spec/002-crate-scaffold.json index 1d42fd8..4f1a98d 100644 --- a/.derived/spec-registry/by-spec/002-crate-scaffold.json +++ b/.derived/spec-registry/by-spec/002-crate-scaffold.json @@ -23,7 +23,7 @@ } ], "id": "002-crate-scaffold", - "implementation": "pending", + "implementation": "complete", "sectionHeadings": [ "002: Crate scaffold", "1. Territory", @@ -36,6 +36,6 @@ "summary": "The Rust crate for the single binary named stagecraft: clap-based command tree, layered configuration (flags > env > config file), structured output discipline (human tables on TTY, JSON with --output json), and a CI workflow (fmt, clippy -D warnings, test, release build). No network calls yet; spec 003 adds auth and the API client. After this spec, `cargo install --path .` yields a binary whose skeleton every later verb hangs off.\n", "title": "The stagecraft binary: crate scaffold, config, CI" }, - "shardHash": "9a8409782c45acb4a68399179148539d878438316f9c6769687f0ffb2f15ff7a", + "shardHash": "8576aef9f1f550842270168da67a5fc80a2c886fdbecac54781f3963a854f3d2", "specVersion": "1.1.0" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63cb148 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +# Rust CI (spec 002 §2): fmt, clippy -D warnings, test, release build. +# The spec-spine governance gate lives in its own workflow (spec-spine.yml) +# and is unchanged. Actions are pinned to a commit SHA with a version comment. +name: ci +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Toolchain components + run: rustup component add rustfmt clippy + - name: Format + run: cargo fmt --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test + - name: Release build + run: cargo build --release diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..04facd5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,446 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "stagecraft-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "directories", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a043dc3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "stagecraft-cli" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Stagecraft governance verbs as CLI subcommands for humans and an MCP server for agents." +repository = "https://github.com/stagecraft-ing/stagecraft-cli" +readme = "README.md" + +# spec-spine couples this crate's code paths to its owning spec. +[package.metadata.spec-spine] +spec = "002-crate-scaffold" + +# One binary named `stagecraft` (spec 001: the OPC name retires). +[[bin]] +name = "stagecraft" +path = "src/main.rs" + +[dependencies] +# CLI parsing and shell completions (no async; that lands in spec 003). +clap = { version = "4", features = ["derive"] } +clap_complete = "4" +# Serialization: stable JSON output shapes are API from the first verb. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +# Error handling: anyhow for operational context, thiserror for the taxonomy. +anyhow = "1" +thiserror = "2" +# Platform-appropriate config paths. +directories = "6" diff --git a/specs/002-crate-scaffold/spec.md b/specs/002-crate-scaffold/spec.md index 45dfa36..9eddb03 100644 --- a/specs/002-crate-scaffold/spec.md +++ b/specs/002-crate-scaffold/spec.md @@ -3,7 +3,7 @@ id: "002-crate-scaffold" title: "The stagecraft binary: crate scaffold, config, CI" status: approved created: "2026-07-14" -implementation: pending +implementation: complete depends_on: - "001-cli-mcp-thesis" establishes: @@ -34,9 +34,11 @@ defaults should already cover it; verify with `spec-spine index`). ## 2. Behavior -- Dependencies (keep the tree lean): clap (derive), serde + serde_json, - toml, anyhow, thiserror, directories (config paths). Async arrives - with spec 003 (tokio + reqwest); do not pre-add. +- Dependencies (keep the tree lean): clap (derive), clap_complete (the + `completions` verb's script generator; part of the clap ecosystem, no + async), serde + serde_json, toml, anyhow, thiserror, directories + (config paths). Async arrives with spec 003 (tokio + reqwest); do not + pre-add. - Command tree v1 (stubs that print a clear "not implemented until spec NNN" error and exit 2, so help text is honest from day one): `stagecraft login|whoami` (003), `tenants list|show` (004), diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..4046e5c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,110 @@ +//! The `stagecraft` command tree (spec 002 §2). +//! +//! Stub verbs are present from day one so `--help` is honest: each carries +//! about-text naming the spec that implements it, and its handler exits 2. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use crate::output::OutputFormat; + +/// Stagecraft: governance verbs for humans (CLI) and agents (MCP). +#[derive(Debug, Parser)] +#[command( + name = "stagecraft", + version, + about = "Stagecraft governance verbs: CLI subcommands for humans, MCP server for agents.", + subcommand_required = true, + arg_required_else_help = true +)] +pub struct Cli { + /// Output format: human-readable text, or stable machine JSON. + #[arg(long, global = true, value_enum, value_name = "FORMAT")] + pub output: Option, + + /// Stagecraft control-plane base URL (overrides config / env). + #[arg(long, global = true, value_name = "URL")] + pub base_url: Option, + + /// Path to the config file (defaults to the platform config directory). + #[arg(long, global = true, value_name = "PATH")] + pub config: Option, + + #[command(subcommand)] + pub command: Command, +} + +/// Top-level verbs. Stubs name their owning spec in about-text. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Authenticate against a Stagecraft control plane (spec 003). + Login, + /// Show the currently authenticated identity (spec 003). + Whoami, + /// Inspect tenants (spec 004). + Tenants { + #[command(subcommand)] + command: TenantsCommand, + }, + /// Governance stamps (spec 004). + Stamp { + #[command(subcommand)] + command: StampCommand, + }, + /// Fleet operations (spec 004). + Fleet { + #[command(subcommand)] + command: FleetCommand, + }, + /// Run the MCP server over stdio (spec 005). + Mcp, + /// Print version information. + Version, + /// Inspect configuration. + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + /// Generate a shell completion script. + Completions { + /// Target shell. + shell: clap_complete::Shell, + }, +} + +#[derive(Debug, Subcommand)] +pub enum TenantsCommand { + /// List tenants (spec 004). + List, + /// Show one tenant (spec 004). + Show, +} + +#[derive(Debug, Subcommand)] +pub enum StampCommand { + /// Request a new governance stamp (spec 004). + New, + /// Check stamp status (spec 004). + Status, +} + +#[derive(Debug, Subcommand)] +pub enum FleetCommand { + /// List fleet members (spec 004). + List, + /// Deploy to the fleet (spec 004). + Deploy, + /// Update fleet members (spec 004). + Update, + /// Back up fleet state (spec 004). + Backup, + /// Remove a fleet member (spec 004). + Remove, +} + +#[derive(Debug, Subcommand)] +pub enum ConfigCommand { + /// Print the effective, merged configuration with sources annotated. + Show, +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..70a3045 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,191 @@ +//! Command dispatch and handlers (spec 002 §2). +//! +//! Stub verbs return [`AppError::NotImplemented`] (exit 2, spec named). The +//! three working verbs (`version`, `config show`, `completions`) render through +//! the shared output layer so `--output json` is honored uniformly. + +use std::io; +use std::path::PathBuf; + +use clap::CommandFactory; +use serde::Serialize; + +use crate::cli::{Cli, Command, ConfigCommand, FleetCommand, StampCommand, TenantsCommand}; +use crate::config::{self, FlagConfig, ResolvedConfig, Sourced}; +use crate::error::{AppError, AppResult}; +use crate::output::{self, OutputFormat}; + +/// Resolve config, then run the selected command. +pub fn dispatch(cli: Cli) -> AppResult<()> { + let resolved = load_config(&cli)?; + let format = resolved.output_format(); + + match &cli.command { + Command::Login => Err(AppError::not_implemented("login", "003-auth-api-client")), + Command::Whoami => Err(AppError::not_implemented("whoami", "003-auth-api-client")), + Command::Tenants { command } => tenants(command), + Command::Stamp { command } => stamp(command), + Command::Fleet { command } => fleet(command), + Command::Mcp => Err(AppError::not_implemented("mcp", "005-mcp-server")), + Command::Version => { + version(format); + Ok(()) + } + Command::Config { command } => config_cmd(command, &cli, &resolved), + Command::Completions { shell } => { + completions(*shell); + Ok(()) + } + } +} + +/// Where the config file lives: explicit `--config`, else the platform default. +fn config_path(cli: &Cli) -> Option { + cli.config.clone().or_else(config::default_config_path) +} + +/// Merge file + env + flag layers into the effective config. +fn load_config(cli: &Cli) -> AppResult { + let file = match config_path(cli) { + Some(path) => config::load_file(&path)?, + None => config::FileConfig::default(), + }; + let env = config::load_env()?; + let flags = FlagConfig { + base_url: cli.base_url.clone(), + output: cli.output, + }; + Ok(config::resolve(file, env, flags)) +} + +// --- stub verbs (spec 004) ------------------------------------------------- + +fn tenants(command: &TenantsCommand) -> AppResult<()> { + let verb = match command { + TenantsCommand::List => "list", + TenantsCommand::Show => "show", + }; + Err(AppError::not_implemented( + format!("tenants {verb}"), + "004-governance-verbs", + )) +} + +fn stamp(command: &StampCommand) -> AppResult<()> { + let verb = match command { + StampCommand::New => "new", + StampCommand::Status => "status", + }; + Err(AppError::not_implemented( + format!("stamp {verb}"), + "004-governance-verbs", + )) +} + +fn fleet(command: &FleetCommand) -> AppResult<()> { + let verb = match command { + FleetCommand::List => "list", + FleetCommand::Deploy => "deploy", + FleetCommand::Update => "update", + FleetCommand::Backup => "backup", + FleetCommand::Remove => "remove", + }; + Err(AppError::not_implemented( + format!("fleet {verb}"), + "004-governance-verbs", + )) +} + +// --- working verbs --------------------------------------------------------- + +/// Version payload; stable JSON shape (API from the first verb). +#[derive(Serialize)] +struct VersionInfo { + name: &'static str, + version: &'static str, +} + +fn version(format: OutputFormat) { + let info = VersionInfo { + name: "stagecraft", + version: env!("CARGO_PKG_VERSION"), + }; + output::emit(format, &info, || format!("stagecraft {}", info.version)); +} + +/// Config-file location and existence, for `config show`. +#[derive(Serialize)] +struct ConfigFileInfo { + path: Option, + exists: bool, +} + +/// The `config show` payload: file location plus each field with its source. +#[derive(Serialize)] +struct ConfigShow<'a> { + config_file: ConfigFileInfo, + base_url: &'a Sourced>, + output: &'a Sourced, +} + +fn config_cmd(command: &ConfigCommand, cli: &Cli, resolved: &ResolvedConfig) -> AppResult<()> { + match command { + ConfigCommand::Show => { + let path = config_path(cli); + let payload = ConfigShow { + config_file: ConfigFileInfo { + exists: path.as_ref().is_some_and(|p| p.exists()), + path: path.as_ref().map(|p| p.display().to_string()), + }, + base_url: &resolved.base_url, + output: &resolved.output, + }; + output::emit(resolved.output_format(), &payload, || { + render_config_human(&payload) + }); + Ok(()) + } + } +} + +fn render_config_human(payload: &ConfigShow) -> String { + use std::fmt::Write; + + let mut out = String::new(); + let base = payload + .base_url + .value + .clone() + .unwrap_or_else(|| "(unset)".to_string()); + let _ = writeln!( + out, + "base_url = {base} # source: {}", + payload.base_url.source.label() + ); + let _ = writeln!( + out, + "output = {} # source: {}", + payload.output.value.as_str(), + payload.output.source.label() + ); + match &payload.config_file.path { + Some(path) => { + let state = if payload.config_file.exists { + "found" + } else { + "not found" + }; + let _ = write!(out, "config file: {path} ({state})"); + } + None => { + let _ = write!(out, "config file: (no home directory)"); + } + } + out +} + +fn completions(shell: clap_complete::Shell) { + let mut command = Cli::command(); + let name = command.get_name().to_string(); + clap_complete::generate(shell, &mut command, name, &mut io::stdout()); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5cf1995 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,282 @@ +//! Layered configuration (spec 002 §2): flags > env > config file > default. +//! +//! Resolution is a pure function over three raw layers so precedence is +//! unit-testable without touching the filesystem or the process environment. +//! `config show` reports the effective value of each field with its source. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::output::OutputFormat; + +/// Environment-variable prefix for config overrides. +pub const ENV_PREFIX: &str = "STAGECRAFT_"; + +/// Where a resolved value came from, highest precedence last. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Source { + Default, + File, + Env, + Flag, +} + +impl Source { + /// Human-facing label for `config show`. + pub fn label(self) -> &'static str { + match self { + Source::Default => "default", + Source::File => "config file", + Source::Env => "env", + Source::Flag => "flag", + } + } +} + +/// A resolved value together with the layer it came from. +#[derive(Clone, Debug, Serialize)] +pub struct Sourced { + pub value: T, + pub source: Source, +} + +/// Config as parsed from `config.toml`. Every field optional. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileConfig { + pub base_url: Option, + pub output: Option, +} + +/// Config drawn from the environment (`STAGECRAFT_*`). Every field optional. +#[derive(Clone, Debug, Default)] +pub struct EnvConfig { + pub base_url: Option, + pub output: Option, +} + +/// Config drawn from command-line flags. Every field optional. +#[derive(Clone, Debug, Default)] +pub struct FlagConfig { + pub base_url: Option, + pub output: Option, +} + +/// The merged configuration with per-field provenance for `config show`. +#[derive(Clone, Debug, Serialize)] +pub struct ResolvedConfig { + pub base_url: Sourced>, + pub output: Sourced, +} + +impl ResolvedConfig { + /// The effective output format. + pub fn output_format(&self) -> OutputFormat { + self.output.value + } +} + +/// Merge the three raw layers into the effective config, recording provenance. +/// +/// Precedence, highest first: flag, env, file, built-in default. +pub fn resolve(file: FileConfig, env: EnvConfig, flags: FlagConfig) -> ResolvedConfig { + ResolvedConfig { + base_url: pick_opt(flags.base_url, env.base_url, file.base_url), + output: pick( + flags.output, + env.output, + file.output, + OutputFormat::default(), + ), + } +} + +/// Pick the highest-precedence present value, falling back to `default`. +fn pick(flag: Option, env: Option, file: Option, default: T) -> Sourced { + if let Some(value) = flag { + Sourced { + value, + source: Source::Flag, + } + } else if let Some(value) = env { + Sourced { + value, + source: Source::Env, + } + } else if let Some(value) = file { + Sourced { + value, + source: Source::File, + } + } else { + Sourced { + value: default, + source: Source::Default, + } + } +} + +/// Like [`pick`], but the field is itself optional (absent stays `None`). +fn pick_opt( + flag: Option, + env: Option, + file: Option, +) -> Sourced> { + if let Some(value) = flag { + Sourced { + value: Some(value), + source: Source::Flag, + } + } else if let Some(value) = env { + Sourced { + value: Some(value), + source: Source::Env, + } + } else if let Some(value) = file { + Sourced { + value: Some(value), + source: Source::File, + } + } else { + Sourced { + value: None, + source: Source::Default, + } + } +} + +/// The default config file path (`~/.config/stagecraft/config.toml` on Linux), +/// derived via the `directories` crate. `None` if no home directory is known. +pub fn default_config_path() -> Option { + directories::ProjectDirs::from("", "", "stagecraft") + .map(|dirs| dirs.config_dir().join("config.toml")) +} + +/// Load and parse the config file. A missing file is not an error (empty config). +pub fn load_file(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => { + toml::from_str(&text).with_context(|| format!("parsing config file {}", path.display())) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()), + Err(err) => Err(err).with_context(|| format!("reading config file {}", path.display())), + } +} + +/// Read config overrides from the process environment (`STAGECRAFT_*`). +/// +/// Empty values are treated as unset. An unrecognized `STAGECRAFT_OUTPUT` +/// is misuse (exit 2), surfaced as [`AppError::Usage`]. +pub fn load_env() -> AppResult { + let base_url = non_empty_var(&format!("{ENV_PREFIX}BASE_URL")); + let output = match non_empty_var(&format!("{ENV_PREFIX}OUTPUT")) { + Some(raw) => Some(OutputFormat::parse_token(&raw).ok_or_else(|| { + AppError::Usage(format!( + "{ENV_PREFIX}OUTPUT is `{raw}`; expected `human` or `json`" + )) + })?), + None => None, + }; + Ok(EnvConfig { base_url, output }) +} + +/// Read an env var, treating unset or empty as absent. +fn non_empty_var(key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(base: Option<&str>, out: Option) -> FileConfig { + FileConfig { + base_url: base.map(String::from), + output: out, + } + } + fn env(base: Option<&str>, out: Option) -> EnvConfig { + EnvConfig { + base_url: base.map(String::from), + output: out, + } + } + fn flags(base: Option<&str>, out: Option) -> FlagConfig { + FlagConfig { + base_url: base.map(String::from), + output: out, + } + } + + #[test] + fn default_when_no_layer_sets_a_value() { + let r = resolve(file(None, None), env(None, None), flags(None, None)); + assert_eq!(r.base_url.value, None); + assert_eq!(r.base_url.source, Source::Default); + assert_eq!(r.output.value, OutputFormat::Human); + assert_eq!(r.output.source, Source::Default); + } + + #[test] + fn file_beats_default() { + let r = resolve( + file(Some("https://file.example"), Some(OutputFormat::Json)), + env(None, None), + flags(None, None), + ); + assert_eq!(r.base_url.value.as_deref(), Some("https://file.example")); + assert_eq!(r.base_url.source, Source::File); + assert_eq!(r.output.value, OutputFormat::Json); + assert_eq!(r.output.source, Source::File); + } + + #[test] + fn env_beats_file() { + let r = resolve( + file(Some("https://file.example"), Some(OutputFormat::Human)), + env(Some("https://env.example"), Some(OutputFormat::Json)), + flags(None, None), + ); + assert_eq!(r.base_url.value.as_deref(), Some("https://env.example")); + assert_eq!(r.base_url.source, Source::Env); + assert_eq!(r.output.value, OutputFormat::Json); + assert_eq!(r.output.source, Source::Env); + } + + #[test] + fn flag_beats_env_and_file() { + let r = resolve( + file(Some("https://file.example"), Some(OutputFormat::Human)), + env(Some("https://env.example"), Some(OutputFormat::Human)), + flags(Some("https://flag.example"), Some(OutputFormat::Json)), + ); + assert_eq!(r.base_url.value.as_deref(), Some("https://flag.example")); + assert_eq!(r.base_url.source, Source::Flag); + assert_eq!(r.output.value, OutputFormat::Json); + assert_eq!(r.output.source, Source::Flag); + } + + #[test] + fn layers_are_resolved_per_field_independently() { + // base_url from the file, output from a flag: sources do not have to agree. + let r = resolve( + file(Some("https://file.example"), None), + env(None, None), + flags(None, Some(OutputFormat::Json)), + ); + assert_eq!(r.base_url.source, Source::File); + assert_eq!(r.output.source, Source::Flag); + } + + #[test] + fn file_parses_known_keys_and_rejects_unknown() { + let ok: FileConfig = toml::from_str("base_url = \"https://x\"\noutput = \"json\"").unwrap(); + assert_eq!(ok.base_url.as_deref(), Some("https://x")); + assert_eq!(ok.output, Some(OutputFormat::Json)); + assert!(toml::from_str::("nope = 1").is_err()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1dfb9b8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,74 @@ +//! Process error taxonomy and exit-code contract (spec 002 §2). +//! +//! Exit codes are product surface: `0` ok, `1` operational failure, +//! `2` usage / not-implemented. Errors print to stderr, never stdout. + +use thiserror::Error; + +/// Command succeeded. +pub const EXIT_OK: u8 = 0; +/// A governed operation failed at runtime (I/O, parse, later: network). +pub const EXIT_OPERATIONAL: u8 = 1; +/// Misuse: bad flags, or a verb whose implementation lands in a later spec. +pub const EXIT_USAGE: u8 = 2; + +/// The failure modes a command handler can surface to `main`. +#[derive(Debug, Error)] +pub enum AppError { + /// A stub verb whose real implementation arrives in a later spec. + #[error("`stagecraft {command}` is not implemented until spec {spec}")] + NotImplemented { command: String, spec: &'static str }, + + /// Invalid usage we detect ourselves (as opposed to clap's own parse errors). + #[error("{0}")] + Usage(String), + + /// Any operational failure; carries anyhow context up to `main`. + #[error(transparent)] + Operational(#[from] anyhow::Error), +} + +impl AppError { + /// The process exit code this error maps to. + pub fn code(&self) -> u8 { + match self { + AppError::NotImplemented { .. } | AppError::Usage(_) => EXIT_USAGE, + AppError::Operational(_) => EXIT_OPERATIONAL, + } + } + + /// Construct the stub error for a verb owned by a later spec. + pub fn not_implemented(command: impl Into, spec: &'static str) -> Self { + AppError::NotImplemented { + command: command.into(), + spec, + } + } +} + +/// Result alias for command handlers. +pub type AppResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_implemented_maps_to_usage_exit_code() { + let err = AppError::not_implemented("login", "003-auth-api-client"); + assert_eq!(err.code(), EXIT_USAGE); + } + + #[test] + fn not_implemented_message_names_the_owning_spec() { + let err = AppError::not_implemented("tenants list", "004-governance-verbs"); + assert!(err.to_string().contains("004-governance-verbs")); + assert!(err.to_string().contains("tenants list")); + } + + #[test] + fn operational_maps_to_operational_exit_code() { + let err: AppError = anyhow::anyhow!("disk gone").into(); + assert_eq!(err.code(), EXIT_OPERATIONAL); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..406001f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,30 @@ +//! `stagecraft`: one binary, two faces (spec 001). This crate is the scaffold +//! (spec 002); auth (003), governance verbs (004), and the MCP server (005) +//! hang off the command tree established here. + +mod cli; +mod commands; +mod config; +mod error; +mod output; + +use std::process::ExitCode; + +use clap::Parser; + +use crate::cli::Cli; +use crate::error::EXIT_OK; + +fn main() -> ExitCode { + // clap handles `--help`/`--version` and its own usage errors (exit 2) + // by terminating the process before returning here. + let cli = Cli::parse(); + + match commands::dispatch(cli) { + Ok(()) => ExitCode::from(EXIT_OK), + Err(err) => { + eprintln!("error: {err}"); + ExitCode::from(err.code()) + } + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..8e9f984 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,76 @@ +//! Output discipline (spec 002 §2): one layer every command renders through. +//! +//! Human-readable text on a TTY; stable machine JSON with `--output json`. +//! The JSON shapes are the contract the MCP face (spec 005) and scripts +//! consume later, so they are treated as versioned API from the first verb. + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; + +/// The rendering format, selected by `--output` / `STAGECRAFT_OUTPUT` / config. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + /// Human-readable text. + #[default] + Human, + /// Stable machine JSON. + Json, +} + +impl OutputFormat { + /// The canonical lowercase token for this format. + pub fn as_str(self) -> &'static str { + match self { + OutputFormat::Human => "human", + OutputFormat::Json => "json", + } + } + + /// Parse a format from an env / string value; `None` if unrecognized. + pub fn parse_token(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "human" => Some(OutputFormat::Human), + "json" => Some(OutputFormat::Json), + _ => None, + } + } +} + +/// Render `value` to stdout: pretty JSON, or the `human` closure's text. +/// +/// Serialization of our own owned payloads cannot fail in practice; a failure +/// here is a programming error, not an operational one, so it panics rather +/// than muddying the exit-code contract. +pub fn emit(format: OutputFormat, value: &T, human: impl FnOnce() -> String) { + match format { + OutputFormat::Json => { + let rendered = serde_json::to_string_pretty(value) + .expect("serializing an owned output payload cannot fail"); + println!("{rendered}"); + } + OutputFormat::Human => println!("{}", human()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_token_is_case_insensitive() { + assert_eq!(OutputFormat::parse_token("JSON"), Some(OutputFormat::Json)); + assert_eq!( + OutputFormat::parse_token(" human "), + Some(OutputFormat::Human) + ); + assert_eq!(OutputFormat::parse_token("yaml"), None); + } + + #[test] + fn as_str_round_trips_through_parse() { + for f in [OutputFormat::Human, OutputFormat::Json] { + assert_eq!(OutputFormat::parse_token(f.as_str()), Some(f)); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..29b39e1 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,125 @@ +//! End-to-end checks that drive the built `stagecraft` binary (spec 002 §3). +//! +//! Cargo sets `CARGO_BIN_EXE_stagecraft`, so no extra test crates are needed. + +use std::process::{Command, Output}; + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_stagecraft")) + .args(args) + // Neutralize ambient config so tests are deterministic. + .env_remove("STAGECRAFT_BASE_URL") + .env_remove("STAGECRAFT_OUTPUT") + .output() + .expect("failed to run stagecraft binary") +} + +#[test] +fn stub_verb_exits_2_names_spec_and_keeps_stdout_clean() { + // One representative stub per owning spec. + for (args, spec) in [ + (["login"].as_slice(), "003"), + (["whoami"].as_slice(), "003"), + (["tenants", "list"].as_slice(), "004"), + (["stamp", "new"].as_slice(), "004"), + (["fleet", "remove"].as_slice(), "004"), + (["mcp"].as_slice(), "005"), + ] { + let out = run(args); + assert_eq!(out.status.code(), Some(2), "args {args:?} should exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(spec), + "args {args:?}: stderr should name spec {spec}, got: {stderr}" + ); + assert!( + out.stdout.is_empty(), + "args {args:?}: errors must not print to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + } +} + +#[test] +fn help_lists_the_full_command_tree() { + let out = run(&["--help"]); + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&out.stdout); + for verb in [ + "login", + "whoami", + "tenants", + "stamp", + "fleet", + "mcp", + "version", + "config", + "completions", + ] { + assert!(stdout.contains(verb), "help missing `{verb}`: {stdout}"); + } +} + +#[test] +fn version_json_shape_is_stable() { + let out = run(&["--output", "json", "version"]); + assert_eq!(out.status.code(), Some(0)); + let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(value["name"], "stagecraft"); + assert!(value["version"].is_string()); +} + +#[test] +fn completions_zsh_emits_a_script() { + let out = run(&["completions", "zsh"]); + assert_eq!(out.status.code(), Some(0)); + assert!( + !out.stdout.is_empty(), + "completion script should not be empty" + ); +} + +#[test] +fn config_show_reports_flag_as_the_base_url_source() { + let out = run(&[ + "--output", + "json", + "--base-url", + "https://flag.example", + "config", + "show", + ]); + assert_eq!(out.status.code(), Some(0)); + let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(value["base_url"]["value"], "https://flag.example"); + assert_eq!(value["base_url"]["source"], "flag"); +} + +#[test] +fn invalid_env_output_value_is_a_usage_error() { + let out = Command::new(env!("CARGO_BIN_EXE_stagecraft")) + .args(["version"]) + .env_remove("STAGECRAFT_BASE_URL") + .env("STAGECRAFT_OUTPUT", "yaml") + .output() + .expect("failed to run stagecraft binary"); + assert_eq!(out.status.code(), Some(2)); + assert!( + out.stdout.is_empty(), + "usage errors must not print to stdout" + ); +} + +#[test] +fn config_show_reports_env_as_the_base_url_source() { + let out = Command::new(env!("CARGO_BIN_EXE_stagecraft")) + .args(["--output", "json", "config", "show"]) + .env_remove("STAGECRAFT_OUTPUT") + .env("STAGECRAFT_BASE_URL", "https://env.example") + .output() + .expect("failed to run stagecraft binary"); + assert_eq!(out.status.code(), Some(0)); + let value: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON"); + assert_eq!(value["base_url"]["value"], "https://env.example"); + assert_eq!(value["base_url"]["source"], "env"); +}