Repository: mario-eth/soldeer
Branch: main
Commit: e4aac2865953
Files: 56
Total size: 455.1 KB
Directory structure:
gitextract_fq4q0vwy/
├── .config/
│ └── nextest.toml
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── feature_request.yml
│ │ └── registry_request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── release.yml
│ └── rust.yml
├── .gitignore
├── .vscode/
│ └── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Cargo.toml
├── LICENSE
├── README.md
├── USAGE.md
├── clippy.toml
├── crates/
│ ├── cli/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs
│ ├── commands/
│ │ ├── Cargo.toml
│ │ ├── src/
│ │ │ ├── commands/
│ │ │ │ ├── clean.rs
│ │ │ │ ├── init.rs
│ │ │ │ ├── install.rs
│ │ │ │ ├── login.rs
│ │ │ │ ├── mod.rs
│ │ │ │ ├── push.rs
│ │ │ │ ├── uninstall.rs
│ │ │ │ └── update.rs
│ │ │ ├── lib.rs
│ │ │ └── utils.rs
│ │ └── tests/
│ │ ├── tests-clean.rs
│ │ ├── tests-init.rs
│ │ ├── tests-install.rs
│ │ ├── tests-login.rs
│ │ ├── tests-push.rs
│ │ ├── tests-uninstall.rs
│ │ └── tests-update.rs
│ └── core/
│ ├── Cargo.toml
│ └── src/
│ ├── auth.rs
│ ├── config.rs
│ ├── download.rs
│ ├── errors.rs
│ ├── install.rs
│ ├── lib.rs
│ ├── lock/
│ │ └── forge.rs
│ ├── lock.rs
│ ├── push.rs
│ ├── registry.rs
│ ├── remappings.rs
│ ├── update.rs
│ └── utils.rs
├── flake.nix
├── release-plz.toml
└── rustfmt.toml
================================================
FILE CONTENTS
================================================
================================================
FILE: .config/nextest.toml
================================================
[profile.default]
retries = { backoff = "exponential", count = 2, delay = "2s", jitter = true }
slow-timeout = { period = "1m", terminate-after = 3 }
fail-fast = false
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
The Soldeer project adheres to the
[Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct).
This code of conduct describes the minimum behavior expected from all contributors.
Instances of violations of the Code of Conduct can contact the project maintainers on the
[Contributors' Telegram Chat](https://t.me/+tn6gOCJseD83OTZk).
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: 🐛 Bug Report
description: Report an issue found in Soldeer.
labels: ['bug']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug!
Please fill out the sections below to help us reproduce and fix the bug as quickly as possible.
- type: checkboxes
attributes:
label: 'I have checked the following:'
options:
- label: 'I have searched the issues of this repository and believe that this is not a duplicate.'
required: true
- label: 'I have checked that the bug is reproducible with the latest version of Soldeer.'
required: true
- type: input
id: version
attributes:
label: Soldeer Version
description: What is the result of running `soldeer version` or `forge soldeer version`
placeholder: soldeer x.y.z
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What Happened?
description: Describe the issue you are experiencing. You can run `soldeer` commands with the `-vvv` flag to see debug logs.
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: Describe what you expected to happen.
placeholder: A clear and concise description of what you expected to happen in such a case.
validations:
required: false
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: Provide a detailed list of steps to reproduce the issue.
placeholder: |
1. Insert the "..." options into the config file
2. Run the command `...`
3. Observe that ... happens
validations:
required: false
- type: textarea
id: configuration
attributes:
label: Configuration
description: Provide the relevant sections of your `foundry.toml` or `soldeer.toml` file
render: toml
placeholder: |
[soldeer]
# Insert the relevant configuration options here
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: true
contact_links:
- name: Soldeer Contributors Telegram
url: https://t.me/+tn6gOCJseD83OTZk
about: Please ask and answer questions here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: 💡 Feature Request
description: Suggest a feature for Soldeer
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature!
Please fill out the sections below to help us understand your request.
- type: checkboxes
attributes:
label: 'I have checked the following:'
options:
- label: 'I have searched the issues of this repository and believe that this is not a duplicate.'
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What problem are you facing that you believe this feature would solve?
placeholder: A clear and concise description of what the problem is.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
description: Describe the solution you'd like to see.
placeholder: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/registry_request.yml
================================================
name: 📦 Registry Addition
description: Suggest a missing package for the Soldeer registry.
labels: ['add-dependency']
assignees: ['mario-eth']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a package for the Soldeer registry!
Please fill out the sections below to help us understand your request.
- type: checkboxes
attributes:
label: 'I have checked the following:'
options:
- label: 'I have searched the issues of this repository and believe that this is not a duplicate.'
required: true
- type: input
id: package-name
attributes:
label: Package Name
description: What is the name of the package you would like to see added to the registry?
placeholder: soldeer-package-name
validations:
required: true
- type: input
id: project-url
attributes:
label: Project URL
description: Provide a link to the package repository or documentation.
placeholder: https://github.com/...
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any context to help us understand why this package should be added.
validations:
required: false
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Check for updates every Monday
schedule:
interval: "weekly"
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
permissions:
pull-requests: write
contents: write
on:
push:
branches:
- main
jobs:
# Release unpublished packages.
release-plz-release:
name: Release-plz release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Generate GitHub token
uses: actions/create-github-app-token@v2
id: generate-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.generate-token.outputs.token }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: release-plz/action@v0.5
with:
command: release
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
# CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Create a PR with the new versions and changelog, preparing the next release.
release-plz-pr:
name: Release-plz PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: release-plz-${{ github.ref }}
cancel-in-progress: false
steps:
- name: Generate GitHub token
uses: actions/create-github-app-token@v2
id: generate-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.generate-token.outputs.token }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: release-plz/action@v0.5
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
# CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
================================================
FILE: .github/workflows/rust.yml
================================================
name: Rust
on:
push:
branches: ['main']
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
build-test:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@nextest
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run tests
run: cargo nextest run
doctests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: cargo test --workspace --doc
feature-checks:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-hack
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: cargo hack
run: cargo hack check --feature-powerset --depth 2
clippy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: cargo clippy --workspace --all-targets --all-features
env:
RUSTFLAGS: -Dwarnings
docs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: cargo doc --workspace --all-features --no-deps --document-private-items
env:
RUSTDOCFLAGS: '--cfg docsrs -D warnings'
fmt:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- run: cargo fmt --all --check
================================================
FILE: .gitignore
================================================
/target
dependencies/
.dependency_reading.toml
remappings.txt
crawler/target/
*.DS_Store*
package-lock.json
package.json
repositories.db
crawler/node_modules/
crawler/zipped/*
crawler/zipped/
src/soldeer.toml
*soldeer.lock
test/*
!emptyfile
!emptyfile2
test_push_sensitive
test_push_skip_sensitive
.soldeer/
================================================
FILE: .vscode/settings.json
================================================
{
"git.ignoreLimitWarning": true,
"editor.formatOnSave": true,
"rust-analyzer.rustfmt.extraArgs": ["+nightly"],
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"rust-analyzer.cargo.features": "all"
}
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## `soldeer` - [0.11.0](https://github.com/mario-eth/soldeer/compare/v0.10.1...v0.11.0) - 2026-04-16
### Fixed
- *(commands)* do not init logging backend in the library crate ([#350](https://github.com/mario-eth/soldeer/pull/350))
## `soldeer-commands` - [0.11.0](https://github.com/mario-eth/soldeer/compare/soldeer-commands-v0.10.1...soldeer-commands-v0.11.0) - 2026-04-16
### Fixed
- *(commands)* do not init logging backend in the library crate ([#350](https://github.com/mario-eth/soldeer/pull/350))
### Other
- *(deps)* update dependencies ([#355](https://github.com/mario-eth/soldeer/pull/355))
## `soldeer-core` - [0.11.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.10.1...soldeer-core-v0.11.0) - 2026-04-16
### Other
- *(deps)* update dependencies ([#355](https://github.com/mario-eth/soldeer/pull/355))
- *(install)* concurrent subdependencies install ([#352](https://github.com/mario-eth/soldeer/pull/352))
## `soldeer-core` - [0.10.1](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.10.0...soldeer-core-v0.10.1) - 2026-02-16
### Added
- *(core)* support foundry.lock file ([#347](https://github.com/mario-eth/soldeer/pull/347))
## `soldeer-core` - [0.10.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.9.0...soldeer-core-v0.10.0) - 2025-12-03
### Added
- *(config)* [**breaking**] allow to specify the project root path for dependencies ([#341](https://github.com/mario-eth/soldeer/pull/341))
## `soldeer` - [0.9.0](https://github.com/mario-eth/soldeer/compare/v0.8.0...v0.9.0) - 2025-10-16
### Other
- update Cargo.lock dependencies
## `soldeer-commands` - [0.9.0](https://github.com/mario-eth/soldeer/compare/soldeer-commands-v0.8.0...soldeer-commands-v0.9.0) - 2025-10-16
### Added
- detect project root ([#333](https://github.com/mario-eth/soldeer/pull/333))
- *(commands)* add `soldeer clean` command ([#332](https://github.com/mario-eth/soldeer/pull/332))
## `soldeer-core` - [0.9.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.8.0...soldeer-core-v0.9.0) - 2025-10-16
### Added
- detect project root ([#333](https://github.com/mario-eth/soldeer/pull/333))
### Other
- *(deps)* update deps ([#336](https://github.com/mario-eth/soldeer/pull/336))
## `soldeer-commands` - [0.8.0](https://github.com/mario-eth/soldeer/compare/soldeer-commands-v0.7.1...soldeer-commands-v0.8.0) - 2025-09-29
### Added
- add support for private packages ([#327](https://github.com/mario-eth/soldeer/pull/327))
## `soldeer-core` - [0.8.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.7.1...soldeer-core-v0.8.0) - 2025-09-29
### Added
- add support for private packages ([#327](https://github.com/mario-eth/soldeer/pull/327))
## `soldeer-core` - [0.7.1](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.7.0...soldeer-core-v0.7.1) - 2025-09-19
### Fixed
- *(core)* install git submodules ([#328](https://github.com/mario-eth/soldeer/pull/328))
## `soldeer` - [0.7.0](https://github.com/mario-eth/soldeer/compare/v0.6.1...v0.7.0) - 2025-09-02
### Other
- rust edition 2024 ([#319](https://github.com/mario-eth/soldeer/pull/319))
## `soldeer-commands` - [0.7.0](https://github.com/mario-eth/soldeer/compare/soldeer-commands-v0.6.1...soldeer-commands-v0.7.0) - 2025-09-02
### Added
- *(registry)* use new API endpoints ([#318](https://github.com/mario-eth/soldeer/pull/318))
- add support for CLI tokens ([#311](https://github.com/mario-eth/soldeer/pull/311))
### Fixed
- *(cmd)* avoid panicking if logger was already initialized ([#312](https://github.com/mario-eth/soldeer/pull/312))
### Other
- rust edition 2024 ([#319](https://github.com/mario-eth/soldeer/pull/319))
## `soldeer-core` - [0.7.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.6.1...soldeer-core-v0.7.0) - 2025-09-02
### Added
- *(registry)* use new API endpoints ([#318](https://github.com/mario-eth/soldeer/pull/318))
- add support for CLI tokens ([#311](https://github.com/mario-eth/soldeer/pull/311))
### Fixed
- *(cmd)* avoid panicking if logger was already initialized ([#312](https://github.com/mario-eth/soldeer/pull/312))
### Other
- rust edition 2024 ([#319](https://github.com/mario-eth/soldeer/pull/319))
## `soldeer-core` - [0.6.1](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.6.0...soldeer-core-v0.6.1) - 2025-07-23
### Other
- add nix flake and fix clippy ([#301](https://github.com/mario-eth/soldeer/pull/301))
- remove bzip2 support ([#298](https://github.com/mario-eth/soldeer/pull/298))
## `soldeer` - [0.6.0](https://github.com/mario-eth/soldeer/compare/v0.5.4...v0.6.0) - 2025-07-10
### Other
- update Cargo.lock dependencies
## `soldeer-commands` - [0.6.0](https://github.com/mario-eth/soldeer/compare/soldeer-commands-v0.5.4...soldeer-commands-v0.6.0) - 2025-07-10
### Added
- *(commands)* if adding a dependency which is already present, re-install all ([#289](https://github.com/mario-eth/soldeer/pull/289))
### Fixed
- *(core)* recursive subdependencies install ([#288](https://github.com/mario-eth/soldeer/pull/288))
- *(commands)* canonicalize path in push command ([#284](https://github.com/mario-eth/soldeer/pull/284))
## `soldeer-core` - [0.6.0](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.5.4...soldeer-core-v0.6.0) - 2025-07-10
### Added
- *(core)* remove forge requirement for recursive install ([#281](https://github.com/mario-eth/soldeer/pull/281))
### Fixed
- *(core)* recursive subdependencies install ([#288](https://github.com/mario-eth/soldeer/pull/288))
- *(commands)* canonicalize path in push command ([#284](https://github.com/mario-eth/soldeer/pull/284))
## `soldeer` - [0.5.4](https://github.com/mario-eth/soldeer/compare/v0.5.3...v0.5.4) - 2025-04-27
### Other
- update Cargo.lock dependencies
## `soldeer-core` - [0.5.4](https://github.com/mario-eth/soldeer/compare/soldeer-core-v0.5.3...soldeer-core-v0.5.4) - 2025-04-27
### Fixed
- *(registry)* version resolution when no SemVer ([#271](https://github.com/mario-eth/soldeer/pull/271))
## `soldeer` - [0.5.3](https://github.com/mario-eth/soldeer/compare/v0.5.2...v0.5.3) - 2025-03-18
### Changed
- fix(core): remove hardcoded git domains by @puuuuh in https://github.com/mario-eth/soldeer/pull/244
- refactor!: logging by @beeb in https://github.com/mario-eth/soldeer/pull/242
- fix(push): ensure version is non-empty when pushing to registry by @kubkon in https://github.com/mario-eth/soldeer/pull/247
- feat!: improve toml validation by @beeb in https://github.com/mario-eth/soldeer/pull/248
- chore(deps): update deps by @beeb in https://github.com/mario-eth/soldeer/pull/257
## `soldeer` - [0.5.2](https://github.com/mario-eth/soldeer/compare/v0.5.1...v0.5.2) - 2024-11-21
### Changed
- fix(core): gitignore config for integrity checksum by @beeb in #233
## `soldeer` - [0.5.1](https://github.com/mario-eth/soldeer/compare/v0.5.0...v0.5.1) - 2024-11-13
### Changed
- fix(core): keep duplicate and orphan remappings by @beeb in #226
## `soldeer` - [0.5.0](https://github.com/mario-eth/soldeer/compare/v0.4.1...v0.5.0) - 2024-11-07
### Changed
- 185 add cli args to skip interaction for all commands by @mario-eth in #218
## `soldeer` - [0.4.1](https://github.com/mario-eth/soldeer/compare/v0.4.0...v0.4.1) - 2024-10-11
### Changed
- updated readme by @mario-eth in #209
- fix(core): all commands add the `[dependencies]` table in config if m… by @mario-eth in #214
- Add core version by @mario-eth in #210
## `soldeer` - [0.4.0](https://github.com/mario-eth/soldeer/compare/v0.3.4...v0.4.0) - 2024-10-07
### Changed
- refactor!: v0.4.0 main rewrite by @beeb in #150
- docs(core): document `auth` and `config` modules by @beeb in #175
- feat: format multiline remappings array by @beeb in #174
- docs(core): add documentation by @beeb in #177
- docs(core): add documentation by @beeb in #178
- docs(core): update and utils modules by @beeb in #179
- test(commands): init integration tests by @beeb in #180
- refactor!: minor refactor and integration tests by @beeb in #186
- test(commands): add integration test (install/uninstall) by @beeb in #190
- feat(core): improve remappings matching by @beeb in #191
- fix(core): updating git dependencies by @beeb in #192
- feat(commands): update libs in foundry config during init by @beeb in #193
- refactor: remove all unwraps by @beeb in #194
- ci: speed up test by using cargo-nextest by @beeb in #196
- perf: lock-free synchronization, add rayon by @crypdoughdoteth in #198
- feat(cli): add banner by @xyizko in #199
- refactor: use new syntax for bon builders by @beeb in #200
- ci: add nextest config by @beeb in #201
- test(commands): integration tests for push by @beeb in #197
- fix(core): `path_matches` semver comparison by @beeb in #205
- fix(cli): respect environment and tty preference for color by @beeb in #206
- test(commands): fix tests when run with `cargo test` by @beeb in #207
## `soldeer` - [0.3.4](https://github.com/mario-eth/soldeer/compare/v0.3.3...v0.3.4) - 2024-09-04
### Changed
- Moving the canonicalization to respect windows slashing by @mario-eth in #172
## `soldeer` - [0.3.3](https://github.com/mario-eth/soldeer/compare/v0.3.2...v0.3.3) - 2024-09-04
### Changed
- chore(deps): bump zip-extract to 0.2.0 by @DaniPopes in #161
- fix(config): preserve existing remappings by @beeb in #171
## `soldeer` - [0.3.2](https://github.com/mario-eth/soldeer/compare/v0.3.1...v0.3.2) - 2024-08-29
### Changed
- hotfix os independent bytes by @mario-eth in #163
- remappings_generated -> remappings_generate typo by @0xCalibur in #164
- fix(utils): always consider relative path in hashing by @beeb in #168
## `soldeer` - [0.3.1](https://github.com/mario-eth/soldeer/compare/v0.3.0...v0.3.1) - 2024-08-27
### Changed
- Hotfix on OS independent bytes on hashing
## `soldeer` - [0.3.0](https://github.com/mario-eth/soldeer/compare/v0.2.19...v0.3.0) - 2024-08-27
### Changed
- Updated readme and version by @mario-eth in #104
- 89 add soldeer uninstall by @mario-eth in #105
- Feat/soldeer init by @Solthodox in #56
- style(fmt): update formatter configuration and improve consistency by @beeb in #111
- refactor!: cleanup, more idiomatic rust by @beeb in #113
- perf(lock): better handling of missing lockfile by @beeb in #114
- refactor!: big rewrite by @beeb in #118
- fix(config)!: fix remappings logic and logging by @beeb in #125
- chore: update deps and remove serde_derive by @beeb in #129
- Handling dependency name sanitization by @mario-eth in #127
- fix: parallel downloads order by @beeb in #133
- Recursive Dependencies by @mario-eth in #136
- Removing transform git to http by @mario-eth in #137
- Hotfixes and extra tests before 0.3.0 by @mario-eth in #139
- Hotfixes after refactor and extra tests by @mario-eth in #141
- feat: add integrity checksum to lockfile by @beeb in #132
- chore: update logo by @beeb in #143
- chore: enable some more lints by @DaniPopes in #160
- chore(deps): replace simple-home-dir with home by @DaniPopes in #157
- chore: remove unused dev dep env_logger by @DaniPopes in #159
- chore(deps): replace `once_cell` with `std::sync` by @DaniPopes in #158
- Using git branch/tag to pull dependencies by @mario-eth in #147
================================================
FILE: CONTRIBUTING.md
================================================
## Contributing to Soldeer
Thanks for your interest in improving Soldeer!
There are multiple opportunities to contribute at any level. It doesn't matter if you are just getting started with Rust
or are the most weathered expert, we can use your help.
This document will help you get started. **Do not let the document intimidate you**.
It should be considered as a guide to help you navigate the process.
The [Contributors' Telegram Chat][telegram] is available for any concerns you may have that are
not covered in this guide.
### Code of Conduct
The Soldeer project adheres to the [Rust Code of Conduct][rust-coc]. This code of conduct describes the _minimum_
behavior expected from all contributors.
Instances of violations of the Code of Conduct can contact the project maintainers on the
[Contributors' Telegram Chat][telegram].
### Ways to contribute
There are fundamentally four ways an individual can contribute:
1. **By opening an issue:** For example, if you believe that you have uncovered a bug
in Soldeer, creating a new issue in the issue tracker is the way to report it.
2. **By adding context:** Providing additional context to existing issues,
such as screenshots and code snippets, which help resolve issues.
3. **By resolving issues:** Typically this is done in the form of either
demonstrating that the issue reported is not a problem after all, or more often,
by opening a pull request that fixes the underlying problem, in a concrete and
reviewable manner.
**Anybody can participate in any stage of contribution**. We urge you to participate in the discussion
around bugs and participate in reviewing PRs.
### Contributions Related to Spelling and Grammar
At this time, we will not be accepting contributions that only fix spelling or grammatical errors in documentation, code
or elsewhere.
### Asking for help
If you have reviewed existing documentation and still have questions, or you are having problems, you can get help in
the following ways:
- **Asking in the support Telegram:** The [Soldeer Support Telegram][telegram] is a fast and easy way to ask questions.
As Soldeer is still in heavy development, the documentation can be a bit scattered.
### Submitting a bug report
When filing a new bug report in the issue tracker, you will be presented with a basic form to fill out.
If you believe that you have uncovered a bug, please fill out the form to the best of your ability. Do not worry if you
cannot answer every detail; just fill in what you can. Contributors will ask follow-up questions if something is
unclear.
The most important pieces of information we need in a bug report are:
- The Soldeer version you are on (and that it is up to date)
- The platform you are on (Windows, macOS, an M1 Mac or Linux)
- Code snippets if this is happening in relation to testing or building code
- Concrete steps to reproduce the bug
In order to rule out the possibility of the bug being in your project, the code snippets should be as minimal
as possible. It is better if you can reproduce the bug with a small snippet as opposed to an entire project!
See [this guide][mcve] on how to create a minimal, complete, and verifiable example.
### Submitting a feature request
When adding a feature request in the issue tracker, you will be presented with a basic form to fill out.
Please include as detailed of an explanation as possible of the feature you would like, adding additional context if
necessary.
If you have examples of other tools that have the feature you are requesting, please include them as well.
### Resolving an issue
Pull requests are the way concrete changes are made to the code, documentation, and dependencies of Soldeer.
Please also make sure that the following commands pass if you have changed the code:
```sh
cargo check --all
cargo test --all --all-features
cargo +nightly fmt -- --check
cargo +nightly clippy --all --all-targets --all-features -- -D warnings
```
If you are working in VSCode, we recommend you install the [rust-analyzer](https://rust-analyzer.github.io/) extension,
and use the following VSCode user settings:
```json
"editor.formatOnSave": true,
"rust-analyzer.rustfmt.extraArgs": ["+nightly"],
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
```
#### Adding tests
If the change being proposed alters code, it is either adding new functionality to Soldeer, or fixing existing, broken
functionality.
In both of these cases, the pull request should include one or more tests to ensure that Soldeer does not regress
in the future.
Types of tests include:
- **Unit tests**: Functions which have very specific tasks should be unit tested.
- **Integration tests**: For general purpose, far reaching functionality, integration tests should be added.
The best way to add a new integration test is to look at existing ones and follow the style.
#### Commits
It is a recommended best practice to keep your changes as logically grouped as possible within individual commits. There
is no limit to the number of commits any single pull request may have, and many contributors find it easier to review
changes that are split across multiple commits.
That said, if you have a number of commits that are "checkpoints" and don't represent a single logical change, please
squash those together.
Please adhere to the [Conventional Commits][conventional-commits] format for commit messages
and PR titles. Prefer all-lowercase descriptions when possible.
The following types should be used:
- **build**: changes that affect the build system or external dependencies (example scope: cargo)
- **chore**: tool configuration, metadata, manifest changes, dependencies updates, miscellaneous changes (anything that doesn't fit the other types)
- **ci**: changes to the CI configuration files and scripts (GitHub Actions)
- **docs**: documentation-only changes (doc comments, mdbook)
- **feat**: a new feature
- **fix**: a bug fix
- **perf**: a code change that improves performance
- **refactor**: a code change that neither fixes a bug nor adds a feature
- **revert**: reverting an older commit or change
- **style**: changes that do not affect the meaning of the code (whitespace, formatting, etc.)
- **test**: adding or modifying tests (no change to lib/binary source code allowed)
#### Opening the pull request
From within GitHub, opening a new pull request will present you with a template that should be filled out. Please try
your best at filling out the details, but feel free to skip parts if you're not sure what to put.
Make sure to use the [Conventional Commits][conventional-commits] format described above for
your PR title.
#### Discuss and update
You will probably get feedback or requests for changes to your pull request.
This is a big part of the submission process, so don't be discouraged! Some contributors may sign off on the pull
request right away, others may have more detailed comments or feedback.
This is a necessary part of the process in order to evaluate whether the changes are correct and necessary.
**Any community member can review a PR, so you might get conflicting feedback**.
Keep an eye out for comments from code owners to provide guidance on conflicting feedback.
#### Reviewing pull requests
**Any Soldeer community member is welcome to review any pull request**.
All contributors who choose to review and provide feedback on pull requests have a responsibility to both the project
and individual making the contribution. Reviews and feedback must be helpful, insightful, and geared towards improving
the contribution as opposed to simply blocking it. If there are reasons why you feel the PR should not be merged,
explain what those are. Do not expect to be able to block a PR from advancing simply because you say "no" without
giving an explanation. Be open to having your mind changed. Be open to working _with_ the contributor to make the pull
request better.
Reviews that are dismissive or disrespectful of the contributor or any other reviewers are strictly counter to the Code
of Conduct.
When reviewing a pull request, the primary goals are for the codebase to improve and for the person submitting the
request to succeed.
**Even if a pull request is not merged, the submitter should come away from the experience feeling like their effort was not unappreciated**.
Every PR from a new contributor is an opportunity to grow the community.
##### Review a bit at a time
Do not overwhelm new contributors.
It is tempting to micro-optimize and make everything about relative performance, perfect grammar, or exact style
matches. Do not succumb to that temptation..
Focus first on the most significant aspects of the change:
1. Does this change make sense for Soldeer?
2. Does this change make Soldeer better, even if only incrementally?
3. Are there clear bugs or larger scale issues that need attending?
4. Are the commit messages readable and correct? If it contains a breaking change, is it clear enough?
Note that only **incremental** improvement is needed to land a PR. This means that the PR does not need to be perfect,
only better than the status quo. Follow-up PRs may be opened to continue iterating.
When changes are necessary, _request_ them, do not _demand_ them, and
**do not assume that the submitter already knows how to add a test or run a benchmark**.
Specific performance optimization techniques, coding styles and conventions change over time. The first impression you
give to a new contributor never does.
Nits (requests for small changes that are not essential) are fine, but try to avoid stalling the pull request. Most nits
can typically be fixed by the Soldeer maintainers merging the pull request, but they can also be an opportunity for the
contributor to learn a bit more about the project.
It is always good to clearly indicate nits when you comment, e.g.:
`Nit: change foo() to bar(). But this is not blocking`.
If your comments were addressed but were not folded after new commits, or if they proved to be mistaken, please,
[hide them][hiding-a-comment] with the appropriate reason to keep the conversation flow concise and relevant.
##### Be aware of the person behind the code
Be aware that _how_ you communicate requests and reviews in your feedback can have a significant impact on the success
of the pull request. Yes, we may merge a particular change that makes Soldeer better, but the individual might just not
want to have anything to do with Soldeer ever again. The goal is not just having good code.
##### Abandoned or stale pull requests
If a pull request appears to be abandoned or stalled, it is polite to first check with the contributor to see if they
intend to continue the work before checking if they would mind if you took it over (especially if it just has nits
left). When doing so, it is courteous to give the original contributor credit for the work they started, either by
preserving their name and e-mail address in the commit log, or by using the `Author: ` or `Co-authored-by: ` metadata
tag in the commits.
_Adapted from the [ethers-rs contributing guide](https://github.com/gakonst/ethers-rs/blob/master/CONTRIBUTING.md)_.
[telegram]: https://t.me/+tn6gOCJseD83OTZk
[rust-coc]: https://www.rust-lang.org/policies/code-of-conduct
[mcve]: https://stackoverflow.com/help/mcve
[hiding-a-comment]: https://help.github.com/articles/managing-disruptive-comments/#hiding-a-comment
[conventional-commits]: https://www.conventionalcommits.org/en/v1.0.0
================================================
FILE: Cargo.toml
================================================
[workspace]
members = ["crates/cli", "crates/core", "crates/commands"]
resolver = "2"
[workspace.package]
authors = ["m4rio"]
categories = ["development-tools"]
description = "A minimal Solidity package manager written in Rust, best used with Foundry"
edition = "2024"
exclude = ["tests/"]
homepage = "https://soldeer.xyz"
keywords = ["solidity", "package-manager", "foundry"]
license = "MIT"
readme = "./README.md"
repository = "https://github.com/mario-eth/soldeer"
rust-version = "1.88"
version = "0.11.0"
[workspace.lints.clippy]
dbg-macro = "warn"
manual-string-new = "warn"
uninlined-format-args = "warn"
use-self = "warn"
redundant-clone = "warn"
unwrap_used = "warn"
[workspace.lints.rust]
rust-2018-idioms = "warn"
unreachable-pub = "warn"
unused-must-use = "warn"
redundant-lifetimes = "warn"
[workspace.dependencies]
bon = "3.0.0"
clap = { version = "4.5.9", features = ["derive"] }
cliclack = "0.5.4"
derive_more = { version = "2.0.1", features = ["from", "display", "from_str"] }
log = { version = "0.4.25", features = ["kv"] }
mockito = "1.5.0"
path-slash = "0.2.1"
rayon = "1.10.0"
reqwest = "0.13.2"
temp-env = { version = "0.3.6", features = ["async_closure"] }
testdir = "0.10.0"
thiserror = "2.0.3"
tokio = { version = "1.38.0", features = [
"io-util",
"macros",
"process",
"rt-multi-thread",
] }
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 mario-eth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Soldeer ![Rust][rust-badge] [![License: MIT][license-badge]][license]
[rust-badge]: https://img.shields.io/badge/Built%20with%20-Rust-e43716.svg
[license]: https://opensource.org/licenses/MIT
[license-badge]: https://img.shields.io/badge/License-MIT-blue.svg
Soldeer is a package manager for Solidity built in Rust and integrated into Foundry.
Solidity development started to become more and more complex. The need for a package manager was evident.
This project was started to solve the following issues:
- git submodules in Foundry are not a good solution for managing dependencies
- npmjs was built for the JS ecosystem, not for Solidity
- github versioning of the releases is a pain and not all the projects are using it correctly
## Installation (Foundry)
Soldeer is already integrated into Foundry. You can use it by running the following command:
```bash
forge soldeer [COMMAND]
```
To check which version of Soldeer is packaged with your Foundry install, run `forge soldeer version`.
## Installation (standalone)
Soldeer is available on [crates.io](https://crates.io/crates/soldeer) and can be installed with:
```bash
cargo install soldeer
```
### Verify installation
```bash
soldeer help
```
## Compile from Source
Clone this repository, then run `cargo build --release` inside the root.
The `soldeer` binary will be located inside the `target/release/` folder.
## Usage
Check out the [usage guide](https://github.com/mario-eth/soldeer/blob/main/USAGE.md) or
[Foundry Book](https://book.getfoundry.sh/projects/soldeer).
## Changelog
Please see the [changelog](https://github.com/mario-eth/soldeer/blob/main/CHANGES.md) for more information about each release.
## Contributing
See the [contribution guide](https://github.com/mario-eth/soldeer/blob/main/CONTRIBUTING.md) for more information.
================================================
FILE: USAGE.md
================================================
# Usage Guide
`Soldeer` is straightforward to use. It can either be invoked from the `forge` tool provided by Foundry, or installed as
a standalone executable named `soldeer`.
Dependencies and configuration options can be specified inside Foundry's `foundry.toml` config file, or inside a
dedicated `soldeer.toml` file.
In the following sections, commands can be prefixed with `forge` to use the built-in version packaged with Foundry.
## Initializing a New Project
```bash
[forge] soldeer init [--clean]
```
The `init` command can be used to setup a project for use with Soldeer. The command will generate or modify the
project's config file (`foundry.toml` or `soldeer.toml`) and perform optional removal of Foundry-style submodule
dependencies with the `--clean` flag.
This command automatically adds the latest `forge-std` dependency to your project.
Note that Soldeer installs dependencies into a folder named `dependencies`. There is currently no way to customize this
path.
## Adding Dependencies
### From the Soldeer Registry
```bash
[forge] soldeer install ~
```
This command searches the Soldeer registry at [https://soldeer.xyz](https://soldeer.xyz) for the specified dependency by
name and version. If a match is found, a ZIP file containing the package source will be downloaded and unzipped into the
`dependencies` directory.
The command also adds the dependency to the project's config file and creates the necessary
[remappings](https://book.getfoundry.sh/projects/dependencies#remapping-dependencies) if configured to do so.
#### Version Requirement
The `VERSION` argument is a version requirement string and can use operators and wildcards to match a range of versions.
By default, if no operator is provided, it defaults to `=` which means "exactly this version".
Examples:
```
1.2.3 // exactly 1.2.3, equivalent to `=1.2.3`
>=1.2.3 // any version greater than or equal to 1.2.3, including any 2.x version or more
^1.2.3 // the patch and minor version can increase, but not the major
1 // any version >=1.0.0 but <2.0.0
1.2 // any version >=1.2.0 but <2.0.0
~1.2.3 // only the patch number can increase
>1.2.3,<1.4.0 // multiple requirements can be separated by a comma
```
Note that this only makes sense when used with the Soldeer registry, as it provides a list of available versions to
select from. Dependencies specified with a custom URL do not use the version requirement string in this way.
### With a Custom URL
#### ZIP file
```bash
[forge] soldeer install ~ --url
```
If the URL to a ZIP file is provided, the registry is not used and the file is downloaded from the URL directly. Note
that a version must still be provided, but it can be freely chosen.
#### Git Repository
```bash
[forge] soldeer install ~ --git
```
If the URL to a git repository is provided, then the repository will be cloned into the `dependencies` folder with the
`git` CLI available on the system. HTTPS and SSH-style URLs are supported (see examples below).
Cloning a specific identifier can be done with the `--rev `, `--branch ` or `--tag ` arguments. If
omitted, then the default branch is checked out.
Some examples:
```bash
[forge] soldeer install test-project~v1 --git git@github.com:test/test.git
[forge] soldeer install test-project~v1 --git git@gitlab.com:test/test.git
```
```bash
[forge] soldeer install test-project~v1 --git https://github.com/test/test.git
[forge] soldeer install test-project~v1 --git https://gitlab.com/test/test.git
```
```bash
[forge] soldeer install test-project~v1 --git git@github.com:test/test.git --rev 345e611cd84bfb4e62c583fa1886c1928bc1a464
[forge] soldeer install test-project~v1 --git git@github.com:test/test.git --branch dev
[forge] soldeer install test-project~v1 --git git@github.com:test/test.git --tag v1
```
Note that a version must still be provided, but it can be freely chosen.
## Installing Existing Dependencies
```bash
[forge] soldeer install
```
When invoked without arguments, the `install` command installs the project's existing dependencies by looking at the
configuration file (`soldeer.toml`/`foundry.toml`) and lockfile `soldeer.lock` if present.
Dependencies which are already present inside the `dependencies` folder are not downloaded again. For dependencies with
a version range specified in the config file, the exact version that is written in the lockfile is used, even if a newer
version exists on the registry. To update the lockfile to use the latest supported version, use `soldeer update`.
### Recursive Installation
With the `--recursive-deps` flag, Soldeer will install the dependencies of each installed dependency, recursively. This
is done internally by running `git submodule update --init --recursive` and/or installing Soldeer dependencies inside of
the dependency's folder. This behavior can also be enabled permanently via the config file.
#### Specifying the Project Root for a Dependency
If recursive installation is enabled, Soldeer must find a `foundry.toml` or `soldeer.toml` config file within the
dependency's directory to know which subdependencies to install.
In case that config file is not located at the root of the dependency's directory (meaning at the root of a git
repository or at the root of the zip file), then the path to the folder containing that file must be specified with
`project_root`:
```toml
# foundry.toml
[dependencies]
mydep = { version = "1.0.0", project_root = "contracts" }
[soldeer]
recursive_deps = true
```
The path is a relative path, starting from the root of the dependency, to the folder containing the config file. You
should use forward slashes (`/`) as separator on all platforms.
#### Note on Sub-Dependencies
Since each dependency is free to use its own remappings, their resolution might become tricky in case of conflicting
versions.
For example:
We have a project called `my-project` with the following dependencies:
- `dependency~1`
- `openzeppelin~5.0.2` with remapping `@openzeppelin/contracts/=dependencies/openzeppelin-5.0.2/`
A contract inside `my-project` has the following import:
```solidity
@openzeppelin/contracts/token/ERC20/ERC20.sol
```
However, `dependency~1` also depends on `openzeppelin`, but it uses version `4.9.2` (with remapping
`@openzeppelin/contracts/=dependencies/openzeppelin-4.9.2/`). The contract inside `dependency-1` has the same import
path because they chose to use the same remappings path as `my-project`:
```solidity
@openzeppelin/contracts/token/ERC20/ERC20.sol
```
This situation creates ambiguity. Furthermore, if `dependency~1` were to import a file that is no longer present in
`v5`, the compiler would give an error.
As such, we recommend to always include the version requirement string as part of the remappings path. The version
requirement string does not need to target a specific version, but could e.g. target a major version:
```toml
[profile.default]
remappings = ["@openzeppelin-contracts-5/=dependencies/@openzeppelin-contracts-5.0.2/contracts/"]
[dependencies]
"@openzeppelin-contracts" = "5"
```
```solidity
import from '@openzeppelin-contracts-5/token/ERC20/ERC20.sol';
```
This approach should ensure that the correct version (or at least a compatible version) of the included file is used.
## Updating Dependencies
```bash
[forge] soldeer update
```
For dependencies from the online registry which specify a version range, the `update` command can be used to retrieve
the latest version that matches the requirements. The `soldeer.lock` lockfile is then updated accordingly. Remappings
are automatically updated to the new version if Soldeer is configured to generate remappings.
For git dependencies which specify no identifier or a branch identifier, the `update` command checks out the latest
commit on the default or specified branch.
## Removing a Dependency
```bash
[forge] soldeer uninstall
```
The `uninstall` command removes the dependency files and entry into the config file, lockfile and remappings.
## Publishing a Package to the Repository
```bash
[forge] soldeer push ~
```
In order to push a new dependency to the repository, an account must first be created at
[https://soldeer.xyz](https://soldeer.xyz). Then, a project with the dependency name must be created through the
website.
Finally, the `[forge] soldeer login` command must be used to retrieve or provide an access token for the API. CLI tokens
can be generated on soldeer.xyz and should be preferred over using the email and password in the CLI, because email
login will be removed in a future version of Soldeer. Alternatively, you can provide a valid CLI token via the
`SOLDEER_API_TOKEN` environment variable.
Example:
Create a project called `my-project` and then use the `[forge] soldeer push my-project~1.0.0`. This will push the
project to the repository as version `1.0.0` and makes it available for anyone to use.
### Specifying a Path
```bash
[forge] soldeer push ~ [PATH]
```
If the files to push are not located in the current directory, a path to the files can be provided.
### Ignoring Files
If you want to ignore certain files from the published package, you need to create one or more `.soldeerignore` files
that must contain the patterns that you want to ignore. These files can be at any level of your directory structure.
They use the `.gitignore` syntax.
Any file that matches a pattern present in `.gitignore` and `.ignore` files is also automatically excluded from the
published package.
### Dry Run
```bash
[forge] soldeer push ~ --dry-run
```
With the `--dry-run` flag, the `push` command only creates a ZIP file containing the published package's content, but
does not upload it to the registry. The file can then be inspected to check that the contents is suitable.
We recommend that everyone runs a dry-run before pushing a new dependency to avoid publishing unwanted files.
**Warning** ⚠️
You are at risk to push sensitive files to the central repository that then can be seen by everyone. Make sure to
exclude sensitive files in the `.soldeerignore` or `.gitignore` file.
Furthermore, we've implemented a warning that gets triggered if the package contains any dotfile (a file with a name
starting with `.`). This warning can be ignored with `--skip-warnings`.
## Configuration
The `foundry.toml`/`soldeer.toml` file can have a `[soldeer]` section to configure the tool's behavior.
See the default configuration below:
```toml
[soldeer]
# whether Soldeer manages remappings
remappings_generate = true
# whether Soldeer re-generates all remappings when installing, updating or uninstalling deps
remappings_regenerate = false
# whether to suffix the remapping with the version requirement string: `name-a.b.c`
remappings_version = true
# a prefix to add to the remappings ("@" would give `@name`)
remappings_prefix = ""
# where to store the remappings ("txt" for `remappings.txt` or "config" for `foundry.toml`)
# ignored when `soldeer.toml` is used as config (uses `remappings.txt`)
remappings_location = "txt"
# whether to install sub-dependencies or not. If true this will install the dependencies of dependencies recursively.
recursive_deps = false
```
## List of Available Commands
For more commands and their usage, see `[forge] soldeer --help` and `[forge] soldeer --help`.
## Remappings Caveats
If you use other dependency managers, such as git submodules or npm, ensure you don't duplicate dependencies between
soldeer and the other manager.
Remappings targeting dependencies installed without Soldeer are not modified or removed when using Soldeer commands,
unless the `--regenerate-remappings` flag is specified or the `remappings_regenerate = true` option is set.
## Dependencies Maintenance
The vision for Soldeer is that major projects such as OpenZeppelin, Solady, Uniswap would start publishing their own
packages to the Soldeer registry so that the community can easily include them and get timely updates.
Until this happens, the Soldeer maintenance team (currently m4rio.eth) will push the most popular dependencies to the
repository by relying on their npmjs or GitHub versions. We are using
[an open-source crawler tool](https://github.com/mario-eth/soldeer-crawler) to crawl and push the dependencies under the
`soldeer` organization.
For those who want an extra layer of security, the `soldeer.lock` file saves a `SHA-256` hash for each downloaded ZIP
file and the corresponding unzipped folder (see `soldeer_core::utils::hash_folder` to see how it gets generated). These
can be compared with the official releases to ensure the files were not manipulated.
**For Project Maintainers**
If you want to move your project from the Soldeer organization and take care of pushing the versions to Soldeer
yourself, please open an issue on GitHub or contact m4rio.eth on [X (formerly Twitter)](https://twitter.com/m4rio_eth).
================================================
FILE: clippy.toml
================================================
allow-unwrap-in-tests = true
================================================
FILE: crates/cli/Cargo.toml
================================================
[package]
name = "soldeer"
description.workspace = true
authors.workspace = true
categories.workspace = true
edition.workspace = true
exclude.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[[bin]]
name = "soldeer"
path = "src/main.rs"
[dependencies]
env_logger = { version = "0.11.9", features = ["unstable-kv"] }
log.workspace = true
soldeer-commands = { path = "../commands", version = "0.11.0" }
tokio.workspace = true
yansi = { version = "1.0.1", features = ["detect-tty", "detect-env"] }
================================================
FILE: crates/cli/src/main.rs
================================================
//! Soldeer is a package manager for Solidity projects
use std::env;
use log::Level;
use soldeer_commands::{Args, commands::Parser as _, run};
use yansi::{Condition, Paint as _};
const HAVE_COLOR: Condition = Condition(|| {
std::env::var_os("NO_COLOR").is_none() &&
(Condition::CLICOLOR_LIVE)() &&
Condition::stdouterr_are_tty_live()
});
#[tokio::main]
async fn main() {
// disable colors if unsupported
yansi::whenever(HAVE_COLOR);
let args = Args::parse();
// setup logging
if env::var("RUST_LOG").is_ok() {
env_logger::builder().init();
} else if let Some(level) = args.verbose.log_level() &&
level > Level::Error
{
// the user requested structured logging (-v[v*])
// init logger
env_logger::Builder::new().filter_level(args.verbose.log_level_filter()).init();
}
if !args.verbose.is_present() {
banner();
}
if let Err(err) = run(args.command, args.verbose).await {
eprintln!("{}", err.to_string().red())
}
}
/// Generate and print a banner
fn banner() {
println!(
"{}",
format!(
"
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
╔═╗╔═╗╦ ╔╦╗╔═╗╔═╗╦═╗ Solidity Package Manager
╚═╗║ ║║ ║║║╣ ║╣ ╠╦╝
╚═╝╚═╝╩═╝═╩╝╚═╝╚═╝╩╚═ github.com/mario-eth/soldeer
v{} soldeer.xyz
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
",
env!("CARGO_PKG_VERSION")
)
.bright_cyan()
);
}
================================================
FILE: crates/commands/Cargo.toml
================================================
[package]
name = "soldeer-commands"
description = "High-level commands for the Soldeer CLI"
authors.workspace = true
categories.workspace = true
edition.workspace = true
exclude.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
bon.workspace = true
clap.workspace = true
clap-verbosity-flag = "3.0.2"
cliclack.workspace = true
derive_more.workspace = true
email-address-parser = "2.0.0"
path-slash.workspace = true
rayon.workspace = true
soldeer-core = { path = "../core", version = "0.11.0" }
tokio.workspace = true
[dev-dependencies]
mockito.workspace = true
reqwest.workspace = true
temp-env.workspace = true
testdir.workspace = true
[features]
serde = ["soldeer-core/serde"]
================================================
FILE: crates/commands/src/commands/clean.rs
================================================
use crate::utils::success;
use clap::Parser;
use soldeer_core::{Result, config::Paths};
use std::fs;
/// Clean downloaded dependencies and generated artifacts
#[derive(Debug, Clone, Default, Parser, bon::Builder)]
#[builder(on(String, into))]
#[clap(after_help = "For more information, read the README.md")]
#[non_exhaustive]
pub struct Clean {
// No options for basic implementation
}
pub(crate) fn clean_command(paths: &Paths, _cmd: &Clean) -> Result<()> {
// Remove dependencies folder if it exists
if paths.dependencies.exists() {
fs::remove_dir_all(&paths.dependencies)?;
success!("Dependencies folder removed");
}
Ok(())
}
================================================
FILE: crates/commands/src/commands/init.rs
================================================
use crate::{
ConfigLocation,
utils::{Progress, remark, success},
};
use clap::Parser;
use soldeer_core::{
Result,
config::{Paths, add_to_config, read_soldeer_config, update_config_libs},
install::{InstallProgress, ensure_dependencies_dir, install_dependency},
lock::add_to_lockfile,
registry::get_latest_version,
remappings::{RemappingsAction, edit_remappings},
utils::remove_forge_lib,
};
use std::fs;
/// Convert a Foundry project to use Soldeer
#[derive(Debug, Clone, Default, Parser, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(ConfigLocation, into))]
#[clap(after_help = "For more information, read the README.md")]
#[non_exhaustive]
pub struct Init {
/// Clean the Foundry project by removing .gitmodules and the lib directory
#[arg(long, default_value_t = false)]
#[builder(default)]
pub clean: bool,
/// Specify the config location.
///
/// This prevents prompting the user if the automatic detection can't determine the config
/// location.
#[arg(long, value_enum)]
pub config_location: Option,
}
pub(crate) async fn init_command(paths: &Paths, cmd: Init) -> Result<()> {
if cmd.clean {
remark!("Flag `--clean` was set, removing `lib` dir and submodules");
remove_forge_lib(&paths.root).await?;
}
let config = read_soldeer_config(&paths.config)?;
success!("Done reading config");
ensure_dependencies_dir(&paths.dependencies)?;
let dependency = get_latest_version("forge-std").await?;
let (progress, monitor) = InstallProgress::new();
let bars = Progress::new(format!("Installing {dependency}"), 1, monitor);
bars.start_all();
let lock = install_dependency(&dependency, None, &paths.dependencies, None, false, progress)
.await
.inspect_err(|e| {
bars.set_error(e);
})?;
bars.stop_all();
add_to_config(&dependency, &paths.config)?;
let foundry_config = paths.root.join("foundry.toml");
if foundry_config.exists() {
update_config_libs(foundry_config)?;
}
success!("Dependency added to config");
add_to_lockfile(lock, &paths.lock)?;
success!("Dependency added to lockfile");
edit_remappings(&RemappingsAction::Add(dependency), &config, paths)?;
success!("Dependency added to remappings");
let gitignore_path = paths.root.join(".gitignore");
if gitignore_path.exists() {
let mut gitignore = fs::read_to_string(&gitignore_path)?;
if !gitignore.contains("dependencies") {
gitignore.push_str("\n\n# Soldeer\n/dependencies\n");
fs::write(&gitignore_path, gitignore)?;
}
}
success!("Added `dependencies` to .gitignore");
Ok(())
}
================================================
FILE: crates/commands/src/commands/install.rs
================================================
use super::validate_dependency;
use crate::{
ConfigLocation,
utils::{Progress, remark, success, warning},
};
use clap::Parser;
use soldeer_core::{
Result,
config::{
Dependency, GitIdentifier, Paths, UrlType, add_to_config, read_config_deps,
read_soldeer_config,
},
errors::{InstallError, LockError},
install::{InstallProgress, ensure_dependencies_dir, install_dependencies, install_dependency},
lock::{add_to_lockfile, generate_lockfile_contents, read_lockfile},
remappings::{RemappingsAction, edit_remappings},
};
use std::fs;
/// Install a dependency
#[derive(Debug, Clone, Default, Parser, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(ConfigLocation, into))]
#[clap(
long_about = "Install a dependency
If used with arguments, a dependency will be added to the configuration. When used without argument, installs all dependencies that are missing.
Examples:
- Install all: soldeer install
- Add from registry: soldeer install lib_name~2.3.0
- Add with custom URL: soldeer install lib_name~2.3.0 --url https://foo.bar/lib.zip
- Add with git: soldeer install lib_name~2.3.0 --git git@github.com:foo/bar.git
- Add with git (commit): soldeer install lib_name~2.3.0 --git git@github.com:foo/bar.git --rev 05f218fb6617932e56bf5388c3b389c3028a7b73
- Add with git (tag): soldeer install lib_name~2.3.0 --git git@github.com:foo/bar.git --tag v2.3.0
- Add with git (branch): soldeer install lib_name~2.3.0 --git git@github.com:foo/bar.git --branch feature/baz",
after_help = "For more information, read the README.md"
)]
#[non_exhaustive]
pub struct Install {
/// The dependency name and version, separated by a tilde. The version is always required.
///
/// If not present, this command will install all dependencies which are missing.
#[arg(value_parser = validate_dependency, value_name = "DEPENDENCY~VERSION")]
pub dependency: Option,
/// The URL to the dependency zip file.
///
/// Example: https://my-domain/dep.zip
#[arg(long = "url", requires = "dependency", conflicts_with = "git_url")]
pub zip_url: Option,
/// The URL to the dependency repository.
///
/// Example: git@github.com:foo/bar.git
#[arg(long = "git", requires = "dependency", conflicts_with = "zip_url")]
pub git_url: Option,
/// A Git commit hash
#[arg(long, group = "identifier", requires = "git_url")]
pub rev: Option,
/// A Git tag
#[arg(long, group = "identifier", requires = "git_url")]
pub tag: Option,
/// A Git branch
#[arg(long, group = "identifier", requires = "git_url")]
pub branch: Option,
/// If set, this command will delete the existing remappings and re-create them
#[arg(short = 'g', long, default_value_t = false)]
#[builder(default)]
pub regenerate_remappings: bool,
/// If set, this command will install dependencies recursively (via git submodules or via
/// soldeer)
#[arg(short = 'd', long, default_value_t = false)]
#[builder(default)]
pub recursive_deps: bool,
/// Perform a clean install by re-installing all dependencies
#[arg(long, default_value_t = false)]
#[builder(default)]
pub clean: bool,
/// Specify the config location without prompting.
///
/// This prevents prompting the user if the automatic detection can't determine the config
/// location.
#[arg(long, value_enum)]
pub config_location: Option,
}
pub(crate) async fn install_command(paths: &Paths, cmd: Install) -> Result<()> {
let mut config = read_soldeer_config(&paths.config)?;
if cmd.regenerate_remappings {
config.remappings_regenerate = true;
}
if cmd.recursive_deps {
config.recursive_deps = true;
}
success!("Done reading config");
ensure_dependencies_dir(&paths.dependencies)?;
let (dependencies, warnings) = read_config_deps(&paths.config)?;
for w in warnings {
warning!(format!("Config warning: {w}"));
}
match &cmd.dependency {
None => {
let lockfile = read_lockfile(&paths.lock)?;
success!("Done reading lockfile");
if cmd.clean {
remark!("Flag `--clean` was set, re-installing all dependencies");
fs::remove_dir_all(&paths.dependencies).map_err(|e| InstallError::IOError {
path: paths.dependencies.clone(),
source: e,
})?;
ensure_dependencies_dir(&paths.dependencies)?;
}
let (progress, monitor) = InstallProgress::new();
let bars = Progress::new("Installing dependencies", dependencies.len(), monitor);
bars.start_all();
let new_locks = install_dependencies(
&dependencies,
&lockfile.entries,
&paths.dependencies,
config.recursive_deps,
progress,
)
.await?;
bars.stop_all();
let new_lockfile_content = generate_lockfile_contents(new_locks);
if !lockfile.raw.is_empty() && new_lockfile_content != lockfile.raw {
warning!(
"Warning: the lock file is out of sync with the dependencies. Consider running `soldeer update` to re-generate the lockfile."
);
} else if lockfile.raw.is_empty() {
fs::write(&paths.lock, new_lockfile_content).map_err(LockError::IOError)?;
}
edit_remappings(&RemappingsAction::Update, &config, paths)?;
success!("Updated remappings");
}
Some(dependency) => {
let identifier = match (&cmd.rev, &cmd.branch, &cmd.tag) {
(Some(rev), None, None) => Some(GitIdentifier::from_rev(rev)),
(None, Some(branch), None) => Some(GitIdentifier::from_branch(branch)),
(None, None, Some(tag)) => Some(GitIdentifier::from_tag(tag)),
(None, None, None) => None,
_ => unreachable!("clap should prevent this"),
};
let url =
cmd.zip_url.as_ref().map(UrlType::http).or(cmd.git_url.as_ref().map(UrlType::git));
let mut dep = Dependency::from_name_version(dependency, url, identifier)?;
if dependencies
.iter()
.any(|d| d.name() == dep.name() && d.version_req() == dep.version_req())
{
remark!(format!("{dep} is already installed, running `install` instead"));
Box::pin(install_command(
paths,
Install::builder()
.regenerate_remappings(cmd.regenerate_remappings)
.recursive_deps(cmd.recursive_deps)
.clean(cmd.clean)
.maybe_config_location(cmd.config_location)
.build(),
))
.await?;
return Ok(());
}
let (progress, monitor) = InstallProgress::new();
let bars = Progress::new(format!("Installing {dep}"), 1, monitor);
bars.start_all();
let lock = install_dependency(
&dep,
None,
&paths.dependencies,
None,
config.recursive_deps,
progress,
)
.await?;
bars.stop_all();
// for git deps, we need to add the commit hash before adding them to the
// config, unless a branch/tag was specified
if let Some(git_dep) = dep.as_git_mut() &&
git_dep.identifier.is_none()
{
git_dep.identifier = Some(GitIdentifier::from_rev(
&lock.as_git().expect("lock entry should be of type git").rev,
));
}
add_to_config(&dep, &paths.config)?;
success!("Dependency added to config");
add_to_lockfile(lock, &paths.lock)?;
success!("Dependency added to lockfile");
edit_remappings(&RemappingsAction::Add(dep), &config, paths)?;
success!("Dependency added to remappings");
}
}
Ok(())
}
================================================
FILE: crates/commands/src/commands/login.rs
================================================
use crate::utils::{info, remark, step, success, warning};
use clap::Parser;
use email_address_parser::{EmailAddress, ParsingOptions};
use path_slash::PathBufExt as _;
use soldeer_core::{
Result,
auth::{Credentials, check_token, execute_login, save_token},
errors::AuthError,
};
use std::path::PathBuf;
/// Log into the central repository to push packages
///
/// The credentials are saved by default into ~/.soldeer.
/// If you want to overwrite that location, use the SOLDEER_LOGIN_FILE env var.
#[derive(Debug, Clone, Default, Parser, bon::Builder)]
#[builder(on(String, into))]
#[clap(after_help = "For more information, read the README.md")]
#[non_exhaustive]
pub struct Login {
/// Specify the email without prompting.
#[arg(long, conflicts_with = "token")]
pub email: Option,
/// Specify the password without prompting.
#[arg(long, conflicts_with = "token")]
pub password: Option,
/// Login with a token created via soldeer.xyz.
#[arg(long)]
pub token: Option,
}
pub(crate) async fn login_command(cmd: Login) -> Result<()> {
remark!("If you do not have an account, please visit soldeer.xyz to create one.");
if let Some(token) = cmd.token {
let token = token.trim();
let username = check_token(token).await?;
let token_path = save_token(token)?;
info!(format!(
"Token is valid for user {username} and was saved in: {}",
PathBuf::from_slash_lossy(&token_path).to_string_lossy() /* normalize separators */
));
return Ok(());
}
warning!(
"The option to login via email and password will be removed in a future version of Soldeer. Please update your usage by either using `soldeer login --token [YOUR CLI TOKEN]` or passing the `SOLDEER_API_TOKEN` environment variable to the `push` command."
);
let email: String = match cmd.email {
Some(email) => {
if EmailAddress::parse(&email, Some(ParsingOptions::default())).is_none() {
return Err(AuthError::InvalidCredentials.into());
}
step!(format!("Email: {email}"));
email
}
None => {
if !crate::TUI_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
return Err(AuthError::TuiDisabled.into());
}
cliclack::input("Email address")
.validate(|input: &String| {
if input.is_empty() {
Err("Email is required")
} else {
match EmailAddress::parse(input, Some(ParsingOptions::default())) {
None => Err("Invalid email address"),
Some(_) => Ok(()),
}
}
})
.interact()?
}
};
let password = match cmd.password {
Some(pw) => pw,
None => {
if !crate::TUI_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
return Err(AuthError::TuiDisabled.into());
}
cliclack::password("Password").mask('▪').interact()?
}
};
let token_path = execute_login(&Credentials { email, password }).await?;
success!("Login successful");
info!(format!(
"Token saved in: {}",
PathBuf::from_slash_lossy(&token_path).to_string_lossy() /* normalize separators */
));
Ok(())
}
================================================
FILE: crates/commands/src/commands/mod.rs
================================================
pub use clap::{Parser, Subcommand};
use clap_verbosity_flag::{LogLevel, VerbosityFilter};
use derive_more::derive::From;
pub mod clean;
pub mod init;
pub mod install;
pub mod login;
pub mod push;
pub mod uninstall;
pub mod update;
#[derive(Copy, Clone, Debug, Default)]
pub struct CustomLevel;
impl LogLevel for CustomLevel {
fn default_filter() -> VerbosityFilter {
VerbosityFilter::Error
}
fn verbose_help() -> Option<&'static str> {
Some("Use structured logging and increase verbosity")
}
fn verbose_long_help() -> Option<&'static str> {
Some(
r#"Use structured logging and increase verbosity
Pass multiple times to increase the logging level (e.g. -v, -vv, -vvv).
If omitted, then a pretty TUI output will be used.
Otherwise:
- 1 (-v): print logs with level error and warning
- 2 (-vv): print logs with level info
- 3 (-vvv): print logs with level debug
- 4 (-vvvv): print logs with level trace
"#,
)
}
fn quiet_help() -> Option<&'static str> {
Some("Disable logs and output, or reduce verbosity")
}
}
/// A minimal Solidity dependency manager
#[derive(Parser, Debug, bon::Builder)]
#[clap(name = "soldeer", author = "m4rio.eth", version)]
#[non_exhaustive]
pub struct Args {
#[clap(subcommand)]
pub command: Command,
/// Test
#[command(flatten)]
pub verbose: clap_verbosity_flag::Verbosity,
}
/// The available commands for Soldeer
#[derive(Debug, Clone, Subcommand, From)]
#[non_exhaustive]
pub enum Command {
Init(init::Init),
Install(install::Install),
Update(update::Update),
Login(login::Login),
Push(push::Push),
Uninstall(uninstall::Uninstall),
Clean(clean::Clean),
Version(Version),
}
/// Display the version of Soldeer
#[derive(Debug, Clone, Default, Parser)]
#[non_exhaustive]
pub struct Version {}
fn validate_dependency(dep: &str) -> std::result::Result {
if dep.split('~').count() != 2 {
return Err("The dependency should be in the format ~".to_string());
}
Ok(dep.to_string())
}
================================================
FILE: crates/commands/src/commands/push.rs
================================================
use super::validate_dependency;
use crate::utils::{info, remark, success, warning};
use clap::Parser;
use soldeer_core::{
Result,
errors::PublishError,
push::{filter_ignored_files, push_version, validate_name, validate_version},
utils::{canonicalize_sync, check_dotfiles},
};
use std::{env, path::PathBuf, sync::atomic::Ordering};
/// Push a dependency to the repository
#[derive(Debug, Clone, Parser, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(PathBuf, into))]
#[clap(
long_about = "Push a dependency to the soldeer.xyz repository.
You need to be logged in first (soldeer login) or provide the `SOLDEER_API_TOKEN` environment variable with a valid
CLI token generated on soldeer.xyz.
Examples:
- Current directory: soldeer push mypkg~0.1.0
- Custom directory: soldeer push mypkg~0.1.0 /path/to/dep
- Dry run: soldeer push mypkg~0.1.0 --dry-run
To ignore certain files, create a `.soldeerignore` file in the root of the project and add the files you want to ignore. The `.soldeerignore` uses the same syntax as `.gitignore`.",
after_help = "For more information, read the README.md"
)]
#[non_exhaustive]
pub struct Push {
/// The dependency name and version, separated by a tilde.
///
/// This should always be used when you want to push a dependency to the central repository: ``.
#[arg(value_parser = validate_dependency, value_name = "DEPENDENCY>~,
/// If set, does not publish the package but generates a zip file that can be inspected.
#[arg(short, long, default_value_t = false)]
#[builder(default)]
pub dry_run: bool,
/// Use this if you want to skip the warnings that can be triggered when trying to push
/// dotfiles like .env.
#[arg(long, default_value_t = false)]
#[builder(default)]
pub skip_warnings: bool,
}
pub(crate) async fn push_command(cmd: Push) -> Result<()> {
let path = cmd.path.unwrap_or(env::current_dir()?);
let path = canonicalize_sync(&path)?;
let files_to_copy: Vec = filter_ignored_files(&path);
// Check for sensitive files or directories
if !cmd.dry_run &&
!cmd.skip_warnings &&
check_dotfiles(&files_to_copy) &&
!prompt_user_for_confirmation()?
{
return Err(PublishError::UserAborted.into());
}
if cmd.dry_run {
remark!("Running in dry-run mode, a zip file will be created for inspection");
}
if cmd.skip_warnings {
warning!("Sensitive file warnings are being ignored as requested");
}
let (dependency_name, dependency_version) =
cmd.dependency.split_once('~').expect("dependency string should have name and version");
validate_name(dependency_name)?;
validate_version(dependency_version)?;
if let Some(zip_path) =
push_version(dependency_name, dependency_version, path, &files_to_copy, cmd.dry_run).await?
{
info!(format!("Zip file created at {}", zip_path.to_string_lossy()));
} else {
success!("Pushed to repository!");
}
Ok(())
}
// Function to prompt the user for confirmation
fn prompt_user_for_confirmation() -> Result {
remark!("You are about to include some sensitive files in this version");
info!(
"If you are not sure which files will be included, you can run the command with `--dry-run`and inspect the generated zip file."
);
if crate::TUI_ENABLED.load(Ordering::Relaxed) {
cliclack::confirm("Do you want to continue?")
.interact()
.map_err(|e| PublishError::IOError { path: PathBuf::new(), source: e }.into())
} else {
Ok(true)
}
}
================================================
FILE: crates/commands/src/commands/uninstall.rs
================================================
use crate::utils::success;
use clap::Parser;
use soldeer_core::{
Result, SoldeerError,
config::{Paths, delete_from_config, read_soldeer_config},
download::delete_dependency_files_sync,
lock::remove_lock,
remappings::{RemappingsAction, edit_remappings},
};
/// Uninstall a dependency
#[derive(Debug, Clone, Parser, bon::Builder)]
#[builder(on(String, into))]
#[clap(after_help = "For more information, read the README.md")]
#[non_exhaustive]
pub struct Uninstall {
/// The dependency name. Specifying a version is not necessary.
pub dependency: String,
}
pub(crate) fn uninstall_command(paths: &Paths, cmd: &Uninstall) -> Result<()> {
let config = read_soldeer_config(&paths.config)?;
success!("Done reading config");
// delete from the config file and return the dependency
let dependency = delete_from_config(&cmd.dependency, &paths.config)?;
success!("Dependency removed from config file");
edit_remappings(&RemappingsAction::Remove(dependency.clone()), &config, paths)?;
success!("Dependency removed from remappings");
// deleting the files
delete_dependency_files_sync(&dependency, &paths.dependencies)
.map_err(|e| SoldeerError::DownloadError { dep: dependency.to_string(), source: e })?;
success!("Dependency removed from disk");
remove_lock(&dependency, &paths.lock)?;
success!("Dependency removed from lockfile");
Ok(())
}
================================================
FILE: crates/commands/src/commands/update.rs
================================================
use crate::{
ConfigLocation,
utils::{Progress, success, warning},
};
use clap::Parser;
use soldeer_core::{
Result,
config::{Paths, read_config_deps, read_soldeer_config},
errors::LockError,
install::{InstallProgress, ensure_dependencies_dir},
lock::{generate_lockfile_contents, read_lockfile},
remappings::{RemappingsAction, edit_remappings},
update::update_dependencies,
};
use std::fs;
/// Update dependencies by reading the config file
#[derive(Debug, Clone, Default, Parser, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(ConfigLocation, into))]
#[clap(after_help = "For more information, read the README.md")]
#[non_exhaustive]
pub struct Update {
/// If set, this command will delete the existing remappings and re-create them
#[arg(short = 'g', long, default_value_t = false)]
#[builder(default)]
pub regenerate_remappings: bool,
/// If set, this command will install the dependencies recursively (via submodules or via
/// soldeer)
#[arg(short = 'd', long, default_value_t = false)]
#[builder(default)]
pub recursive_deps: bool,
/// Specify the config location without prompting.
///
/// This prevents prompting the user if the automatic detection can't determine the config
/// location.
#[arg(long, value_enum)]
pub config_location: Option,
}
// TODO: add a parameter for a dependency name, where we would only update that particular
// dependency
pub(crate) async fn update_command(paths: &Paths, cmd: Update) -> Result<()> {
let mut config = read_soldeer_config(&paths.config)?;
if cmd.regenerate_remappings {
config.remappings_regenerate = true;
}
if cmd.recursive_deps {
config.recursive_deps = true;
}
success!("Done reading config");
ensure_dependencies_dir(&paths.dependencies)?;
let (dependencies, warnings) = read_config_deps(&paths.config)?;
for w in warnings {
warning!(format!("Config warning: {w}"));
}
let lockfile = read_lockfile(&paths.lock)?;
success!("Done reading lockfile");
let (progress, monitor) = InstallProgress::new();
let bars = Progress::new("Updating dependencies", dependencies.len(), monitor);
bars.start_all();
let new_locks = update_dependencies(
&dependencies,
&lockfile.entries,
&paths.dependencies,
config.recursive_deps,
progress,
)
.await?;
bars.stop_all();
let new_lockfile_content = generate_lockfile_contents(new_locks);
fs::write(&paths.lock, new_lockfile_content).map_err(LockError::IOError)?;
success!("Updated lockfile");
edit_remappings(&RemappingsAction::Update, &config, paths)?;
success!("Updated remappings");
Ok(())
}
================================================
FILE: crates/commands/src/lib.rs
================================================
//! High-level commands for the Soldeer CLI
#![cfg_attr(docsrs, feature(doc_cfg))]
pub use crate::commands::{Args, Command};
use clap::builder::PossibleValue;
pub use clap_verbosity_flag::Verbosity;
use clap_verbosity_flag::log::Level;
use commands::CustomLevel;
use derive_more::derive::FromStr;
use soldeer_core::{Result, config::Paths};
use std::{
env,
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
};
use utils::{get_config_location, intro, outro, outro_cancel, step};
pub mod commands;
pub mod utils;
static TUI_ENABLED: AtomicBool = AtomicBool::new(true);
/// The location where the Soldeer config should be stored.
///
/// This is a new type so we can implement the `ValueEnum` trait for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromStr)]
pub struct ConfigLocation(soldeer_core::config::ConfigLocation);
impl clap::ValueEnum for ConfigLocation {
fn value_variants<'a>() -> &'a [Self] {
&[
Self(soldeer_core::config::ConfigLocation::Foundry),
Self(soldeer_core::config::ConfigLocation::Soldeer),
]
}
fn to_possible_value(&self) -> Option {
Some(match self.0 {
soldeer_core::config::ConfigLocation::Foundry => PossibleValue::new("foundry"),
soldeer_core::config::ConfigLocation::Soldeer => PossibleValue::new("soldeer"),
})
}
}
impl From for soldeer_core::config::ConfigLocation {
fn from(value: ConfigLocation) -> Self {
value.0
}
}
impl From for ConfigLocation {
fn from(value: soldeer_core::config::ConfigLocation) -> Self {
Self(value)
}
}
pub async fn run(command: Command, verbosity: Verbosity) -> Result<()> {
if let Some(level) = verbosity.log_level() &&
level <= Level::Error &&
env::var("RUST_LOG").is_err()
{
// enable TUI if no `-v` flag and no RUST_LOG is provided
TUI_ENABLED.store(true, Ordering::Relaxed);
} else {
TUI_ENABLED.store(false, Ordering::Relaxed);
}
match command {
Command::Init(cmd) => {
intro!("🦌 Soldeer Init 🦌");
step!("Initialize Foundry project to use Soldeer");
// for init, we always use the current dir as root, unless specified by env
let root = env::var("SOLDEER_PROJECT_ROOT")
.ok()
.filter(|p| !p.is_empty())
.map_or(env::current_dir()?, PathBuf::from);
let paths = Paths::with_root_and_config(
&root,
Some(get_config_location(&root, cmd.config_location)?),
)?;
commands::init::init_command(&paths, cmd).await.inspect_err(|_| {
outro_cancel!("An error occurred during initialization");
})?;
outro!("Done initializing!");
}
Command::Install(cmd) => {
intro!("🦌 Soldeer Install 🦌");
let root = Paths::get_root_path();
let paths = Paths::with_root_and_config(
&root,
Some(get_config_location(&root, cmd.config_location)?),
)?;
commands::install::install_command(&paths, cmd).await.inspect_err(|_| {
outro_cancel!("An error occurred during install");
})?;
outro!("Done installing!");
}
Command::Update(cmd) => {
intro!("🦌 Soldeer Update 🦌");
let root = Paths::get_root_path();
let paths = Paths::with_root_and_config(
&root,
Some(get_config_location(&root, cmd.config_location)?),
)?;
commands::update::update_command(&paths, cmd).await.inspect_err(|_| {
outro_cancel!("An error occurred during the update");
})?;
outro!("Done updating!");
}
Command::Uninstall(cmd) => {
intro!("🦌 Soldeer Uninstall 🦌");
let root = Paths::get_root_path();
let paths =
Paths::with_root_and_config(&root, Some(get_config_location(&root, None)?))?;
commands::uninstall::uninstall_command(&paths, &cmd).inspect_err(|_| {
outro_cancel!("An error occurred during uninstall");
})?;
outro!("Done uninstalling!");
}
Command::Clean(cmd) => {
intro!("🦌 Soldeer Clean 🦌");
let root = Paths::get_root_path();
let paths =
Paths::with_root_and_config(&root, Some(get_config_location(&root, None)?))?;
commands::clean::clean_command(&paths, &cmd).inspect_err(|_| {
outro_cancel!("An error occurred during clean");
})?;
outro!("Done cleaning!");
}
Command::Login(cmd) => {
intro!("🦌 Soldeer Login 🦌");
commands::login::login_command(cmd).await.inspect_err(|_| {
outro_cancel!("An error occurred during login");
})?;
outro!("Done logging in!");
}
Command::Push(cmd) => {
intro!("🦌 Soldeer Push 🦌");
commands::push::push_command(cmd).await.inspect_err(|_| {
outro_cancel!("An error occurred during push");
})?;
outro!("Done!");
}
Command::Version(_) => {
const VERSION: &str = env!("CARGO_PKG_VERSION");
println!("soldeer {VERSION}");
}
}
Ok(())
}
================================================
FILE: crates/commands/src/utils.rs
================================================
#![allow(unused_macros)]
//! Utils for the commands crate
use std::{fmt, path::Path};
use crate::ConfigLocation;
use cliclack::{MultiProgress, ProgressBar, multi_progress, progress_bar, select};
use soldeer_core::{Result, config::detect_config_location, install::InstallMonitoring};
/// Template for the progress bars.
pub const PROGRESS_TEMPLATE: &str = "[{elapsed_precise}] {bar:30.magenta} ({pos}/{len}) {msg}";
/// A collection of progress bars for the installation/update process.
#[derive(Clone, Default)]
pub struct Progress {
multi: Option,
versions: Option,
downloads: Option,
unzip: Option,
subdependencies: Option,
integrity: Option,
}
impl Progress {
/// Create a new progress bar object.
///
/// A title and the total number of dependencies to install must be passed as an argument.
pub fn new(title: impl fmt::Display, total: usize, mut monitor: InstallMonitoring) -> Self {
if !crate::TUI_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
tokio::task::spawn(async move { while (monitor.logs.recv().await).is_some() {} });
tokio::task::spawn(async move { while (monitor.versions.recv().await).is_some() {} });
tokio::task::spawn(async move { while (monitor.downloads.recv().await).is_some() {} });
tokio::task::spawn(async move { while (monitor.unzip.recv().await).is_some() {} });
tokio::task::spawn(
async move { while (monitor.subdependencies.recv().await).is_some() {} },
);
tokio::task::spawn(async move { while (monitor.integrity.recv().await).is_some() {} });
return Self::default();
}
let multi = multi_progress(title);
let versions = multi.add(progress_bar(total as u64).with_template(PROGRESS_TEMPLATE));
let downloads = multi.add(progress_bar(total as u64).with_template(PROGRESS_TEMPLATE));
let unzip = multi.add(progress_bar(total as u64).with_template(PROGRESS_TEMPLATE));
let subdependencies =
multi.add(progress_bar(total as u64).with_template(PROGRESS_TEMPLATE));
let integrity = multi.add(progress_bar(total as u64).with_template(PROGRESS_TEMPLATE));
tokio::task::spawn({
let multi = multi.clone();
async move {
while let Some(log) = monitor.logs.recv().await {
multi.println(log);
}
}
});
tokio::task::spawn({
let versions = versions.clone();
async move {
while let Some(dep) = monitor.versions.recv().await {
versions.inc(1);
versions.set_message(format!("Got version for {dep}"));
}
}
});
tokio::task::spawn({
let downloads = downloads.clone();
async move {
while let Some(dep) = monitor.downloads.recv().await {
downloads.inc(1);
downloads.set_message(format!("Downloaded {dep}"));
}
}
});
tokio::task::spawn({
let unzip = unzip.clone();
async move {
while let Some(dep) = monitor.unzip.recv().await {
unzip.inc(1);
unzip.set_message(format!("Unzipped {dep}"));
}
}
});
tokio::task::spawn({
let subdependencies = subdependencies.clone();
async move {
while let Some(dep) = monitor.subdependencies.recv().await {
subdependencies.inc(1);
subdependencies.set_message(format!("Installed subdeps for {dep}"));
}
}
});
tokio::task::spawn({
let integrity = integrity.clone();
async move {
while let Some(dep) = monitor.integrity.recv().await {
integrity.inc(1);
integrity.set_message(format!("Checked integrity of {dep}"));
}
}
});
Self {
multi: Some(multi),
versions: Some(versions),
downloads: Some(downloads),
unzip: Some(unzip),
subdependencies: Some(subdependencies),
integrity: Some(integrity),
}
}
/// Start all progress bars.
pub fn start_all(&self) {
self.versions.as_ref().inspect(|p| p.start("Retrieving versions..."));
self.downloads.as_ref().inspect(|p| p.start("Downloading dependencies..."));
self.unzip.as_ref().inspect(|p| p.start("Unzipping dependencies..."));
self.subdependencies.as_ref().inspect(|p| p.start("Installing subdependencies..."));
self.integrity.as_ref().inspect(|p| p.start("Checking integrity..."));
}
/// Stop all progress bars.
pub fn stop_all(&self) {
self.versions.as_ref().inspect(|p| p.stop("Done retrieving versions"));
self.downloads.as_ref().inspect(|p| p.stop("Done downloading dependencies"));
self.unzip.as_ref().inspect(|p| p.stop("Done unzipping dependencies"));
self.subdependencies.as_ref().inspect(|p| p.stop("Done installing subdependencies"));
self.integrity.as_ref().inspect(|p| p.stop("Done checking integrity"));
self.multi.as_ref().inspect(|p| p.stop());
}
pub fn set_error(&self, error: impl fmt::Display) {
self.multi.as_ref().inspect(|m| m.error(error));
}
}
/// Auto-detect config location or prompt the user for preference.
pub fn get_config_location(
root: impl AsRef,
arg: Option,
) -> Result {
Ok(match arg {
Some(loc) => loc.into(),
None => match detect_config_location(root) {
Some(loc) => loc,
None => prompt_config_location()?.into(),
},
})
}
/// Prompt the user for their desired config location in case it cannot be auto-detected.
pub fn prompt_config_location() -> Result {
Ok(select("Select how you want to configure Soldeer")
.initial_value("foundry")
.item("foundry", "Using foundry.toml", "recommended")
.item("soldeer", "Using soldeer.toml", "for non-foundry projects")
.interact()?
.parse()
.expect("all options should be valid variants of the ConfigLocation enum"))
}
macro_rules! define_cliclack_macro {
($name:ident, $path:path) => {
macro_rules! $name {
($expression:expr) => {
if $crate::TUI_ENABLED.load(::std::sync::atomic::Ordering::Relaxed) {
$path($expression).ok();
}
};
}
};
}
define_cliclack_macro!(intro, ::cliclack::intro);
define_cliclack_macro!(note, ::cliclack::note);
define_cliclack_macro!(outro, ::cliclack::outro);
define_cliclack_macro!(outro_cancel, ::cliclack::outro_cancel);
define_cliclack_macro!(outro_note, ::cliclack::outro_note);
define_cliclack_macro!(error, ::cliclack::log::error);
define_cliclack_macro!(info, ::cliclack::log::info);
define_cliclack_macro!(remark, ::cliclack::log::remark);
define_cliclack_macro!(step, ::cliclack::log::step);
define_cliclack_macro!(success, ::cliclack::log::success);
define_cliclack_macro!(warning, ::cliclack::log::warning);
#[allow(unused_imports)]
pub(crate) use error;
pub(crate) use info;
pub(crate) use intro;
#[allow(unused_imports)]
pub(crate) use note;
pub(crate) use outro;
pub(crate) use outro_cancel;
#[allow(unused_imports)]
pub(crate) use outro_note;
pub(crate) use remark;
pub(crate) use step;
pub(crate) use success;
pub(crate) use warning;
================================================
FILE: crates/commands/tests/tests-clean.rs
================================================
use soldeer_commands::{
Command, Verbosity,
commands::{clean::Clean, install::Install},
run,
};
use soldeer_core::{
config::read_config_deps,
lock::{SOLDEER_LOCK, read_lockfile},
};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
fs,
path::{Path, PathBuf},
};
use temp_env::async_with_vars;
use testdir::testdir;
#[allow(clippy::unwrap_used)]
fn check_clean_success(dir: &Path, config_filename: &str) {
assert!(!dir.join("dependencies").exists(), "Dependencies folder should be removed");
let config_path = dir.join(config_filename);
assert!(config_path.exists(), "Config file should be preserved");
let (deps, _) = read_config_deps(&config_path).unwrap();
assert_eq!(deps.len(), 2, "Config should still have 2 dependencies");
assert_eq!(deps[0].name(), "@openzeppelin-contracts");
assert_eq!(deps[1].name(), "solady");
}
#[allow(clippy::unwrap_used)]
fn check_artifacts_exist(dir: &Path) {
assert!(dir.join("dependencies").exists(), "Dependencies folder should exist");
assert!(dir.join(SOLDEER_LOCK).exists(), "Lock file should exist");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(lock.entries.len(), 2, "Lock file should have 2 entries");
let deps_dir = dir.join("dependencies");
let entries: Vec<_> = fs::read_dir(&deps_dir).unwrap().collect::, _>>().unwrap();
assert!(!entries.is_empty(), "Dependencies directory should have content");
}
#[allow(clippy::unwrap_used)]
async fn setup_project_with_dependencies(config_filename: &str) -> PathBuf {
let dir = testdir!();
let mut contents = r#"[dependencies]
"@openzeppelin-contracts" = "5.0.2"
solady = "0.0.238"
"#
.to_string();
if config_filename == "foundry.toml" {
contents = format!(
r#"[profile.default]
libs = ["dependencies"]
{contents}"#
);
}
fs::write(dir.join(config_filename), contents).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
dir
}
#[tokio::test]
async fn test_clean_basic() {
let dir = setup_project_with_dependencies("soldeer.toml").await;
assert!(dir.join("dependencies").exists());
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_clean_success(&dir, "soldeer.toml");
}
#[tokio::test]
async fn test_clean_foundry_config() {
let dir = setup_project_with_dependencies("foundry.toml").await;
check_artifacts_exist(&dir);
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_clean_success(&dir, "foundry.toml");
}
#[tokio::test]
async fn test_clean_no_artifacts() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
// Run clean on empty project (no dependencies folder or lock file)
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
// Should succeed silently
assert!(res.is_ok(), "{res:?}");
}
#[tokio::test]
async fn test_clean_restores_with_install() {
let dir = setup_project_with_dependencies("soldeer.toml").await;
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(!dir.join("dependencies").exists());
assert!(dir.join(SOLDEER_LOCK).exists(), "Lock file should remain after clean");
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(dir.join("dependencies").exists());
let dependencies_dir = dir.join("dependencies");
let entries: Vec<_> =
fs::read_dir(dependencies_dir).unwrap().collect::, _>>().unwrap();
assert!(!entries.is_empty(), "Dependencies should be installed");
}
#[tokio::test]
async fn test_clean_with_complex_file_structure() {
let dir = setup_project_with_dependencies("soldeer.toml").await;
let complex_path = dir.join("dependencies").join("nested").join("deep").join("structure");
fs::create_dir_all(&complex_path).unwrap();
fs::write(complex_path.join("test.txt"), "nested content").unwrap();
// Create symlink (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let _ = symlink(dir.join("soldeer.toml"), dir.join("dependencies").join("config_link"));
}
// Create large file to test performance
let large_content = "x".repeat(1024 * 1024); // 1MB
fs::write(dir.join("dependencies").join("large_file.txt"), large_content).unwrap();
let cmd: Command = Clean::builder().build().into();
let res: Result<(), soldeer_core::SoldeerError> = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_clean_success(&dir, "soldeer.toml");
}
#[tokio::test]
async fn test_clean_permission_error() {
let dir = setup_project_with_dependencies("soldeer.toml").await;
#[cfg(unix)]
{
let deps_path = dir.join("dependencies");
let mut perms = fs::metadata(&deps_path).unwrap().permissions();
perms.set_mode(0o444); // Read-only
fs::set_permissions(&deps_path, perms).unwrap();
let cmd: Command = Clean::builder().build().into();
let res: Result<(), soldeer_core::SoldeerError> = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
// Should fail due to permission error
assert!(res.is_err(), "Clean should fail with permission error");
let mut perms = fs::metadata(&deps_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&deps_path, perms).unwrap();
}
#[cfg(not(unix))]
{
// On non-Unix systems, just run a successful clean
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
}
}
#[tokio::test]
async fn test_clean_with_soldeer_config_variations() {
let dir = testdir!();
let contents = r#"[soldeer]
remappings_generate = false
remappings_regenerate = true
remappings_location = "config"
[dependencies]
"@openzeppelin-contracts" = "5.0.2"
solady = "0.0.238"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_artifacts_exist(&dir);
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_clean_success(&dir, "soldeer.toml");
// Verify custom config is preserved
let config_content = fs::read_to_string(dir.join("soldeer.toml")).unwrap();
assert!(config_content.contains("remappings_generate = false"));
assert!(config_content.contains("remappings_location = \"config\""));
}
#[tokio::test]
async fn test_clean_multiple_times() {
let dir = setup_project_with_dependencies("soldeer.toml").await;
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let cmd: Command = Clean::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// Verify final state
check_clean_success(&dir, "soldeer.toml");
}
================================================
FILE: crates/commands/tests/tests-init.rs
================================================
use soldeer_commands::{Command, Verbosity, commands::init::Init, run};
use soldeer_core::{
config::{ConfigLocation, read_config_deps},
lock::{SOLDEER_LOCK, read_lockfile},
registry::get_latest_version,
utils::run_git_command,
};
use std::fs;
use temp_env::async_with_vars;
use testdir::testdir;
#[tokio::test]
async fn test_init_clean() {
let dir = testdir!();
run_git_command(
["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."],
Some(&dir),
)
.await
.unwrap();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command =
Init::builder().clean(true).config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(!dir.join("lib").exists());
assert!(!dir.join(".gitmodules").exists());
assert!(dir.join("dependencies").exists());
let (deps, _) = read_config_deps(dir.join("soldeer.toml")).unwrap();
assert_eq!(deps.first().unwrap().name(), "forge-std");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(lock.entries.first().unwrap().name(), "forge-std");
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert!(remappings.contains("forge-std"));
let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gitignore.contains("/dependencies"));
let foundry_config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
assert!(foundry_config.contains("libs = [\"dependencies\"]"));
}
#[tokio::test]
async fn test_init_no_clean() {
let dir = testdir!();
run_git_command(
["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."],
Some(&dir),
)
.await
.unwrap();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Init::builder().config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(dir.join("lib").exists());
assert!(dir.join(".gitmodules").exists());
assert!(dir.join("dependencies").exists());
let (deps, _) = read_config_deps(dir.join("soldeer.toml")).unwrap();
assert_eq!(deps.first().unwrap().name(), "forge-std");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(lock.entries.first().unwrap().name(), "forge-std");
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert!(remappings.contains("forge-std"));
let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gitignore.contains("/dependencies"));
let foundry_config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
assert!(foundry_config.contains("libs = [\"dependencies\"]"));
}
#[tokio::test]
async fn test_init_no_remappings() {
let dir = testdir!();
run_git_command(
["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."],
Some(&dir),
)
.await
.unwrap();
let contents = r"[soldeer]
remappings_generate = false
[dependencies]
";
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command =
Init::builder().clean(true).config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(!dir.join("remappings.txt").exists());
}
#[tokio::test]
async fn test_init_no_gitignore() {
let dir = testdir!();
run_git_command(
["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."],
Some(&dir),
)
.await
.unwrap();
fs::remove_file(dir.join(".gitignore")).unwrap();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command =
Init::builder().clean(true).config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(!dir.join(".gitignore").exists());
}
#[tokio::test]
async fn test_init_select_foundry_location() {
let dir = testdir!();
let cmd: Command =
Init::builder().clean(true).config_location(ConfigLocation::Foundry).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let forge_std = get_latest_version("forge-std").await.unwrap();
let config_path = dir.join("foundry.toml");
assert!(config_path.exists());
let contents = format!(
r#"[profile.default]
src = "src"
out = "out"
libs = ["dependencies"]
[dependencies]
forge-std = "{}"
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
"#,
forge_std.version_req()
);
assert_eq!(fs::read_to_string(config_path).unwrap(), contents);
}
#[tokio::test]
async fn test_init_select_soldeer_location() {
let dir = testdir!();
let cmd: Command =
Init::builder().clean(true).config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let forge_std = get_latest_version("forge-std").await.unwrap();
let config_path = dir.join("soldeer.toml");
assert!(config_path.exists());
let contents = format!(
r#"[dependencies]
forge-std = "{}"
"#,
forge_std.version_req()
);
assert_eq!(fs::read_to_string(config_path).unwrap(), contents);
}
================================================
FILE: crates/commands/tests/tests-install.rs
================================================
#![allow(clippy::unwrap_used)]
use mockito::Matcher;
use soldeer_commands::{Command, Verbosity, commands::install::Install, run};
use soldeer_core::{
SoldeerError,
config::{ConfigLocation, read_config_deps},
download::download_file,
errors::InstallError,
lock::{SOLDEER_LOCK, read_lockfile},
push::zip_file,
utils::hash_file,
};
use std::{
fs::{self},
path::{Path, PathBuf},
};
use temp_env::async_with_vars;
use testdir::testdir;
fn check_install(dir: &Path, name: &str, version_req: &str) {
assert!(dir.join("dependencies").exists());
let mut config_path = dir.join("soldeer.toml");
if !config_path.exists() {
config_path = dir.join("foundry.toml");
}
let (deps, _) = read_config_deps(config_path).unwrap();
assert_eq!(deps.first().unwrap().name(), name);
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert!(remappings.contains(name));
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(lock.entries.first().unwrap().name(), name);
let version = lock.entries.first().unwrap().version();
assert!(version.starts_with(version_req));
assert!(dir.join("dependencies").join(format!("{name}-{version}")).exists());
}
fn create_zip_monorepo(testdir: &Path) -> PathBuf {
let root = testdir.join("monorepo");
fs::create_dir(&root).unwrap();
let contracts = root.join("contracts");
fs::create_dir(&contracts).unwrap();
let mut files = Vec::new();
files.push(root.join("README.md"));
fs::write(
files.last().unwrap(),
"Root of the repo is here, foundry project is under `contracts`",
)
.unwrap();
files.push(contracts.join("foundry.toml"));
fs::write(
files.last().unwrap(),
r#"[profile.default]
libs = ["dependencies"]
remappings = ["forge-std/=dependencies/forge-std-1.11.0/src/"]
[dependencies]
forge-std = "1.11.0"
[soldeer]
remappings_location = "config"
recursive_deps = true"#,
)
.unwrap();
zip_file(&root, &files, "test").unwrap() // zip is inside the `monorepo` folder
}
fn create_zip_with_foundry_lock(testdir: &Path, branch: Option<&str>) -> PathBuf {
let root = testdir.join("foundry_lock_project");
fs::create_dir(&root).unwrap();
let lib = root.join("lib");
fs::create_dir(&lib).unwrap();
let mut files = Vec::new();
files.push(root.join("foundry.toml"));
fs::write(
files.last().unwrap(),
r#"[profile.default]
src = "src"
out = "out"
libs = ["lib"]
"#,
)
.unwrap();
files.push(root.join(".gitmodules"));
let gitmodules_content = if let Some(branch) = branch {
format!(
r#"[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
branch = {branch}
"#
)
} else {
r#"[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
"#
.to_string()
};
fs::write(files.last().unwrap(), gitmodules_content).unwrap();
files.push(root.join("foundry.lock"));
let foundry_lock_content = if let Some(branch) = branch {
format!(
r#"{{
"lib/forge-std": {{
"branch": {{
"name": "{branch}",
"rev": "c29afdd40a82db50a3d3709d324416be50050e5e"
}}
}}
}}"#
)
} else {
r#"{
"lib/forge-std": {
"rev": "c29afdd40a82db50a3d3709d324416be50050e5e"
}
}"#
.to_string()
};
fs::write(files.last().unwrap(), foundry_lock_content).unwrap();
zip_file(&root, &files, "test").unwrap()
}
#[tokio::test]
async fn test_install_registry_any_version() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder().dependency("@openzeppelin-contracts~5").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "@openzeppelin-contracts", "5");
}
#[tokio::test]
async fn test_install_registry_wildcard() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder().dependency("solady~*").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "solady", "");
}
#[tokio::test]
async fn test_install_registry_specific_version() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command =
Install::builder().dependency("@openzeppelin-contracts~4.9.5").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "@openzeppelin-contracts", "4.9.5");
}
#[tokio::test]
async fn test_install_custom_http() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder().dependency("mylib~1.0.0")
.zip_url("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip")
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "1.0.0");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lock.entries.first().unwrap().as_http().unwrap().url,
"https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip"
);
assert!(&dir.join("dependencies").join("mylib-1.0.0").join("README.md").exists());
}
#[tokio::test]
async fn test_install_git_main() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder()
.dependency("mylib~0.1.0")
.git_url("https://github.com/beeb/test-repo.git")
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "0.1.0");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lock.entries.first().unwrap().as_git().unwrap().rev,
"d5d72fa135d28b2e8307650b3ea79115183f2406"
);
assert!(&dir.join("dependencies").join("mylib-0.1.0").join("foo.txt").exists());
}
#[tokio::test]
async fn test_install_git_commit() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder()
.dependency("mylib~0.1.0")
.git_url("https://github.com/beeb/test-repo.git")
.rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f")
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "0.1.0");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lock.entries.first().unwrap().as_git().unwrap().rev,
"78c2f6a1a54db26bab6c3f501854a1564eb3707f"
);
assert!(!&dir.join("dependencies").join("mylib-1.0.0").join("foo.txt").exists());
}
#[tokio::test]
async fn test_install_git_tag() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder()
.dependency("mylib~0.1.0")
.git_url("https://github.com/beeb/test-repo.git")
.tag("v0.1.0")
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "0.1.0");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lock.entries.first().unwrap().as_git().unwrap().rev,
"78c2f6a1a54db26bab6c3f501854a1564eb3707f"
);
assert!(!&dir.join("dependencies").join("mylib-1.0.0").join("foo.txt").exists());
}
#[tokio::test]
async fn test_install_git_branch() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder()
.dependency("mylib~dev")
.git_url("https://github.com/beeb/test-repo.git")
.branch("dev")
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "dev");
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lock.entries.first().unwrap().as_git().unwrap().rev,
"8d903e557e8f1b6e62bde768aa456d4ddfca72c4"
);
assert!(!&dir.join("dependencies").join("mylib-1.0.0").join("test.txt").exists());
}
#[tokio::test]
async fn test_install_foundry_config() {
let dir = testdir!();
fs::write(dir.join("foundry.toml"), "[dependencies]\n").unwrap();
let cmd: Command = Install::builder().dependency("@openzeppelin-contracts~5").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "@openzeppelin-contracts", "5");
}
#[tokio::test]
async fn test_install_foundry_remappings() {
let dir = testdir!();
let contents = r#"[profile.default]
[soldeer]
remappings_location = "config"
[dependencies]
"@openzeppelin-contracts" = "5.1.0"
"#;
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
assert!(config.contains(
"remappings = [\"@openzeppelin-contracts-5.1.0/=dependencies/@openzeppelin-contracts-5.1.0/\"]"
));
}
#[tokio::test]
async fn test_install_missing_no_lock() {
let dir = testdir!();
let contents = r#"[dependencies]
"@openzeppelin-contracts" = "5.0.2"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "@openzeppelin-contracts", "5.0.2");
}
#[tokio::test]
async fn test_install_missing_with_lock() {
let dir = testdir!();
let contents = r#"[dependencies]
mylib = "1.1"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lock = r#"[[dependencies]]
name = "mylib"
version = "1.1.0"
url = "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip"
checksum = "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
integrity = "f3c628f3e9eae4db14fe14f9ab29e49a0107c47b8ee956e4cee57b616b493fc2"
"#;
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
check_install(&dir, "mylib", "1.1");
}
#[tokio::test]
async fn test_install_second_time() {
let dir = testdir!();
let contents = r#"[dependencies]
mylib = "1.1"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
// get zip file locally for mock
let zip_file = download_file(
"https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip",
&dir,
"tmp",
)
.await
.unwrap();
// serve the file with mock server
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/file.zip").with_body_from_file(zip_file).create_async().await;
let mock = mock.expect(1); // download link should be called exactly once
let lock = format!(
r#"[[dependencies]]
name = "mylib"
version = "1.1.0"
url = "{}/file.zip"
checksum = "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
integrity = "f3c628f3e9eae4db14fe14f9ab29e49a0107c47b8ee956e4cee57b616b493fc2"
"#,
server.url()
);
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.assert(); // download link was called
// second install
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.assert(); // download link was not called a second time
}
#[tokio::test]
async fn test_install_private_second_time() {
let dir = testdir!();
let contents = r#"[dependencies]
test-private = "0.1.0"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
// get zip file locally for mock
let zip_file = download_file(
"https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip",
&dir,
"tmp",
)
.await
.unwrap();
// serve the file with mock server
let mut server = mockito::Server::new_async().await;
let data = format!(
r#"{{"data":[{{"created_at":"2025-09-28T12:36:09.526660Z","deleted":false,"id":"0440c261-8cdf-4738-9139-c4dc7b0c7f3e","internal_name":"test-private/0_1_0_28-09-2025_12:36:08_test-private.zip","private":true,"project_id":"14f419e7-2d64-49e4-86b9-b44b36627786","url":"{}/file.zip","version":"0.1.0"}}],"status":"success"}}"#,
server.url()
);
server.mock("GET", "/file.zip").with_body_from_file(zip_file).create_async().await;
server
.mock("GET", "/api/v1/revision-cli")
.match_query(Matcher::Any)
.with_header("content-type", "application/json")
.with_body(data)
.create_async()
.await;
let lock = r#"[[dependencies]]
name = "test-private"
version = "0.1.0"
checksum = "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
integrity = "f3c628f3e9eae4db14fe14f9ab29e49a0107c47b8ee956e4cee57b616b493fc2"
"#;
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url().as_str())),
("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref())),
],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// second install
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url().as_str())),
("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref())),
],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
}
#[tokio::test]
async fn test_install_add_existing_reinstall() {
let dir = testdir!();
let contents = r#"[dependencies]
"@openzeppelin-contracts" = "5.0.2"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok());
// remove dependencies folder and lockfile
fs::remove_dir_all(dir.join("dependencies")).unwrap();
fs::remove_file(dir.join(SOLDEER_LOCK)).unwrap();
// re-add the same dep, should re-install it
let cmd: Command =
Install::builder().dependency("@openzeppelin-contracts~5.0.2").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok());
let dep_path = dir.join("dependencies").join("@openzeppelin-contracts-5.0.2");
assert!(dep_path.exists());
}
#[tokio::test]
async fn test_install_clean() {
let dir = testdir!();
let contents = r#"[dependencies]
"@openzeppelin-contracts" = "5.0.2"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let test_path = dir.join("dependencies").join("foo");
fs::create_dir_all(&test_path).unwrap();
fs::write(test_path.join("foo.txt"), "test").unwrap();
let cmd: Command = Install::builder().clean(true).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert!(!test_path.exists());
}
#[tokio::test]
async fn test_install_recursive_deps() {
let dir = testdir!();
let contents = r#"[dependencies]
foo = { version = "0.1.0", git = "https://github.com/foundry-rs/forge-template.git" }
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder().recursive_deps(true).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let dep_path = dir.join("dependencies").join("foo-0.1.0");
assert!(dep_path.exists());
let sub_dirs_path = dep_path.join("lib");
assert!(sub_dirs_path.exists());
assert!(sub_dirs_path.join("forge-std").join("src").exists());
}
#[tokio::test]
async fn test_install_recursive_deps_soldeer() {
let dir = testdir!();
// this template uses soldeer to install forge-std
let contents = r#"[dependencies]
foo = { version = "0.1.0", git = "https://github.com/beeb/forge-template.git" }
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder().recursive_deps(true).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let dep_path = dir.join("dependencies").join("foo-0.1.0");
assert!(dep_path.exists());
let sub_dirs_path = dep_path.join("dependencies");
assert!(sub_dirs_path.exists());
assert!(sub_dirs_path.join("forge-std-1.9.7").join("src").exists());
}
#[tokio::test]
async fn test_install_recursive_deps_nested() {
let dir = testdir!();
let contents = r#"[dependencies]
"@uniswap-permit2" = { version = "1.0.0", url = "https://github.com/Uniswap/permit2/archive/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219.zip" }
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder().recursive_deps(true).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let paths = [
"@uniswap-permit2-1.0.0/lib/forge-std/src",
"@uniswap-permit2-1.0.0/lib/forge-gas-snapshot/dependencies/forge-std-1.9.2/src",
"@uniswap-permit2-1.0.0/lib/openzeppelin-contracts/lib/erc4626-tests/ERC4626.test.sol",
"@uniswap-permit2-1.0.0/lib/openzeppelin-contracts/lib/forge-std/src",
"@uniswap-permit2-1.0.0/lib/openzeppelin-contracts/lib/halmos-cheatcodes/src",
"@uniswap-permit2-1.0.0/lib/solmate/lib/ds-test/src",
];
for path in paths {
let dep_path = dir.join("dependencies").join(path);
assert!(dep_path.exists());
}
}
#[tokio::test]
async fn test_install_recursive_project_root() {
let dir = testdir!();
let zip_path = create_zip_monorepo(&dir);
let checksum = hash_file(&zip_path).unwrap();
let contents = r#"[dependencies]
mylib = { version = "1.0.0", project_root = "contracts" }
[soldeer]
recursive_deps = true
"#;
// serve the dependency which uses foundry in a `contracts` subfolder
let mut server = mockito::Server::new_async().await;
server.mock("GET", "/file.zip").with_body_from_file(zip_path).create_async().await;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lock = format!(
r#"[[dependencies]]
name = "mylib"
version = "1.0.0"
url = "{}/file.zip"
checksum = "{checksum}"
integrity = "e629088e5b74df78f116a24c328a64fd002b4e42449607b6ca78f9afb799374d"
"#,
server.url()
);
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// check that we recursively installed all deps
assert!(dir.join("dependencies/mylib-1.0.0/contracts/dependencies/forge-std-1.11.0").is_dir());
}
#[tokio::test]
async fn test_install_recursive_project_root_invalid_path() {
let dir = testdir!();
let zip_path = create_zip_monorepo(&dir);
let checksum = hash_file(&zip_path).unwrap();
// directory traversal is forbidden
let contents = r#"[dependencies]
mylib = { version = "1.0.0", project_root = "../../../contracts" }
[soldeer]
recursive_deps = true
"#;
// serve the dependency which uses foundry in a `contracts` subfolder
let mut server = mockito::Server::new_async().await;
server.mock("GET", "/file.zip").with_body_from_file(zip_path).create_async().await;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lock = format!(
r#"[[dependencies]]
name = "mylib"
version = "1.0.0"
url = "{}/file.zip"
checksum = "{checksum}"
integrity = "e629088e5b74df78f116a24c328a64fd002b4e42449607b6ca78f9afb799374d"
"#,
server.url()
);
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(matches!(
res.unwrap_err(),
SoldeerError::InstallError(InstallError::ConfigError(
soldeer_core::errors::ConfigError::InvalidProjectRoot { .. }
))
));
}
#[tokio::test]
async fn test_install_regenerate_remappings() {
let dir = testdir!();
fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap();
fs::write(dir.join("remappings.txt"), "foo=bar").unwrap();
let cmd: Command = Install::builder()
.dependency("@openzeppelin-contracts~5")
.regenerate_remappings(true)
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert!(!remappings.contains("foo=bar"));
assert!(remappings.contains("@openzeppelin-contracts"));
}
#[tokio::test]
async fn test_add_remappings() {
let dir = testdir!();
let contents = r#"[profile.default]
src = "src"
out = "out"
libs = ["dependencies"]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
[soldeer]
remappings_generate = true
remappings_prefix = "@custom-f@"
remappings_location = "config"
remappings_regenerate = true
[dependencies]
"#;
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Install::builder().dependency("forge-std~1.8.1").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let updated_contents = r#"[profile.default]
src = "src"
out = "out"
libs = ["dependencies"]
remappings = ["@custom-f@forge-std-1.8.1/=dependencies/forge-std-1.8.1/"]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
[soldeer]
remappings_generate = true
remappings_prefix = "@custom-f@"
remappings_location = "config"
remappings_regenerate = true
[dependencies]
forge-std = "1.8.1"
"#;
assert_eq!(updated_contents, fs::read_to_string(dir.join("foundry.toml")).unwrap());
}
#[tokio::test]
async fn test_modifying_remappings_prefix_config() {
let dir = testdir!();
let contents = r#"[profile.default]
libs = ["dependencies"]
remappings = ["@custom-f@forge-std-1.8.1/=dependencies/forge-std-1.8.1/"]
[soldeer]
remappings_prefix = "!custom-f!"
remappings_regenerate = true
remappings_location = "config"
[dependencies]
"#;
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Install::builder().dependency("forge-std~1.8.1").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let expected = r#"[profile.default]
libs = ["dependencies"]
remappings = ["!custom-f!forge-std-1.8.1/=dependencies/forge-std-1.8.1/"]
[soldeer]
remappings_prefix = "!custom-f!"
remappings_regenerate = true
remappings_location = "config"
[dependencies]
forge-std = "1.8.1"
"#;
assert_eq!(expected, fs::read_to_string(dir.join("foundry.toml")).unwrap());
}
#[tokio::test]
async fn test_modifying_remappings_prefix_txt() {
let dir = testdir!();
let contents = r#"[profile.default]
[soldeer]
remappings_prefix = "!custom-f!"
remappings_regenerate = true
[dependencies]
"#;
fs::write(
dir.join("remappings.txt"),
"@custom-f@forge-std-1.8.1/=dependencies/forge-std-1.8.1/",
)
.unwrap();
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Install::builder().dependency("forge-std~1.8.1").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let updated_contents = r#"!custom-f!forge-std-1.8.1/=dependencies/forge-std-1.8.1/
"#;
assert_eq!(updated_contents, fs::read_to_string(dir.join("remappings.txt")).unwrap());
}
#[tokio::test]
async fn test_install_new_foundry_no_dependency_tag() {
let dir = testdir!();
let contents = r#"[profile.default]
libs = ["lib"]
"#;
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Install::builder()
.dependency("@openzeppelin-contracts~5")
.config_location(ConfigLocation::Foundry)
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
let content = r#"[profile.default]
libs = ["lib", "dependencies"]
[dependencies]
"@openzeppelin-contracts" = "5"
"#;
assert_eq!(config, content);
}
#[tokio::test]
async fn test_install_new_soldeer_no_soldeer_toml() {
let dir = testdir!();
let cmd: Command = Install::builder()
.dependency("@openzeppelin-contracts~5")
.config_location(ConfigLocation::Soldeer)
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("soldeer.toml")).unwrap();
let content = r#"[dependencies]
"@openzeppelin-contracts" = "5"
"#;
assert_eq!(config, content);
}
#[tokio::test]
async fn test_install_new_soldeer_no_dependency_tag() {
let dir = testdir!();
let contents = r#"[soldeer]
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Install::builder()
.dependency("@openzeppelin-contracts~5")
.config_location(ConfigLocation::Soldeer)
.build()
.into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("soldeer.toml")).unwrap();
let content = r#"[soldeer]
[dependencies]
"@openzeppelin-contracts" = "5"
"#;
assert_eq!(config, content);
}
#[tokio::test]
async fn test_install_recursive_deps_with_foundry_lock() {
let dir = testdir!();
let zip_path = create_zip_with_foundry_lock(&dir, None);
let checksum = hash_file(&zip_path).unwrap();
let contents = r#"[dependencies]
mylib = "1.0.0"
[soldeer]
recursive_deps = true
"#;
// Serve the dependency via mock server
let mut server = mockito::Server::new_async().await;
server.mock("GET", "/file.zip").with_body_from_file(&zip_path).create_async().await;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lock = format!(
r#"[[dependencies]]
name = "mylib"
version = "1.0.0"
url = "{}/file.zip"
checksum = "{checksum}"
integrity = "placeholder"
"#,
server.url()
);
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// Verify the submodule exists
let forge_std_path = dir.join("dependencies/mylib-1.0.0/lib/forge-std");
assert!(forge_std_path.exists());
// Verify it's checked out at the specific revision from foundry.lock
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&forge_std_path)
.output()
.expect("failed to run git rev-parse");
let current_rev = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(current_rev, "c29afdd40a82db50a3d3709d324416be50050e5e");
}
#[tokio::test]
async fn test_install_recursive_deps_with_foundry_lock_branch() {
let dir = testdir!();
let zip_path = create_zip_with_foundry_lock(&dir, Some("master"));
let checksum = hash_file(&zip_path).unwrap();
let contents = r#"[dependencies]
mylib = "1.0.0"
[soldeer]
recursive_deps = true
"#;
// Serve the dependency via mock server
let mut server = mockito::Server::new_async().await;
server.mock("GET", "/file.zip").with_body_from_file(&zip_path).create_async().await;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lock = format!(
r#"[[dependencies]]
name = "mylib"
version = "1.0.0"
url = "{}/file.zip"
checksum = "{checksum}"
integrity = "placeholder"
"#,
server.url()
);
fs::write(dir.join(SOLDEER_LOCK), lock).unwrap();
let cmd: Command = Install::builder().build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd.clone(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// Verify the submodule exists
let forge_std_path = dir.join("dependencies/mylib-1.0.0/lib/forge-std");
assert!(forge_std_path.exists());
// Verify it's checked out at the specific revision from foundry.lock
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&forge_std_path)
.output()
.expect("failed to run git rev-parse");
let current_rev = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(current_rev, "c29afdd40a82db50a3d3709d324416be50050e5e",);
}
================================================
FILE: crates/commands/tests/tests-login.rs
================================================
use std::{fs, path::PathBuf};
use mockito::{Matcher, Mock, ServerGuard};
use soldeer_commands::{Command, Verbosity, commands::login::Login, run};
use temp_env::async_with_vars;
use testdir::testdir;
async fn mock_api_server() -> (ServerGuard, Mock) {
let mut server = mockito::Server::new_async().await;
let body = r#"{"status":"success","token": "example_token_jwt"}"#;
let mock = server
.mock("POST", "/api/v1/auth/login")
.match_query(Matcher::Any)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_api_server_token() -> (ServerGuard, Mock) {
let mut server = mockito::Server::new_async().await;
let body = r#"{"status":"success","data":{"created_at": "2024-08-04T14:21:31.622589Z","email": "test@test.net","id": "b6d56bf0-00a5-474f-b732-f416bef53e92","organization": "test","role": "owner","updated_at": "2024-08-04T14:21:31.622589Z","username": "test","verified": true}}"#;
let mock = server
.mock("GET", "/api/v1/auth/validate-cli-token")
.match_query(Matcher::Any)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
#[tokio::test]
async fn test_login_without_prompt_err_400() {
let cmd: Command = Login::builder().email("test@test.com").password("111111").build().into();
let res = run(cmd, Verbosity::default()).await;
assert_eq!(
res.unwrap_err().to_string(),
"error during login: http error during login: HTTP status client error (400 Bad Request) for url (https://api.soldeer.xyz/api/v1/auth/login)"
);
}
#[tokio::test]
async fn test_login_without_prompt_success() {
let (server, mock) = mock_api_server().await;
let dir = testdir!();
let login_file: PathBuf = dir.join("test_save_jwt");
let cmd: Command = Login::builder().email("test@test.com").password("111111").build().into();
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok());
assert!(login_file.exists());
assert_eq!(fs::read_to_string(login_file).unwrap(), "example_token_jwt");
mock.expect(1);
}
#[tokio::test]
async fn test_login_token_success() {
let (server, mock) = mock_api_server_token().await;
let dir = testdir!();
let login_file: PathBuf = dir.join("test_save_jwt");
let cmd: Command = Login::builder().token("example_token_jwt").build().into();
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok());
assert!(login_file.exists());
assert_eq!(fs::read_to_string(login_file).unwrap(), "example_token_jwt");
mock.expect(1);
}
#[tokio::test]
async fn test_login_token_failure() {
let cmd: Command = Login::builder().token("asdf").build().into();
let res = run(cmd, Verbosity::default()).await;
assert_eq!(res.unwrap_err().to_string(), "error during login: login error: invalid token");
}
================================================
FILE: crates/commands/tests/tests-push.rs
================================================
use mockito::{Matcher, Mock, ServerGuard};
use reqwest::StatusCode;
use soldeer_commands::{Verbosity, commands::push::Push, run};
use soldeer_core::{SoldeerError, errors::PublishError};
use std::{env, fs, path::PathBuf};
use temp_env::async_with_vars;
use testdir::testdir;
#[allow(clippy::unwrap_used)]
fn setup_project(dotfile: bool) -> (PathBuf, PathBuf) {
let dir = testdir!();
let login_file: PathBuf = dir.join("test_save_jwt");
fs::write(&login_file, "jwt_token_example").unwrap();
let project_path = dir.join("mypkg");
fs::create_dir(&project_path).unwrap();
fs::write(project_path.join("foundry.toml"), "[dependencies]\n").unwrap();
if dotfile {
fs::write(project_path.join(".env"), "super-secret-stuff").unwrap();
}
(login_file, project_path)
}
async fn mock_api_server(status_code: Option) -> (ServerGuard, Mock) {
let mut server = mockito::Server::new_async().await;
let body = r#"{"data":[{"created_at":"2024-02-27T19:19:23.938837Z","created_by":"96228bb5-f777-4c19-ba72-363d14b8beed","deleted":false,"deprecated":false,"description":"","downloads":648041,"github_url":"","id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","image":"","latest_version":"1.10.0","long_description":"","name":"mock","organization_id":"ff9c0d8e-9275-4f6f-a1b7-2e822450a7ba","organization_name":"","organization_verified":true,"updated_at":"2024-02-27T19:19:23.938837Z"}],"status":"success"}"#;
server
.mock("GET", "/api/v2/project")
.match_query(Matcher::Any)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
let mock = match status_code {
Some(status_code) => {
server
.mock("POST", "/api/v1/revision/upload")
.with_header("content-type", "application/json")
.with_status(status_code.as_u16() as usize)
.with_body(r#"{"status":"fail","message": "failure"}"#)
.create_async()
.await
}
None => {
server
.mock("POST", "/api/v1/revision/upload")
.with_header("content-type", "application/json")
.with_body(r#"{"status":"success","data":{"data":{"project_id":"mock"}}}"#)
.create_async()
.await
}
};
(server, mock)
}
#[tokio::test]
async fn test_push_success() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(None).await;
env::set_current_dir(&project_path).unwrap();
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(Push::builder().dependency("mypkg~0.1.0").build().into(), Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.expect(1);
}
#[tokio::test]
async fn test_push_other_dir_success() {
let dir = testdir!();
fs::write(dir.join("foundry.toml"), "[dependencies]\n").unwrap();
let login_file = dir.join("test_save_jwt");
fs::write(&login_file, "jwt_token_example").unwrap();
let project_path = dir.join("mypkg");
fs::create_dir(&project_path).unwrap();
fs::write(project_path.join("test.sol"), "contract Foo {}\n").unwrap();
let (server, mock) = mock_api_server(None).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.expect(1);
}
#[tokio::test]
async fn test_push_not_found() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(Some(StatusCode::NO_CONTENT)).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::ProjectNotFound))));
mock.expect(1);
}
#[tokio::test]
async fn test_push_already_exists() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(Some(StatusCode::ALREADY_REPORTED)).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::AlreadyExists))));
mock.expect(1);
}
#[tokio::test]
async fn test_push_unauthorized() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(Some(StatusCode::UNAUTHORIZED)).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::AuthError(_)))));
mock.expect(1);
}
#[tokio::test]
async fn test_push_payload_too_large() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(Some(StatusCode::PAYLOAD_TOO_LARGE)).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::PayloadTooLarge))));
mock.expect(1);
}
#[tokio::test]
async fn test_push_other_error() {
let (login_file, project_path) = setup_project(false);
let (server, mock) = mock_api_server(Some(StatusCode::INTERNAL_SERVER_ERROR)).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder().dependency("mypkg~0.1.0").path(project_path).build().into(),
Verbosity::default(),
),
)
.await;
assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::HttpError(_)))));
mock.expect(1);
}
#[tokio::test]
async fn test_push_dry_run() {
let (login_file, project_path) = setup_project(true); // insert a .env file
let (server, mock) = mock_api_server(None).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder()
.dependency("mypkg~0.1.0")
.path(&project_path)
.dry_run(true)
.build()
.into(),
Verbosity::default(),
),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.expect(0);
assert!(project_path.join("mypkg.zip").exists());
}
#[tokio::test]
async fn test_push_skip_warnings() {
let (login_file, project_path) = setup_project(true); // insert a .env file
let (server, mock) = mock_api_server(None).await;
let res = async_with_vars(
[
("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())),
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())),
],
run(
Push::builder()
.dependency("mypkg~0.1.0")
.path(&project_path)
.skip_warnings(true)
.build()
.into(),
Verbosity::default(),
),
)
.await;
assert!(res.is_ok(), "{res:?}");
mock.expect(1);
}
================================================
FILE: crates/commands/tests/tests-uninstall.rs
================================================
use soldeer_commands::{
Command, Verbosity,
commands::{install::Install, uninstall::Uninstall},
run,
};
use soldeer_core::{
config::read_config_deps,
lock::{SOLDEER_LOCK, read_lockfile},
};
use std::{fs, path::PathBuf};
use temp_env::async_with_vars;
use testdir::testdir;
#[allow(clippy::unwrap_used)]
async fn setup(config_filename: &str) -> PathBuf {
let dir = testdir!();
let mut contents = r#"[dependencies]
"@openzeppelin-contracts" = "5.0.2"
solady = "0.0.238"
"#
.to_string();
if config_filename == "foundry.toml" {
contents = format!(
r#"[profile.default]
[soldeer]
remappings_location = "config"
{contents}"#
);
}
fs::write(dir.join(config_filename), contents).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
dir
}
#[tokio::test]
async fn test_uninstall_one() {
let dir = setup("soldeer.toml").await;
let cmd: Command = Uninstall::builder().dependency("solady").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let (deps, _) = read_config_deps(dir.join("soldeer.toml")).unwrap();
assert!(!deps.iter().any(|d| d.name() == "solady"));
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert!(!remappings.contains("solady"));
let lock = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert!(!lock.entries.iter().any(|d| d.name() == "solady"));
assert!(!dir.join("dependencies").join("solady-0.0.238").exists());
}
#[tokio::test]
async fn test_uninstall_all() {
let dir = setup("soldeer.toml").await;
let cmd: Command = Uninstall::builder().dependency("solady").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let cmd: Command = Uninstall::builder().dependency("@openzeppelin-contracts").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let (deps, _) = read_config_deps(dir.join("soldeer.toml")).unwrap();
assert!(deps.is_empty());
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert_eq!(remappings, "");
assert!(!dir.join(SOLDEER_LOCK).exists());
}
#[tokio::test]
async fn test_uninstall_foundry_config() {
let dir = setup("foundry.toml").await;
let cmd: Command = Uninstall::builder().dependency("solady").build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let (deps, _) = read_config_deps(dir.join("foundry.toml")).unwrap();
assert!(!deps.iter().any(|d| d.name() == "solady"));
let config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
assert!(!config.contains("solady"));
}
================================================
FILE: crates/commands/tests/tests-update.rs
================================================
use soldeer_commands::{
Command, Verbosity,
commands::{install::Install, update::Update},
run,
};
use soldeer_core::{
config::ConfigLocation,
lock::{SOLDEER_LOCK, read_lockfile},
};
use std::{fs, path::PathBuf};
use temp_env::async_with_vars;
use testdir::testdir;
#[allow(clippy::unwrap_used)]
async fn setup(config_filename: &str) -> PathBuf {
// install v1.9.0 of forge-std (faking an old install)
let dir = testdir!();
let mut contents = r#"[dependencies]
forge-std = "1.9.0"
"#
.to_string();
if config_filename == "foundry.toml" {
contents = format!(
r#"[profile.default]
[soldeer]
remappings_location = "config"
{contents}"#
);
}
fs::write(dir.join(config_filename), &contents).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// change install requirement to forge-std ^1.0.0 (making the current install outdated)
contents = contents.replace("1.9.0", "1");
fs::write(dir.join(config_filename), &contents).unwrap();
// update remappings accordingly
fs::write(dir.join("remappings.txt"), "forge-std-1/=dependencies/forge-std-1.9.0/\n").unwrap();
dir
}
#[tokio::test]
async fn test_update_existing() {
let dir = setup("soldeer.toml").await;
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
let version = lockfile.entries.first().unwrap().version();
assert_ne!(version, "1.9.0");
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert_eq!(remappings, format!("forge-std-1/=dependencies/forge-std-{version}/\n"));
assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists());
}
#[tokio::test]
async fn test_update_foundry_config() {
let dir = setup("foundry.toml").await;
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
let version = lockfile.entries.first().unwrap().version();
assert_ne!(version, "1.9.0");
assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists());
}
#[tokio::test]
async fn test_update_missing() {
let dir = testdir!();
let contents = r#"[dependencies]
forge-std = "1"
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
let version = lockfile.entries.first().unwrap().version();
assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists());
}
#[tokio::test]
async fn test_update_custom_remappings() {
let dir = setup("soldeer.toml").await;
// customize remappings before update
fs::write(dir.join("remappings.txt"), "forge-std/=dependencies/forge-std-1.9.0/src/\n")
.unwrap();
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
let version = lockfile.entries.first().unwrap().version();
let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap();
assert_eq!(remappings, format!("forge-std/=dependencies/forge-std-{version}/src/\n"));
}
#[tokio::test]
async fn test_update_git_main() {
let dir = testdir!();
// install older commit in "main" branch
let contents = r#"[dependencies]
my-lib = { version = "branch-main", git = "https://github.com/beeb/test-repo.git" }
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lockfile = r#"[[dependencies]]
name = "my-lib"
version = "branch-main"
git = "https://github.com/beeb/test-repo.git"
rev = "78c2f6a1a54db26bab6c3f501854a1564eb3707f"
"#;
fs::write(dir.join(SOLDEER_LOCK), lockfile).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// update to latest commit in "main" branch
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lockfile.entries.first().unwrap().as_git().unwrap().rev,
"d5d72fa135d28b2e8307650b3ea79115183f2406"
);
}
#[tokio::test]
async fn test_update_git_branch() {
let dir = testdir!();
// install older commit in "dev" branch
let contents = r#"[dependencies]
my-lib = { version = "branch-dev", git = "https://github.com/beeb/test-repo.git", branch = "dev" }
"#;
fs::write(dir.join("soldeer.toml"), contents).unwrap();
let lockfile = r#"[[dependencies]]
name = "my-lib"
version = "branch-dev"
git = "https://github.com/beeb/test-repo.git"
rev = "78c2f6a1a54db26bab6c3f501854a1564eb3707f"
"#;
fs::write(dir.join(SOLDEER_LOCK), lockfile).unwrap();
let cmd: Command = Install::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
// update to latest commit in "dev" branch
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let lockfile = read_lockfile(dir.join(SOLDEER_LOCK)).unwrap();
assert_eq!(
lockfile.entries.first().unwrap().as_git().unwrap().rev,
"8d903e557e8f1b6e62bde768aa456d4ddfca72c4"
);
}
#[tokio::test]
async fn test_update_foundry_config_multi_dep() {
let dir = testdir!();
let contents = r#"[profile.default]
[dependencies]
"@tt" = {version = "1.6.1", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip"}
forge-std = { version = "1.8.1" }
solmate = "6.7.0"
mario = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", rev = "22868f426bd4dd0e682b5ec5f9bd55507664240c" }
mario-custom-tag = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-tag" }
mario-custom-branch = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-branch" }
[soldeer]
remappings_location = "config"
"#;
fs::write(dir.join("foundry.toml"), contents).unwrap();
let cmd: Command = Update::default().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let deps = dir.join("dependencies");
assert!(deps.join("@tt-1.6.1").exists());
assert!(deps.join("forge-std-1.8.1").exists());
assert!(deps.join("solmate-6.7.0").exists());
assert!(deps.join("mario-1.0").exists());
assert!(deps.join("mario-custom-tag-1.0").exists());
assert!(deps.join("mario-custom-branch-1.0").exists());
}
#[tokio::test]
async fn test_install_new_foundry_no_foundry_toml() {
let dir = testdir!();
let cmd: Command = Update::builder().config_location(ConfigLocation::Foundry).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("foundry.toml")).unwrap();
let expected = r#"[profile.default]
src = "src"
out = "out"
libs = ["dependencies"]
[dependencies]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
"#;
assert_eq!(config, expected);
}
#[tokio::test]
async fn test_install_new_soldeer_no_soldeer_toml() {
let dir = testdir!();
let cmd: Command = Update::builder().config_location(ConfigLocation::Soldeer).build().into();
let res = async_with_vars(
[("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))],
run(cmd, Verbosity::default()),
)
.await;
assert!(res.is_ok(), "{res:?}");
let config = fs::read_to_string(dir.join("soldeer.toml")).unwrap();
let content = "[dependencies]\n";
assert_eq!(config, content);
}
================================================
FILE: crates/core/Cargo.toml
================================================
[package]
name = "soldeer-core"
description = "Core functionality for Soldeer"
authors.workspace = true
categories.workspace = true
edition.workspace = true
exclude.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
bon.workspace = true
chrono = { version = "0.4.38", default-features = false, features = [
"serde",
"std",
] }
const-hex = "1.12.0"
derive_more.workspace = true
dunce = "1.0.5"
home = "0.5.9"
ignore = { version = "0.4.24", features = ["simd-accel"] }
log = { workspace = true, features = ["kv_std"] }
path-slash.workspace = true
rayon.workspace = true
regex = "1.10.5"
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
sanitize-filename = "0.6.0"
semver = "1.0.23"
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.120"
sha2 = "0.10.8"
thiserror.workspace = true
tokio.workspace = true
toml_edit = { version = "0.25.11", features = ["serde"] }
uuid = { version = "1.10.0", features = ["serde", "v4"] }
zip = { version = "4.0.0", default-features = false, features = ["deflate"] }
zip-extract = { version = "0.4.0", default-features = false, features = [
"deflate",
] }
[dev-dependencies]
mockito.workspace = true
temp-env.workspace = true
testdir.workspace = true
[features]
serde = []
================================================
FILE: crates/core/src/auth.rs
================================================
//! Registry authentication
use crate::{errors::AuthError, registry::api_url, utils::login_file_path};
use log::{debug, info, warn};
use reqwest::{
Client, StatusCode,
header::{AUTHORIZATION, HeaderMap, HeaderValue},
};
use serde::{Deserialize, Serialize};
use std::{env, fs, path::PathBuf};
pub type Result = std::result::Result;
/// Credentials to be used for login
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct Credentials {
pub email: String,
pub password: String,
}
/// Response from the login endpoint
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct LoginResponse {
pub status: String,
/// JWT token
pub token: String,
}
/// Get the JWT token from the environment or from the login file
///
/// Precedence is given to the `SOLDEER_API_TOKEN` environment variable.
pub fn get_token() -> Result {
if let Ok(token) = env::var("SOLDEER_API_TOKEN") &&
!token.is_empty()
{
return Ok(token)
}
let token_path = login_file_path()?;
let jwt =
fs::read_to_string(&token_path).map_err(|_| AuthError::MissingToken)?.trim().to_string();
if jwt.is_empty() {
debug!(token_path:?; "token file exists but is empty");
return Err(AuthError::MissingToken);
}
debug!(token_path:?; "token retrieved from file");
Ok(jwt)
}
/// Get a header map with the bearer token set up if it exists
pub fn get_auth_headers() -> Result {
let mut headers: HeaderMap = HeaderMap::new();
let Ok(token) = get_token() else {
return Ok(headers);
};
let header_value =
HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| AuthError::InvalidToken)?;
headers.insert(AUTHORIZATION, header_value);
Ok(headers)
}
/// Save an access token in the login file
pub fn save_token(token: &str) -> Result {
let token_path = login_file_path()?;
fs::write(&token_path, token)?;
Ok(token_path)
}
/// Retrieve user profile for the token to check its validity, returning the username
pub async fn check_token(token: &str) -> Result {
let client = Client::new();
let url = api_url("v1", "auth/validate-cli-token", &[]);
let mut headers: HeaderMap = HeaderMap::new();
let header_value =
HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| AuthError::InvalidToken)?;
headers.insert(AUTHORIZATION, header_value);
let response = client.get(url).headers(headers).send().await?;
match response.status() {
s if s.is_success() => {
#[derive(Deserialize)]
struct User {
id: String,
username: String,
}
#[derive(Deserialize)]
struct UserResponse {
data: User,
}
let res: UserResponse = response.json().await?;
debug!("token is valid for user {} with ID {}", res.data.username, res.data.id);
Ok(res.data.username)
}
StatusCode::UNAUTHORIZED => Err(AuthError::InvalidToken),
_ => Err(AuthError::HttpError(
response.error_for_status().expect_err("result should be an error"),
)),
}
}
/// Execute the login request and store the JWT token in the login file
pub async fn execute_login(login: &Credentials) -> Result {
warn!(
"the option to login via email and password will be removed in a future version of Soldeer. Please update your usage by either using `soldeer login --token [YOUR CLI TOKEN]` or passing the `SOLDEER_API_TOKEN` environment variable to the `push` command."
);
let token_path = login_file_path()?;
let url = api_url("v1", "auth/login", &[]);
let client = Client::new();
let res = client.post(url).json(login).send().await?;
match res.status() {
s if s.is_success() => {
debug!("login request completed");
let response: LoginResponse = res.json().await?;
fs::write(&token_path, response.token)?;
info!(token_path:?; "login successful");
Ok(token_path)
}
StatusCode::UNAUTHORIZED => Err(AuthError::InvalidCredentials),
_ => Err(AuthError::HttpError(
res.error_for_status().expect_err("result should be an error"),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use temp_env::{async_with_vars, with_var};
use testdir::testdir;
#[tokio::test]
async fn test_login_success() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/api/v1/auth/login")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"200","token":"jwt_token_example"}"#)
.create_async()
.await;
let test_file = testdir!().join("test_save_jwt");
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
],
execute_login(&Credentials {
email: "test@test.com".to_string(),
password: "1234".to_string(),
}),
)
.await;
assert!(res.is_ok(), "{res:?}");
assert_eq!(fs::canonicalize(res.unwrap()).unwrap(), fs::canonicalize(&test_file).unwrap());
assert_eq!(fs::read_to_string(test_file).unwrap(), "jwt_token_example");
}
#[tokio::test]
async fn test_login_401() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/api/v1/auth/login")
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"401"}"#)
.create_async()
.await;
let test_file = testdir!().join("test_save_jwt");
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
],
execute_login(&Credentials {
email: "test@test.com".to_string(),
password: "1234".to_string(),
}),
)
.await;
assert!(matches!(res, Err(AuthError::InvalidCredentials)), "{res:?}");
}
#[tokio::test]
async fn test_login_500() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/api/v1/auth/login")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"500"}"#)
.create_async()
.await;
let test_file = testdir!().join("test_save_jwt");
let res = async_with_vars(
[
("SOLDEER_API_URL", Some(server.url())),
("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())),
],
execute_login(&Credentials {
email: "test@test.com".to_string(),
password: "1234".to_string(),
}),
)
.await;
assert!(matches!(res, Err(AuthError::HttpError(_))), "{res:?}");
}
#[tokio::test]
async fn test_check_token_success() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/api/v1/auth/validate-cli-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"success","data":{"created_at": "2024-08-04T14:21:31.622589Z","email": "test@test.net","id": "b6d56bf0-00a5-474f-b732-f416bef53e92","organization": "test","role": "owner","updated_at": "2024-08-04T14:21:31.622589Z","username": "test","verified": true}}"#,
)
.create_async()
.await;
let res =
async_with_vars([("SOLDEER_API_URL", Some(server.url()))], check_token("eyJ0..."))
.await;
assert!(res.is_ok(), "{res:?}");
assert_eq!(res.unwrap(), "test");
}
#[tokio::test]
async fn test_check_token_failure() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/api/v1/auth/validate-cli-token")
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"fail","message":"Invalid token"}"#)
.create_async()
.await;
let res =
async_with_vars([("SOLDEER_API_URL", Some(server.url()))], check_token("foobar")).await;
assert!(res.is_err(), "{res:?}");
}
#[test]
fn test_get_token_env() {
let res = with_var("SOLDEER_API_TOKEN", Some("test"), get_token);
assert!(res.is_ok(), "{res:?}");
assert_eq!(res.unwrap(), "test");
}
}
================================================
FILE: crates/core/src/config.rs
================================================
//! Manage the Soldeer configuration and dependencies list.
use crate::{
download::{find_install_path, find_install_path_sync},
errors::ConfigError,
lock::SOLDEER_LOCK,
remappings::RemappingsLocation,
};
use derive_more::derive::{Display, From, FromStr};
use log::{debug, warn};
use serde::Deserialize;
use std::{
env, fmt, fs,
path::{Path, PathBuf},
};
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, value};
pub type Result = std::result::Result;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum UrlType {
Git(String),
Http(String),
}
impl UrlType {
pub fn git(url: impl Into) -> Self {
Self::Git(url.into())
}
pub fn http(url: impl Into) -> Self {
Self::Http(url.into())
}
}
/// The paths used by Soldeer.
///
/// The paths are canonicalized on creation of the object.
///
/// To create this object, the [`Paths::new`] and [`Paths::from_root`] methods can be used.
///
/// # Examples
///
/// ```
/// # use soldeer_core::config::Paths;
/// # let dir = testdir::testdir!();
/// # std::env::set_current_dir(&dir).unwrap();
/// # std::fs::write("foundry.toml", "[dependencies]\n").unwrap();
/// let paths = Paths::new().unwrap(); // foundry.toml exists in the current path
/// assert_eq!(paths.root, std::env::current_dir().unwrap());
/// assert_eq!(paths.config, std::env::current_dir().unwrap().join("foundry.toml"));
///
/// let paths = Paths::from_root(&dir).unwrap(); // root is the given path
/// assert_eq!(paths.root, dir);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
// making sure the struct is not constructible from the outside without using the new/from methods
#[non_exhaustive]
pub struct Paths {
/// The root directory of the project.
///
/// At the moment, the current directory or the path given by the `SOLDEER_PROJECT_ROOT`
/// environment variable.
pub root: PathBuf,
/// The path to the config file.
///
/// `foundry.toml` if it contains a `[dependencies]` table, otherwise `soldeer.toml` if it
/// exists. Otherwise, the `foundry.toml` file is used by default. When the config file does
/// not exist, a new one is created with default contents.
pub config: PathBuf,
/// The path to the dependencies folder (does not need to exist).
///
/// This is `/dependencies` inside the root directory.
pub dependencies: PathBuf,
/// The path to the lockfile (does not need to exist).
///
/// This is `/soldeer.lock` inside the root directory.
pub lock: PathBuf,
/// The path to the remappings file (does not need to exist).
///
/// This path gets ignored if the remappings should be generated in the `foundry.toml` file.
/// This is `/remappings.txt` inside the root directory.
pub remappings: PathBuf,
}
impl Paths {
/// Instantiate all the paths needed for Soldeer.
///
/// The root path defaults to the current directory but can be overridden with the
/// `SOLDEER_PROJECT_ROOT` environment variable.
///
/// The paths are canonicalized.
pub fn new() -> Result {
Self::with_config(None)
}
/// Instantiate all the paths needed for Soldeer.
///
/// The root path is automatically detected (by traversing the path) but can be overridden with
/// the `SOLDEER_PROJECT_ROOT` environment variable.
/// Alternatively, the [`Paths::with_root_and_config`] constructor can be used.
///
/// If a config location is provided, it bypasses auto-detection and uses that. If `None`, then
/// the location is auto-detected or if impossible, the `foundry.toml` file is used. If the
/// config file does not exist yet, it gets created with default content.
///
/// The paths are canonicalized.
pub fn with_config(config_location: Option) -> Result {
let root = dunce::canonicalize(Self::get_root_path())?;
Self::with_root_and_config(root, config_location)
}
/// Instantiate all the paths needed for Soldeer.
///
/// If a config location is provided, it bypasses auto-detection and uses that. If `None`, then
/// the location is auto-detected or if impossible, the `foundry.toml` file is used. If the
/// config file does not exist yet, it gets created with default content.
///
/// The paths are canonicalized.
pub fn with_root_and_config(
root: impl AsRef,
config_location: Option,
) -> Result {
let root = root.as_ref();
let config = Self::get_config_path(root, config_location)?;
let dependencies = root.join("dependencies");
let lock = root.join(SOLDEER_LOCK);
let remappings = root.join("remappings.txt");
Ok(Self { root: root.to_path_buf(), config, dependencies, lock, remappings })
}
/// Generate the paths object from a known root directory.
///
/// The `SOLDEER_PROJECT_ROOT` environment variable is ignored.
///
/// The paths are canonicalized.
pub fn from_root(root: impl AsRef) -> Result {
let root = dunce::canonicalize(root.as_ref())?;
let config = Self::get_config_path(&root, None)?;
let dependencies = root.join("dependencies");
let lock = root.join(SOLDEER_LOCK);
let remappings = root.join("remappings.txt");
Ok(Self { root, config, dependencies, lock, remappings })
}
/// Get the root directory path.
///
/// If `SOLDEER_PROJECT` root is present in the environment, this is the returned value. Else,
/// we search for the root of the project with `find_project_root`.
pub fn get_root_path() -> PathBuf {
let res = env::var("SOLDEER_PROJECT_ROOT").map_or_else(
|_| {
debug!("SOLDEER_PROJECT_ROOT not defined, searching for project root");
find_project_root(None::).expect("could not find project root")
},
|p| {
if p.is_empty() {
debug!("SOLDEER_PROJECT_ROOT exists but is empty, searching for project root");
find_project_root(None::).expect("could not find project root")
} else {
debug!(path = p; "root set by SOLDEER_PROJECT_ROOT");
PathBuf::from(p)
}
},
);
debug!(path:? = res; "found project root");
res
}
/// Get the path to the config file.
///
/// If a parameter is given for `config_location`, it will be used. Otherwise, the function will
/// try to auto-detect the location based on the existence of the `dependencies` entry in
/// the foundry config file, or the existence of a `soldeer.toml` file. If no config can be
/// found, `foundry.toml` is used by default.
fn get_config_path(
root: impl AsRef,
config_location: Option,
) -> Result {
let foundry_path = root.as_ref().join("foundry.toml");
let soldeer_path = root.as_ref().join("soldeer.toml");
// use the user preference if available
let location = config_location.or_else(|| {
debug!("no preferred config location, trying to detect automatically");
detect_config_location(root)
}).unwrap_or_else(|| {
warn!("config file location could not be determined automatically, using foundry by default");
ConfigLocation::Foundry
});
debug!("using config location {location:?}");
create_or_modify_config(location, &foundry_path, &soldeer_path)
}
/// Default Foundry config file path
pub fn foundry_default() -> PathBuf {
let root: PathBuf =
dunce::canonicalize(Self::get_root_path()).expect("could not get the root");
root.join("foundry.toml")
}
/// Default Soldeer config file path
pub fn soldeer_default() -> PathBuf {
let root: PathBuf =
dunce::canonicalize(Self::get_root_path()).expect("could not get the root");
root.join("soldeer.toml")
}
}
/// For clap
fn default_true() -> bool {
true
}
/// The Soldeer config options.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SoldeerConfig {
/// Whether to generate remappings or completely leave them untouched.
///
/// Defaults to `true`.
#[serde(default = "default_true")]
pub remappings_generate: bool,
/// Whether to regenerate the remappings every time and ignore existing content.
///
/// Defaults to `false`.
#[serde(default)]
pub remappings_regenerate: bool,
/// Whether to include the version requirement string in the left part of the remappings.
///
/// Defaults to `true`.
#[serde(default = "default_true")]
pub remappings_version: bool,
/// A prefix to add to each dependency name in the left part of the remappings.
///
/// None by default.
#[serde(default)]
pub remappings_prefix: String,
/// The location where the remappings file should be generated.
///
/// Either inside the `foundry.toml` config file or as a separate `remappings.txt` file.
/// This gets ignored if the config file is `soldeer.toml`, in which case the remappings
/// are always generated in a separate file.
///
/// Defaults to [`RemappingsLocation::Txt`].
#[serde(default)]
pub remappings_location: RemappingsLocation,
/// Whether to include dependencies from dependencies.
///
/// For dependencies which use soldeer, the `soldeer install` command will be invoked.
/// Git dependencies which have submodules will see their submodules cloned as well.
///
/// Defaults to `false`.
#[serde(default)]
pub recursive_deps: bool,
}
impl Default for SoldeerConfig {
fn default() -> Self {
Self {
remappings_generate: true,
remappings_regenerate: false,
remappings_version: true,
remappings_prefix: String::new(),
remappings_location: RemappingsLocation::default(),
recursive_deps: false,
}
}
}
/// A git identifier used to specify a revision, branch or tag.
///
/// # Examples
///
/// ```
/// # use soldeer_core::config::GitIdentifier;
/// let rev = GitIdentifier::from_rev("082692fcb6b5b1ab8f856914897f7f2b46b84fd2");
/// let branch = GitIdentifier::from_branch("feature/foo");
/// let tag = GitIdentifier::from_tag("v1.0.0");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub enum GitIdentifier {
/// A commit hash
Rev(String),
/// A branch name
Branch(String),
/// A tag name
Tag(String),
}
impl GitIdentifier {
/// Create a new git identifier from a revision hash.
pub fn from_rev(rev: impl Into) -> Self {
let rev: String = rev.into();
Self::Rev(rev)
}
/// Create a new git identifier from a branch name.
pub fn from_branch(branch: impl Into) -> Self {
let branch: String = branch.into();
Self::Branch(branch)
}
/// Create a new git identifier from a tag name.
pub fn from_tag(tag: impl Into) -> Self {
let tag: String = tag.into();
Self::Tag(tag)
}
}
/// A git dependency config item.
///
/// This struct is used to represent a git dependency from the config file.
#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(PathBuf, into))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub struct GitDependency {
/// The name of the dependency (user-defined).
pub name: String,
/// The version requirement string (semver).
///
/// Example: `>=1.9.3 || ^2.0.0`
///
/// When no operator is used before the version number, it defaults to `=` which pins the
/// version.
#[cfg_attr(feature = "serde", serde(rename = "version"))]
pub version_req: String,
/// The git URL, must end with `.git`.
pub git: String,
/// The git identifier (revision, branch or tag).
///
/// If omitted, the default branch is used.
pub identifier: Option,
/// An optional relative path to the project's root within the repository.
///
/// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
/// then the repo's root must contain a Soldeer config.
pub project_root: Option,
}
impl fmt::Display for GitDependency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}~{}", self.name, self.version_req)
}
}
/// An HTTP dependency config item.
///
/// This struct is used to represent an HTTP dependency from the config file.
#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
#[allow(clippy::duplicated_attributes)]
#[builder(on(String, into), on(PathBuf, into))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub struct HttpDependency {
/// The name of the dependency (user-defined).
pub name: String,
/// The version requirement string (semver).
///
/// Example: `>=1.9.3 || ^2.0.0`
///
/// When no operator is used before the version number, it defaults to `=` which pins the
/// version.
#[cfg_attr(feature = "serde", serde(rename = "version"))]
pub version_req: String,
/// The URL to the dependency.
///
/// If omitted, the registry will be contacted to get the download URL for that dependency (by
/// name).
pub url: Option,
/// An optional relative path to the project's root within the zip file.
///
/// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
/// then the zip's root must contain a Soldeer config.
pub project_root: Option,
}
impl fmt::Display for HttpDependency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}~{}", self.name, self.version_req)
}
}
/// A git or HTTP dependency config item.
///
/// A builder can be used to create the underlying [`HttpDependency`] or [`GitDependency`] and then
/// converted into this type with `.into()`.
///
/// # Examples
///
/// ```
/// # use soldeer_core::config::{Dependency, HttpDependency};
/// let dep: Dependency = HttpDependency::builder()
/// .name("my-dep")
/// .version_req("^1.0.0")
/// .url("https://...")
/// .build()
/// .into();
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, From)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub enum Dependency {
#[from(HttpDependency)]
Http(HttpDependency),
#[from(GitDependency)]
Git(GitDependency),
}
impl Dependency {
/// Create a new dependency from a name and version requirement string.
///
/// The string should be in the format `name~version_req`.
///
/// The version requirement string can use the semver format.
///
/// Example: `dependency~^1.0.0`
///
/// If a custom URL is provided, then the version requirement string
/// cannot contain the `=` character, as it would break the remappings.
///
/// # Examples
///
/// ```
/// # use soldeer_core::config::{Dependency, HttpDependency, GitDependency, GitIdentifier, UrlType};
/// assert_eq!(
/// Dependency::from_name_version("my-lib~^1.0.0", Some(UrlType::http("https://foo.bar/zip.zip")), None)
/// .unwrap(),
/// HttpDependency::builder()
/// .name("my-lib")
/// .version_req("^1.0.0")
/// .url("https://foo.bar/zip.zip")
/// .build()
/// .into()
/// );
/// assert_eq!(
/// Dependency::from_name_version(
/// "my-lib~^1.0.0",
/// Some(UrlType::git("git@github.com:foo/bar.git")),
/// Some(GitIdentifier::from_tag("v1.0.0")),
/// )
/// .unwrap(),
/// GitDependency::builder()
/// .name("my-lib")
/// .version_req("^1.0.0")
/// .git("git@github.com:foo/bar.git")
/// .identifier(GitIdentifier::from_tag("v1.0.0"))
/// .build()
/// .into()
/// );
/// ```
pub fn from_name_version(
name_version: &str,
custom_url: Option,
identifier: Option,
) -> Result {
let (dependency_name, dependency_version_req) = name_version
.split_once('~')
.ok_or(ConfigError::InvalidNameAndVersion(name_version.to_string()))?;
if dependency_version_req.is_empty() {
return Err(ConfigError::EmptyVersion(dependency_name.to_string()));
}
Ok(match custom_url {
Some(url) => {
// in this case (custom url or git dependency), the version requirement string is
// going to be used as part of the folder name inside the
// dependencies folder. As such, it's not allowed to contain the "="
// character, because that would break the remappings.
if dependency_version_req.contains('=') {
return Err(ConfigError::InvalidVersionReq(dependency_name.to_string()));
}
debug!(url:% = url; "using custom url");
match url {
UrlType::Git(url) => GitDependency {
name: dependency_name.to_string(),
version_req: dependency_version_req.to_string(),
git: url,
identifier,
project_root: None,
}
.into(),
UrlType::Http(url) => HttpDependency {
name: dependency_name.to_string(),
version_req: dependency_version_req.to_string(),
url: Some(url),
project_root: None,
}
.into(),
}
}
None => HttpDependency {
name: dependency_name.to_string(),
version_req: dependency_version_req.to_string(),
url: None,
project_root: None,
}
.into(),
})
}
/// Get the name of the dependency.
pub fn name(&self) -> &str {
match self {
Self::Http(dep) => &dep.name,
Self::Git(dep) => &dep.name,
}
}
/// Get the version requirement string of the dependency.
pub fn version_req(&self) -> &str {
match self {
Self::Http(dep) => &dep.version_req,
Self::Git(dep) => &dep.version_req,
}
}
/// Get the URL of the dependency.
pub fn url(&self) -> Option<&String> {
match self {
Self::Http(dep) => dep.url.as_ref(),
Self::Git(dep) => Some(&dep.git),
}
}
/// Get the install path of the dependency (must exist already).
pub fn install_path_sync(&self, deps: impl AsRef) -> Option {
debug!(dep:% = self; "trying to find installation path of dependency (sync)");
find_install_path_sync(self, deps)
}
/// Get the install path of the dependency in an async way (must exist already).
pub async fn install_path(&self, deps: impl AsRef) -> Option {
debug!(dep:% = self; "trying to find installation path of dependency (async)");
find_install_path(self, deps).await
}
/// Get the relative path to the project root (config file location).
pub fn project_root(&self) -> Option {
match self {
Self::Http(dep) => dep.project_root.clone(),
Self::Git(dep) => dep.project_root.clone(),
}
}
/// Convert the dependency to a TOML value for saving to the config file.
pub fn to_toml_value(&self) -> (String, Item) {
match self {
Self::Http(dep) => (
dep.name.clone(),
match &dep.url {
Some(url) => {
let mut table = InlineTable::new();
table.insert(
"version",
value(&dep.version_req)
.into_value()
.expect("version should be a valid toml value"),
);
table.insert(
"url",
value(url).into_value().expect("url should be a valid toml value"),
);
if let Some(path) = dep.project_root.as_ref() {
table.insert(
"project_root",
value(path.to_string_lossy().into_owned())
.into_value()
.expect("project_root should be a valid toml value"),
);
}
value(table)
}
None => value(&dep.version_req),
},
),
Self::Git(dep) => {
let mut table = InlineTable::new();
table.insert(
"version",
value(&dep.version_req)
.into_value()
.expect("version should be a valid toml value"),
);
table.insert(
"git",
value(&dep.git).into_value().expect("git URL should be a valid toml value"),
);
match &dep.identifier {
Some(GitIdentifier::Rev(rev)) => {
table.insert(
"rev",
value(rev).into_value().expect("rev should be a valid toml value"),
);
}
Some(GitIdentifier::Branch(branch)) => {
table.insert(
"branch",
value(branch)
.into_value()
.expect("branch should be a valid toml value"),
);
}
Some(GitIdentifier::Tag(tag)) => {
table.insert(
"tag",
value(tag).into_value().expect("tag should be a valid toml value"),
);
}
None => {}
}
if let Some(path) = dep.project_root.as_ref() {
table.insert(
"project_root",
value(path.to_string_lossy().into_owned())
.into_value()
.expect("project_root should be a valid toml value"),
);
}
(dep.name.clone(), value(table))
}
}
}
/// Check if the dependency is an HTTP dependency.
pub fn is_http(&self) -> bool {
matches!(self, Self::Http(_))
}
/// Cast to a HTTP dependency if it is one.
pub fn as_http(&self) -> Option<&HttpDependency> {
if let Self::Http(v) = self { Some(v) } else { None }
}
/// Cast to a mutable HTTP dependency if it is one.
pub fn as_http_mut(&mut self) -> Option<&mut HttpDependency> {
if let Self::Http(v) = self { Some(v) } else { None }
}
/// Check if the dependency is a git dependency.
pub fn is_git(&self) -> bool {
matches!(self, Self::Git(_))
}
/// Cast to a git dependency if it is one.
pub fn as_git(&self) -> Option<&GitDependency> {
if let Self::Git(v) = self { Some(v) } else { None }
}
/// Cast to a mutable git dependency if it is one.
pub fn as_git_mut(&mut self) -> Option<&mut GitDependency> {
if let Self::Git(v) = self { Some(v) } else { None }
}
}
impl From<&HttpDependency> for Dependency {
fn from(dep: &HttpDependency) -> Self {
Self::Http(dep.clone())
}
}
impl From<&GitDependency> for Dependency {
fn from(dep: &GitDependency) -> Self {
Self::Git(dep.clone())
}
}
/// The location where the Soldeer config should be stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromStr)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub enum ConfigLocation {
/// The `foundry.toml` file.
Foundry,
/// The `soldeer.toml` file.
Soldeer,
}
impl From for PathBuf {
fn from(value: ConfigLocation) -> Self {
match value {
ConfigLocation::Foundry => Paths::foundry_default(),
ConfigLocation::Soldeer => Paths::soldeer_default(),
}
}
}
/// A warning generated during parsing of a dependency from the config file.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub struct ParsingWarning {
dependency_name: String,
message: String,
}
impl fmt::Display for ParsingWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.dependency_name, self.message)
}
}
/// The result of parsing a dependency from the config file.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))]
pub struct ParsingResult {
pub dependency: Dependency,
pub warnings: Vec,
}
impl ParsingResult {
/// Whether the parsing result contains one or more warnings.
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
impl From for ParsingResult {
fn from(value: HttpDependency) -> Self {
Self { dependency: value.into(), warnings: Vec::default() }
}
}
impl From for ParsingResult {
fn from(value: GitDependency) -> Self {
Self { dependency: value.into(), warnings: Vec::default() }
}
}
impl From for ParsingResult {
fn from(value: Dependency) -> Self {
Self { dependency: value, warnings: Vec::default() }
}
}
/// Detect the location of the config file in case no user preference is available.
///
/// The function will try to auto-detect the location based on the existence of the
/// `dependencies` entry in the foundry config file, or the existence of a `soldeer.toml` file.
/// If no config can be found, `None` is returned.
pub fn detect_config_location(root: impl AsRef) -> Option {
let foundry_path = root.as_ref().join("foundry.toml");
let soldeer_path = root.as_ref().join("soldeer.toml");
if let Ok(contents) = fs::read_to_string(&foundry_path) {
debug!(path:? = foundry_path; "found foundry.toml file");
if let Ok(doc) = contents.parse::() {
if doc.contains_table("dependencies") {
debug!("found `dependencies` table in foundry.toml, so using that file for config");
return Some(ConfigLocation::Foundry);
} else {
debug!("foundry.toml does not contain `dependencies`, trying to use soldeer.toml");
}
} else {
warn!(path:? = foundry_path; "foundry.toml could not be parsed a toml");
}
} else if soldeer_path.exists() {
debug!(path:? = soldeer_path; "soldeer.toml exists, using that file for config");
return Some(ConfigLocation::Soldeer);
}
debug!("could not determine existing config file location");
None
}
/// Read the list of dependencies from the config file.
///
/// Dependencies are stored in a TOML table under the `dependencies` key.
/// Each key inside of the table is the name of the dependency and the value can be:
/// - a string representing the version requirement
/// - a table with the following fields:
/// - `version` (required): the version requirement string
/// - `url` (optional): the URL to the dependency's zip file
/// - `git` (optional): the git URL for git dependencies
/// - `rev` (optional): the revision hash for git dependencies
/// - `branch` (optional): the branch name for git dependencies
/// - `tag` (optional): the tag name for git dependencies
/// - `project_root` (optional): relative path to the folder containing the config file
pub fn read_config_deps(path: impl AsRef) -> Result<(Vec, Vec)> {
let contents = fs::read_to_string(&path)?;
let doc: DocumentMut = contents.parse::()?;
let Some(Some(data)) = doc.get("dependencies").map(|v| v.as_table()) else {
warn!("no `dependencies` table in config file");
return Ok(Default::default());
};
let mut dependencies: Vec = Vec::new();
let mut warnings: Vec = Vec::new();
for (name, v) in data {
let mut res = parse_dependency(name, v)?;
dependencies.push(res.dependency);
warnings.append(&mut res.warnings);
}
debug!(path:? = path.as_ref(); "found {} dependencies in config file", dependencies.len());
Ok((dependencies, warnings))
}
/// Read the Soldeer config from the config file.
pub fn read_soldeer_config(path: impl AsRef) -> Result {
#[derive(Deserialize)]
struct SoldeerConfigParsed {
#[serde(default)]
soldeer: SoldeerConfig,
}
let contents = fs::read_to_string(&path)?;
let config: SoldeerConfigParsed = toml_edit::de::from_str(&contents)?;
debug!(path:? = path.as_ref(); "parsed soldeer config from file");
Ok(config.soldeer)
}
/// Add a dependency to the config file.
pub fn add_to_config(dependency: &Dependency, config_path: impl AsRef) -> Result<()> {
let contents = fs::read_to_string(&config_path)?;
let mut doc: DocumentMut = contents.parse::()?;
// in case we don't have the dependencies section defined in the config file, we add it
if !doc.contains_table("dependencies") {
debug!("`dependencies` table added to config file because it was missing");
doc.insert("dependencies", Item::Table(Table::default()));
}
let (name, value) = dependency.to_toml_value();
doc["dependencies"]
.as_table_mut()
.expect("dependencies should be a table")
.insert(&name, value);
fs::write(&config_path, doc.to_string())?;
debug!(dep:% = dependency, path:? = config_path.as_ref(); "added dependency to config file");
Ok(())
}
/// Delete a dependency from the config file.
pub fn delete_from_config(dependency_name: &str, path: impl AsRef) -> Result {
let contents = fs::read_to_string(&path)?;
let mut doc: DocumentMut = contents.parse::().expect("invalid doc");
let Some(dependencies) = doc["dependencies"].as_table_mut() else {
debug!("no `dependencies` table in config file");
return Err(ConfigError::MissingDependency(dependency_name.to_string()));
};
let Some(item_removed) = dependencies.remove(dependency_name) else {
debug!("dependency not present in config file");
return Err(ConfigError::MissingDependency(dependency_name.to_string()));
};
let dependency = parse_dependency(dependency_name, &item_removed)?;
fs::write(&path, doc.to_string())?;
debug!(dep = dependency_name, path:? = path.as_ref(); "removed dependency from config file");
Ok(dependency.dependency)
}
/// Update the config file to add the `dependencies` folder as a source for libraries and the
/// `[dependencies]` table if necessary.
pub fn update_config_libs(foundry_config: impl AsRef) -> Result<()> {
let contents = fs::read_to_string(&foundry_config)?;
let mut doc: DocumentMut = contents.parse::()?;
if !doc.contains_key("profile") {
debug!("missing `profile` in config file, adding it");
let mut profile = Table::default();
profile["default"] = Item::Table(Table::default());
profile.set_implicit(true);
doc["profile"] = Item::Table(profile);
}
let profile = doc["profile"].as_table_mut().expect("profile should be a table");
if !profile.contains_key("default") {
debug!("missing `default` profile in config file, adding it");
profile["default"] = Item::Table(Table::default());
}
let default_profile =
profile["default"].as_table_mut().expect("default profile should be a table");
if !default_profile.contains_key("libs") {
debug!("missing `libs` array in config file, adding it");
default_profile["libs"] = value(Array::from_iter(&["dependencies".to_string()]));
}
let libs = default_profile["libs"].as_array_mut().expect("libs should be an array");
if !libs.iter().any(|v| v.as_str() == Some("dependencies")) {
debug!("adding `dependencies` folder to `libs` array");
libs.push("dependencies");
}
// in case we don't have the dependencies section defined in the config file, we add it
if !doc.contains_table("dependencies") {
debug!("adding `dependencies` table in config file");
doc.insert("dependencies", Item::Table(Table::default()));
}
fs::write(&foundry_config, doc.to_string())?;
debug!(path:? = foundry_config.as_ref(); "config file updated");
Ok(())
}
/// Find the top-level directory of the working git tree.
///
/// If no `.git` folder is found in the ancestors, `None` is returned.
fn find_git_root(relative_to: impl AsRef) -> Result