From 4790f7fecf09f3c027fcaba90734c6b115e540ca Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 15:54:08 +0200 Subject: [PATCH 1/8] feat(config): toml inheritance support --- .github/workflows/benchmarks.yml | 4 +- .github/workflows/bump-forge-std.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/nextest.yml | 6 +- .github/workflows/nix.yml | 4 +- .github/workflows/release.yml | 8 +- .github/workflows/test.yml | 14 +- Dockerfile | 4 +- Makefile | 2 +- crates/cheatcodes/assets/cheatcodes.json | 28 +- crates/cheatcodes/spec/src/vm.rs | 28 +- crates/cheatcodes/src/fs.rs | 42 +- crates/cli/src/utils/abi.rs | 5 +- crates/cli/src/utils/mod.rs | 4 + crates/config/src/lib.rs | 796 +++++++++++++++++++++++ crates/config/src/providers/ext.rs | 112 +++- crates/config/src/soldeer.rs | 2 +- crates/forge/src/cmd/init.rs | 18 +- crates/forge/tests/cli/cmd.rs | 47 ++ crates/forge/tests/cli/config.rs | 1 + crates/wallets/src/utils.rs | 3 +- testdata/cheats/Vm.sol | 28 +- 22 files changed, 1075 insertions(+), 85 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 45f76cff8e11e..b3296006ef063 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -33,7 +33,7 @@ jobs: runs-on: foundry-runner steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install build dependencies run: | @@ -153,7 +153,7 @@ jobs: runs-on: foundry-runner steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download benchmark results uses: actions/download-artifact@v4 diff --git a/.github/workflows/bump-forge-std.yml b/.github/workflows/bump-forge-std.yml index 137e8c465e914..13e7c35855892 100644 --- a/.github/workflows/bump-forge-std.yml +++ b/.github/workflows/bump-forge-std.yml @@ -12,7 +12,7 @@ jobs: name: update forge-std tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Fetch and update forge-std tag run: curl 'https://api.github.com/repos/foundry-rs/forge-std/tags' | jq '.[0].commit.sha' -jr > testdata/forge-std-rev - name: Create pull request diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 25a96b82b06fe..b0df4aa9fd87b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,7 +24,7 @@ jobs: contents: read timeout-minutes: 120 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/nextest.yml b/.github/workflows/nextest.yml index ac8ed5898dd15..a842149c487cf 100644 --- a/.github/workflows/nextest.yml +++ b/.github/workflows/nextest.yml @@ -24,7 +24,7 @@ jobs: outputs: test-matrix: ${{ steps.gen.outputs.test-matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v4 with: python-version: "3.11" @@ -50,7 +50,7 @@ jobs: ETH_RPC_URL: https://reth-ethereum.ithaca.xyz/rpc CARGO_PROFILE_DEV_DEBUG: 0 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: target: ${{ matrix.target }} @@ -61,7 +61,7 @@ jobs: if: contains(matrix.name, 'external') uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Install Bun if: contains(matrix.name, 'external') && !contains(matrix.runner_label, 'windows') uses: oven-sh/setup-bun@v1 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 168f378f9673b..5e9659e777e54 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: DeterminateSystems/determinate-nix-action@v3 - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: DeterminateSystems/update-flake-lock@main with: pr-title: "Update flake.lock" @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.runs-on }} steps: - uses: DeterminateSystems/determinate-nix-action@v3 - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Update flake.lock run: nix flake update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c54ac06662de..e5acdd8ebe1d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: release_name: ${{ steps.release_info.outputs.release_name }} changelog: ${{ steps.build_changelog.outputs.changelog }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -128,7 +128,7 @@ jobs: platform: win32 arch: amd64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -279,7 +279,7 @@ jobs: needs: release if: always() steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # Moves the `nightly` tag to `HEAD` - name: Move nightly tag @@ -304,7 +304,7 @@ jobs: needs: [prepare, release-docker, release, cleanup] if: failure() steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b2b2324ba04e..ddc3d78a7531b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@nightly - uses: Swatinem/rust-cache@v2 with: @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -57,14 +57,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: crate-ci/typos@v1 clippy: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@clippy - uses: Swatinem/rust-cache@v2 with: @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@nightly with: components: rustfmt @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -100,7 +100,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-hack - uses: Swatinem/rust-cache@v2 diff --git a/Dockerfile b/Dockerfile index 89dfe8277030c..6d7fe761f3f80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.4 -FROM alpine:3.21 AS build-environment +FROM alpine:3.22 AS build-environment ARG TARGETARCH WORKDIR /opt @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/root/.cargo/registry --mount=type=cache,target=/r && strip out/chisel \ && strip out/anvil; -FROM alpine:3.21 AS foundry-client +FROM alpine:3.22 AS foundry-client RUN apk add --no-cache linux-headers git gcompat libstdc++ diff --git a/Makefile b/Makefile index 0b8cfb44d9f2d..a822c0034b68b 100644 --- a/Makefile +++ b/Makefile @@ -108,7 +108,7 @@ lint-clippy: ## Run clippy on the codebase. .PHONY: lint-typos lint-typos: ## Run typos on the codebase. @command -v typos >/dev/null || { \ - echo "typos not found. Please install it by running the command `cargo install typos-cli` or refer to the following link for more information: https://github.com/crate-ci/typos" \ + echo "typos not found. Please install it by running the command `cargo install typos-cli` or refer to the following link for more information: https://github.com/crate-ci/typos"; \ exit 1; \ } typos diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 68151f1466bb5..f2987421ca6ec 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -5868,7 +5868,7 @@ "func": { "id": "forkAddress", "description": "Gets the value for the key `key` from the currently active fork and parses it as `address`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkAddress(string memory key) external view returns (address);", + "declaration": "function forkAddress(string calldata key) external view returns (address);", "visibility": "external", "mutability": "view", "signature": "forkAddress(string)", @@ -5888,7 +5888,7 @@ "func": { "id": "forkBool", "description": "Gets the value for the key `key` from the currently active fork and parses it as `bool`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkBool(string memory key) external view returns (bool);", + "declaration": "function forkBool(string calldata key) external view returns (bool);", "visibility": "external", "mutability": "view", "signature": "forkBool(string)", @@ -5908,7 +5908,7 @@ "func": { "id": "forkBytes", "description": "Gets the value for the key `key` from the currently active fork and parses it as `bytes`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkBytes(string memory key) external view returns (bytes memory);", + "declaration": "function forkBytes(string calldata key) external view returns (bytes memory);", "visibility": "external", "mutability": "view", "signature": "forkBytes(string)", @@ -5928,7 +5928,7 @@ "func": { "id": "forkBytes32", "description": "Gets the value for the key `key` from the currently active fork and parses it as `bytes32`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkBytes32(string memory key) external view returns (bytes32);", + "declaration": "function forkBytes32(string calldata key) external view returns (bytes32);", "visibility": "external", "mutability": "view", "signature": "forkBytes32(string)", @@ -5968,7 +5968,7 @@ "func": { "id": "forkChainAddress", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `address`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainAddress(uint256 chain, string memory key) external view returns (address);", + "declaration": "function forkChainAddress(uint256 chain, string calldata key) external view returns (address);", "visibility": "external", "mutability": "view", "signature": "forkChainAddress(uint256,string)", @@ -5988,7 +5988,7 @@ "func": { "id": "forkChainBool", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bool`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainBool(uint256 chain, string memory key) external view returns (bool);", + "declaration": "function forkChainBool(uint256 chain, string calldata key) external view returns (bool);", "visibility": "external", "mutability": "view", "signature": "forkChainBool(uint256,string)", @@ -6008,7 +6008,7 @@ "func": { "id": "forkChainBytes", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bytes`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainBytes(uint256 chain, string memory key) external view returns (bytes memory);", + "declaration": "function forkChainBytes(uint256 chain, string calldata key) external view returns (bytes memory);", "visibility": "external", "mutability": "view", "signature": "forkChainBytes(uint256,string)", @@ -6028,7 +6028,7 @@ "func": { "id": "forkChainBytes32", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bytes32`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainBytes32(uint256 chain, string memory key) external view returns (bytes32);", + "declaration": "function forkChainBytes32(uint256 chain, string calldata key) external view returns (bytes32);", "visibility": "external", "mutability": "view", "signature": "forkChainBytes32(uint256,string)", @@ -6088,7 +6088,7 @@ "func": { "id": "forkChainInt", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `int256`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainInt(uint256 chain, string memory key) external view returns (int256);", + "declaration": "function forkChainInt(uint256 chain, string calldata key) external view returns (int256);", "visibility": "external", "mutability": "view", "signature": "forkChainInt(uint256,string)", @@ -6128,7 +6128,7 @@ "func": { "id": "forkChainString", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `string`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainString(uint256 chain, string memory key) external view returns (string memory);", + "declaration": "function forkChainString(uint256 chain, string calldata key) external view returns (string memory);", "visibility": "external", "mutability": "view", "signature": "forkChainString(uint256,string)", @@ -6148,7 +6148,7 @@ "func": { "id": "forkChainUint", "description": "Gets the value for the key `key` from the fork config for chain `chain` and parses it as `uint256`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkChainUint(uint256 chain, string memory key) external view returns (uint256);", + "declaration": "function forkChainUint(uint256 chain, string calldata key) external view returns (uint256);", "visibility": "external", "mutability": "view", "signature": "forkChainUint(uint256,string)", @@ -6188,7 +6188,7 @@ "func": { "id": "forkInt", "description": "Gets the value for the key `key` from the currently active fork and parses it as `int256`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkInt(string memory key) external view returns (int256);", + "declaration": "function forkInt(string calldata key) external view returns (int256);", "visibility": "external", "mutability": "view", "signature": "forkInt(string)", @@ -6228,7 +6228,7 @@ "func": { "id": "forkString", "description": "Gets the value for the key `key` from the currently active fork and parses it as `string`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkString(string memory key) external view returns (string memory);", + "declaration": "function forkString(string calldata key) external view returns (string memory);", "visibility": "external", "mutability": "view", "signature": "forkString(string)", @@ -6248,7 +6248,7 @@ "func": { "id": "forkUint", "description": "Gets the value for the key `key` from the currently active fork and parses it as `uint256`.\nReverts if the key was not found or the value could not be parsed.", - "declaration": "function forkUint(string memory key) external view returns (uint256);", + "declaration": "function forkUint(string calldata key) external view returns (uint256);", "visibility": "external", "mutability": "view", "signature": "forkUint(string)", diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index a0eb2f59e04ee..cc4df9ee1540c 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -2218,72 +2218,72 @@ interface Vm { /// Gets the value for the key `key` from the currently active fork and parses it as `bool`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkBool(string memory key) external view returns (bool); + function forkBool(string calldata key) external view returns (bool); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bool`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainBool(uint256 chain, string memory key) external view returns (bool); + function forkChainBool(uint256 chain, string calldata key) external view returns (bool); /// Gets the value for the key `key` from the currently active fork and parses it as `int256`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkInt(string memory key) external view returns (int256); + function forkInt(string calldata key) external view returns (int256); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `int256`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainInt(uint256 chain, string memory key) external view returns (int256); + function forkChainInt(uint256 chain, string calldata key) external view returns (int256); /// Gets the value for the key `key` from the currently active fork and parses it as `uint256`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkUint(string memory key) external view returns (uint256); + function forkUint(string calldata key) external view returns (uint256); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `uint256`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainUint(uint256 chain, string memory key) external view returns (uint256); + function forkChainUint(uint256 chain, string calldata key) external view returns (uint256); /// Gets the value for the key `key` from the currently active fork and parses it as `address`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkAddress(string memory key) external view returns (address); + function forkAddress(string calldata key) external view returns (address); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `address`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainAddress(uint256 chain, string memory key) external view returns (address); + function forkChainAddress(uint256 chain, string calldata key) external view returns (address); /// Gets the value for the key `key` from the currently active fork and parses it as `bytes32`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkBytes32(string memory key) external view returns (bytes32); + function forkBytes32(string calldata key) external view returns (bytes32); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bytes32`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainBytes32(uint256 chain, string memory key) external view returns (bytes32); + function forkChainBytes32(uint256 chain, string calldata key) external view returns (bytes32); /// Gets the value for the key `key` from the currently active fork and parses it as `string`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkString(string memory key) external view returns (string memory); + function forkString(string calldata key) external view returns (string memory); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `string`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainString(uint256 chain, string memory key) external view returns (string memory); + function forkChainString(uint256 chain, string calldata key) external view returns (string memory); /// Gets the value for the key `key` from the currently active fork and parses it as `bytes`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkBytes(string memory key) external view returns (bytes memory); + function forkBytes(string calldata key) external view returns (bytes memory); /// Gets the value for the key `key` from the fork config for chain `chain` and parses it as `bytes`. /// Reverts if the key was not found or the value could not be parsed. #[cheatcode(group = Forking)] - function forkChainBytes(uint256 chain, string memory key) external view returns (bytes memory); + function forkChainBytes(uint256 chain, string calldata key) external view returns (bytes memory); // ======== Scripts ======== // -------- Broadcasting Transactions -------- diff --git a/crates/cheatcodes/src/fs.rs b/crates/cheatcodes/src/fs.rs index a9585a31d99fa..9820c695273fd 100644 --- a/crates/cheatcodes/src/fs.rs +++ b/crates/cheatcodes/src/fs.rs @@ -527,12 +527,25 @@ impl Cheatcode for ffiCall { let Self { commandInput: input } = self; let output = ffi(state, input)?; - // TODO: check exit code? + + // Check the exit code of the command. + if output.exitCode != 0 { + // If the command failed, return an error with the exit code and stderr. + return Err(fmt_err!( + "ffi command {:?} exited with code {}. stderr: {}", + input, + output.exitCode, + String::from_utf8_lossy(&output.stderr) + )); + } + + // If the command succeeded but still wrote to stderr, log it as a warning. if !output.stderr.is_empty() { let stderr = String::from_utf8_lossy(&output.stderr); - error!(target: "cheatcodes", ?input, ?stderr, "non-empty stderr"); + warn!(target: "cheatcodes", ?input, ?stderr, "ffi command wrote to stderr"); } - // we already hex-decoded the stdout in `ffi` + + // We already hex-decoded the stdout in the `ffi` helper function. Ok(output.stdout.abi_encode()) } } @@ -884,6 +897,29 @@ mod tests { assert_eq!(output.stdout, Bytes::from(msg.as_bytes())); } + #[test] + fn test_ffi_fails_on_error_code() { + let mut cheats = cheats(); + + // Use a command that is guaranteed to fail with a non-zero exit code on any platform. + #[cfg(unix)] + let args = vec!["false".to_string()]; + #[cfg(windows)] + let args = vec!["cmd".to_string(), "/c".to_string(), "exit 1".to_string()]; + + let result = ffiCall { commandInput: args }.apply(&mut cheats); + + // Assert that the cheatcode returned an error. + assert!(result.is_err(), "Expected ffi cheatcode to fail, but it succeeded"); + + // Assert that the error message contains the expected information. + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("exited with code 1"), + "Error message did not contain exit code: {err_msg}" + ); + } + #[test] fn test_artifact_parsing() { let s = include_str!("../../evm/test-data/solc-obj.json"); diff --git a/crates/cli/src/utils/abi.rs b/crates/cli/src/utils/abi.rs index e159db9c05ab0..09ac87af82447 100644 --- a/crates/cli/src/utils/abi.rs +++ b/crates/cli/src/utils/abi.rs @@ -48,8 +48,11 @@ pub async fn parse_function_args>( // a regular function signature with parentheses get_func(sig)? } else { + info!( + "function signature does not contain parentheses, fetching function data from Etherscan" + ); let etherscan_api_key = etherscan_api_key.ok_or_eyre( - "If you wish to fetch function data from Etherscan, please provide an Etherscan API key.", + "Function signature does not contain parentheses. If you wish to fetch function data from Etherscan, please provide an API key.", )?; let to = to.ok_or_eyre("A 'to' address must be provided to fetch function data.")?; get_func_etherscan(sig, to, &args, chain, etherscan_api_key, etherscan_api_version).await? diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 70ced14a7cf21..29ba7938dad6d 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -489,6 +489,10 @@ impl<'a> Git<'a> { self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success()) } + pub fn is_repo_root(self) -> Result { + self.cmd().args(["rev-parse", "--show-cdup"]).exec().map(|out| out.stdout.is_empty()) + } + pub fn is_clean(self) -> Result { self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty()) } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 332fadf0d6662..8ffbfbb8000fb 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -181,6 +181,13 @@ pub struct Config { #[serde(default = "root_default", skip_serializing)] pub root: PathBuf, + /// Path to another foundry.toml (base) file to inherit from. + /// + /// This is a relative path from this config file. + /// Base files cannot inherit from other files. + #[serde(default, skip_serializing)] + pub inherit_from: Option, + /// path of the source contracts dir, like `src` or `contracts` pub src: PathBuf, /// path of the test dir @@ -2350,6 +2357,7 @@ impl Default for Config { fs_permissions: FsPermissions::new([PathPermission::read("out")]), isolate: cfg!(feature = "isolate-by-default"), root: root_default(), + inherit_from: None, src: "src".into(), test: "test".into(), script: "script".into(), @@ -5201,4 +5209,792 @@ mod tests { Ok(()) }); } + + #[test] + fn test_can_inherit_a_base_toml() { + figment::Jail::expect_with(|jail| { + // Create base config file with optimizer_runs = 800 + jail.create_file( + "base-config.toml", + r#" + [profile.default] + optimizer_runs = 800 + + [invariant] + runs = 1000 + + [rpc_endpoints] + mainnet = "https://example.com" + optimism = "https://example-2.com/" + "#, + )?; + + // Create local config that inherits from base-config.toml + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base-config.toml" + + [invariant] + runs = 333 + depth = 15 + + [rpc_endpoints] + mainnet = "https://reth-ethereum.ithaca.xyz/rpc" + "#, + )?; + + let config = Config::load().unwrap(); + + assert_eq!( + config.inherit_from.map(|path| path.to_string_lossy().to_string()).unwrap(), + "base-config.toml".to_string() + ); + + // optimizer_runs should be inherited from base-config.toml + assert_eq!(config.optimizer_runs, Some(800)); + + // invariant settings should be overridden by local config + assert_eq!(config.invariant.runs, 333); + assert_eq!(config.invariant.depth, 15); + + // rpc_endpoints.mainnet should be overridden by local config + // optimism should be inherited from base config + let endpoints = config.rpc_endpoints.resolved(); + assert!( + endpoints + .get("mainnet") + .unwrap() + .url() + .unwrap() + .contains("reth-ethereum.ithaca.xyz") + ); + assert!(endpoints.get("optimism").unwrap().url().unwrap().contains("example-2.com")); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_validation() { + figment::Jail::expect_with(|jail| { + // Test 1: Base file with inherit_from should fail + jail.create_file( + "base-with-inherit.toml", + r#" + [profile.default] + inherit_from = "another.toml" + optimizer_runs = 800 + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base-with-inherit.toml" + "#, + )?; + + // Should fail because base file has inherit_from + let result = Config::load(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Nested inheritance is not allowed")); + + // Test 2: Circular reference should fail + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "foundry.toml" + "#, + )?; + + let result = Config::load(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot inherit from itself")); + + // Test 3: Non-existent base file should fail + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "non-existent.toml" + "#, + )?; + + let result = Config::load(); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not exist") + || err_msg.contains("Failed to resolve inherited config path"), + "Error message: {}", + err_msg + ); + + Ok(()) + }); + } + + #[test] + fn test_complex_inheritance_merging() { + figment::Jail::expect_with(|jail| { + // Create a comprehensive base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 1000 + via_ir = false + solc = "0.8.19" + + [invariant] + runs = 500 + depth = 100 + + [fuzz] + runs = 256 + seed = "0x123" + + [rpc_endpoints] + mainnet = "https://base-mainnet.com" + optimism = "https://base-optimism.com" + arbitrum = "https://base-arbitrum.com" + "#, + )?; + + // Create local config that overrides some values + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + optimizer_runs = 200 # Override + via_ir = true # Override + # optimizer and solc are inherited + + [invariant] + runs = 333 # Override + # depth is inherited + + # fuzz section is fully inherited + + [rpc_endpoints] + mainnet = "https://local-mainnet.com" # Override + # optimism and arbitrum are inherited + polygon = "https://local-polygon.com" # New + "#, + )?; + + let config = Config::load().unwrap(); + + // Check profile.default values + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.via_ir, true); + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, 19)))); + + // Check invariant section + assert_eq!(config.invariant.runs, 333); + assert_eq!(config.invariant.depth, 100); + + // Check fuzz section (fully inherited) + assert_eq!(config.fuzz.runs, 256); + assert_eq!(config.fuzz.seed, Some(U256::from(0x123))); + + // Check rpc_endpoints + let endpoints = config.rpc_endpoints.resolved(); + assert!(endpoints.get("mainnet").unwrap().url().unwrap().contains("local-mainnet")); + assert!(endpoints.get("optimism").unwrap().url().unwrap().contains("base-optimism")); + assert!(endpoints.get("arbitrum").unwrap().url().unwrap().contains("base-arbitrum")); + assert!(endpoints.get("polygon").unwrap().url().unwrap().contains("local-polygon")); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_different_profiles() { + figment::Jail::expect_with(|jail| { + // Create base config with multiple profiles + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + + [profile.ci] + optimizer = true + optimizer_runs = 10000 + via_ir = true + + [profile.dev] + optimizer = false + "#, + )?; + + // Local config inherits from base - only for default profile + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + verbosity = 3 + + [profile.ci] + optimizer_runs = 5000 # This doesn't inherit from base.toml's ci profile + "#, + )?; + + // Test default profile + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.verbosity, 3); + + // Test CI profile (NO inherit_from, so doesn't inherit from base) + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(5000)); + assert_eq!(config.optimizer, Some(true)); + // via_ir is not set in local ci profile and there's no inherit_from, so default + assert_eq!(config.via_ir, false); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_env_vars() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer_runs = 500 + sender = "0x0000000000000000000000000000000000000001" + verbosity = 1 + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + verbosity = 2 + "#, + )?; + + // Environment variables should override both base and local values + jail.set_env("FOUNDRY_OPTIMIZER_RUNS", "999"); + jail.set_env("FOUNDRY_VERBOSITY", "4"); + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(999)); + assert_eq!(config.verbosity, 4); + assert_eq!( + config.sender, + "0x0000000000000000000000000000000000000001" + .parse::() + .unwrap() + ); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_subdirectories() { + figment::Jail::expect_with(|jail| { + // Create base config in a subdirectory + jail.create_dir("configs")?; + jail.create_file( + "configs/base.toml", + r#" + [profile.default] + optimizer_runs = 800 + src = "contracts" + "#, + )?; + + // Reference it with relative path + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "configs/base.toml" + test = "tests" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(800)); + assert_eq!(config.src, PathBuf::from("contracts")); + assert_eq!(config.test, PathBuf::from("tests")); + + // Test with parent directory reference + jail.create_dir("project")?; + jail.create_file( + "shared-base.toml", + r#" + [profile.default] + optimizer_runs = 1500 + "#, + )?; + + jail.create_file( + "project/foundry.toml", + r#" + [profile.default] + inherit_from = "../shared-base.toml" + "#, + )?; + + std::env::set_current_dir(jail.directory().join("project")).unwrap(); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(1500)); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_empty_files() { + figment::Jail::expect_with(|jail| { + // Empty base file + jail.create_file( + "base.toml", + r#" + [profile.default] + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + optimizer_runs = 300 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(300)); + + // Empty local file (only inherit_from) + jail.create_file( + "base2.toml", + r#" + [profile.default] + optimizer_runs = 400 + via_ir = true + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base2.toml" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(400)); + assert!(config.via_ir); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_array_and_table_merging() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + extra_output = ["metadata", "ir"] + + [profile.default.model_checker] + engine = "chc" + timeout = 10000 + targets = ["assert"] + + [profile.default.optimizer_details] + peephole = true + inliner = true + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + libs = ["custom-lib"] # Replaces base array + ignored_error_codes = [2018] # Replaces base array + + [profile.default.model_checker] + timeout = 5000 # Overrides base value + # engine and targets are inherited + + [profile.default.optimizer_details] + jumpdest_remover = true # Adds new field + # peephole and inliner are inherited + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays are completely replaced + assert_eq!(config.libs, vec![PathBuf::from("custom-lib")]); + assert_eq!( + config.ignored_error_codes, + vec![SolidityErrorCode::FunctionStateMutabilityCanBeRestricted] + ); + + // Tables are deep-merged + assert_eq!(config.model_checker.as_ref().unwrap().timeout, Some(5000)); + assert_eq!( + config.model_checker.as_ref().unwrap().engine, + Some(ModelCheckerEngine::CHC) + ); + assert_eq!( + config.model_checker.as_ref().unwrap().targets, + Some(vec![ModelCheckerTarget::Assert]) + ); + + // optimizer_details table is actually merged, not replaced + assert_eq!(config.optimizer_details.as_ref().unwrap().peephole, Some(true)); + assert_eq!(config.optimizer_details.as_ref().unwrap().inliner, Some(true)); + assert_eq!(config.optimizer_details.as_ref().unwrap().jumpdest_remover, None); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_special_sections() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + # Base file should not have inherit_from to avoid nested inheritance + + [labels] + "0x0000000000000000000000000000000000000001" = "Alice" + "0x0000000000000000000000000000000000000002" = "Bob" + + [[profile.default.fs_permissions]] + access = "read" + path = "./src" + + [[profile.default.fs_permissions]] + access = "read-write" + path = "./cache" + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + + [labels] + "0x0000000000000000000000000000000000000002" = "Bob Updated" + "0x0000000000000000000000000000000000000003" = "Charlie" + + [[profile.default.fs_permissions]] + access = "read" + path = "./test" + "#, + )?; + + let config = Config::load().unwrap(); + + // Labels should be merged + assert_eq!( + config.labels.get( + &"0x0000000000000000000000000000000000000001" + .parse::() + .unwrap() + ), + Some(&"Alice".to_string()) + ); + assert_eq!( + config.labels.get( + &"0x0000000000000000000000000000000000000002" + .parse::() + .unwrap() + ), + Some(&"Bob Updated".to_string()) + ); + assert_eq!( + config.labels.get( + &"0x0000000000000000000000000000000000000003" + .parse::() + .unwrap() + ), + Some(&"Charlie".to_string()) + ); + + // fs_permissions array should be replaced + assert_eq!(config.fs_permissions.permissions.len(), 1); + assert_eq!(config.fs_permissions.permissions[0].path.to_str().unwrap(), "./test"); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_compilation_settings() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + solc = "0.8.19" + evm_version = "paris" + via_ir = false + optimizer = true + optimizer_runs = 200 + + [profile.default.optimizer_details] + peephole = true + inliner = false + jumpdest_remover = true + order_literals = false + deduplicate = true + cse = true + constant_optimizer = true + yul = true + + [profile.default.optimizer_details.yul_details] + stack_allocation = true + optimizer_steps = "dhfoDgvulfnTUtnIf" + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + evm_version = "shanghai" # Override + optimizer_runs = 1000 # Override + + [profile.default.optimizer_details] + inliner = true # Override + # Rest inherited + "#, + )?; + + let config = Config::load().unwrap(); + + // Check compilation settings + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, 19)))); + assert_eq!(config.evm_version, EvmVersion::Shanghai); + assert_eq!(config.via_ir, false); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(1000)); + + // Check optimizer details - the table is actually merged + let details = config.optimizer_details.as_ref().unwrap(); + assert_eq!(details.peephole, Some(true)); + assert_eq!(details.inliner, Some(true)); + assert_eq!(details.jumpdest_remover, None); + assert_eq!(details.order_literals, None); + assert_eq!(details.deduplicate, Some(true)); + assert_eq!(details.cse, Some(true)); + assert_eq!(details.constant_optimizer, None); + assert_eq!(details.yul, Some(true)); + + // Check yul details - inherited from base + if let Some(yul_details) = details.yul_details.as_ref() { + assert_eq!(yul_details.stack_allocation, Some(true)); + assert_eq!(yul_details.optimizer_steps, Some("dhfoDgvulfnTUtnIf".to_string())); + } + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_remappings() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + remappings = [ + "forge-std/=lib/forge-std/src/", + "@openzeppelin/=lib/openzeppelin-contracts/", + "ds-test/=lib/ds-test/src/" + ] + auto_detect_remappings = false + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + inherit_from = "base.toml" + remappings = [ + "@custom/=lib/custom/", + "ds-test/=lib/forge-std/lib/ds-test/src/" # Override ds-test + ] + "#, + )?; + + let config = Config::load().unwrap(); + + // Remappings array should be replaced entirely + assert_eq!(config.remappings.len(), 2); + assert!(config.remappings.iter().any(|r| r.to_string().contains("@custom/"))); + assert!(config.remappings.iter().any(|r| r.to_string().contains("ds-test/"))); + // forge-std from base should not be present (array replaced not merged) + assert!( + !config + .remappings + .iter() + .any(|r| r.to_string().contains("forge-std/=lib/forge-std/src/")) + ); + + // auto_detect_remappings should be inherited + assert!(!config.auto_detect_remappings); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_multiple_profiles_and_single_file() { + figment::Jail::expect_with(|jail| { + // Create base config with prod and test profiles + jail.create_file( + "base.toml", + r#" + [profile.prod] + optimizer = true + optimizer_runs = 10000 + via_ir = true + + [profile.test] + optimizer = false + + [profile.test.fuzz] + runs = 100 + "#, + )?; + + // Local config inherits from base for prod profile + jail.create_file( + "foundry.toml", + r#" + [profile.prod] + inherit_from = "base.toml" + evm_version = "shanghai" # Additional setting + + [profile.test] + inherit_from = "base.toml" + + [profile.test.fuzz] + runs = 500 # Override + "#, + )?; + + // Test prod profile + jail.set_env("FOUNDRY_PROFILE", "prod"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(10000)); + assert_eq!(config.via_ir, true); + assert_eq!(config.evm_version, EvmVersion::Shanghai); + + // Test test profile + jail.set_env("FOUNDRY_PROFILE", "test"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(false)); + assert_eq!(config.fuzz.runs, 500); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_multiple_profiles_and_files() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "prod.toml", + r#" + [profile.prod] + optimizer = true + optimizer_runs = 20000 + gas_limit = 50000000 + "#, + )?; + jail.create_file( + "dev.toml", + r#" + [profile.dev] + optimizer = true + optimizer_runs = 333 + gas_limit = 555555 + "#, + )?; + + // Local config with only both profiles + jail.create_file( + "foundry.toml", + r#" + [profile.dev] + inherit_from = "dev.toml" + sender = "0x0000000000000000000000000000000000000001" + + [profile.prod] + inherit_from = "prod.toml" + sender = "0x0000000000000000000000000000000000000002" + "#, + )?; + + // Test that prod profile correctly inherits even without a default profile + jail.set_env("FOUNDRY_PROFILE", "dev"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(333)); + assert_eq!(config.gas_limit, 555555.into()); + assert_eq!( + config.sender, + "0x0000000000000000000000000000000000000001" + .parse::() + .unwrap() + ); + + // Test that prod profile correctly inherits even without a default profile + jail.set_env("FOUNDRY_PROFILE", "prod"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(20000)); + assert_eq!(config.gas_limit, 50000000.into()); + assert_eq!( + config.sender, + "0x0000000000000000000000000000000000000002" + .parse::() + .unwrap() + ); + + Ok(()) + }); + } } diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index 79bd47b1b3678..fa8062d9b9d1c 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -80,22 +80,118 @@ impl TomlFileProvider { } fn read(&self) -> Result, Error> { - use serde::de::Error as _; - if let Some(file) = self.env_val() { - let path = Path::new(&file); - if !path.exists() { + use serde::{Deserialize, de::Error as _}; + use std::collections::HashMap; + + #[derive(Deserialize, Default)] + struct InheritConfig { + #[serde(default)] + inherit_from: Option, + } + + #[derive(Deserialize, Default)] + struct PartialConfig { + #[serde(default)] + profile: Option>, + } + + let local_path = self.file(); + if !local_path.exists() { + if let Some(file) = self.env_val() { return Err(Error::custom(format!( "Config file `{}` set in env var `{}` does not exist", file, self.env_var.unwrap() ))); } - Toml::file(file) + return Ok(Map::new()); + } + + let local_provider = Toml::file(local_path.clone()).nested(); + + let local_path_str = local_path.to_string_lossy(); + let local_content = std::fs::read_to_string(&local_path) + .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; + let partial_config: PartialConfig = toml::from_str(&local_content) + .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; + + // Get the currently selected profile + let selected_profile = Config::selected_profile(); + + // Check if the current profile has an inherit_from field + let inherit_from = partial_config + .profile + .as_ref() + .and_then(|profiles| { + // Convert Profile to String for HashMap lookup + let profile_str = selected_profile.to_string(); + profiles.get(&profile_str) + }) + .and_then(|profile| profile.inherit_from.clone()); + + if let Some(relative_base_path) = inherit_from { + let local_dir = local_path.parent().ok_or_else(|| { + Error::custom(format!( + "Could not determine parent directory of config file: {}", + local_path.display() + )) + })?; + + let base_path = + foundry_compilers::utils::canonicalize(local_dir.join(&relative_base_path)) + .map_err(|e| { + Error::custom(format!( + "Failed to resolve inherited config path: {}: {e}", + relative_base_path.display() + )) + })?; + + if !base_path.is_file() { + return Err(Error::custom(format!( + "Inherited config file does not exist or is not a file: {}", + base_path.display() + ))); + } + + if foundry_compilers::utils::canonicalize(&local_path).ok() == Some(base_path.clone()) { + return Err(Error::custom(format!( + "Config file {} cannot inherit from itself.", + local_path.display() + ))); + } + + let base_path_str = base_path.to_string_lossy(); + let base_content = std::fs::read_to_string(&base_path) + .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; + let base_partial: PartialConfig = toml::from_str(&base_content) + .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; + + // Check if the base file's same profile also has inherit_from + let base_inherit_from = base_partial + .profile + .as_ref() + .and_then(|profiles| { + // Convert Profile to String for HashMap lookup + let profile_str = selected_profile.to_string(); + profiles.get(&profile_str) + }) + .and_then(|profile| profile.inherit_from.as_ref()); + + if base_inherit_from.is_some() { + return Err(Error::custom(format!( + "Nested inheritance is not allowed. Base file '{}' cannot have an 'inherit_from' field in profile '{}'.", + base_path.display(), + selected_profile + ))); + } + + let base_provider = Toml::file(base_path).nested(); + + // Merge base configuration first, then apply local overrides + Figment::new().merge(base_provider).merge(local_provider).data() } else { - Toml::file(&self.default) + local_provider.data() } - .nested() - .data() } } diff --git a/crates/config/src/soldeer.rs b/crates/config/src/soldeer.rs index 24ecce0955f12..997e012d2d6fe 100644 --- a/crates/config/src/soldeer.rs +++ b/crates/config/src/soldeer.rs @@ -32,7 +32,7 @@ pub struct MapDependency { /// Type for Soldeer configs, under dependencies tag in the foundry.toml #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SoldeerDependencyConfig(BTreeMap); +pub struct SoldeerDependencyConfig(pub BTreeMap); impl AsRef for SoldeerDependencyConfig { fn as_ref(&self) -> &Self { diff --git a/crates/forge/src/cmd/init.rs b/crates/forge/src/cmd/init.rs index 7866f14d63908..52b0b6a6a3b78 100644 --- a/crates/forge/src/cmd/init.rs +++ b/crates/forge/src/cmd/init.rs @@ -37,13 +37,18 @@ pub struct InitArgs { #[arg(long, conflicts_with = "template")] pub vscode: bool, + /// Use the parent git repository instead of initializing a new one. + /// Only valid if the target is in a git repository. + #[arg(long, conflicts_with = "template")] + pub use_parent_git: bool, + #[command(flatten)] pub install: DependencyInstallOpts, } impl InitArgs { pub fn run(self) -> Result<()> { - let Self { root, template, branch, install, offline, force, vscode } = self; + let Self { root, template, branch, install, offline, force, vscode, use_parent_git } = self; let DependencyInstallOpts { shallow, no_git, commit } = install; // create the root dir if it does not exist @@ -141,7 +146,7 @@ impl InitArgs { // set up the repo if !no_git { - init_git_repo(git, commit)?; + init_git_repo(git, commit, use_parent_git)?; } // install forge-std @@ -166,14 +171,15 @@ impl InitArgs { } } -/// Initialises `root` as a git repository, if it isn't one already. +/// Initialises `root` as a git repository, if it isn't one already, unless 'use_parent_git' is +/// true. /// /// Creates `.gitignore` and `.github/workflows/test.yml`, if they don't exist already. /// /// Commits everything in `root` if `commit` is true. -fn init_git_repo(git: Git<'_>, commit: bool) -> Result<()> { - // git init - if !git.is_in_repo()? { +fn init_git_repo(git: Git<'_>, commit: bool, use_parent_git: bool) -> Result<()> { + // `git init` + if !git.is_in_repo()? || (!use_parent_git && !git.is_repo_root()?) { git.init()?; } diff --git a/crates/forge/tests/cli/cmd.rs b/crates/forge/tests/cli/cmd.rs index 0be5e77956fc5..a7f88567580a5 100644 --- a/crates/forge/tests/cli/cmd.rs +++ b/crates/forge/tests/cli/cmd.rs @@ -501,6 +501,53 @@ Warning: Target directory is not empty, but `--force` was specified assert_eq!(gitignore, "not foundry .gitignore"); }); +// `forge init --use-parent-git` works on already initialized git repository +forgetest!(can_init_using_parent_repo, |prj, cmd| { + let root = prj.root(); + + // initialize new git repo + let status = Command::new("git") + .arg("init") + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("could not run git init"); + assert!(status.success()); + assert!(root.join(".git").exists()); + + prj.create_file("README.md", "non-empty dir"); + prj.create_file(".gitignore", "not foundry .gitignore"); + + let folder = "foundry-folder"; + cmd.arg("init").arg(folder).arg("--force").arg("--use-parent-git").assert_success().stdout_eq( + str![[r#" +Initializing [..]... +Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + Installed forge-std[..] + Initialized forge project + +"#]], + ); + + assert!(root.join(folder).join("lib/forge-std").exists()); + + // not overwritten + let gitignore = root.join(".gitignore"); + let gitignore = fs::read_to_string(gitignore).unwrap(); + assert_eq!(gitignore, "not foundry .gitignore"); + + // submodules are registered at root + let gitmodules = root.join(".gitmodules"); + let gitmodules = fs::read_to_string(gitmodules).unwrap(); + assert!(gitmodules.contains( + " + path = foundry-folder/lib/forge-std + url = https://github.com/foundry-rs/forge-std +" + )); +}); + // Checks that remappings.txt and .vscode/settings.json is generated forgetest!(can_init_vscode, |prj, cmd| { prj.wipe(); diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 2eac161ade76c..e7a55c7bea832 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -36,6 +36,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { // `profiles` is not serialized. profiles: vec![], root: ".".into(), + inherit_from: None, src: "test-src".into(), test: "test-test".into(), script: "test-script".into(), diff --git a/crates/wallets/src/utils.rs b/crates/wallets/src/utils.rs index 52125c60521df..f5282496ed0ca 100644 --- a/crates/wallets/src/utils.rs +++ b/crates/wallets/src/utils.rs @@ -42,10 +42,11 @@ pub fn create_mnemonic_signer( index: u32, ) -> Result { let mnemonic = if Path::new(mnemonic).is_file() { - fs::read_to_string(mnemonic)?.replace('\n', "") + fs::read_to_string(mnemonic)? } else { mnemonic.to_owned() }; + let mnemonic = mnemonic.split_whitespace().collect::>().join(" "); Ok(WalletSigner::from_mnemonic(&mnemonic, passphrase, hd_path, index)?) } diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index c9247b2de9abd..e238d6eed7d3d 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -286,26 +286,26 @@ interface Vm { function expectSafeMemoryCall(uint64 min, uint64 max) external; function fee(uint256 newBasefee) external; function ffi(string[] calldata commandInput) external returns (bytes memory result); - function forkAddress(string memory key) external view returns (address); - function forkBool(string memory key) external view returns (bool); - function forkBytes(string memory key) external view returns (bytes memory); - function forkBytes32(string memory key) external view returns (bytes32); + function forkAddress(string calldata key) external view returns (address); + function forkBool(string calldata key) external view returns (bool); + function forkBytes(string calldata key) external view returns (bytes memory); + function forkBytes32(string calldata key) external view returns (bytes32); function forkChain() external view returns (string memory); - function forkChainAddress(uint256 chain, string memory key) external view returns (address); - function forkChainBool(uint256 chain, string memory key) external view returns (bool); - function forkChainBytes(uint256 chain, string memory key) external view returns (bytes memory); - function forkChainBytes32(uint256 chain, string memory key) external view returns (bytes32); + function forkChainAddress(uint256 chain, string calldata key) external view returns (address); + function forkChainBool(uint256 chain, string calldata key) external view returns (bool); + function forkChainBytes(uint256 chain, string calldata key) external view returns (bytes memory); + function forkChainBytes32(uint256 chain, string calldata key) external view returns (bytes32); function forkChainId() external view returns (uint256); function forkChainIds() external view returns (uint256[] memory); - function forkChainInt(uint256 chain, string memory key) external view returns (int256); + function forkChainInt(uint256 chain, string calldata key) external view returns (int256); function forkChainRpcUrl(uint256 id) external view returns (string memory); - function forkChainString(uint256 chain, string memory key) external view returns (string memory); - function forkChainUint(uint256 chain, string memory key) external view returns (uint256); + function forkChainString(uint256 chain, string calldata key) external view returns (string memory); + function forkChainUint(uint256 chain, string calldata key) external view returns (uint256); function forkChains() external view returns (string[] memory); - function forkInt(string memory key) external view returns (int256); + function forkInt(string calldata key) external view returns (int256); function forkRpcUrl() external view returns (string memory); - function forkString(string memory key) external view returns (string memory); - function forkUint(string memory key) external view returns (uint256); + function forkString(string calldata key) external view returns (string memory); + function forkUint(string calldata key) external view returns (uint256); function foundryVersionAtLeast(string calldata version) external view returns (bool); function foundryVersionCmp(string calldata version) external view returns (int256); function fsMetadata(string calldata path) external view returns (FsMetadata memory metadata); From b6935fb1782a1c6f6ebbb83d8043cbd29330c1bc Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 16:10:47 +0200 Subject: [PATCH 2/8] style: rename to 'extends' --- crates/config/src/lib.rs | 65 +++++++++++++++--------------- crates/config/src/providers/ext.rs | 12 +++--- crates/forge/tests/cli/config.rs | 2 +- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 8ffbfbb8000fb..2fdf2f204daab 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -181,12 +181,12 @@ pub struct Config { #[serde(default = "root_default", skip_serializing)] pub root: PathBuf, - /// Path to another foundry.toml (base) file to inherit from. + /// Path to another foundry.toml (base) file that should be extended (inherited). /// /// This is a relative path from this config file. - /// Base files cannot inherit from other files. + /// Base files cannot extend (inherit) other files. #[serde(default, skip_serializing)] - pub inherit_from: Option, + pub extends: Option, /// path of the source contracts dir, like `src` or `contracts` pub src: PathBuf, @@ -2357,7 +2357,7 @@ impl Default for Config { fs_permissions: FsPermissions::new([PathPermission::read("out")]), isolate: cfg!(feature = "isolate-by-default"), root: root_default(), - inherit_from: None, + extends: None, src: "src".into(), test: "test".into(), script: "script".into(), @@ -5234,7 +5234,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base-config.toml" + extends = "base-config.toml" [invariant] runs = 333 @@ -5248,7 +5248,7 @@ mod tests { let config = Config::load().unwrap(); assert_eq!( - config.inherit_from.map(|path| path.to_string_lossy().to_string()).unwrap(), + config.extends.map(|path| path.to_string_lossy().to_string()).unwrap(), "base-config.toml".to_string() ); @@ -5279,12 +5279,12 @@ mod tests { #[test] fn test_inheritance_validation() { figment::Jail::expect_with(|jail| { - // Test 1: Base file with inherit_from should fail + // Test 1: Base file with 'extends' should fail jail.create_file( "base-with-inherit.toml", r#" [profile.default] - inherit_from = "another.toml" + extends = "another.toml" optimizer_runs = 800 "#, )?; @@ -5293,11 +5293,11 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base-with-inherit.toml" + extends = "base-with-inherit.toml" "#, )?; - // Should fail because base file has inherit_from + // Should fail because base file has 'extends' let result = Config::load(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Nested inheritance is not allowed")); @@ -5307,7 +5307,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "foundry.toml" + extends = "foundry.toml" "#, )?; @@ -5320,7 +5320,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "non-existent.toml" + extends = "non-existent.toml" "#, )?; @@ -5330,8 +5330,7 @@ mod tests { assert!( err_msg.contains("does not exist") || err_msg.contains("Failed to resolve inherited config path"), - "Error message: {}", - err_msg + "Error message: {err_msg}" ); Ok(()) @@ -5371,7 +5370,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" optimizer_runs = 200 # Override via_ir = true # Override # optimizer and solc are inherited @@ -5442,7 +5441,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" verbosity = 3 [profile.ci] @@ -5456,12 +5455,12 @@ mod tests { assert_eq!(config.optimizer_runs, Some(200)); assert_eq!(config.verbosity, 3); - // Test CI profile (NO inherit_from, so doesn't inherit from base) + // Test CI profile (NO 'extends', so doesn't inherit from base) jail.set_env("FOUNDRY_PROFILE", "ci"); let config = Config::load().unwrap(); assert_eq!(config.optimizer_runs, Some(5000)); assert_eq!(config.optimizer, Some(true)); - // via_ir is not set in local ci profile and there's no inherit_from, so default + // via_ir is not set in local ci profile and there's no 'extends', so default assert_eq!(config.via_ir, false); Ok(()) @@ -5485,7 +5484,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" verbosity = 2 "#, )?; @@ -5527,7 +5526,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "configs/base.toml" + extends = "configs/base.toml" test = "tests" "#, )?; @@ -5551,7 +5550,7 @@ mod tests { "project/foundry.toml", r#" [profile.default] - inherit_from = "../shared-base.toml" + extends = "../shared-base.toml" "#, )?; @@ -5578,7 +5577,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" optimizer_runs = 300 "#, )?; @@ -5586,7 +5585,7 @@ mod tests { let config = Config::load().unwrap(); assert_eq!(config.optimizer_runs, Some(300)); - // Empty local file (only inherit_from) + // Empty local file (only 'extends') jail.create_file( "base2.toml", r#" @@ -5600,7 +5599,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base2.toml" + extends = "base2.toml" "#, )?; @@ -5638,7 +5637,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" libs = ["custom-lib"] # Replaces base array ignored_error_codes = [2018] # Replaces base array @@ -5688,7 +5687,7 @@ mod tests { "base.toml", r#" [profile.default] - # Base file should not have inherit_from to avoid nested inheritance + # Base file should not have 'extends' to avoid nested inheritance [labels] "0x0000000000000000000000000000000000000001" = "Alice" @@ -5708,7 +5707,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" [labels] "0x0000000000000000000000000000000000000002" = "Bob Updated" @@ -5789,7 +5788,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" evm_version = "shanghai" # Override optimizer_runs = 1000 # Override @@ -5849,7 +5848,7 @@ mod tests { "foundry.toml", r#" [profile.default] - inherit_from = "base.toml" + extends = "base.toml" remappings = [ "@custom/=lib/custom/", "ds-test/=lib/forge-std/lib/ds-test/src/" # Override ds-test @@ -5903,11 +5902,11 @@ mod tests { "foundry.toml", r#" [profile.prod] - inherit_from = "base.toml" + extends = "base.toml" evm_version = "shanghai" # Additional setting [profile.test] - inherit_from = "base.toml" + extends = "base.toml" [profile.test.fuzz] runs = 500 # Override @@ -5959,11 +5958,11 @@ mod tests { "foundry.toml", r#" [profile.dev] - inherit_from = "dev.toml" + extends = "dev.toml" sender = "0x0000000000000000000000000000000000000001" [profile.prod] - inherit_from = "prod.toml" + extends = "prod.toml" sender = "0x0000000000000000000000000000000000000002" "#, )?; diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index fa8062d9b9d1c..b39259f368029 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -84,15 +84,15 @@ impl TomlFileProvider { use std::collections::HashMap; #[derive(Deserialize, Default)] - struct InheritConfig { + struct ExtendConfig { #[serde(default)] - inherit_from: Option, + extends: Option, } #[derive(Deserialize, Default)] struct PartialConfig { #[serde(default)] - profile: Option>, + profile: Option>, } let local_path = self.file(); @@ -127,7 +127,7 @@ impl TomlFileProvider { let profile_str = selected_profile.to_string(); profiles.get(&profile_str) }) - .and_then(|profile| profile.inherit_from.clone()); + .and_then(|profile| profile.extends.clone()); if let Some(relative_base_path) = inherit_from { let local_dir = local_path.parent().ok_or_else(|| { @@ -175,11 +175,11 @@ impl TomlFileProvider { let profile_str = selected_profile.to_string(); profiles.get(&profile_str) }) - .and_then(|profile| profile.inherit_from.as_ref()); + .and_then(|profile| profile.extends.as_ref()); if base_inherit_from.is_some() { return Err(Error::custom(format!( - "Nested inheritance is not allowed. Base file '{}' cannot have an 'inherit_from' field in profile '{}'.", + "Nested inheritance is not allowed. Base file '{}' cannot have an 'extends' field in profile '{}'.", base_path.display(), selected_profile ))); diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index e7a55c7bea832..9c32f8d47c103 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -36,7 +36,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { // `profiles` is not serialized. profiles: vec![], root: ".".into(), - inherit_from: None, + extends: None, src: "test-src".into(), test: "test-test".into(), script: "test-script".into(), From 62696c98278d8ea394b3b75fea4875bb2b4cd130 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 18:17:15 +0200 Subject: [PATCH 3/8] fix: better docs + string rather than pathbuf + avoid clonning --- crates/config/src/lib.rs | 71 ++++++++++++++++++++---------- crates/config/src/providers/ext.rs | 69 ++++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 36 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2fdf2f204daab..b651d11a35657 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -186,7 +186,7 @@ pub struct Config { /// This is a relative path from this config file. /// Base files cannot extend (inherit) other files. #[serde(default, skip_serializing)] - pub extends: Option, + pub extends: Option, /// path of the source contracts dir, like `src` or `contracts` pub src: PathBuf, @@ -5246,11 +5246,7 @@ mod tests { )?; let config = Config::load().unwrap(); - - assert_eq!( - config.extends.map(|path| path.to_string_lossy().to_string()).unwrap(), - "base-config.toml".to_string() - ); + assert_eq!(config.extends, Some("base-config.toml".to_string())); // optimizer_runs should be inherited from base-config.toml assert_eq!(config.optimizer_runs, Some(800)); @@ -5638,8 +5634,8 @@ mod tests { r#" [profile.default] extends = "base.toml" - libs = ["custom-lib"] # Replaces base array - ignored_error_codes = [2018] # Replaces base array + libs = ["custom-lib"] # Concatenates with base array + ignored_error_codes = [2018] # Concatenates with base array [profile.default.model_checker] timeout = 5000 # Overrides base value @@ -5653,11 +5649,22 @@ mod tests { let config = Config::load().unwrap(); - // Arrays are completely replaced - assert_eq!(config.libs, vec![PathBuf::from("custom-lib")]); + // Arrays are now concatenated with admerge (base + local) + assert_eq!( + config.libs, + vec![ + PathBuf::from("lib"), + PathBuf::from("node_modules"), + PathBuf::from("custom-lib") + ] + ); assert_eq!( config.ignored_error_codes, - vec![SolidityErrorCode::FunctionStateMutabilityCanBeRestricted] + vec![ + SolidityErrorCode::UnusedFunctionParameter, // 5667 from base.toml + SolidityErrorCode::SpdxLicenseNotProvided, // 1878 from base.toml + SolidityErrorCode::FunctionStateMutabilityCanBeRestricted // 2018 from local + ] ); // Tables are deep-merged @@ -5747,9 +5754,30 @@ mod tests { Some(&"Charlie".to_string()) ); - // fs_permissions array should be replaced - assert_eq!(config.fs_permissions.permissions.len(), 1); - assert_eq!(config.fs_permissions.permissions[0].path.to_str().unwrap(), "./test"); + // fs_permissions array is now concatenated with addmerge (base + local) + assert_eq!(config.fs_permissions.permissions.len(), 3); // 2 from base + 1 from local + // Check that all permissions are present + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./src") + ); + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./cache") + ); + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./test") + ); Ok(()) }); @@ -5851,24 +5879,19 @@ mod tests { extends = "base.toml" remappings = [ "@custom/=lib/custom/", - "ds-test/=lib/forge-std/lib/ds-test/src/" # Override ds-test + "ds-test/=lib/forge-std/lib/ds-test/src/" # Note: This will be added alongside base remappings ] "#, )?; let config = Config::load().unwrap(); - // Remappings array should be replaced entirely - assert_eq!(config.remappings.len(), 2); + // Remappings array is now concatenated with admerge (base + local) + // All remappings from base and local should be present assert!(config.remappings.iter().any(|r| r.to_string().contains("@custom/"))); assert!(config.remappings.iter().any(|r| r.to_string().contains("ds-test/"))); - // forge-std from base should not be present (array replaced not merged) - assert!( - !config - .remappings - .iter() - .any(|r| r.to_string().contains("forge-std/=lib/forge-std/src/")) - ); + assert!(config.remappings.iter().any(|r| r.to_string().contains("forge-std/"))); + assert!(config.remappings.iter().any(|r| r.to_string().contains("@openzeppelin/"))); // auto_detect_remappings should be inherited assert!(!config.auto_detect_remappings); diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index b39259f368029..11c28e4fc0c72 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -79,14 +79,44 @@ impl TomlFileProvider { self } + /// Reads and processes the TOML configuration file, handling inheritance if configured. + /// + /// This function performs the following steps: + /// 1. Loads the TOML file (from env var or default path) + /// 2. Checks if the current profile has an `extends` field + /// 3. If inheritance is configured: + /// - Resolves the base config file path relative to the current config + /// - Validates that the base file exists and isn't self-referential + /// - Ensures no nested inheritance (base files cannot have `extends`) + /// - Merges base and local configurations using `admerge` strategy + /// 4. Returns the final configuration data + /// + /// # Inheritance Behavior + /// + /// When a profile specifies `extends = "path/to/base.toml"`: + /// - The base configuration is loaded first + /// - Local configuration is applied on top using `admerge`: + /// - Arrays are concatenated (base + local) + /// - Other values are replaced (local overrides base) + /// - The `extends` field itself is preserved in the final config + /// + /// # Error Conditions + /// + /// Returns an error if: + /// - Config file specified in env var doesn't exist + /// - Base config file doesn't exist or isn't a file + /// - Config attempts to inherit from itself + /// - Base config also has an `extends` field (nested inheritance) + /// - TOML parsing fails for either file fn read(&self) -> Result, Error> { use serde::{Deserialize, de::Error as _}; use std::collections::HashMap; + // Helper structs to extract just the extends field from profiles #[derive(Deserialize, Default)] struct ExtendConfig { #[serde(default)] - extends: Option, + extends: Option, } #[derive(Deserialize, Default)] @@ -95,6 +125,7 @@ impl TomlFileProvider { profile: Option>, } + // Get the config file path and validate it exists let local_path = self.file(); if !local_path.exists() { if let Some(file) = self.env_val() { @@ -107,19 +138,21 @@ impl TomlFileProvider { return Ok(Map::new()); } + // Create a provider for the local config file let local_provider = Toml::file(local_path.clone()).nested(); + // Parse the local config to check for extends field let local_path_str = local_path.to_string_lossy(); let local_content = std::fs::read_to_string(&local_path) .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; let partial_config: PartialConfig = toml::from_str(&local_content) .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; - // Get the currently selected profile + // Determine which profile is active (e.g., "default", "test", etc.) let selected_profile = Config::selected_profile(); - // Check if the current profile has an inherit_from field - let inherit_from = partial_config + // Check if the current profile has an extends field + let extends_value = partial_config .profile .as_ref() .and_then(|profiles| { @@ -129,7 +162,9 @@ impl TomlFileProvider { }) .and_then(|profile| profile.extends.clone()); - if let Some(relative_base_path) = inherit_from { + // If inheritance is configured, load and merge the base config + if let Some(extends) = extends_value { + let relative_base_path = PathBuf::from(&extends); let local_dir = local_path.parent().ok_or_else(|| { Error::custom(format!( "Could not determine parent directory of config file: {}", @@ -146,6 +181,7 @@ impl TomlFileProvider { )) })?; + // Validate the base config file exists if !base_path.is_file() { return Err(Error::custom(format!( "Inherited config file does not exist or is not a file: {}", @@ -153,31 +189,34 @@ impl TomlFileProvider { ))); } - if foundry_compilers::utils::canonicalize(&local_path).ok() == Some(base_path.clone()) { + // Prevent self-inheritance which would cause infinite recursion + if foundry_compilers::utils::canonicalize(&local_path).ok().as_ref() == Some(&base_path) + { return Err(Error::custom(format!( "Config file {} cannot inherit from itself.", local_path.display() ))); } + // Parse the base config to check for nested inheritance let base_path_str = base_path.to_string_lossy(); let base_content = std::fs::read_to_string(&base_path) .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; let base_partial: PartialConfig = toml::from_str(&base_content) .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; - // Check if the base file's same profile also has inherit_from - let base_inherit_from = base_partial + // Check if the base file's same profile also has extends (nested inheritance) + let base_extends = base_partial .profile .as_ref() .and_then(|profiles| { - // Convert Profile to String for HashMap lookup let profile_str = selected_profile.to_string(); profiles.get(&profile_str) }) .and_then(|profile| profile.extends.as_ref()); - if base_inherit_from.is_some() { + // Prevent nested inheritance to avoid complexity and potential cycles + if base_extends.is_some() { return Err(Error::custom(format!( "Nested inheritance is not allowed. Base file '{}' cannot have an 'extends' field in profile '{}'.", base_path.display(), @@ -185,11 +224,17 @@ impl TomlFileProvider { ))); } + // Load base configuration as a Figment provider let base_provider = Toml::file(base_path).nested(); - // Merge base configuration first, then apply local overrides - Figment::new().merge(base_provider).merge(local_provider).data() + // Merge configurations: base first, then local overrides + // Using admerge strategy: + // - Arrays are concatenated (base elements + local elements) + // - Other values are replaced (local values override base values) + // - The extends field is preserved in the final configuration + Figment::new().merge(base_provider).admerge(local_provider).data() } else { + // No inheritance - return the local config as-is local_provider.data() } } From 23b5b46543b017435289bf36e8cd2ef17042b807 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 21:57:39 +0200 Subject: [PATCH 4/8] impl extend strategies --- crates/config/src/extend.rs | 81 ++++++++ crates/config/src/lib.rs | 289 ++++++++++++++++++++++++++++- crates/config/src/providers/ext.rs | 108 +++++++---- 3 files changed, 434 insertions(+), 44 deletions(-) create mode 100644 crates/config/src/extend.rs diff --git a/crates/config/src/extend.rs b/crates/config/src/extend.rs new file mode 100644 index 0000000000000..6bb0774dfec38 --- /dev/null +++ b/crates/config/src/extend.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Strategy for extending configuration from a base file. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExtendStrategy { + /// Uses `admerge` figment strategy. + /// Arrays are concatenated (base elements + local elements). + /// Other values are replaced (local values override base values). + ExtendArrays, + + /// Uses `merge` figment strategy. + /// Arrays are replaced entirely (local arrays replace base arrays). + /// Other values are replaced (local values override base values). + ReplaceArrays, + + /// Throws an error if any of the keys in the inherited toml file are also in `foundry.toml`. + NoCollision, +} + +impl Default for ExtendStrategy { + fn default() -> Self { + Self::ExtendArrays + } +} + +/// Configuration for extending from a base file. +/// +/// Supports two formats: +/// - String: `extends = "base.toml"` +/// - Object: `extends = { path = "base.toml", strategy = "no-collision" }` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Extends { + /// Simple string path to base file + Path(String), + /// Detailed configuration with path and strategy + Config(ExtendConfig), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExtendConfig { + pub path: String, + #[serde(default)] + pub strategy: Option, +} + +impl Extends { + /// Get the path to the base file + pub fn path(&self) -> &str { + match self { + Self::Path(path) => path, + Self::Config(config) => &config.path, + } + } + + /// Get the strategy to use for extending + pub fn strategy(&self) -> ExtendStrategy { + match self { + Self::Path(_) => ExtendStrategy::default(), + Self::Config(config) => config.strategy.clone().unwrap_or_default(), + } + } +} + +// -- HELPERS ----------------------------------------------------------------- + +// Helper structs to only extract the 'extends' field and its strategy from the profiles +#[derive(Deserialize, Default)] +pub(crate) struct ExtendsPartialConfig { + #[serde(default)] + pub profile: Option>, +} + +#[derive(Deserialize, Default)] +pub(crate) struct ExtendsHelper { + #[serde(default)] + pub extends: Option, +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b651d11a35657..f83385b1b3531 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -127,6 +127,9 @@ use bind_json::BindJsonConfig; mod compilation; pub use compilation::{CompilationRestrictions, SettingsOverrides}; +pub mod extend; +use extend::Extends; + /// Foundry configuration /// /// # Defaults @@ -181,12 +184,12 @@ pub struct Config { #[serde(default = "root_default", skip_serializing)] pub root: PathBuf, - /// Path to another foundry.toml (base) file that should be extended (inherited). + /// Configuration for extending from another foundry.toml (base) file. /// - /// This is a relative path from this config file. + /// Can be either a string path or an object with path and strategy. /// Base files cannot extend (inherit) other files. #[serde(default, skip_serializing)] - pub extends: Option, + pub extends: Option, /// path of the source contracts dir, like `src` or `contracts` pub src: PathBuf, @@ -5246,7 +5249,7 @@ mod tests { )?; let config = Config::load().unwrap(); - assert_eq!(config.extends, Some("base-config.toml".to_string())); + assert_eq!(config.extends, Some(Extends::Path("base-config.toml".to_string()))); // optimizer_runs should be inherited from base-config.toml assert_eq!(config.optimizer_runs, Some(800)); @@ -5887,7 +5890,6 @@ mod tests { let config = Config::load().unwrap(); // Remappings array is now concatenated with admerge (base + local) - // All remappings from base and local should be present assert!(config.remappings.iter().any(|r| r.to_string().contains("@custom/"))); assert!(config.remappings.iter().any(|r| r.to_string().contains("ds-test/"))); assert!(config.remappings.iter().any(|r| r.to_string().contains("forge-std/"))); @@ -6019,4 +6021,281 @@ mod tests { Ok(()) }); } + + #[test] + fn test_extends_strategy_extend_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config with arrays + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + optimizer_runs = 200 + "#, + )?; + + // Local config extends with extend-arrays strategy (concatenates arrays) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["mylib", "customlib"] + ignored_error_codes = [1234] + optimizer_runs = 500 + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays should be concatenated (base + local) + assert_eq!(config.libs.len(), 4); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("mylib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("customlib"))); + + assert_eq!(config.ignored_error_codes.len(), 3); + assert!( + config.ignored_error_codes.contains(&SolidityErrorCode::UnusedFunctionParameter) + ); // 5667 + assert!( + config.ignored_error_codes.contains(&SolidityErrorCode::SpdxLicenseNotProvided) + ); // 1878 + assert!(config.ignored_error_codes.contains(&SolidityErrorCode::from(1234u64))); // 1234 - generic + + // Non-array values should be replaced + assert_eq!(config.optimizer_runs, Some(500)); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_replace_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config with arrays + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + optimizer_runs = 200 + "#, + )?; + + // Local config extends with replace-arrays strategy (replaces arrays entirely) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "replace-arrays" } + libs = ["mylib", "customlib"] + ignored_error_codes = [1234] + optimizer_runs = 500 + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays should be replaced entirely (only local values) + assert_eq!(config.libs.len(), 2); + assert!(config.libs.iter().any(|l| l.to_str() == Some("mylib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("customlib"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + + assert_eq!(config.ignored_error_codes.len(), 1); + assert!(config.ignored_error_codes.contains(&SolidityErrorCode::from(1234u64))); // 1234 + assert!( + !config.ignored_error_codes.contains(&SolidityErrorCode::UnusedFunctionParameter) + ); // 5667 + + // Non-array values should be replaced + assert_eq!(config.optimizer_runs, Some(500)); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_no_collision_success() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + src = "src" + "#, + )?; + + // Local config extends with no-collision strategy and no conflicts + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + test = "tests" + libs = ["lib"] + "#, + )?; + + let config = Config::load().unwrap(); + + // Values from base should be present + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.src, PathBuf::from("src")); + + // Values from local should be present + assert_eq!(config.test, PathBuf::from("tests")); + assert_eq!(config.libs.len(), 1); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_no_collision_error() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + libs = ["lib", "node_modules"] + "#, + )?; + + // Local config extends with no-collision strategy but has conflicts + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + optimizer_runs = 500 + libs = ["mylib"] + "#, + )?; + + // Loading should fail due to key collision + let result = Config::load(); + + if let Ok(config) = result { + panic!( + "Expected error but got config with optimizer_runs: {:?}, libs: {:?}", + config.optimizer_runs, config.libs + ); + } + + let err = result.unwrap_err(); + let err_str = err.to_string(); + assert!( + err_str.contains("Key collision detected") || err_str.contains("collision"), + "Error message doesn't mention collision: {err_str}" + ); + + Ok(()) + }); + } + + #[test] + fn test_extends_both_syntaxes() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib"] + optimizer = true + "#, + )?; + + // Test 1: Simple string syntax (should use default extend-arrays) + jail.create_file( + "foundry_string.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["custom"] + "#, + )?; + + // Test 2: Object syntax with explicit strategy + jail.create_file( + "foundry_object.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "replace-arrays" } + libs = ["custom"] + "#, + )?; + + // Test string syntax (default extend-arrays) + jail.set_env("FOUNDRY_CONFIG", "foundry_string.toml"); + let config = Config::load().unwrap(); + assert_eq!(config.libs.len(), 2); // Should concatenate + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + + // Test object syntax (replace-arrays) + jail.set_env("FOUNDRY_CONFIG", "foundry_object.toml"); + let config = Config::load().unwrap(); + assert_eq!(config.libs.len(), 1); // Should replace + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("lib"))); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_default_is_extend_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + optimizer = true + "#, + )?; + + // Local config extends without specifying strategy (should default to extend-arrays) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["custom"] + optimizer = false + "#, + )?; + + // Should work with default extend-arrays strategy + let config = Config::load().unwrap(); + + // Arrays should be concatenated by default + assert_eq!(config.libs.len(), 3); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + + // Non-array values should be replaced + assert_eq!(config.optimizer, Some(false)); + + Ok(()) + }); + } } diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index 11c28e4fc0c72..c4362bc9d9071 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -1,4 +1,4 @@ -use crate::{Config, utils}; +use crate::{Config, extend, utils}; use figment::{ Error, Figment, Metadata, Profile, Provider, providers::{Env, Format, Toml}, @@ -109,21 +109,7 @@ impl TomlFileProvider { /// - Base config also has an `extends` field (nested inheritance) /// - TOML parsing fails for either file fn read(&self) -> Result, Error> { - use serde::{Deserialize, de::Error as _}; - use std::collections::HashMap; - - // Helper structs to extract just the extends field from profiles - #[derive(Deserialize, Default)] - struct ExtendConfig { - #[serde(default)] - extends: Option, - } - - #[derive(Deserialize, Default)] - struct PartialConfig { - #[serde(default)] - profile: Option>, - } + use serde::de::Error as _; // Get the config file path and validate it exists let local_path = self.file(); @@ -145,26 +131,23 @@ impl TomlFileProvider { let local_path_str = local_path.to_string_lossy(); let local_content = std::fs::read_to_string(&local_path) .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; - let partial_config: PartialConfig = toml::from_str(&local_content) + let partial_config: extend::ExtendsPartialConfig = toml::from_str(&local_content) .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; // Determine which profile is active (e.g., "default", "test", etc.) let selected_profile = Config::selected_profile(); - // Check if the current profile has an extends field - let extends_value = partial_config - .profile - .as_ref() - .and_then(|profiles| { - // Convert Profile to String for HashMap lookup - let profile_str = selected_profile.to_string(); - profiles.get(&profile_str) - }) - .and_then(|profile| profile.extends.clone()); + // Check if the current profile has an 'extends' field + let extends_config = partial_config.profile.as_ref().and_then(|profiles| { + let profile_str = selected_profile.to_string(); + profiles.get(&profile_str).and_then(|cfg| cfg.extends.clone()) + }); // If inheritance is configured, load and merge the base config - if let Some(extends) = extends_value { - let relative_base_path = PathBuf::from(&extends); + if let Some(extends_config) = extends_config { + let extends_path = extends_config.path(); + let extends_strategy = extends_config.strategy(); + let relative_base_path = PathBuf::from(extends_path); let local_dir = local_path.parent().ok_or_else(|| { Error::custom(format!( "Could not determine parent directory of config file: {}", @@ -202,7 +185,7 @@ impl TomlFileProvider { let base_path_str = base_path.to_string_lossy(); let base_content = std::fs::read_to_string(&base_path) .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; - let base_partial: PartialConfig = toml::from_str(&base_content) + let base_partial: extend::ExtendsPartialConfig = toml::from_str(&base_content) .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; // Check if the base file's same profile also has extends (nested inheritance) @@ -218,21 +201,68 @@ impl TomlFileProvider { // Prevent nested inheritance to avoid complexity and potential cycles if base_extends.is_some() { return Err(Error::custom(format!( - "Nested inheritance is not allowed. Base file '{}' cannot have an 'extends' field in profile '{}'.", - base_path.display(), - selected_profile + "Nested inheritance is not allowed. Base file '{}' cannot have an 'extends' field in profile '{selected_profile}'.", + base_path.display() ))); } // Load base configuration as a Figment provider let base_provider = Toml::file(base_path).nested(); - // Merge configurations: base first, then local overrides - // Using admerge strategy: - // - Arrays are concatenated (base elements + local elements) - // - Other values are replaced (local values override base values) - // - The extends field is preserved in the final configuration - Figment::new().merge(base_provider).admerge(local_provider).data() + // Apply the selected merge strategy + match extends_strategy { + extend::ExtendStrategy::ExtendArrays => { + // Using 'admerge' strategy: + // - Arrays are concatenated (base elements + local elements) + // - Other values are replaced (local values override base values) + // - The extends field is preserved in the final configuration + Figment::new().merge(base_provider).admerge(local_provider).data() + } + extend::ExtendStrategy::ReplaceArrays => { + // Using 'merge' strategy: + // - Arrays are replaced entirely (local arrays replace base arrays) + // - Other values are replaced (local values override base values) + Figment::new().merge(base_provider).merge(local_provider).data() + } + extend::ExtendStrategy::NoCollision => { + // Check for key collisions between base and local configs + let base_data = base_provider.data()?; + let local_data = local_provider.data()?; + + let profile_key = Profile::new("profile"); + if let (Some(local_profiles), Some(base_profiles)) = + (local_data.get(&profile_key), base_data.get(&profile_key)) + { + // Extract dicts for the selected profile + let profile_str = selected_profile.to_string(); + let base_dict = base_profiles.get(&profile_str).and_then(|v| v.as_dict()); + let local_dict = local_profiles.get(&profile_str).and_then(|v| v.as_dict()); + + // Find colliding keys + if let (Some(local_dict), Some(base_dict)) = (local_dict, base_dict) { + let collisions: Vec<&str> = local_dict + .keys() + .filter(|key| { + // Ignore the "extends" key as it's expected + *key != "extends" && base_dict.contains_key(*key) + }) + .cloned() + .collect(); + + if !collisions.is_empty() { + return Err(Error::custom(format!( + "Key collision detected in profile '{profile_str}' when extending '{extends_path}'. \ + Conflicting keys: {collisions:?}. Use 'extends.strategy' or 'extends_strategy' to specify how to handle conflicts." + ))); + } + } + } + + // No collisions, merge the configs (base values only where local doesn't have + // them) + Figment::new().merge(base_provider).merge(local_provider).data() + } + } } else { // No inheritance - return the local config as-is local_provider.data() From 20744cb447e9e411af32932a20268e81d2602455 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 21:58:05 +0200 Subject: [PATCH 5/8] use refs when possible --- crates/config/src/extend.rs | 2 +- crates/config/src/providers/ext.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/config/src/extend.rs b/crates/config/src/extend.rs index 6bb0774dfec38..b6638a6a0cca2 100644 --- a/crates/config/src/extend.rs +++ b/crates/config/src/extend.rs @@ -60,7 +60,7 @@ impl Extends { pub fn strategy(&self) -> ExtendStrategy { match self { Self::Path(_) => ExtendStrategy::default(), - Self::Config(config) => config.strategy.clone().unwrap_or_default(), + Self::Config(config) => config.strategy.unwrap_or_default(), } } } diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index c4362bc9d9071..fce1cec1d75cd 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -140,7 +140,7 @@ impl TomlFileProvider { // Check if the current profile has an 'extends' field let extends_config = partial_config.profile.as_ref().and_then(|profiles| { let profile_str = selected_profile.to_string(); - profiles.get(&profile_str).and_then(|cfg| cfg.extends.clone()) + profiles.get(&profile_str).and_then(|cfg| cfg.extends.as_ref()) }); // If inheritance is configured, load and merge the base config @@ -240,13 +240,12 @@ impl TomlFileProvider { // Find colliding keys if let (Some(local_dict), Some(base_dict)) = (local_dict, base_dict) { - let collisions: Vec<&str> = local_dict + let collisions: Vec<&String> = local_dict .keys() .filter(|key| { // Ignore the "extends" key as it's expected *key != "extends" && base_dict.contains_key(*key) }) - .cloned() .collect(); if !collisions.is_empty() { From 26db86829a2ccebea23ca00e48f5b486b9369db3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 22:01:36 +0200 Subject: [PATCH 6/8] update docs --- crates/config/src/providers/ext.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index fce1cec1d75cd..0a44762c4f389 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -80,34 +80,6 @@ impl TomlFileProvider { } /// Reads and processes the TOML configuration file, handling inheritance if configured. - /// - /// This function performs the following steps: - /// 1. Loads the TOML file (from env var or default path) - /// 2. Checks if the current profile has an `extends` field - /// 3. If inheritance is configured: - /// - Resolves the base config file path relative to the current config - /// - Validates that the base file exists and isn't self-referential - /// - Ensures no nested inheritance (base files cannot have `extends`) - /// - Merges base and local configurations using `admerge` strategy - /// 4. Returns the final configuration data - /// - /// # Inheritance Behavior - /// - /// When a profile specifies `extends = "path/to/base.toml"`: - /// - The base configuration is loaded first - /// - Local configuration is applied on top using `admerge`: - /// - Arrays are concatenated (base + local) - /// - Other values are replaced (local overrides base) - /// - The `extends` field itself is preserved in the final config - /// - /// # Error Conditions - /// - /// Returns an error if: - /// - Config file specified in env var doesn't exist - /// - Base config file doesn't exist or isn't a file - /// - Config attempts to inherit from itself - /// - Base config also has an `extends` field (nested inheritance) - /// - TOML parsing fails for either file fn read(&self) -> Result, Error> { use serde::de::Error as _; From 55c946c55e028066a51e219366539a9bd7507cec Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 12 Aug 2025 22:01:46 +0200 Subject: [PATCH 7/8] more docs --- crates/config/src/providers/ext.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index 0a44762c4f389..dec1b43300af8 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -106,10 +106,8 @@ impl TomlFileProvider { let partial_config: extend::ExtendsPartialConfig = toml::from_str(&local_content) .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; - // Determine which profile is active (e.g., "default", "test", etc.) + // Check if the currently active profile has an 'extends' field let selected_profile = Config::selected_profile(); - - // Check if the current profile has an 'extends' field let extends_config = partial_config.profile.as_ref().and_then(|profiles| { let profile_str = selected_profile.to_string(); profiles.get(&profile_str).and_then(|cfg| cfg.extends.as_ref()) @@ -229,8 +227,7 @@ impl TomlFileProvider { } } - // No collisions, merge the configs (base values only where local doesn't have - // them) + // Safe to merge the configs without collisions Figment::new().merge(base_provider).merge(local_provider).data() } } From 5d1a23dcac317377a4fa65fce18e59b10414d475 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Wed, 13 Aug 2025 09:33:10 +0200 Subject: [PATCH 8/8] style: derive default Co-authored-by: onbjerg --- crates/config/src/extend.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/config/src/extend.rs b/crates/config/src/extend.rs index b6638a6a0cca2..671b391450493 100644 --- a/crates/config/src/extend.rs +++ b/crates/config/src/extend.rs @@ -3,12 +3,13 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; /// Strategy for extending configuration from a base file. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ExtendStrategy { /// Uses `admerge` figment strategy. /// Arrays are concatenated (base elements + local elements). /// Other values are replaced (local values override base values). + #[default] ExtendArrays, /// Uses `merge` figment strategy. @@ -20,12 +21,6 @@ pub enum ExtendStrategy { NoCollision, } -impl Default for ExtendStrategy { - fn default() -> Self { - Self::ExtendArrays - } -} - /// Configuration for extending from a base file. /// /// Supports two formats: