diff --git a/.clang-format b/.clang-format deleted file mode 100644 index a7152a249..000000000 --- a/.clang-format +++ /dev/null @@ -1,9 +0,0 @@ ---- -BasedOnStyle: LLVM -AlwaysBreakTemplateDeclarations: Yes -BreakBeforeBinaryOperators: NonAssignment -Cpp11BracedListStyle: false -IncludeBlocks: Regroup -IndentPPDirectives: AfterHash -PointerAlignment: Left -QualifierAlignment: Left diff --git a/.clang-tidy b/.clang-tidy deleted file mode 100644 index ea21e84a9..000000000 --- a/.clang-tidy +++ /dev/null @@ -1,53 +0,0 @@ ---- -UseColor: true -WarningsAsErrors: true,* -HeaderFilterRegex: "src/" -Checks: '-*,clang-diagnostic-*,llvm-*,-llvm-header-guard,bugprone-*,-bugprone-branch-clone,-bugprone-easily-swappable-parameters,-bugprone-exception-escape,-bugprone-unchecked-optional-access,-bugprone-crtp-constructor-accessibility,cert-*,-cert-err58-cpp,clang-analyzer-*,concurrency-*,-concurrency-mt-unsafe,cppcoreguidelines-*,-cppcoreguidelines-pro-bounds-array-to-pointer-decay,-cppcoreguidelines-avoid-const-or-ref-data-members,google-*,-google-readability-todo,misc-*,-misc-include-cleaner,-misc-no-recursion,-misc-use-anonymous-namespace,modernize-*,-misc-use-internal-linkage,-modernize-use-nodiscard,-modernize-use-trailing-return-type,performance-*,portability-*,-portability-avoid-pragma-once,readability-*,-*-else-after-return,-readability-implicit-bool-conversion,-readability-identifier-length,-readability-function-cognitive-complexity' -CheckOptions: - - key: misc-non-private-member-variables-in-classes.IgnoreClassesWithAllMemberVariablesBeingPublic - value: true - - - key: cppcoreguidelines-avoid-do-while.IgnoreMacros - value: true - - key: readability-simplify-boolean-expr.IgnoreMacros - value: true - - # https://clang.llvm.org/extra/clang-tidy/checks/readability/identifier-naming.html - - key: readability-identifier-naming.ClassCase - value: CamelCase - - key: readability-identifier-naming.ConceptCase - value: CamelCase - - key: readability-identifier-naming.EnumCase - value: CamelCase - - key: readability-identifier-naming.EnumConstantCase - value: CamelCase - - key: readability-identifier-naming.TypeAliasCase - value: CamelCase - - key: readability-identifier-naming.TemplateParameterCase - value: CamelCase - - key: readability-identifier-naming.UnionCase - value: CamelCase - - - key: readability-identifier-naming.FunctionCase - value: camelBack - - key: readability-identifier-naming.LocalConstantCase - value: camelBack - - key: readability-identifier-naming.LocalVariableCase - value: camelBack - - key: readability-identifier-naming.NamespaceCase - value: camelBack - - key: readability-identifier-naming.MemberCase - value: camelBack - - key: readability-identifier-naming.MethodCase - value: camelBack - - key: readability-identifier-naming.ParameterCase - value: camelBack - - key: readability-identifier-naming.VariableCase - value: camelBack - - - key: readability-identifier-naming.GlobalConstantCase - value: UPPER_CASE - - key: readability-identifier-naming.GlobalVariableCase - value: UPPER_CASE - - key: readability-identifier-naming.MacroDefinitionCase - value: UPPER_CASE diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 619de2175..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile -{ - "name": "Existing Dockerfile", - "build": { - "context": "..", - "dockerfile": "../Dockerfile", - "target": "dev", - "args": { - "base": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" - } - } - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Uncomment the next line to run commands after the container is created. - // "postCreateCommand": "cat /etc/os-release" - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "devcontainer" -} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..30b44c08f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +target/ +build/ +.git/ +dist/ +vendor/ +repomix-output.xml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 950affd87..000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,3 +0,0 @@ -self-hosted-runner: - labels: - - ubuntu-24.04 diff --git a/.github/actions/build-and-test/action.yml b/.github/actions/build-and-test/action.yml deleted file mode 100644 index 26c081167..000000000 --- a/.github/actions/build-and-test/action.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Build & Test -description: Shared build/test steps for all runners -inputs: - build: - required: true - coverage: - required: true -runs: - using: "composite" - steps: - - name: Setup Sharness - shell: bash - run: | - wget https://raw.githubusercontent.com/felipec/sharness/refs/tags/v1.2.1/sharness.sh - wget https://raw.githubusercontent.com/felipec/sharness/refs/tags/v1.2.1/lib-sharness/functions.sh - mv sharness.sh tests/ - mkdir tests/lib-sharness - mv functions.sh tests/lib-sharness/ - - - name: Print versions - shell: bash - run: make versions - - - name: Stage 0 - Build Stage 1 - shell: bash - run: make BUILD=${{ inputs.build }} -j4 - - - name: Stage 1 - Print version - shell: bash - run: ./build/cabin version --verbose - - - name: Stage 1 - Test - shell: bash - run: | - [[ '${{ inputs.coverage }}' == 'true' ]] && COVERAGE='--coverage' - # shellcheck disable=SC2086 - ./build/cabin test -vv $COVERAGE - env: - CABIN: ${{ github.workspace }}/build/cabin - - - name: Stage 1 - Build Stage 2 - shell: bash - run: | - [[ '${{ inputs.build }}' == 'release' ]] && RELEASE='--release' - # shellcheck disable=SC2086 - ./build/cabin -vv build $RELEASE - - - name: Stage 2 - Print version - shell: bash - run: ./cabin-out/${{ inputs.build }}/cabin version --verbose - - - name: Stage 2 - Test - shell: bash - run: ./cabin-out/${{ inputs.build }}/cabin test -vv - env: - CABIN: ${{ github.workspace }}/cabin-out/${{ inputs.build }}/cabin diff --git a/.github/actions/setup-llvm/action.yml b/.github/actions/setup-llvm/action.yml deleted file mode 100644 index afd84b70c..000000000 --- a/.github/actions/setup-llvm/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Setup LLVM -description: Install LLVM from apt.llvm.org -inputs: - version: - required: true -runs: - using: "composite" - steps: - - name: Install LLVM - shell: bash - run: | - set -euo pipefail - wget https://apt.llvm.org/llvm.sh - chmod +x ./llvm.sh - sudo ./llvm.sh "${{ inputs.version }}" all - rm ./llvm.sh diff --git a/.github/actions/setup-macos-deps/action.yml b/.github/actions/setup-macos-deps/action.yml deleted file mode 100644 index 6538a38ce..000000000 --- a/.github/actions/setup-macos-deps/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: "Setup macOS dependencies" -description: "Setup macOS dependencies" -runs: - using: "composite" - steps: - - name: "Setup dependencies" - shell: bash - run: | - brew install \ - curl \ - fmt \ - spdlog \ - libgit2 \ - ninja \ - nlohmann-json \ - tbb diff --git a/.github/actions/setup-ubuntu-deps/action.yml b/.github/actions/setup-ubuntu-deps/action.yml deleted file mode 100644 index e3418b755..000000000 --- a/.github/actions/setup-ubuntu-deps/action.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Setup Ubuntu dependencies" -description: "Setup Ubuntu dependencies" -runs: - using: "composite" - steps: - - name: "Setup dependencies" - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y \ - libcurl4-openssl-dev \ - libfmt-dev \ - libspdlog-dev \ - libgit2-dev \ - ninja-build \ - nlohmann-json3-dev \ - libtbb-dev diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7aafd9f55..ab1044573 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,14 +11,13 @@ updates: schedule: interval: monthly commit-message: - prefix: "fix" - prefix-development: "chore" + prefix: "ci" include: "scope" - - package-ecosystem: "devcontainers" + + - package-ecosystem: "cargo" directory: "/" schedule: interval: monthly commit-message: - prefix: "fix" - prefix-development: "chore" + prefix: "chore" include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 881eca1f5..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,8 +0,0 @@ - - -## Checklist - -- [ ] Did you follow [CONTRIBUTING.md](https://github.com/cabinpkg/cabin/blob/main/CONTRIBUTING.md)? -- [ ] Did you follow [Pull Request Style](https://github.com/cabinpkg/cabin/blob/main/CONTRIBUTING.md#pull-request-style)? diff --git a/.github/release-header.md b/.github/release-header.md deleted file mode 100644 index 891ce4bd6..000000000 --- a/.github/release-header.md +++ /dev/null @@ -1,17 +0,0 @@ -:sparkling_heart: I maintain **Cabin** in my spare time. Buy me a coffee on [GitHub Sponsors](https://github.com/sponsors/ken-matsui) so I can keep shipping features! - -## Installation - -### Ubuntu/Debian - -```bash -wget https://github.com/cabinpkg/cabin/releases/download/${TAG}/cabin_${TAG}_amd64.deb -sudo dpkg -i cabin_${TAG}_amd64.deb -``` - -### RHEL/CentOS/Fedora - -```bash -wget https://github.com/cabinpkg/cabin/releases/download/${TAG}/cabin-${TAG}-1.x86_64.rpm -sudo rpm -i cabin-${TAG}-1.x86_64.rpm -``` diff --git a/.github/release-please-config.json b/.github/release-please-config.json deleted file mode 100644 index 0bc03949a..000000000 --- a/.github/release-please-config.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "simple", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "skip-changelog": true, - "packages": { - ".": { - "extra-files": [ - { - "type": "toml", - "path": "cabin.toml", - "jsonpath": "$.package.version" - } - ] - } - } -} diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json deleted file mode 100644 index ed21d28cb..000000000 --- a/.github/release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.13.0" -} diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 000000000..eaba2b09c --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,23 @@ +name: Auto Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + auto-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Release + uses: softprops/action-gh-release@v2 + with: + draft: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index cba27cdfa..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,85 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: "27 11 * * 2" - -env: - CXX: g++-14 - -jobs: - analyze: - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners - # Consider using larger runners for possible analysis time improvements. - runs-on: ubuntu-24.04 - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ["c-cpp"] - # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] - # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - name: Setup dependencies - uses: ./.github/actions/setup-ubuntu-deps - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml deleted file mode 100644 index 7b127dd34..000000000 --- a/.github/workflows/cpp.yml +++ /dev/null @@ -1,228 +0,0 @@ -name: C++ - -on: - push: - branches: [main] - pull_request: - -env: - CABIN_TERM_COLOR: always - CXX: g++-14 # default compiler to build - LLVM_VER: 21 - -permissions: - contents: read - -jobs: - apple-clang: - name: "build & test (macOS ${{ matrix.osver }} - Apple Clang - ${{ matrix.build }})" - runs-on: macos-${{ matrix.osver }} - strategy: - fail-fast: false - matrix: - osver: [15, 26] - build: [dev, release] - env: - CXX: c++ - steps: - - uses: actions/checkout@v6 - - - name: Setup macOS dependencies - uses: ./.github/actions/setup-macos-deps - - - name: Build & Test - uses: ./.github/actions/build-and-test - with: - build: ${{ matrix.build }} - coverage: false - - clang: - name: "build & test (Linux - Clang ${{ matrix.ver }} - ${{ matrix.build }})" - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - build: [dev, release] - ver: [19, 20, 21] - env: - CXX: clang++-${{ matrix.ver }} - steps: - - uses: actions/checkout@v6 - - - name: Setup Ubuntu dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Setup Clang ${{ matrix.ver }} - uses: ./.github/actions/setup-llvm - with: - version: ${{ matrix.ver }} - - - name: Build & Test - uses: ./.github/actions/build-and-test - with: - build: ${{ matrix.build }} - coverage: false - - gcc: - name: "build & test (Linux - GCC ${{ matrix.ver }} - ${{ matrix.build }}${{ matrix.coverage && ' - coverage' || '' }})" - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - build: [dev, release] - ver: [14, 15] - coverage: [false] - include: - # Coverage testing with GCC 14 - - build: dev - ver: 14 - coverage: true - env: - CXX: g++-${{ matrix.ver }} - steps: - - uses: actions/checkout@v6 - - - name: Setup Ubuntu dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Setup GCC - if: matrix.ver == 15 - run: | - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - brew install gcc@15 binutils - echo "$(brew --prefix binutils)/bin" >> "$GITHUB_PATH" - echo "CXX=$(brew --prefix gcc@15)/bin/g++-15" >> "$GITHUB_ENV" - - - name: Install lcov - if: matrix.coverage - run: sudo apt-get install -y lcov - - - name: Build & Test - uses: ./.github/actions/build-and-test - with: - build: ${{ matrix.build }} - coverage: ${{ matrix.coverage }} - - - name: Print coverage - if: success() && matrix.coverage - run: | - lcov --capture --directory . --no-external \ - --gcov-tool "gcov-${{ matrix.ver }}" \ - --output-file coverage.info - lcov --list coverage.info - - package-test: - needs: [apple-clang, clang, gcc] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - - name: Setup Ubuntu dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Build cabin - run: make BUILD=release -j4 - - - name: Install nFPM - run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm - - - name: Test DEB package creation - run: nfpm pkg --packager deb - env: - CABIN_VERSION: 0.0.0+${{ github.sha }} - - - name: Test RPM package creation - run: nfpm pkg --packager rpm - env: - CABIN_VERSION: 0.0.0+${{ github.sha }} - - - name: List created packages - run: ls -la ./*.deb ./*.rpm - - - name: Verify package contents - run: | - dpkg-deb --contents ./*.deb | grep '/usr/bin/cabin' - rpm -qlp ./*.rpm | grep '/usr/bin/cabin' - - - name: Test DEB package installation - run: sudo dpkg -i ./*.deb - - - name: Create test project - run: cabin new test_pj - working-directory: ${{ runner.temp }} - - - name: Test cabin functionality - run: cabin run | grep 'Hello, world!' - working-directory: ${{ runner.temp }}/test_pj - - format: - needs: [apple-clang, clang, gcc] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - - name: Setup dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Build Cabin - run: make BUILD=release -j4 - - - name: Install clang-format-${{ env.LLVM_VER }} - uses: ./.github/actions/setup-llvm - with: - version: ${{ env.LLVM_VER }} - - - name: cabin fmt - run: ./build/cabin fmt --check -vv - env: - CABIN_FMT: clang-format-${{ env.LLVM_VER }} - - lint: - needs: [apple-clang, clang, gcc] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - - name: Setup dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Build Cabin - run: make BUILD=release -j4 - - - uses: actions/setup-python@v6 - with: - python-version: '3.*' - - - name: Install cpplint - run: pip install cpplint - - - name: Show cpplint version - run: cpplint --version - - - name: cabin lint - run: ./build/cabin lint -vv - - clang-tidy: - needs: [apple-clang, clang, gcc] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - - name: Setup dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Build Cabin - run: make BUILD=release -j4 - - - name: Install clang-tidy-${{ env.LLVM_VER }} - uses: ./.github/actions/setup-llvm - with: - version: ${{ env.LLVM_VER }} - - - name: cabin tidy - run: ./build/cabin tidy -vv - env: - CABIN_TIDY: run-clang-tidy-${{ env.LLVM_VER }} diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 000000000..421b57a6d --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,78 @@ +name: Demo + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + generate: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install build tools + # `cargo build` needs only the Rust toolchain to compile + # Cabin itself; Ninja, gcc, and g++ are pulled in for the + # demo's `cabin run`, which builds and executes a fresh + # C++ package. + run: | + sudo apt-get update + sudo apt-get install -y ninja-build gcc g++ + + - name: Build Cabin (release) + run: cargo build --workspace --release --locked + + - name: Install Cabin + run: | + mkdir -p "$HOME/.local/bin" + cp target/release/cabin "$HOME/.local/bin/cabin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install ttyd + run: | + wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 -P "$HOME/.local/bin" + mv "$HOME/.local/bin/ttyd.x86_64" "$HOME/.local/bin/ttyd" + chmod +x "$HOME/.local/bin/ttyd" + + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y ffmpeg + + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Install VHS + run: go install github.com/charmbracelet/vhs@latest + + - name: Install Nerd Font + run: | + mkdir -p ~/.local/share/fonts + wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.3.0/FiraCode.zip + unzip FiraCode.zip -d ~/.local/share/fonts/ + fc-cache -fv + + - name: Install Zsh + run: sudo apt update && sudo apt install -y zsh + + - name: Install zsh-syntax-highlighting + run: | + git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ~/z + echo 'source ~/z/zsh-syntax-highlighting.zsh' >> ~/.zshrc + + - name: Install Starship + run: | + curl -sS https://starship.rs/install.sh | sh -s -- -y + echo 'eval "$(starship init zsh)"' >> ~/.zshrc + + - name: Move demo.tape + run: mv demo.tape ${{ runner.temp }} + + - name: Generate and publish a new demo + run: vhs --publish demo.tape + working-directory: ${{ runner.temp }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6d2f0ed59..0a8156118 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,9 +2,12 @@ name: Docs on: push: - branches: [main] + branches: [ main ] pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -15,7 +18,7 @@ jobs: with: python-version: '3.x' - - name: Install mkdocs + - name: Install MkDocs run: pip install mkdocs - name: Build docs @@ -24,11 +27,11 @@ jobs: deploy: if: github.ref == 'refs/heads/main' needs: build + runs-on: ubuntu-latest permissions: contents: write - runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -36,7 +39,7 @@ jobs: with: python-version: '3.x' - - name: Install mkdocs + - name: Install MkDocs run: pip install mkdocs - name: Publish docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a62457c65..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: Release - -on: - push: - branches: [main] - -permissions: - contents: write - pull-requests: write - -env: - CXX: g++-14 - -jobs: - bump: - runs-on: ubuntu-24.04 - outputs: - release_created: ${{ steps.rp.outputs.release_created }} - tag: ${{ steps.rp.outputs.tag_name }} - steps: - - uses: googleapis/release-please-action@v5 - id: rp - with: - token: ${{ secrets.GITHUB_TOKEN }} - config-file: .github/release-please-config.json - manifest-file: .github/release-please-manifest.json - - build: - needs: bump - if: needs.bump.outputs.release_created == 'true' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.bump.outputs.tag }} - - - name: Setup Ubuntu dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Build cabin - run: make BUILD=release -j4 - - - name: Upload build artifact - uses: actions/upload-artifact@v7 - with: - name: cabin-binary - path: build/cabin - - package: - needs: [bump, build] - if: needs.bump.outputs.release_created == 'true' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.bump.outputs.tag }} - - - name: Download build artifact - uses: actions/download-artifact@v8 - with: - name: cabin-binary - path: build/ - - - name: Make binary executable - run: chmod +x build/cabin - - - name: Install nFPM - run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm - - - name: Create DEB package - run: nfpm pkg --packager deb - env: - CABIN_VERSION: ${{ needs.bump.outputs.tag }} - - - name: Create RPM package - run: nfpm pkg --packager rpm - env: - CABIN_VERSION: ${{ needs.bump.outputs.tag }} - - - name: Upload package artifacts - uses: actions/upload-artifact@v7 - with: - name: rel-packages - path: | - *.deb - *.rpm - - demo: - needs: [bump, build] - if: needs.bump.outputs.release_created == 'true' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.bump.outputs.tag }} - - - name: Download build artifact - uses: actions/download-artifact@v8 - with: - name: cabin-binary - path: build/ - - - name: Make binary executable - run: chmod +x build/cabin - - - name: Setup dependencies - uses: ./.github/actions/setup-ubuntu-deps - - - name: Install Cabin - run: | - mkdir -p "$HOME/.local/bin" - mv build/cabin "$HOME/.local/bin/" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install ttyd - run: | - wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 -P "$HOME/.local/bin" - mv "$HOME/.local/bin/ttyd.x86_64" "$HOME/.local/bin/ttyd" - chmod +x "$HOME/.local/bin/ttyd" - - - name: Install ffmpeg - run: sudo apt-get update && sudo apt-get install -y ffmpeg - - - uses: actions/setup-go@v6 - with: - go-version: 'stable' - - - name: Install VHS - run: go install github.com/charmbracelet/vhs@latest - - - name: Install Nerd Font - run: | - mkdir -p ~/.local/share/fonts - wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.3.0/FiraCode.zip - unzip FiraCode.zip -d ~/.local/share/fonts/ - fc-cache -fv - - - name: Install Zsh - run: sudo apt update && sudo apt install -y zsh - - - name: Install zsh-syntax-highlighting - run: | - git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ~/z - echo 'source ~/z/zsh-syntax-highlighting.zsh' >> ~/.zshrc - - - name: Install Starship - run: | - curl -sS https://starship.rs/install.sh | sh -s -- -y - echo 'eval "$(starship init zsh)"' >> ~/.zshrc - - - name: Move demo.tape - run: mv demo.tape ${{ runner.temp }} - - - name: Generate demo.gif - run: vhs demo.tape - working-directory: ${{ runner.temp }} - - - name: Upload demo artifact - uses: actions/upload-artifact@v7 - with: - name: rel-demo - path: ${{ runner.temp }}/demo.gif - - release: - needs: [bump, package, demo] - if: needs.bump.outputs.release_created == 'true' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.bump.outputs.tag }} - - - name: Prepend header to RP notes - uses: actions/github-script@v9 - with: - script: | - const tag = '${{ needs.bump.outputs.tag }}'; - const { owner, repo } = context.repo; - const { data: rel } = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); - const fs = require('fs'); - let header = fs.readFileSync('.github/release-header.md', 'utf8'); - header = header.replace(/\$\{TAG\}/g, tag); - await github.rest.repos.updateRelease({ - owner, repo, release_id: rel.id, - body: `${header}\n\n${rel.body ?? ''}` - }); - - - name: Download release artifacts - uses: actions/download-artifact@v8 - with: - pattern: rel-* - path: artifacts/ - merge-multiple: true - - - name: Upload release artifacts - run: gh release upload '${{ needs.bump.outputs.tag }}' artifacts/* --clobber diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 000000000..0cf9fdeef --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,100 @@ +name: Rust + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: read + +jobs: + format: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all --verbose -- --check + + clippy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Run clippy + run: cargo clippy --workspace --all-targets --locked --verbose -- -D warnings -D clippy::pedantic + + build-and-test: + needs: + - format + - clippy + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux build tools + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build gcc g++ \ + clang-format clang-tidy pkg-config + + - name: Install macOS build tools + if: runner.os == 'macOS' + run: | + brew update + brew install ninja llvm pkg-config + echo "$(brew --prefix llvm)/bin" >> "$GITHUB_PATH" + + - name: Check formatting + run: cargo fmt --all --verbose -- --check + + - name: Run clippy + # Lint denials (`-D warnings`, `-D clippy::pedantic`) live in + # the root `Cargo.toml` under `[workspace.lints]`, so no + # trailing `--` flags are needed here. + run: cargo clippy --workspace --all-targets --locked --verbose + + - name: Build (locked) + run: cargo check --workspace --all-targets --locked --verbose + + - name: Run tests (locked) + env: + RUST_BACKTRACE: "1" + run: | + cargo test --workspace --all-targets --all-features --locked \ + --verbose -- --show-output + + - name: Build documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --workspace --no-deps --locked --verbose diff --git a/.gitignore b/.gitignore index baa743626..1a953f655 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,20 @@ -# macOS -.DS_Store - -# IDE -.idea/ -.vscode/ +# Rust build artifacts +/target +**/*.rs.bk +Cargo.lock.bak -# Make +# Cabin build output (default --build-dir). build/ -# Cabin -cabin-out/ +# MkDocs build output +/site -# Gcov -*.gcno -*.gcda -*.gcov - -# Doxygen -html +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo -# MkDocs -site/ +# OS +.DS_Store +Thumbs.db diff --git a/.rgignore b/.rgignore deleted file mode 100644 index 2eb21669a..000000000 --- a/.rgignore +++ /dev/null @@ -1,3 +0,0 @@ -!.clang-format -!.clang-tidy -!/.github/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c49747a95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1237 @@ +# AGENTS.md + +Guidance for contributors working on this repository. + +## Project goal + +Cabin is a **package manager and build system for C and C++**, +distributed as the public local OSS core. The repository is the +Rust implementation. Cabin is *Cargo-inspired*, not +*Cargo-compatible*: it borrows Cargo's vocabulary where the +semantics line up and diverges where C/C++ semantics demand it. + +The local core is **pre-1.0**. +Capabilities already in this repository: + +- C / C++ / mixed-C-and-C++ multi-package builds via Ninja, with + a typed `BuildGraph` and a Clang-compatible + `compile_commands.json`. +- `cabin run` (build + execute a `cpp_executable` with `--` + arg forwarding and a `CABIN_*` env overlay). +- `cabin test` for `cpp_test` targets, with a deterministic + per-test `CABIN_*` env. +- Two dependency kinds (`normal`, `dev`) plus a + `system = true` sourcing flag, with documented activation + rules. +- Workspace semantics: member globs, `exclude`, + `default-members`, shared `[workspace.-dependencies]`, + selection-aware loading. +- Cabin-native backtracking resolver, `cabin.lock`, + artifact pipeline (fetch / verify / extract). +- `cabin package` + local file-registry `cabin publish` + (no remote registry protocol). +- Sparse HTTP index read path. +- Features + cross-package feature resolver. +- `[target.'cfg(...)'.]` dependency conditions. +- Build profiles, toolchain selection, capability detection, + and `ccache` / `sccache` compiler-cache wrappers. +- Typed `.cabin/config.toml` system with documented precedence. +- Patch / override / source-replacement. +- `cabin vendor` + `--offline` / `CABIN_NET_OFFLINE`. +- `cabin metadata` / `cabin tree` / `cabin explain`. +- The Cargo-inspired interface foundation: `--build-dir ` + is the build-output flag (default `build/`), `--target` is + reserved for the future platform / toolchain target flag and + is *not* a manifest-target selector on any command, + `cabin run` / `cabin test` get a deterministic `CABIN_*` + overlay. +- Developer tooling: `cabin fmt` (clang-format) and + `cabin tidy` (run-clang-tidy) — both sharing the + `cabin-source-discovery` walker, an env-override for the + underlying tool, and the same workspace-selection flags. +- C / C++ environment-flag ingestion: `CPPFLAGS` / `CFLAGS` / + `CXXFLAGS` / `LDFLAGS` parse with shell-style quoting and + route to the matching compile / link commands. +- `system = true` dependency probing via `pkg-config` + (`CABIN_PKG_CONFIG` overrides the executable); cflags / libs + flow into the build planner and `compile_commands.json`. +- `-j` / `--jobs ` build / run / tidy parallelism with a typed + validated model and a documented precedence chain. +- `cabin new ` / `cabin init` with `--bin` / `--lib` + scaffold parity. +- `cabin version` (concise + verbose forms) and `cabin --list` + (full subcommand directory; `cabin --help` stays curated). + +Probe compilations beyond `--version`, distcc / icecc compile- +server wrappers, full Windows / MSVC support, cross-compilation, +SARIF / structured-diagnostic frameworks, sanitiser frameworks, +coverage instrumentation and reporting, a benchmark target kind +or harness, broad CMake / Meson compatibility, and any remote +build cache are explicitly deferred — see +[`docs/architecture.md`](docs/architecture.md). + +## Where compiler / tool detection work belongs + +- `cabin-core::compiler` owns `CompilerKind`, `ArchiverKind`, + `CompilerVersion`, `CompilerIdentity`, `ArchiverIdentity`, + `CompilerCapabilities`, `ArchiverCapabilities`, `Capability`, + `CapabilitySource`, `ToolDetection`, + `ToolchainDetectionReport`, and `ToolDetectionError`. It also + holds the pure parsers (`parse_cxx_version_output`, + `parse_ar_version_output`), the capability-derivation + functions (`derive_*_capabilities`), and the backend + validators (`validate_*_for_backend`). +- `cabin-toolchain::detect` owns subprocess spawning. The + `ToolRunner` trait abstracts `tool --version` so detection is + testable without real binaries; `ProcessRunner` is the + production implementation. Detection never touches the + network and never compiles probe sources. +- `cabin-build::validate_toolchain_for_backend` consumes the + detection report and rejects compilers / archivers that + cannot run the GCC/Clang-style commands the planner emits. + Validation runs *before* any Ninja file is written. +- `cabin-cli` runs detection after toolchain resolution, + validates before planning, and surfaces the report under + `toolchain.detected` in `cabin metadata`. +- `cabin-package`, `cabin-index`, and `cabin-registry-file` + must **never** serialise detection results into package / + index metadata — the report is local-environment state. + +**Do not** put version-output parsing, process probing, +capability decisions, or backend support policy in `cabin-cli`. +The CLI calls the typed APIs above and renders the result; new +detection logic belongs in the owning crate, not in +`cabin-cli/src/cli.rs`. + +## Where build-profile work belongs + +- `cabin-core::profile` owns `ProfileName`, `OptLevel`, + `BuiltinProfile`, `ProfileDefinition`, `ProfileSelection`, + `ResolvedProfile`, `ProfileSource`, and `resolve_profile`. + These are the typed values every other crate consumes. +- `cabin-manifest` parses `[profile.*]` tables and rejects + unsupported fields. Raw serde structs stay private to the + crate; the public surface returns + `cabin_core::ProfileDefinition` values. +- `cabin-workspace` rejects member / path-dep manifests that + declare `[profile.*]` tables, because only the entry-point + manifest's profile tables count. +- `cabin-build` consumes `ResolvedProfile` to derive C++ compile + flags and the per-profile output directory. It does not parse + CLI flags or manifests. +- `cabin-package` preserves manifest `[profile.*]` declarations + in the canonical metadata; `cabin-index` round-trips them + opaquely. Index resolution remains profile-independent. + +**Do not** put profile parsing, inheritance resolution, build- +graph policy, or compiler-flag mapping in `cabin-cli`. The CLI +parses `--profile` / `--release` and converts them into +`cabin_core::ProfileSelection`; everything else lives behind a +typed API in the owning crate. + +## Where toolchain / build-flag work belongs + +- `cabin-core::toolchain` owns `ToolKind`, `ToolSpec`, + `ToolSource`, `ToolSelection`, `ResolvedTool`, `ResolvedToolchain`, + `ToolchainSettings`, `ConditionalToolchainDecl`, `ToolchainDecl`, + and `ToolchainResolutionError`. +- `cabin-core::build_flags` owns `ProfileFlags`, + `ConditionalProfileFlags`, `ProfileSettings`, + `ResolvedProfileFlags`, the `resolve_build_flags` merge function, + and `BuildFlagsValidationError`. +- `cabin-toolchain::resolve` owns the precedence walk + (CLI â–ļ env â–ļ matching `[target.'cfg(...)'.toolchain]` â–ļ + `[toolchain]` â–ļ default fallback list), `PATH` search, and + the unsupported-MSVC compiler rejection. +- `cabin-manifest` parses `[toolchain]`, `[profile]`, + `[profile.]`, `[target.'cfg(...)'.toolchain]`, and + `[target.'cfg(...)'.profile]` tables and rejects unknown fields. + Raw serde structs stay private. +- `cabin-workspace` rejects member / path-dep manifests that + declare `[toolchain]` tables, mirroring the existing + `[profile.*]` rule. +- `cabin-build` consumes the `ResolvedToolchain` and the + per-package `ResolvedProfileFlags` map. It maps semantic flags + onto the existing compile / link / archive commands without + parsing CLI flags or manifests. +- `cabin-package`, `cabin-index`, and `cabin-registry-file` + round-trip *manifest-declared* `[toolchain]`, `[profile]`, + and `[target.'cfg(...)'.profile]` declarations only. CLI- or + env-derived selections must never flow into the canonical + metadata document or the file registry. + +## Where patch / override / source-replacement work belongs + +- `cabin-core::patch` owns `PatchSource`, `PatchSourceKind`, + `PatchProvenance`, `DeclaredPatch`, `PatchManifestSettings` + (root-only manifest model), and the typed + `PatchValidationError`. Pure data + small parsers only. +- `cabin-core::source_replacement` owns `SourceLocator`, + `SourceReplacementEntry`, `SourceReplacementSettings`, the + cycle-detecting `resolve()`, and the typed + `SourceReplacementError`. +- `cabin-manifest` parses the `[patch]` table on root manifests + with stable rejection messages for `git` / `url` / `version`. + Member manifests with `[patch]` are rejected by + `cabin-workspace`. +- `cabin-config` parses `[patch]` and `[source-replacement]` + tables, rejects credentials in URLs and unsupported source + kinds, and threads patches through into `EffectiveConfig`. +- `cabin-workspace::patch` resolves the merged manifest + + config patch policy, validates each entry (path, manifest, + name, version), and exposes `ActivePatchSet` for downstream + consumers. `load_workspace_with_registry_and_patches` + stitches each active patch as a `kind = Local` package. +- `cabin-cli`'s `patch_glue` module orchestrates: it converts + `EffectiveConfig` into `cabin-workspace`-shaped inputs, + applies source replacement to the resolved index source, + threads patches into the artifact pipeline / lockfile / + metadata view, and renders the deterministic JSON / lockfile + records. +- `cabin-package` rejects manifests with a non-empty `[patch]` + table to keep local override policy out of published + archives. +- `cabin-lockfile` exposes top-level `[[patch]]` and + `[[source-replacement]]` arrays for stale-detection under + `--locked`. Old lockfiles without these arrays remain valid. + +**Do not** put patch parsing, config merging, source +replacement, resolver candidate modification, lockfile patch +state, or publish validation in `cabin-cli/src/cli.rs`. The +typed surfaces above own the policy; the CLI layer only +threads typed values through. New patch source kinds extend +[`cabin_core::PatchSource`] explicitly — never as stringly +typed strings — and add a matching parser in +`cabin-manifest` / `cabin-config`. The patch / override layer +explicitly does not implement Git sources, vendoring, registry +authentication, credentials handling, new registry protocols, +HTTP publish, or registry-server work — those are tracked +separately in [`docs/architecture.md`](docs/architecture.md). + +## Where config-file work belongs + +- `cabin-config` owns config discovery, raw + TOML deserialisation (private serde types behind + `deny_unknown_fields`), validation, merging, and the typed + [`EffectiveConfig`] consumed by the rest of the workspace. + Reuses typed models from `cabin-core` (`ToolSpec`, + `CompilerWrapperRequest`, `ConfigValueSource`) so the config + layer never invents parallel grammars. +- `cabin-cli` only *orchestrates* config: it loads the + effective config via the typed API, threads it into existing + resolvers and into the metadata view, and exposes the + documented env vars (`CABIN_NO_CONFIG`, `CABIN_CONFIG`, + `CABIN_CONFIG_HOME`). Discovery, parsing, merging, validation, + and precedence policy do **not** belong in + `cabin-cli/src/cli.rs`. The thin glue helpers live in + `cabin-cli/src/config_glue.rs`. +- `cabin-core::config_source` owns the cross-cutting + `ConfigValueSource` enum used by metadata reporting for paths, + profile, and registry settings. Tool/wrapper-specific source + enums (`ToolSource`, `CompilerWrapperSource`) gain matching + `*Config` variants so the existing precedence walkers can + attribute a value to the exact config file. +- `cabin-toolchain::resolve` and `cabin-toolchain::wrapper` + accept an optional config layer (`ConfigToolchainLayer` / + `ConfigWrapperLayer`) that slots between the env variable and + the manifest. The resolvers do not parse config TOML or know + about file discovery — they just consume the typed layer. +- `cabin-package`, `cabin-index`, `cabin-index-http`, and + `cabin-publish` must **never** serialise effective config into + package or index metadata. Local config files (`.cabin/`) are + excluded from deterministic source archives by the existing + `EXCLUDED_DIR_NAMES` policy. + +**Do not** put config discovery, parsing, merging, precedence +policy, validation, secrets handling, source replacement, or +vendoring in `cabin-cli`. The config layer's public surface is +intentionally narrow: `[registry]`, `[paths]`, `[build]`, +`[build.cache]`, `[toolchain]`, `[patch]`, and +`[source-replacement]` tables — nothing else, no auth, no +tokens, no `[target.'cfg(...)']`-conditioned config tables. + +## Where compiler-cache wrapper work belongs + +- `cabin-core::compiler_wrapper` owns `CompilerWrapperKind`, + `CompilerWrapperRequest`, `CompilerWrapperManifestSettings`, + `ConditionalCompilerWrapperDecl`, `CompilerWrapperSource`, + `CompilerWrapperIdentity`, `ResolvedCompilerWrapper`, + `CompilerWrapperSummary`, and `CompilerWrapperParseError`. +- `cabin-toolchain::wrapper` owns the precedence walk + (CLI â–ļ `CABIN_COMPILER_WRAPPER` env â–ļ config + `[build.cache]` â–ļ matching + `[target.'cfg(...)'.profile.cache]` â–ļ workspace-root + manifest `[profile.cache]` â–ļ no wrapper), `PATH` search via the same + `EnvLookup` / `ExecutableProbe` callbacks the toolchain + resolver uses, and an optional `--version` probe through + `ToolRunner`. +- `cabin-manifest` parses `[profile.cache]` (and the + target-conditioned `[target.'cfg(...)'.profile.cache]` variant) + into the typed + `CompilerWrapperManifestSettings`. Member / path-dep manifests + with non-empty cache settings are rejected via the workspace + loader's new `MemberDeclaresCompilerWrapper` error. +- `cabin-build`'s planner accepts `Option<&ResolvedCompilerWrapper>` + on `PlanRequest` and prepends the wrapper to every C++ compile + command on the Ninja path *only*. Link and archive commands are + deliberately never wrapped, and `compile_commands.json` keeps + the underlying compiler so clangd / IDE tooling stays accurate. +- `cabin-package`, `cabin-index`, and `cabin-registry-file` + round-trip *manifest-declared* `[profile.cache]` settings only. + CLI / env-derived selections must never flow into canonical + metadata. + +**Do not** put wrapper parsing, precedence walking, `PATH` +search, version probing, or planner integration in `cabin-cli`. +The CLI parses `--compiler-wrapper` / `--no-compiler-wrapper`, +calls the typed APIs above, and threads the result through +`PlanRequest` and `MetadataView`. + +**Do not** put toolchain resolution, condition evaluation, flag +merging, or build-graph policy in `cabin-cli`. The CLI parses +`--cc` / `--cxx` / `--ar` and converts them into +`cabin_core::ToolchainSelection`; everything else lives behind a +typed API in the owning crate. + +## Where dependency-kind work belongs + +- `cabin-core` owns `DependencyKind`, `Dependency`, + `SystemDependency`, and the per-kind `Project` collections. + Add new kind-related types here only when they are needed by + more than one downstream crate. +- `cabin-manifest` is the only crate allowed to parse + `[dependencies]` and `[dev-dependencies]` text (including + `system = true` entries). Raw serde structs stay private. +- `cabin-workspace` owns kind-specific workspace inheritance and + the package-graph edge model (`DependencyEdge` carries + `(index, kind)`). +- `cabin-resolver` only ever sees the resolvable kinds (normal). + System deps must never reach it; dev deps are excluded by + default. +- `cabin-build` only links normal-kind edges into ordinary + targets. Build / dev deps must not auto-link. +- `cabin-package`, `cabin-index`, and `cabin-registry-file` + preserve every kind end-to-end through canonical metadata. + +**Do not** put dependency parsing, dependency-kind policy, +dependency-graph algorithms, or resolver-input construction +logic in `cabin-cli`. The CLI translates clap inputs into the +typed APIs above and renders the result; new dependency-kind +behaviour belongs in the owning crate, not in +`cabin-cli/src/cli.rs`. + +**Do not** implement future dependency features +opportunistically. Cross-compilation remains explicitly +deferred — manifest fields that gesture at it must stay rejected +with clear errors. + +## Where system-dependency probing work belongs + +- `cabin-system-deps` owns `PkgConfigTool`, the typed + `SystemDependencyProbeRequest` / `SystemDependencyProbeReport` + / `SystemDependencyFlags` model, the + `probe_system_dependency` entry point, the + `cabin::system_deps::*` `PkgConfigError` diagnostic family, + the pkg-config argv builder (including the + SemVer-comparator → pkg-config-operator translation), and the + minimal-quoting splitter used to parse `--cflags` / `--libs` + output. Must not parse manifests, walk the workspace graph, + or mutate `ResolvedProfileFlags`. +- `cabin-cli::system_deps_glue` is the orchestration shell: it + collects active system dependencies from + `cabin_workspace::PackageGraph::primary_packages`, applies + conditional declarations against the host platform, calls + `probe_system_dependency` once per active dep, and merges + the resulting flags into the per-package + `HashMap` that flows through the + build pipeline. The single helper + `augment_build_flags_with_system_deps` is called from every + command that constructs a build configuration — + `cabin build`, `cabin run`, `cabin test`, `cabin tidy`, + `cabin metadata`, and `cabin explain build-config` — after + `resolve_per_package_build_flags` and before + `resolve_build_configurations` so the + `BuildConfiguration::fingerprint` observes the discovered + flags. The other `cabin explain` subcommands (`package`, + `target`, `source`, `feature`) do not build a configuration + and therefore skip probing. +- `cabin-env` exposes `CABIN_PKG_CONFIG` alongside the other + read-side env var constants. No new env-handling logic + belongs in `cabin-cli`. + +**Do not** add `pkg-config` invocation code, flag-classifier +logic, version-comparator translation, or executable-resolution +policy to `cabin-cli/src/cli.rs`. The CLI threads the typed +report into the existing build-configuration pipeline; the +probing layer stays in `cabin-system-deps`. + +**Do not** route discovered flags into canonical package +metadata (`cabin-package`, `cabin-index`, `cabin-registry-file`) +or into the lockfile. `system = true` declarations +round-trip end-to-end; pkg-config probe results are local +build-time state. + +**Do not** expand system dependency probing into a broader +package-manager integration (vcpkg / Conan / Homebrew / apt). +Cabin queries `pkg-config` and nothing else. + +## Where dev / test / example target work belongs + +- `cabin-core` owns `TargetKind` and the per-kind classifier + predicates (`is_default_buildable`, `is_dev_only`, `is_test`, + `is_cpp`, `produces_executable`). Add new kinds and + classifiers here only when more than one downstream crate + needs them. +- `cabin-manifest` parses `cpp_test` / `cpp_example` strings + into `TargetKind` variants. Raw serde structs stay private. +- `cabin-workspace` thread an `include_dev_for: &BTreeSet` + set through `WorkspaceLoadOptions` and the `_with_dev` closure + helpers so `cabin test` activates dev-deps for the *selected* + packages without affecting `cabin build`. Dev-dep activation + never propagates to transitive deps. +- `cabin-build` knows that `cpp_test` / `cpp_example` link as + executables and excludes them from the default-target + enumeration. `select_targets_of_kind` is the typed "all + `cpp_test` selectors in selected packages" convenience for + `cabin test`. +- `cabin-test` owns the test execution plan (`TestPlan`, + `TestExecutable`), the sequential runner (`run_tests`), the + output sink trait, and the typed summary (`TestSummary`, + `TestRunStatus`). It does not parse manifests, build + dependency graphs, generate Ninja, or know about config / + patches. +- `cabin-cli/src/test_glue.rs` is the orchestration shell for + `cabin test`: it parses CLI args, drives the existing + build pipeline, hands the resulting `BuildGraph` to + `cabin-test`, and renders the summary. It must not own test + discovery, build-graph target-kind policy, or test execution + business logic. + +**Do not** put `cpp_test` / `cpp_example` policy, test +discovery, test runner business logic, or build-graph +target-kind policy in `cabin-cli/src/cli.rs`. + +**Do not** implement test framework integration +(GoogleTest / Catch2 / doctest output parsing, XML / JUnit +output, in-binary test discovery), sanitiser frameworks, +benchmark target kinds / harnesses, coverage instrumentation, +or `cabin run --example` commands here. Those remain tracked +separately in [`docs/architecture.md`](docs/architecture.md). +`cabin tidy` (run-clang-tidy) already ships — see its owning +crate and docs ([`docs/tidy.md`](docs/tidy.md), +[`docs/testing.md`](docs/testing.md)). + +## Where C / C++ language work belongs + +Cabin treats C and C++ as related but distinct source +languages. Future changes must keep C support first-class; do not +let C++ assumptions leak back in. The owning crates are: + +- `cabin-core` owns the typed `SourceLanguage` enum, the + per-source classifier (`classify_source`), the link-driver + predicate (`link_driver_language`), and the `validate_cc_for_backend` + / `validate_cxx_for_backend` capability validators. Add + language-related typed concepts here, not in downstream crates. +- `cabin-manifest` parses `cflags`, `cxxflags`, and `ldflags` + separately; raw serde structs stay private. Keep the C-only, + C++-only, and link argument buckets distinct. +- `cabin-toolchain` resolves CC and CXX as separate slots and + tries the documented C-compiler fallback list opportunistically + so a C compiler is populated by default. +- `cabin-build` classifies every source per-file, dispatches + compiles through the language-appropriate driver and standard + flag (`-std=c11` for C, `-std=c++17` for C++), keeps the + CFLAGS / CXXFLAGS argv spaces strictly separate, and selects + the link driver by walking the target's own objects plus + every transitively reachable library object. +- `cabin-ninja` declares `c_compile` and `cxx_compile` rules + separately and a single language-neutral `link_executable` + rule; the link driver lives in the action's `command`, not in + the rule name. + +Acceptance guidance for *every* future change: + +- Add or update C-specific fixtures alongside C++ fixtures + whenever changes touch the build planner, the manifest + parser, the build flags, the toolchain layer, the build + graph, the Ninja generator, the package archive, the lockfile, + the artifact pipeline, or the metadata view. +- Keep CFLAGS and CXXFLAGS separate. A new escape-hatch field + must be classed as language-neutral, C-only, or C++-only at + declaration time. +- Keep C and C++ standard flags separate. Do not hardcode + `-std=c++NN` for a C compile and do not hardcode `-std=cNN` + for a C++ compile. +- Keep CC capability detection separate from CXX capability + detection. A C-only feature must not require the CXX side to + support it. +- Document the link-driver pick when adding any new linking + surface (e.g. shared libraries, future plugin targets) — the + rule is "C++ if any reachable C++ object, C otherwise" and + any deviation must be justified in `docs/architecture.md`. + The build planner exposes the predicate as + `cabin_core::link_driver_language(&[SourceLanguage])`; do + not reimplement the rule in another crate. +- Treat the typed dispatch surfaces as the contract: prefer + extending `cabin_core::SourceLanguage`, + `cabin_core::classify_source`, the planner's + `CompileDispatch` (internal to `cabin-build`), and + `cabin_build::flags_for_profile` over scattering language + conditionals across the planner. New language-related work + should land at one of those points. +- Keep public C / C++ headers and private headers separate. The + existing `include_dirs` propagation is the public path; do + not collapse it into a private-also concept without an + explicit language change. +- Keep system dependencies usable for C system libraries + (glibc / libm / libpthread / etc.) the same way they are for + C++ system libraries. + +## Test portability rules + +The shared `cabin()` helper in +`crates/cabin-cli/tests/cli.rs` clears or pins the env vars that +commonly affect integration-test output and tool selection: +`CC` / `CXX` / `AR`, `CPPFLAGS` / `CFLAGS` / `CXXFLAGS` / +`LDFLAGS`, `NINJA`, `CABIN_NO_CONFIG`, `CABIN_CONFIG`, +`CABIN_CONFIG_HOME`, `CABIN_NET_OFFLINE`, `CABIN_FMT`, +`CABIN_TIDY`, `CABIN_PKG_CONFIG`, +`CABIN_COMPILER_WRAPPER`, `CABIN_CACHE_DIR`, standard +pkg-config lookup vars, terminal-colour vars, and a pinned +`CABIN_TERM_COLOR=never`. Use it for every integration test. +Tests whose assertions depend on `CABIN_BUILD_DIR`, +`CABIN_BUILD_JOBS`, `CABIN_TERM_VERBOSE`, or +`CABIN_TERM_QUIET` must set or remove those vars explicitly. +Tests that exercise env precedence opt back in with a plain +`.env(KEY, VALUE)` after `cabin()` returns. + +Tool-availability gating uses one of: + +- `ninja_available` — Ninja only; +- `c_compiler_available` — at least one of `cc` / `clang` / + `gcc`; +- `cxx_compiler_available` — at least one of `c++` / `clang++` + / `g++`; +- `build_tools_available` — Ninja + a C++ compiler; +- `c_and_cxx_build_tools_available` — Ninja + both compilers. + +Tests that compile real `.c` sources gate on +`c_and_cxx_build_tools_available`. Tests that compile only +real C++ sources gate on `build_tools_available`. Pure +data-model unit tests (planner, lockfile, metadata round-trip) +do not need to gate on tool availability and may use fake +absolute paths because the planner records paths as data +without executing them. + +External-tool smoke tests are different: they intentionally fail +by default when `ninja`, `clang-format`, `run-clang-tidy`, or +`pkg-config` is missing. Set +`CABIN_SKIP_EXTERNAL_TOOL_TESTS=1` only when you deliberately want +those smoke tests to use the bundled fake-tool binaries instead. + +Hardcoded host-specific absolute paths (`/tmp/...`, +`/usr/bin/...`, `/this/path/does/not/exist/...`) are +forbidden in integration tests. Use +`dir.path().join("missing-cc")` for the "this path will fail +to resolve" idiom. + +When asserting on driver selection, prefer rule-name +assertions (`c_compile` / `cxx_compile` / `link_executable`) +over substring checks for `c++` / `g++` / `clang++`. When the +actual driver path matters, read it from +`cabin metadata --format json` and compare structurally. +See `docs/testing.md` for the full set of test portability +rules. + +## Where vendoring / offline-mode work belongs + +`cabin vendor` materialises the selected dependency closure +into a deterministic local file-registry directory (default +`vendor/`). The owning crates are: + +- `cabin-vendor` owns the typed `VendorPlan` / + `VendorEntry` / `VendorOptions` model, the deterministic + write logic (`materialise`), the `cabin-vendor.json` + summary, and the path-traversal-safe archive copy. It + re-uses `cabin_registry_file::FileRegistry` so the on-disk + layout is byte-equivalent to what `cabin publish + --registry-dir` writes. +- `cabin-cli/src/vendor_glue.rs` is the orchestration shell + for `cabin vendor`: it parses CLI args, drives the existing + `run_artifact_pipeline`, reads each per-package index entry + from the source `--index-path`, builds a `VendorPlan`, and + hands it to `cabin-vendor`. It must not own vendor-write + logic, plan ordering, or checksum verification — all that + lives in `cabin-vendor`. + +`--offline` is the cross-cutting flag that forbids network +access. The single enforcement point is +`crate::config_glue::enforce_offline_index_source`, called +from every command that resolves an index source. New +commands that touch the network must thread `args.offline` +through that helper. + +Future changes must keep these invariants: + +- `cabin vendor`'s output is a Cabin file registry, byte- + equivalent to `cabin publish --registry-dir`. Do not + introduce a parallel on-disk format. +- `--offline` enforcement lives in one place; do not + duplicate the URL-rejection check across crates. +- Local path dependencies and patched packages are *not* + vendored. Document any change to that policy in + `docs/vendoring-offline.md` first. +- Vendoring re-verifies every archive checksum before + writing. Do not weaken that check. +- HTTP-source vendoring is intentionally deferred. If a + future change adds it, the per-package JSON re-fetch belongs + in `cabin-vendor`'s plan-construction layer (or a new + helper), not in `cabin-cli`. + +## Where metadata / tree / explain work belongs + +`cabin metadata`, `cabin tree`, and `cabin explain` form one +observability surface over the resolved project state. The +owning crates are: + +- `cabin-explain` owns the typed model: `TreeNode`, + `SourceProvenance`, the `Explanation` tagged union + (`Package`, `Target`, `Source`, `Feature`), the + `BuildConfig` query helper, the `ExplainError` family, + and the deterministic renderers + (`render_tree_human` / `render_tree_json` / + `render_explanation_human` / `render_explanation_json`). + This crate must never run the resolver, parse manifests, + plan builds, or perform I/O — it consumes the typed values + the orchestration layer hands it. +- `cabin-cli/src/tree_glue.rs` and + `cabin-cli/src/explain_glue.rs` orchestrate the workspace / + config / patch / lockfile / feature-resolution preamble + (the same preamble `cabin metadata` runs) and hand the + typed values to `cabin-explain`. They must not own tree + rendering, explanation chains, or provenance labelling — + all that lives in `cabin-explain`. +- `cabin metadata` itself stays in `cabin-cli/src/cli.rs` + for now; future moves of the metadata view into a dedicated + crate would go alongside `cabin-explain`, not into it. + +Future changes must keep these invariants: + +- The `cabin metadata` JSON contract is stable. New fields + may be added (and must be deterministic); existing fields + must keep their shape. +- `cabin tree --format json` and every `cabin explain ... + --format json` document is byte-stable across runs given + the same workspace + lockfile + config inputs. +- Tree children sort by `(dependency_kind, name, version)`; + explanation paths sort by `(length, joined name sequence)`. + Do not introduce alternate orderings. +- Provenance labelling lives in `cabin-explain`. Adding a new + source kind (e.g. `git`, `oci`) is one variant addition to + `SourceProvenance` plus matching arms in renderers and + explain queries — do not push the kind detection into the + CLI glue. +- New `cabin explain` subcommands extend the + `ExplainCommand` enum and the typed `Explanation` model. The + glue dispatches; the domain logic stays in `cabin-explain`. +- Renaming a serialised field requires updating + `docs/metadata-tree-explain.md` in the same commit. + +## Where diagnostic / error-rendering work belongs + +User-facing diagnostics are produced through a single +presentation layer: + +- **Domain crates own typed errors.** Every `cabin-*` crate + exposes a `thiserror`-derived `Error` enum that carries the + load-bearing field values in its variants. Rich diagnostics + that need source snippets or variant-specific help derive + `miette::Diagnostic` with a stable + `#[diagnostic(code(cabin::::))]`; simpler + user-facing domain errors are registered with an area-level + stable code and adapted through `cabin_diagnostics::CodedError` + / `CodedMessage`. +- **`cabin-diagnostics` is the single renderer.** It owns the + byte-stable formatter, the source-snippet boundary + (`annotate-snippets` is reachable only through this crate), + and the path-normalisation helpers golden tests use. New + source-annotated diagnostics expose `#[source_code]` / + `#[label]` on the diagnostic-bearing struct; the renderer + then emits a Cargo-style snippet automatically. +- **`cabin-cli` does not own error construction.** The + dispatcher walks `anyhow::Error`'s source chain, downcasts + to the deepest typed diagnostic or coded domain error, and + routes it through `cabin_diagnostics::render`. Adding a new + diagnostic-bearing type or coded domain error is a small + addition to `crates/cabin-cli/src/lib.rs::downcast_diagnostic` + plus, for a new code, `cabin_diagnostics::code`. + +Future changes must keep these invariants: + +- **Avoid duplicative `with_context("failed to load X at ")` + wrappers around typed domain errors.** Typed domain errors + already include the path / operation in their own `Display`; + wrapping them produces the duplicated chain Cabin used to emit + (`failed to read X: failed to read X: No such file or + directory: No such file or directory`). Generic filesystem / + subprocess calls may still add local context, but when a domain + error already carries the operation and path, use `?` and let + the typed error flow up. +- **Codes are stable user-facing API.** Renaming + `cabin::workspace::manifest_not_found` is a breaking change + for any tooling that grep-matches Cabin's stderr. Bump + documentation alongside any rename. +- **Help text means actionable next action.** If there is no + fix the user can take, omit `help(...)`. Don't write filler + help. +- **Source-snippet diagnostics live in the owning crate.** + `cabin-manifest::ManifestParseError` carries + `#[source_code]` + `#[label]`. `cabin-diagnostics::render` + picks them up. New parse / validation errors that have a + source span must follow this pattern; do not construct + `annotate-snippets` snippets in `cabin-cli`. +- **Machine-readable stdout stays clean.** Diagnostics go to + stderr through `render_error`; stdout remains parseable + JSON for `cabin metadata`, `cabin tree --format json`, + `cabin explain --format json`, etc. + +## Where Cargo-inspired interface work belongs + +Cabin's surface — subcommands, flags, config keys, env vars, +manifest names, help text — is *Cargo-inspired*, not +*Cargo-compatible*. Two pages are the social contract: + +- [`docs/cargo-inspired-interface.md`](docs/cargo-inspired-interface.md) + enumerates what is adopted, what is renamed for C/C++ + clarity, and what is intentionally not adopted. +- [`docs/environment-variables.md`](docs/environment-variables.md) + is the single source of truth for read-side / run / test + `CABIN_*` env vars. + +Future changes must keep these invariants: + +- Every `CABIN_*` env var name lives as a `pub const &str` in + [`cabin-env`](crates/cabin-env/src/lib.rs). Adding a new var + is a one-liner there plus a row in + `environment-variables.md`. Do not introduce a `CABIN_*` + string literal anywhere else. +- Read-side env-var precedence is `CLI flag > env > config > + built-in default`. The single helpers + `crate::config_glue::resolve_build_dir_with_env` and + `crate::config_glue::effective_offline` are the only places + the env layer is consulted; commands threading these flags + must reuse them. +- `--target ` is reserved for the future + **platform / toolchain target** flag and is not accepted on + any current command. Manifest-target selection is *not* + exposed under a single flag: `cabin run` uses `--bin ` + for `cpp_executable` targets, `cabin test` builds every + `cpp_test` in the selected packages, and `cabin build` builds + every default-buildable target in the selected packages. + Users narrow the build / test scope by narrowing the package + selection (`--package` / `--workspace` / `--exclude`). Do not + re-introduce a manifest-target overload of `--target`; any + future explicit-kind selector (`--example`, `--test `, + etc.) must use a distinct flag name so `--target` stays free + for the platform-triple meaning. +- `--build-dir ` is the primary build-output flag; the + config key is `[paths] build-dir`; the env var is + `CABIN_BUILD_DIR`. `--target-dir` does *not* exist as a + Cabin alias. +- Default build directory is `build/`. Renaming the default + requires updating the manpages, completions golden tests, + README, and every doc that references it. +- Compile commands do **not** receive automatic + `-DCABIN_PACKAGE_*` macros. Run / test executables receive + the metadata as env vars instead. If a future change adds + opt-in macro injection, it must (a) distinguish private + target compile-defines from public usage-requirements, and + (b) thread the macro values through the build-configuration + fingerprint so a change invalidates the cache. +- Cargo / Rust commands explicitly *not* adopted (`cabin doc`, + `cabin install`, `cabin search`, `cabin login` / `logout` / + `owner` / `yank`, `cabin rustc` / `rustdoc` / `fix`, + `cabin check`) require their own change before landing. + +## Build-configuration fingerprint rules + +`cabin_core::BuildConfiguration::fingerprint` is the canonical +hash over every build-affecting input. It is surfaced through +`cabin metadata` / `cabin explain` and is the value any future +on-disk artifact cache will key on. Future changes must +keep the fingerprint complete: + +- A new build-affecting input must be folded into + `compute_fingerprint` in the same commit that introduces it. + Adding a field to `ResolvedProfileFlags` without extending the + fingerprint is a regression the unit tests in + `crates/cabin-core/src/config.rs::tests` are wired to catch + (one `fingerprint_differs_when_*` test per field). +- The fingerprint must move when a flag changes language slot + (`cflags` ↔ `cxxflags`), even when the argv string is + identical. The `fingerprint_distinguishes_c_only_from_cxx_only_extra_args` + test pins this contract. +- Inputs Cabin does not consume must not appear in the + fingerprint. `CFLAGS`, `CXXFLAGS`, `LDFLAGS`, and `LD` are + intentionally not consumed; the local absolute path to a + config file is not an input either (only the resolved values + the file contributed are). See `docs/toolchains.md` for the + full table. +- Direct `ninja` invocation does not reload Cabin inputs; + documentation must continue to direct users to `cabin build` + after manifest / config / toolchain edits so the fingerprint + and the generated commands stay in sync. + +## Currently in scope + +Anything that does not change the behaviour of any existing +shipped feature is fair game inside the current scope's spec. +Anything that does change behaviour, or that adds a new +feature, must be scoped to the explicit scope it belongs to and +must follow the architecture rules below. + +The current canonical scope is documented in +[`docs/architecture.md`](docs/architecture.md) and must not be +duplicated here. + +The deliberately-deferred list — items that are out of scope +until specifically scoped or until they are moved out of the +deferred band — is: + +- cross-compilation (`--target ` for the C / C++ build) — + Cabin still evaluates `[target.'cfg(...)']` predicates against + the host platform only; +- probe compilations beyond `--version`, distcc / icecc + compile-server wrappers, full Windows / MSVC support, and any + remote build cache; +- SARIF / structured-diagnostic frameworks, sanitiser + frameworks, coverage instrumentation and reporting, benchmark + target kinds / harnesses, and broad CMake / Meson + compatibility; +- Rust binary, test, or proc-macro targets, Rust-to-C++ target + dependencies, and header generation (`cxx`, `autocxx`, + `bindgen`); +- C++ modules; +- network-backed publish, package upload APIs, registry storage + schema, non-local account / ownership / policy / control-plane + / quota logic, and registry authentication; +- a Git repo index (intentionally never planned); +- exposing the underlying solver type from `cabin-resolver`. + +Workspace graph algorithms must stay in `cabin-workspace`; CLI +flag parsing stays in `cabin-cli`. Do **not** put workspace +discovery, member expansion, or selection resolution into +`cabin-cli`. + +See [`docs/architecture.md`](docs/architecture.md) for the full sequence +sequence and [`docs/architecture.md`](docs/architecture.md) for +the seams that future work must not cross prematurely. + +## Implemented behaviour (foundational capabilities) + +The list below covers the foundational local surface that later +capabilities build on. Dependency kinds, optional dependencies, +features resolution, target conditions, profiles, +toolchain selection, capability detection, compiler-cache +wrappers, the typed config system, patch / source-replacement, +dev / test / example targets, vendoring + offline +mode, `cabin metadata` / `cabin tree` / `cabin explain`, +`cabin run`, and the Cargo-inspired `CABIN_*` env-var +foundation) are documented in their dedicated pages under +[`docs/`](docs/) and summarised in +[`docs/architecture.md`](docs/architecture.md). + + +- CLI commands `cabin init`, `cabin metadata`, `cabin build`, + `cabin resolve`, `cabin update`, `cabin fetch`, `cabin package`, + `cabin publish [--dry-run] [--registry-dir ]`, + `cabin compgen`, `cabin mangen`. `resolve` / `fetch` / `build` / + `update` accept either `--index-path ` (local file index) + or `--index-url ` (sparse HTTP index). +- `cabin.toml` parsing with serde + `toml`, including string- and + table-form versioned dependencies. +- Stable internal `Project` / `Dependency` model with + `DependencySource::{Path, Version}`. +- C++ compiler / archiver / Ninja detection. +- Local workspace + path-dep loader producing a topologically-sorted + `PackageGraph`, with optional registry-package stitching via + `cabin-workspace::load_workspace_with_registry`. +- Backend-independent build graph IR with cycle detection. +- Cross-package target dependency resolution (works the same way for + local and registry packages). +- `build.ninja` and `compile_commands.json` generation. +- Local C++ build execution via Ninja, including registry packages + whose sources have been extracted into the artifact cache. +- Local JSON package index loader (`.json`, schema 1) with + optional `source = { type = "archive", path, format = "tar.gz" }`. +- Backtracking dependency resolver with deterministic output, yanked + filtering, conflict diagnostics, and four `ResolveMode` variants: + `PreferLocked`, `Locked`, `UpdateAll`, `UpdatePackage`. +- `cabin.lock` reader / writer / validator (schema `version = 1`, + alphabetical package ordering, `deny_unknown_fields`, deterministic + formatter). +- `cabin resolve --locked` / `--frozen` for non-mutating CI runs. +- `cabin update [--package ]` for refreshing the lockfile. +- `cabin metadata` includes lockfile contents when `cabin.lock` exists. +- `cabin fetch`: resolve, write/update the lockfile, verify SHA-256 + checksums, copy archives into the cache, and safely extract source + trees, with `--cache-dir`, `--locked`, `--frozen`, and `--format`. +- `cabin build --index-path [--cache-dir ] [--locked\|--frozen]`: + same fetch pipeline plus a unified plan + Ninja invocation. +- `cabin package [--manifest-path ] [--output-dir ] [--format human\|json]`: + validate the package, build a deterministic `.tar.gz`, hash it, + generate canonical per-version metadata, and write both files into + `--output-dir` (default `dist/`). Re-running with identical input + succeeds silently; existing on-disk artifacts with different bytes + fail loudly. +- `cabin publish --dry-run [--manifest-path ] [--output-dir ] [--format human\|json]`: + same pipeline, plus a "no registry was modified" report. +- `cabin publish --registry-dir [--manifest-path ] [--format human\|json]`: + publish the staged package into a local file registry. Initialises + the layout (`config.json`, `packages/`, `artifacts/`) on first + use; rejects duplicate versions and orphaned artifacts. The + registry is then consumable by `cabin resolve`, `cabin fetch`, + and `cabin build --index-path `. +- `cabin publish --dry-run --registry-dir `: validate every + pre-write check against the registry without mutating it. +- `cabin publish` without `--dry-run` and without `--registry-dir`: + exits with a clear error. +- `cabin --index-url `: read + the registry over static HTTP. Mutually exclusive with + `--index-path`. `--frozen --index-url` fails with a documented + error because there is no persistent HTTP metadata cache. +- `cabin compgen [--output-dir ]` / + `cabin compgen --all --output-dir `: emit shell completion + scripts (bash / zsh / fish / powershell / elvish) derived from + the clap command tree. +- `cabin mangen [--output-dir ]`: emit `cabin(1)` plus one + `cabin-(1)` per top-level subcommand, including hidden + distribution / machine-interface commands. The root `cabin(1)` + page mirrors normal help and omits hidden commands. Output is + ROFF produced by `clap_mangen` — no hand-written man pages. +- Features foundation: `[features]` manifest table; + `BuildConfiguration` selection model with deterministic SHA-256 + fingerprint; `--features` / `--all-features` / + `--no-default-features` on `cabin build` and `cabin metadata`; + declarations preserved in `cabin package` metadata, file-registry + publish, and HTTP / file index round-trips. Older index entries + that omit the field keep loading. Full protocol in + [`docs/features.md`](docs/features.md). +- Advanced workspace semantics: `[workspace]` with `members` + (paths or trailing-`*` globs), `exclude`, `default-members`, and + `[workspace.dependencies]` shared by `dep = { workspace = true }` + member entries. Cabin walks upward from the current directory to + discover workspace roots; nested workspaces are rejected. The + `--workspace` / `-p / --package` / `--default-members` / + `--exclude` selection-flag bundle works on every workspace-aware + command. `cabin metadata` reports `workspace.members`, + `default_members`, `excluded_members`, and `selected_packages` + (all sorted). `cabin package` and `cabin publish` against a + workspace root require exactly one `--package ` selection. + Full protocol in [`docs/workspaces.md`](docs/workspaces.md). + +## Workspace layout + +``` +crates/ + cabin-artifact/ source-archive cache, checksum verifier, extractor + cabin-build/ backend-independent build graph planner + cabin-cli/ `cabin` binary, command dispatch + cabin-config/ typed `.cabin/config.toml` discovery + merge + cabin-core/ stable internal data model + cabin-diagnostics/ user-facing diagnostic presentation + annotate-snippets boundary + cabin-env/ CABIN_* env-var names + run/test env builder + cabin-explain/ typed model for `cabin tree` / `cabin explain` + cabin-feature/ cross-package feature resolver + cabin-index/ local JSON package index loader + cabin-index-http/ sparse HTTP index client (read-only) + cabin-lockfile/ cabin.lock reader / writer / validator + cabin-manifest/ cabin.toml parsing + cabin-ninja/ build.ninja + compile_commands.json writers + cabin-package/ deterministic source-archive + canonical metadata writer + cabin-publish/ publish-workflow orchestration + cabin-registry-file/ local file-registry layout, atomic-ish writes, lock + cabin-resolver/ dependency resolver with lockfile-aware modes + cabin-system-deps/ pkg-config probing for `system = true` deps + cabin-test/ cpp_test plan + sequential runner + cabin-toolchain/ C/C++ compiler / archiver / Ninja detection + wrappers + cabin-vendor/ typed VendorPlan + file-registry materialiser + cabin-workspace/ local + registry package graph loader, patches, selection +docs/ + architecture.md crates, data flow, current direction + artifacts.md source archive + cache layout + cargo-inspired-interface.md Cabin-vs-Cargo audit / classification + compiler-cache.md ccache / sccache wrappers + config.md .cabin/config.toml schema and discovery + dependency-kinds.md two dependency kinds + activation rules + distribution.md shell completions + man pages + environment-variables.md CABIN_* read / run / test env vars + features.md features foundation + index.md local JSON index format + lockfile.md cabin.lock format reference + manifest.md cabin.toml schema reference + metadata-tree-explain.md `cabin metadata` / `cabin tree` / `cabin explain` + package-format.md package archive + canonical metadata schema + patch-overrides.md patch / override + source-replacement layer + profiles.md build profile model + registry-design.md design-only registry direction + system-dependencies.md `system = true` deps + pkg-config probing + target-dependencies.md target/platform-specific dependency conditions + targets.md target kinds + manifest target model + testing.md cabin test runner + workflow + toolchains.md C/C++ tool selection + capability detection + vendoring-offline.md cabin vendor + offline mode + workspaces.md workspace discovery, member selection, inheritance +``` + +This repository is the **public local OSS core only**. Non-local +registry, account, ownership, policy, control-plane, and +infrastructure surfaces do not live here, and no code, fixture, or +test in this repository should add them. + +## Crate boundaries to preserve + +- `cabin-core` owns the stable domain model: `Project`, + `Target`, `Dependency`, and the build-configuration model + (`Features`, `SelectionRequest`, + `BuildConfiguration` with deterministic SHA-256 fingerprint). + Must not depend on `clap`, parse TOML, know about Ninja, know + about resolver internals, know about lockfile TOML, invoke + processes, or read / write registry index files directly. +- `cabin-manifest` owns `cabin.toml` parsing. Raw serde structs stay + private. Must not load workspaces, run resolution, write Ninja, or + read / write `cabin.lock`. +- `cabin-workspace` owns local package and path-dep loading, + workspace root discovery (upward walk from cwd that errors when + two or more `[workspace]`-bearing manifests stack above the + start path), member globbing + exclude filtering, + default-member validation, workspace dependency inheritance, + nested-workspace rejection, the `PackageSelection` model, the + `ResolvedSelection::closure(graph)` walk over local + path-dependency edges, `collect_closure_versioned_deps`, and + selection-aware registry materialization + (`load_workspace_with_registry_for_selection`). + Versioned dependencies are preserved on each `Project` for the + resolver but not traversed here. Must not run the resolver, + write Ninja, fetch artifacts, or parse CLI flags directly. + Workspace graph algorithms — closure walks, versioned-dep + aggregation, nested-workspace detection — must stay in + `cabin-workspace` rather than `cabin-cli`. +- `cabin-index` owns the local JSON index loader. Must not run + the resolver, fetch artifacts, or read / write `cabin.lock`. + The HTTP sibling lives in `cabin-index-http`. +- `cabin-resolver` owns dependency resolution. The current solver is + a small recursive backtracking engine. The public API is + Cabin-native; the underlying solver is private so a future + algorithm change can land without breaking consumers. Must not + expose the underlying solver, read / write `cabin.lock` directly, + or fetch artifacts. +- `cabin-lockfile` owns the `cabin.lock` model and I/O. Must not run + the resolver, load indexes, parse `cabin.toml`, or fetch artifacts. +- `cabin-artifact` owns the source-archive cache. SHA-256 + verification, fail-closed `.tar.gz` extraction, and the + checksum-addressed cache layout. Must not run the resolver, write + Ninja, invoke C++ compilers, implement networking, or implement + publishing. +- `cabin-package` owns deterministic source-archive creation and + canonical per-version metadata generation. Must not mutate any + registry, run the resolver, fetch artifacts, invoke C++ compilers, + or implement networking. +- `cabin-publish` owns publish-workflow orchestration. It calls + `cabin-package` for staging and `cabin-registry-file` for the + actual file-registry mutation. HTTP / OCI publish and any + server-side functionality stay out of scope. +- `cabin-registry-file` owns the local file-registry layout + (`config.json`, `packages/`, `artifacts/`), the per-package index + file format, atomic-ish artifact + index writes (via + `.partial` rename guards), and the simple + `.cabin-registry.lock` lock file. It must not parse arbitrary + `cabin.toml`s, run the resolver, build packages, or implement + networking. +- `cabin-index-http` owns the read-only sparse HTTP index client. + Wraps `ureq::Agent` for blocking `GET` requests; validates + `/config.json`; fetches `/packages/.json`; + resolves `source.path` values into absolute URLs against the + package metadata URL; downloads source archives. Must not + publish, authenticate, follow redirects to alternate registries, + or persist a metadata cache. The artifact bytes it downloads are + handed to `cabin-artifact` as in-memory bytes so the artifact + layer stays HTTP-free. +- `cabin-toolchain` owns C++ toolchain detection. Must not parse + TOML, run resolution, write lockfiles, or invoke the tools it + locates. +- `cabin-build` owns backend-independent build graph planning. + Must not write Ninja syntax, invoke Ninja, or parse TOML + directly. +- `cabin-ninja` owns Ninja file generation and + `compile_commands.json` generation. Must not parse TOML, resolve + packages, or know about the resolver or the lockfile. +- `cabin-cli` owns CLI parsing and command orchestration. May + call any other crate. Must not contain business logic that + belongs in a reusable crate; keep argument parsing separate + from command execution where practical. The `compgen` (via + `clap_complete`) and `mangen` (via `clap_mangen`) generators + under `crates/cabin-cli/src/{completions.rs,manpages.rs}` + consume `Cli::command()` directly; do not duplicate command + names, flags, or descriptions in either generator. + + **`cabin-cli/src/cli.rs` must not grow further with new + business logic.** When a future change adds new behaviour, the + implementation belongs in the owning crate (e.g. + `cabin-workspace`, `cabin-resolver`, `cabin-build`, + `cabin-publish`), exposed through a typed API; the CLI layer + should only translate clap inputs into that API and render + the result. New top-level commands or any non-trivial command + logic should land in a per-command module under + `cabin-cli/src/cli/` rather than in `cli.rs`. A small, + behaviour-preserving split of view structs or dispatch + helpers is acceptable inside a routine PR; a broad rewrite of + `cli.rs` is not in scope for a routine change. + +The architecture rules above mirror those in +[`docs/architecture.md`](docs/architecture.md). When the two ever +disagree, the architecture document is canonical. + +## Repository content policy + +This repository is a project-level technical codebase. Contributors +must: + +- keep all repository content (documentation, code, comments, tests, + test fixtures, examples, commit messages) at the project level; +- not implement network-backed publish, package upload over the + network, OCI / GHCR transports, + release-packaging workflows, Homebrew formulas, package yanking, + ownership, account handling, or persistent HTTP metadata caches + in this repository — those surfaces belong outside the public + local OSS core; +- not add tests that depend on external internet access — + sparse-HTTP tests must boot a local `tiny_http` server on + `127.0.0.1:0`; +- not add non-local account, ownership, policy, quota, + control-plane, or infrastructure logic, fixtures, or + documentation. These surfaces are out of scope for this + repository regardless of the current scope; +- keep artifact fetching (`cabin-artifact`) separate from resolver + internals (`cabin-resolver`) and from the index loader + (`cabin-index`); +- keep package archive logic in `cabin-package`, publish workflow + orchestration in `cabin-publish`, and file-registry mutation in + `cabin-registry-file`; the CLI must not contain archive, + metadata, or registry-write logic; +- keep publish dry-run separate from any actual registry mutation — + the dry-run path must remain a no-op against any registry; +- treat the clap command tree as the single source of truth for + shell completions and man pages; `cabin compgen` must use + `clap_complete::generate` against `Cli::command()` and + `cabin mangen` must use `clap_mangen::Man` against the same + tree; +- keep archive extraction safe: reject absolute paths, `..` + components, symlinks, hard links, and any tar entry type that is + not a regular file or directory; +- keep package archive creation deterministic: sorted file + enumeration, zeroed mtimes / uid / gid / uname / gname, and a + gzip header with `mtime = 0` plus an `os = 0xff` (unknown) byte; +- treat future external-service work as outside this repository + unless architecture explicitly moves it into scope. + +## Do not implement "not implemented" features + +Cabin's product surface is what is actually implemented today. +If a feature is not currently supported, it must not exist in +public syntax, CLI flags, domain models, docs, fixtures, or +tests merely to say that it is unsupported. + +This rule is normative: future agents must read it before +extending the parser, the CLI, the docs, or the test suite. + +- **Prefer generic unknown-syntax diagnostics.** Unknown manifest + tables, unknown manifest fields, unknown target kinds, unknown + CLI subcommands, and unknown CLI flags must reach the generic + `deny_unknown_fields` / clap unknown-flag path. Do not add a + feature-named arm in a parser or CLI just to emit a + feature-named error. + +- **Feature-specific rejection only protects current invariants.** + A feature-specific rejection is acceptable only when it + protects a current invariant, a security property, a + reproducibility guarantee, or an implemented public contract. + Examples that pass this bar: rejecting `system = true` combined + with `path` on the same dependency table (current dependency + grammar invariant); rejecting an unknown `compiler-wrapper` + value (a typed enum on a supported field); refusing a URL + index source under `cabin vendor` (byte-stable output + invariant). Examples that do **not** pass this bar: rejecting + `[variants]`, `[options]`, `[build-dependencies]`, + `[tool-dependencies]`, `[lint.*]`, `cpp_bench`, `rust_*` + targets, `--coverage`, `--option`, `--variant`, `git` / + `registry` / `source` dependency keys, `git` / `url` / + `version` patch keys, weak-feature `dep?/feature` syntax, or + any other speculative future surface. + +- **Current behaviour docs describe implemented behaviour only.** + Manifest, command, and example docs must not document removed + or speculative future features as "not yet supported", "future + work", "reserved", or similar. Brief roadmap-style mentions + are confined to architecture / roadmap documents and must not + define public syntax for unimplemented features. + +- **No reserved public schema.** Manifest fields, CLI flags, + metadata fields, registry schema, and lockfile fields must not + exist solely to be parsed and rejected. + +- **No placeholder / stub / TODO surface.** Do not add public + syntax, enum variants, struct fields, golden outputs, or + fixtures for a feature that is not currently implemented. The + internal model must not carry a variant whose only consumer is + a feature-named rejection arm. + +- **Removing a feature removes its bloat.** When a feature is + removed, also remove its dedicated unsupported diagnostics, + feature-named negative tests, docs, examples, fixtures, golden + outputs, metadata fields, and internal-model variants — unless + a generic validation test still needs to cover the shape. + +- **Replace feature-specific negative tests with generic ones.** + Coverage for unknown manifest tables, unknown fields, unknown + target kinds, and unknown CLI flags should use one + representative sentinel value (e.g.\ `not-a-real-table`, + `not-a-real-key`, `wasm_executable`), not a real + removed-or-future feature name. + +If a contributor or AI agent finds themselves writing a test or +diagnostic whose name is `*_is_rejected` and whose body is +"`` is unsupported", stop. Use the generic +unknown-syntax path instead. If the feature is genuinely needed, +implement it; if not, do not surface it. + +## Required checks + +Before submitting any change, run: + +```sh +cargo fmt --all --verbose -- --check +cargo clippy --workspace --all-targets --locked --verbose +cargo check --workspace --all-targets --locked --verbose +cargo test --workspace --all-targets --all-features --locked --verbose -- --show-output +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked --verbose +``` + +CI runs the Rust commands above and treats warnings as errors. +Clippy's `-D warnings` and `-D clippy::pedantic` denials are +configured in the root `Cargo.toml` under `[workspace.lints]`, +so the `cargo clippy` invocation above carries no trailing `--` +flags; every workspace member opts in via `[lints] workspace = +true` in its own `Cargo.toml`. CI installs `ninja`, C/C++ +compilers, `clang-format`, `run-clang-tidy`, and `pkg-config` so +the real external tool smoke tests run by default. Set +`CABIN_SKIP_EXTERNAL_TOOL_TESTS=1` only for local runs that should +exercise the bundled fake-tool fallback. + +## Where to extend later + +Future crates should depend on `cabin-core` (plus other lower-level +crates) rather than reaching across layers. The +[`docs/architecture.md`](docs/architecture.md) "Future monorepo +direction" section sketches the intended shape; new crates appear +when needed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..daced9bd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bea883789..77e6ab0d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,140 +1,92 @@ -# Contributing +# Contributing to Cabin -Given our limited resources, we may not review PRs that fail to adhere to this document. +Thanks for your interest in contributing to Cabin. This document +covers local setup, required checks, and PR workflow. The canonical +crate-boundary, scope, and ownership rules live in +[`docs/architecture.md`](docs/architecture.md); do not duplicate +them here. -Note that we have a [code of conduct](https://github.com/cabinpkg/.github/blob/main/CODE_OF_CONDUCT.md), -follow it in all your interactions with the project. +## Prerequisites -You can ignore sections marked as "Under Construction". +- A recent stable Rust toolchain. +- `rustfmt` and `clippy` components installed. +- For end-to-end build coverage: **Ninja** 1.10+, a **C++ + compiler** (`g++`, `clang++`, or `c++`), and a **C compiler** + (`gcc`, `clang`, or `cc`) for tests that exercise `.c` sources. -## How to Contribute +The unit tests in every crate, plus the resolution / lockfile +integration tests, do not require Ninja or C / C++ compilers. The CLI +build integration tests skip themselves gracefully when those tools +are missing. -You can contribute to this repository in one of the following ways: +## Setup -1. **For Trivial Changes**
- If you believe your change is minor (e.g., typo fixes, small improvements), - feel free to make the change directly and submit a pull request (PR). -2. **For Uncertain Changes**
- If you're unsure whether your proposed change aligns with the project's - goals, we encourage you to discuss it first by opening an issue or starting - a discussion. This helps ensure alignment and reduces potential rework. -3. **For Exploratory Contributions**
- If you're unsure but find it easier to share code, you can create a draft PR - with the prefix `RFC:` in the title (e.g., `RFC: build: add new feature X`). - This signals that the PR is a "Request for Comments" and invites feedback - from the maintainers and community. - -## Coding Style - -Consistency is key to maintaining a clean and readable codebase. As stated in the -[LLVM Coding Standards](https://llvm.org/docs/CodingStandards.html#introduction): - -> **If you are extending, enhancing, or bug fixing already implemented code, -> use the style that is already being used so that the source is uniform and -> easy to follow.** - -Please follow this principle to ensure the code remains cohesive and easy to -navigate. - -### Naming Conventions (*Under Construction) - -The project's naming conventions are specified in the -[.clang-tidy](.clang-tidy) file. Here's a brief summary: - -- **Files/Directories**: `PascalCase` -- **Types/Classes**: `PascalCase` -- **Variables**: `snake_case` -- **Class (non-struct) Member Variables**: `snake_case_` -- **Functions**: `camelCase` -- **Class (non-struct) Methods**: `camelCase_` - -(Note: Variables use `snake_case` since they tend to be shorter than functions.) - -Be mindful of when to use structs vs. classes. For guidance, refer to the -[Google C++ Style Guide: Structs vs. Classes](https://google.github.io/styleguide/cppguide.html#Structs_vs._Classes). - -### Formatting and Linting - -Before submitting a PR, ensure your code adheres to the project's coding -standards. Also, always validate your changes to ensure they do not -introduce regressions or break existing functionality: - -1. Run the linter (`cpplint`) - ```bash - ./build/cabin lint - ``` -2. Run the formatter (`clang-format`) - ```bash - ./build/cabin fmt - ``` -3. Run the static analyzer (`clang-tidy`) - ```bash - ./build/cabin tidy - ``` -4. Run tests - ```bash - ./build/cabin test - ``` - -### Packaging - -To test the Linux package creation locally, you can use nFPM: - -```bash -# Install nFPM (if not already installed) -brew install nfpm # macOS -# or follow instructions at https://nfpm.goreleaser.com/install/ - -# Build cabin first -make BUILD=release -j$(nproc) - -# Create DEB package -CABIN_VERSION="1.0.0" nfpm pkg --packager deb - -# Create RPM package -CABIN_VERSION="1.0.0" nfpm pkg --packager rpm - -# Verify packages were created -ls -la *.deb *.rpm -``` - -The packaging configuration is defined in `nfpm.yaml`. Both DEB and RPM -packages are automatically created and published on GitHub releases when tags -are pushed. - -## Documentation - -If your changes affect the project's documentation, ensure you update the -relevant files in the `docs/` directory. You can preview your changes by -running the following command: - -```bash -pip install mkdocs -mkdocs serve +```sh +git clone https://github.com/cabinpkg/cabin.git +cd cabin +cargo build --workspace ``` -This will start a local server at `http://127.0.0.1:8000/`. - -Make sure to update the table of contents in the `mkdocs.yml` file to reflect -your changes. Also, ensure that the documentation is clear, concise, and -formatted correctly. - -Before committing anything, ensure that the documentation builds without -errors by running: +## Required checks -```bash -mkdocs build --strict +```sh +cargo fmt --all --verbose -- --check +cargo clippy --workspace --all-targets --locked --verbose +cargo check --workspace --all-targets --locked --verbose +cargo test --workspace --all-targets --all-features --locked --verbose -- --show-output +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked --verbose ``` -## Commit Message - -Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). - -## Pull Request Style - -1. **CI**: Verify that all CI checks pass on your fork before submitting the - PR. Avoid relying on the CI of this repository to catch errors, as this - can cause delays or stalls for other contributors. -2. **Commits**: There is no need to squash / force push commits within the PR - unless explicitly requested. Keeping separate commits can help reviewers - understand the progression of changes. +The Rust CI workflow runs the commands above and treats warnings +as errors. Clippy's `-D warnings` and `-D clippy::pedantic` +denials are configured in the root `Cargo.toml` under +`[workspace.lints]`, so the `cargo clippy` invocation above +carries no trailing `--` flags. The `--locked` flag pins the +resolution to the committed `Cargo.lock`; reviewers will reject +PRs that silently bump transitive dependency versions. The +separate CI workflow also runs workflow linting and +commit-message linting. + +The test suite includes external-tool smoke tests for `ninja`, +`clang-format`, `run-clang-tidy`, and `pkg-config`. +Those tests fail by default when the real tools are missing. For +local environments that intentionally lack the tools, set +`CABIN_SKIP_EXTERNAL_TOOL_TESTS=1` to route only those smoke tests +through the bundled fake-tool binaries. + +## Code style + +- Idiomatic Rust. Prefer simple, direct code over clever + abstractions. +- Follow the diagnostic and crate-boundary rules in + [`docs/architecture.md`](docs/architecture.md). +- Avoid `unwrap()` / `expect()` outside of tests except where + invariants are obvious and locally proven. +- Public APIs stay small. Add a doc comment when the reason a type + or function exists is not obvious from its signature. +- Tests live next to the code they exercise. CLI integration tests + live in `crates/cabin-cli/tests/cli.rs` and exercise the compiled + `cabin` binary via `assert_cmd`. + +## Architectural rules + +Read [`docs/architecture.md`](docs/architecture.md) before changing +crate boundaries, command ownership, scope, diagnostics, generated +formats, or build / registry / resolver behavior. When in doubt, the +architecture document wins. + +## Pull requests + +- **Keep PRs focused.** One change per PR is easier to review and + to revert. +- **Add tests for behaviour changes.** New workspace, resolver, + or build logic should land with unit tests in the owning crate + plus a CLI integration test in `crates/cabin-cli/tests/cli.rs`. +- **Update documentation when architecture or behaviour changes.** + Update the relevant [`docs/`](docs/) page. If you move code across + crates, update [`docs/architecture.md`](docs/architecture.md) and + [`AGENTS.md`](AGENTS.md). + +If you are unsure whether something belongs to the current scope, +open an issue first or ask in the PR description rather than +implementing it. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..80b3116b4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2168 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b665789884a7e8fb06c84b295e923b03ca51edbb7d08f91a6a50322ecbfe6" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cabin-artifact" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-manifest", + "flate2", + "semver", + "sha2", + "tar", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-build" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-workspace", + "semver", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-cli" +version = "0.14.0" +dependencies = [ + "anyhow", + "assert_cmd", + "cabin-artifact", + "cabin-build", + "cabin-config", + "cabin-core", + "cabin-diagnostics", + "cabin-env", + "cabin-explain", + "cabin-feature", + "cabin-fmt", + "cabin-index", + "cabin-index-http", + "cabin-lockfile", + "cabin-manifest", + "cabin-ninja", + "cabin-package", + "cabin-publish", + "cabin-resolver", + "cabin-source-discovery", + "cabin-system-deps", + "cabin-test", + "cabin-tidy", + "cabin-toolchain", + "cabin-vendor", + "cabin-workspace", + "clap", + "clap_complete", + "clap_mangen", + "flate2", + "os_info", + "predicates", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "termcolor", + "tiny_http", +] + +[[package]] +name = "cabin-config" +version = "0.14.0" +dependencies = [ + "cabin-core", + "serde", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "cabin-core" +version = "0.14.0" +dependencies = [ + "semver", + "serde", + "serde_json", + "sha2", + "thiserror", +] + +[[package]] +name = "cabin-diagnostics" +version = "0.14.0" +dependencies = [ + "annotate-snippets", + "cabin-core", + "miette", + "termcolor", + "thiserror", +] + +[[package]] +name = "cabin-env" +version = "0.14.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "cabin-explain" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-lockfile", + "cabin-workspace", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cabin-feature" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-workspace", + "semver", + "thiserror", +] + +[[package]] +name = "cabin-fmt" +version = "0.14.0" +dependencies = [ + "cabin-source-discovery", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-index" +version = "0.14.0" +dependencies = [ + "cabin-core", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-index-http" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-index", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tiny_http", + "ureq", + "url", +] + +[[package]] +name = "cabin-lockfile" +version = "0.14.0" +dependencies = [ + "cabin-core", + "semver", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "cabin-manifest" +version = "0.14.0" +dependencies = [ + "cabin-core", + "miette", + "semver", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "cabin-ninja" +version = "0.14.0" +dependencies = [ + "cabin-build", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-package" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-manifest", + "flate2", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-publish" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-package", + "cabin-registry-file", + "semver", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-registry-file" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-package", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-resolver" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-index", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cabin-source-discovery" +version = "0.14.0" +dependencies = [ + "ignore", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-system-deps" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-env", + "miette", + "semver", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-test" +version = "0.14.0" +dependencies = [ + "cabin-build", + "cabin-core", + "cabin-workspace", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-tidy" +version = "0.14.0" +dependencies = [ + "cabin-core", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-toolchain" +version = "0.14.0" +dependencies = [ + "cabin-core", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-vendor" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-registry-file", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "cabin-workspace" +version = "0.14.0" +dependencies = [ + "cabin-core", + "cabin-manifest", + "miette", + "semver", + "tempfile", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive", + "once_cell", + "thiserror", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..2a98a7af7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,94 @@ +[workspace] +resolver = "3" +members = [ + "crates/cabin-artifact", + "crates/cabin-build", + "crates/cabin-cli", + "crates/cabin-config", + "crates/cabin-core", + "crates/cabin-diagnostics", + "crates/cabin-env", + "crates/cabin-explain", + "crates/cabin-feature", + "crates/cabin-fmt", + "crates/cabin-index", + "crates/cabin-index-http", + "crates/cabin-lockfile", + "crates/cabin-manifest", + "crates/cabin-ninja", + "crates/cabin-package", + "crates/cabin-publish", + "crates/cabin-registry-file", + "crates/cabin-resolver", + "crates/cabin-source-discovery", + "crates/cabin-system-deps", + "crates/cabin-test", + "crates/cabin-tidy", + "crates/cabin-toolchain", + "crates/cabin-vendor", + "crates/cabin-workspace", +] +default-members = ["crates/cabin-cli"] + +[workspace.package] +version = "0.14.0" +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/cabinpkg/cabin" +rust-version = "1.95" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +ignore = "0.4" +clap_complete = "4" +clap_mangen = "0.2" +annotate-snippets = "0.10" +flate2 = "1" +miette = { version = "5" } +semver = { version = "1", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha2 = "0.10" +tar = "0.4" +termcolor = "1" +thiserror = "1" +tiny_http = "0.12" +toml = "0.8" +ureq = { version = "2", default-features = false, features = ["tls"] } +url = "2" + +cabin-artifact = { path = "crates/cabin-artifact" } +cabin-build = { path = "crates/cabin-build" } +cabin-config = { path = "crates/cabin-config" } +cabin-core = { path = "crates/cabin-core" } +cabin-diagnostics = { path = "crates/cabin-diagnostics" } +cabin-env = { path = "crates/cabin-env" } +cabin-explain = { path = "crates/cabin-explain" } +cabin-feature = { path = "crates/cabin-feature" } +cabin-fmt = { path = "crates/cabin-fmt" } +cabin-index = { path = "crates/cabin-index" } +cabin-index-http = { path = "crates/cabin-index-http" } +cabin-lockfile = { path = "crates/cabin-lockfile" } +cabin-manifest = { path = "crates/cabin-manifest" } +cabin-ninja = { path = "crates/cabin-ninja" } +cabin-package = { path = "crates/cabin-package" } +cabin-publish = { path = "crates/cabin-publish" } +cabin-registry-file = { path = "crates/cabin-registry-file" } +cabin-resolver = { path = "crates/cabin-resolver" } +cabin-source-discovery = { path = "crates/cabin-source-discovery" } +cabin-system-deps = { path = "crates/cabin-system-deps" } +cabin-test = { path = "crates/cabin-test" } +cabin-tidy = { path = "crates/cabin-tidy" } +cabin-toolchain = { path = "crates/cabin-toolchain" } +cabin-vendor = { path = "crates/cabin-vendor" } +cabin-workspace = { path = "crates/cabin-workspace" } + +[workspace.lints.rust] +warnings = "deny" + +[workspace.lints.clippy] +pedantic = { level = "deny", priority = -1 } + +[profile.release] +lto = "thin" diff --git a/Dockerfile b/Dockerfile index 6f96a6679..450a3cbcc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,37 @@ -ARG base=ubuntu:24.04 +# syntax=docker/dockerfile:1 + +ARG rust_image=rust:1-slim-bookworm +ARG runtime_image=debian:bookworm-slim + +FROM ${rust_image} AS dev -FROM $base AS dev RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential ca-certificates git pkg-config \ - clang ninja-build \ - libfmt-dev libspdlog-dev libgit2-dev libcurl4-openssl-dev nlohmann-json3-dev libtbb-dev \ + clang lld ninja-build \ && rm -rf /var/lib/apt/lists/* + WORKDIR /work FROM dev AS builder + WORKDIR /app -COPY .clang-format . -COPY .clang-tidy . -COPY cabin.toml . -COPY .git . -COPY Makefile . -COPY include ./include/ -COPY lib ./lib/ -COPY src ./src/ -COPY semver ./semver/ -RUN make BUILD=release install - -FROM $base AS runtime + +COPY . . + +RUN cargo build --workspace --release \ + && install -Dm755 target/release/cabin /usr/local/bin/cabin + +FROM ${runtime_image} AS runtime + RUN apt-get update \ && apt-get install -y --no-install-recommends \ - build-essential clang ninja-build \ - libfmt-dev libspdlog-dev libgit2-dev libcurl4-openssl-dev nlohmann-json3-dev libtbb-dev \ + build-essential ca-certificates git pkg-config \ + clang lld ninja-build \ && rm -rf /var/lib/apt/lists/* + COPY --from=builder /usr/local/bin/cabin /usr/local/bin/cabin + +WORKDIR /work + CMD ["cabin"] diff --git a/INSTALL.md b/INSTALL.md index cfbfbc413..7ad8e5ee7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,85 +1,46 @@ -# Installing from Source +# Installing Cabin from Source -> [!CAUTION] -> This document explains how to build Cabin from source. Building from source is not recommended unless you are familiar with the process. If your goal is simply to install Cabin, refer to the [README.md](README.md) instead. +The recommended way to get Cabin is from +[the docs site](https://docs.cabinpkg.com/installation). Building from +source is supported for users who need an unreleased revision or want +to verify a build locally. -You will require the following compilers, commands, and libraries: +If you intend to contribute back, read [CONTRIBUTING.md](CONTRIBUTING.md) +instead — it covers the development workflow on top of the steps here. -## Compilers (that support C++23) +## Prerequisites -* GCC: `13` or later -* Clang: `19` or later -* Apple Clang: provided by `macOS Sequoia (15)` or later +- A [Rust toolchain](https://www.rust-lang.org/tools/install) on the + stable channel. +- `git`. -(generally, the latest 3 versions are supported to build/test Cabin.) +The Cabin binary itself has no C / C++ build-time dependency. The +C / C++ toolchains, Ninja, and the format / static-analysis helpers +are runtime requirements for `cabin build` / `cabin fmt` / +`cabin tidy` and are documented in +[Installation: Runtime Requirements](https://docs.cabinpkg.com/installation). -## Commands +## Build -* GNU Make -* Git -* pkg-config -* find -* grep -* mkdir -* rm - -## Libraries - -* fmt: `>=9 && <12` - * `libfmt-dev` on APT (Debian/Ubuntu) - * `fmt-devel` on DNF (Fedora) - * `fmt` on Homebrew -* libgit2: `>=1.7 && <1.10` - * `libgit2-dev` on APT (Debian/Ubuntu) - * `libgit2-devel` on DNF (Fedora) - * `libgit2` on Homebrew -* libcurl: `>=7.79.1 && <9` - * `libcurl4-openssl-dev` on APT (Debian/Ubuntu) - * `libcurl-devel` on DNF (Fedora) - * `curl` on Homebrew -* nlohmann_json: `>=3.10.5 && <4` - * `nlohmann-json3-dev` on APT (Debian/Ubuntu) - * `json-devel` on DNF (Fedora) - * `nlohmann-json` on Homebrew -* oneTBB: `>=2021.5.0 && <2023` - * `libtbb-dev` on APT (Debian/Ubuntu) - * `tbb-devel` on DNF (Fedora) - * `tbb` on Homebrew -* spdlog: `>=1.8.0 && <2.0.0` - * `libspdlog-dev` on APT (Debian/Ubuntu) - * `spdlog-devel` on DNF (Fedora) - * `spdlog` on Homebrew - -Installation scripts for libraries: - -* APT (Debian/Ubuntu): - ```sh - sudo apt-get update - sudo apt-get install -y libfmt-dev libgit2-dev libcurl4-openssl-dev nlohmann-json3-dev libtbb-dev libspdlog-dev - ``` -* DNF (Fedora): - ```sh - sudo dnf install -y fmt-devel libgit2-devel libcurl-devel json-devel tbb-devel spdlog-devel - ``` -* Pacman (Arch/Manjaro): - ```sh - sudo pacman -Syu - sudo pacman -S fmt libgit2 curl nlohmann-json tbb spdlog - ``` -* Homebrew: - ```sh - brew install fmt libgit2 curl nlohmann-json tbb spdlog - ``` +```sh +git clone https://github.com/cabinpkg/cabin +cd cabin +cargo build --release +``` -When running Make, the following libraries will be installed automatically. +The built binary lands at `target/release/cabin`. Copy it onto a +directory on your `$PATH`, or run it directly: -* [toml11](https://github.com/ToruNiina/toml11): [`v4.2.0`](https://github.com/ToruNiina/toml11/releases/tag/v4.2.0) -* [mitama-cpp-result](https://github.com/loliGothicK/mitama-cpp-result): [`v10.0.0`](https://github.com/loliGothicK/mitama-cpp-result/releases/tag/v10.0.0) +```sh +./target/release/cabin --version +``` -Once you have all the necessary requirements in place, you can build Cabin by the following commands: +## Updating -```bash -git clone https://github.com/cabinpkg/cabin.git -cd cabin -make BUILD=release -j$(nproc) install +```sh +git pull +cargo build --release ``` + +`cargo build` reuses the incremental cache so subsequent builds are +fast. diff --git a/Makefile b/Makefile deleted file mode 100644 index 2a5e95c4b..000000000 --- a/Makefile +++ /dev/null @@ -1,113 +0,0 @@ -# Tools -CXX ?= clang++ -GIT ?= git -PKG_CONFIG ?= pkg-config -INSTALL ?= install - -# Configuration -PREFIX ?= /usr/local -DESTDIR ?= -BUILD ?= dev -O := build -PROJECT := $(O)/cabin - -# Git info -COMMIT_HASH ?= $(shell $(GIT) rev-parse HEAD) -COMMIT_SHORT_HASH ?= $(shell $(GIT) rev-parse --short=8 HEAD) -COMMIT_DATE ?= $(shell $(GIT) show -s --date=format-local:'%Y-%m-%d' --format=%cd) - -# cabin.toml -EDITION := $(shell grep -m1 edition cabin.toml | cut -f 2 -d'"') -VERSION := $(shell grep -m1 version cabin.toml | cut -f 2 -d'"') -CUSTOM_CXXFLAGS := $(shell grep -m1 cxxflags cabin.toml | sed 's/cxxflags = \[//; s/\]//; s/"//g' | tr ',' ' ') - -# Git dependency versions -TOML11_VER := $(shell grep -m1 toml11 cabin.toml | sed 's/.*tag = \(.*\)}/\1/' | tr -d '"') -RESULT_VER := v11.0.0 - -GIT_DEPS := $(O)/DEPS/toml11 $(O)/DEPS/mitama-cpp-result $(O)/DEPS/rs-cpp - -# System dependency versions -PKGS := \ - 'libgit2 >= 1.7.0' 'libgit2 < 1.10.0' \ - 'libcurl >= 7.79.1' 'libcurl < 9.0.0' \ - 'nlohmann_json >= 3.10.5' 'nlohmann_json < 4.0.0' \ - 'tbb >= 2021.5.0' 'tbb < 2023.0.0' \ - 'fmt >= 9.0.0' 'fmt < 13.0.0' \ - 'spdlog >= 1.8.0' 'spdlog < 2.0.0' - -PKG_CFLAGS := $(shell $(PKG_CONFIG) --cflags $(PKGS)) -PKG_LIBS := $(shell $(PKG_CONFIG) --libs $(PKGS)) - -# Flags -DEFINES := -DCABIN_CABIN_PKG_VERSION='"$(VERSION)"' \ - -DCABIN_CABIN_COMMIT_HASH='"$(COMMIT_HASH)"' \ - -DCABIN_CABIN_COMMIT_SHORT_HASH='"$(COMMIT_SHORT_HASH)"' \ - -DCABIN_CABIN_COMMIT_DATE='"$(COMMIT_DATE)"' -INCLUDES := -Iinclude -Isrc -Isemver/include -isystem $(O)/DEPS/toml11/include \ - -isystem $(O)/DEPS/mitama-cpp-result/include \ - -isystem $(O)/DEPS/rs-cpp/include - -CXXFLAGS := -std=c++$(EDITION) -fdiagnostics-color $(CUSTOM_CXXFLAGS) \ - $(DEFINES) $(INCLUDES) $(PKG_CFLAGS) -MMD -MP - -ifeq ($(BUILD),dev) - CXXFLAGS += -g -O0 -DDEBUG -else ifeq ($(BUILD),release) - CXXFLAGS += -O3 -DNDEBUG -flto - LDFLAGS += -flto -else - $(error "Unknown BUILD: `$(BUILD)'. Use `dev' or `release'.") -endif - -LDLIBS := $(PKG_LIBS) - -# Source files -SRCS := $(shell find src -name '*.cc') $(shell find lib -name '*.cc') \ - $(shell find semver/lib -name '*.cc') -OBJS := $(SRCS:%.cc=$(O)/%.o) -DEPS := $(OBJS:.o=.d) - -# Targets -.PHONY: all clean install versions check_deps -.DEFAULT_GOAL := all - -all: check_deps $(PROJECT) - -check_deps: - @$(PKG_CONFIG) --print-errors --exists $(PKGS) - -$(PROJECT): $(OBJS) - @mkdir -p $(@D) - $(CXX) $(LDFLAGS) $^ -o $@ $(LDLIBS) - -$(O)/%.o: %.cc $(GIT_DEPS) - @mkdir -p $(@D) - $(CXX) $(CXXFLAGS) -c $< -o $@ - --include $(DEPS) - -install: all - @mkdir -p $(DESTDIR)$(PREFIX)/bin - $(INSTALL) -m 0755 $(PROJECT) $(DESTDIR)$(PREFIX)/bin - -clean: - @rm -rf $(O) - -versions: - @$(MAKE) -v - @$(CXX) --version - -$(O)/DEPS/toml11: - @mkdir -p $(@D) - @$(GIT) clone https://github.com/ToruNiina/toml11.git $@ - @$(GIT) -C $@ reset --hard $(TOML11_VER) - -$(O)/DEPS/mitama-cpp-result: - @mkdir -p $(@D) - @$(GIT) clone https://github.com/loliGothicK/mitama-cpp-result.git $@ - @$(GIT) -C $@ reset --hard $(RESULT_VER) - -$(O)/DEPS/rs-cpp: - @mkdir -p $(@D) - @$(GIT) clone https://github.com/ken-matsui/rs-cpp.git $@ diff --git a/README.md b/README.md index 77147aed9..343a756e9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Cabin > [!CAUTION] -> Cabin is still under development and may contain a bunch of bugs. +> Cabin is pre-1.0 and may still contain bugs. ![demo](https://github.com/cabinpkg/cabin/releases/latest/download/demo.gif) -Cabin is a package manager and build system for C++ users, inspired by Cargo for Rust. Designed as a structure-oriented build system, Cabin minimizes the need for configurations or custom build languages, unlike CMake. If you're tired of dealing with complex build setups, Cabin might be the perfect fit. For now, you can refer to this repository to understand the supported project structure, as Cabin is self-buildable. +Cabin is an opinionated C/C++ build tool written in Rust. Inspired by Cargo, it uses a predictable project structure and declarative metadata to reduce build configuration boilerplate. - +It is designed for conventional C/C++ projects that want a simpler, more reproducible build workflow without relying on custom build languages. ## Installation diff --git a/cabin.toml b/cabin.toml deleted file mode 100644 index 997af929f..000000000 --- a/cabin.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -authors = ["Ken Matsui <26405363+ken-matsui@users.noreply.github.com>"] -description = "C++ package manager and build system" -documentation = "https://docs.cabinpkg.com" -edition = "23" -homepage = "https://cabinpkg.com" -license = "Apache-2.0" -name = "cabin" -readme = "README.md" -repository = "https://github.com/cabinpkg/cabin" -version = "0.13.0" - -[dependencies] -toml11 = {git = "https://github.com/ToruNiina/toml11.git", tag = "v4.4.0"} -fmt = {version = ">=9 && <13", system = true} -spdlog = {version = ">=1.8 && <2", system = true} -libcurl = {version = ">=7.79.1 && <9", system = true} -libgit2 = {version = ">=1.7 && <1.10", system = true} -nlohmann_json = {version = "3.10.5", system = true} -tbb = {version = ">=2021.5.0 && <2023.0.0", system = true} -rs-cpp = {git = "https://github.com/ken-matsui/rs-cpp.git", branch = "main"} -semver = {path = "semver"} - -[dev-dependencies] -boost-ut = {git = "https://github.com/boost-ext/ut.git", tag = "v2.3.1"} - -[profile] -cxxflags = ["-pedantic-errors", "-Wall", "-Wextra", "-Wpedantic", "-fno-rtti"] - -[profile.release] -lto = true - -[profile.test] -cxxflags = ["-fsanitize=undefined"] -ldflags = ["-fsanitize=undefined"] - -[lint.cpplint] -filters = [ - "-build/c++11", - "-build/c++17", - "-build/include_order", # prioritize clang-format - "-build/include_subdir", - "-legal/copyright", - "-readability/braces", # prioritize clang-format - "-readability/nolint", # handle NOLINT comments for clang-tidy - "-readability/todo", - "-runtime/indentation_namespace", # inner namespace should be indented - "-runtime/references", # non-const reference rather than a pointer - "-whitespace", - "+whitespace/ending_newline", -] diff --git a/crates/cabin-artifact/Cargo.toml b/crates/cabin-artifact/Cargo.toml new file mode 100644 index 000000000..1beeaee70 --- /dev/null +++ b/crates/cabin-artifact/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cabin-artifact" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Local source-archive cache, checksum verifier, and safe extractor for Cabin." + +[dependencies] +cabin-core = { workspace = true } +cabin-manifest = { workspace = true } +flate2 = { workspace = true } +semver = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/cabin-artifact/src/cache.rs b/crates/cabin-artifact/src/cache.rs new file mode 100644 index 000000000..d4651864e --- /dev/null +++ b/crates/cabin-artifact/src/cache.rs @@ -0,0 +1,68 @@ +use std::path::{Path, PathBuf}; + +/// Layout of an artifact cache rooted at a directory on disk. +/// +/// The cache is intentionally checksum-addressed: +/// +/// ```text +/// / +/// Archives/sha256/.tar.gz +/// Sources/sha256//cabin.toml +/// /... +/// ``` +/// +/// No per-package or per-version directories appear at the top level, +/// which keeps reuse trivial: the same hash always maps to the same +/// archive and the same extracted source tree. +#[derive(Debug, Clone)] +pub struct ArtifactCache { + root: PathBuf, +} + +impl ArtifactCache { + /// Create a cache rooted at `root`. The directory is not created on + /// construction; the fetch path creates the leaf directories on + /// demand. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// Cache root directory. + pub fn root(&self) -> &Path { + &self.root + } + + /// Filesystem path for an archive identified by its `sha256` hex + /// digest. + pub fn archive_path(&self, hex: &str) -> PathBuf { + self.root + .join("archives") + .join("sha256") + .join(format!("{hex}.tar.gz")) + } + + /// Filesystem path for the extracted source tree of an archive + /// identified by its `sha256` hex digest. + pub fn source_dir(&self, hex: &str) -> PathBuf { + self.root.join("sources").join("sha256").join(hex) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_are_checksum_addressed() { + let cache = ArtifactCache::new("/abs/cache"); + let hex = "deadbeef".to_string() + &"a".repeat(56); + assert_eq!( + cache.archive_path(&hex), + PathBuf::from(format!("/abs/cache/archives/sha256/{hex}.tar.gz")) + ); + assert_eq!( + cache.source_dir(&hex), + PathBuf::from(format!("/abs/cache/sources/sha256/{hex}")) + ); + } +} diff --git a/crates/cabin-artifact/src/error.rs b/crates/cabin-artifact/src/error.rs new file mode 100644 index 000000000..9a0125a6f --- /dev/null +++ b/crates/cabin-artifact/src/error.rs @@ -0,0 +1,112 @@ +use std::io; +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors produced by the artifact layer. +/// +/// Messages are written to be useful as direct CLI output: they identify +/// the package by name + version where relevant, and the failure mode in +/// language a user can act on. +#[derive(Debug, Error)] +pub enum ArtifactError { + #[error("package `{name} {version}` has no source artifact in the index")] + MissingSource { name: String, version: String }, + + #[error( + "missing checksum for `{name} {version}`; cabin fetch requires a sha256: entry in the index" + )] + MissingChecksum { name: String, version: String }, + + #[error( + "invalid checksum {value:?} for `{name} {version}`: must be of the form sha256:<64 hex chars>" + )] + InvalidChecksum { + name: String, + version: String, + value: String, + }, + + #[error( + "source archive for `{name} {version}` does not exist: {}", + path.display() + )] + MissingArchive { + name: String, + version: String, + path: PathBuf, + }, + + #[error( + "checksum mismatch for `{name} {version}`: expected sha256:{expected}, got sha256:{actual}" + )] + ChecksumMismatch { + name: String, + version: String, + expected: String, + actual: String, + }, + + #[error("refusing to extract unsafe archive entry `{0}`")] + UnsafeArchiveEntry(String), + + #[error("refusing to extract unsupported archive entry `{0}`")] + UnsupportedArchiveEntry(String), + + #[error( + "refusing to extract archive entry `{path}`: decompressed size exceeds the {limit}-byte per-entry limit (potential decompression bomb)" + )] + ArchiveEntryTooLarge { path: String, limit: u64 }, + + #[error( + "refusing to extract archive: total decompressed size would exceed the {limit}-byte limit (potential decompression bomb)" + )] + ArchiveTooLarge { limit: u64 }, + + #[error( + "refusing to extract archive: entry count exceeds the {limit} limit (potential decompression bomb)" + )] + ArchiveTooManyEntries { limit: usize }, + + #[error("source archive for `{name} {version}` does not contain cabin.toml at its root")] + MissingArchiveManifest { name: String, version: String }, + + #[error( + "source archive for `{name} {version}` contains package `{actual_name} {actual_version}`" + )] + ManifestMismatch { + name: String, + version: String, + actual_name: String, + actual_version: String, + }, + + #[error( + "cannot fetch artifact for `{name} {version}` because --frozen was specified and the artifact is not cached" + )] + FrozenCacheMiss { name: String, version: String }, + + #[error("failed to read {path}: {source}", path = path.display())] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("failed to extract archive {}: {source}", path.display())] + Extract { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error( + "failed to parse extracted manifest at {}: {source}", + path.display() + )] + Manifest { + path: PathBuf, + #[source] + source: Box, + }, +} diff --git a/crates/cabin-artifact/src/extract.rs b/crates/cabin-artifact/src/extract.rs new file mode 100644 index 000000000..4d1efa3e2 --- /dev/null +++ b/crates/cabin-artifact/src/extract.rs @@ -0,0 +1,533 @@ +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::{Component, Path, PathBuf}; + +use cabin_core::PackageName; + +use crate::error::ArtifactError; + +/// Maximum decompressed bytes Cabin will write for a single tar +/// entry. Single source files larger than 256 MiB do not occur in +/// any C/C++ package this tool is expected to ingest; the cap +/// exists to refuse a `.tar.gz` whose entry headers claim a huge +/// `size` and whose gzip stream expands to that size from a tiny +/// compressed payload (a "decompression bomb"). +const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024; + +/// Maximum aggregate decompressed bytes Cabin will write across +/// every entry in one archive. Even with the per-entry cap, an +/// attacker could ship thousands of max-size entries to fill the +/// user's disk; the aggregate cap bounds total damage to ~1 GiB. +const MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024; + +/// Maximum number of tar entries Cabin will process from one +/// archive. Headers alone (no body) can be cheap to ship and +/// expensive to materialise as filesystem inodes, so the count +/// is capped independently of the byte caps. +const MAX_ENTRIES: usize = 10_000; + +/// Safely extract a `.tar.gz` archive into `dest`. +/// +/// Fail-closed rules: +/// - reject entries with absolute paths or `..` components; +/// - reject entries whose joined destination escapes `dest`; +/// - accept only `Regular` files and `Directory` entries — every other +/// Tar entry type (symlinks, hard links, char/block devices, fifos, +/// Sparse, etc.) is rejected; +/// - cap per-entry decompressed bytes (`MAX_ENTRY_BYTES`), +/// aggregate decompressed bytes (`MAX_TOTAL_BYTES`), and +/// total entry count (`MAX_ENTRIES`) so a decompression-bomb +/// archive (small compressed payload, huge decompressed +/// output) cannot fill the user's disk. +pub(crate) fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), ArtifactError> { + extract_tar_gz_with_limits(archive, dest, MAX_ENTRY_BYTES, MAX_TOTAL_BYTES, MAX_ENTRIES) +} + +fn extract_tar_gz_with_limits( + archive: &Path, + dest: &Path, + max_entry_bytes: u64, + max_total_bytes: u64, + max_entries: usize, +) -> Result<(), ArtifactError> { + let f = File::open(archive).map_err(|source| ArtifactError::Io { + path: archive.to_path_buf(), + source, + })?; + let dec = flate2::read::GzDecoder::new(f); + let mut tar = tar::Archive::new(dec); + + let entries = tar.entries().map_err(|source| ArtifactError::Extract { + path: archive.to_path_buf(), + source, + })?; + + let mut total_bytes: u64 = 0; + let mut entry_count: usize = 0; + + for entry_result in entries { + entry_count += 1; + if entry_count > max_entries { + return Err(ArtifactError::ArchiveTooManyEntries { limit: max_entries }); + } + + let mut entry = entry_result.map_err(|source| ArtifactError::Extract { + path: archive.to_path_buf(), + source, + })?; + let entry_kind = entry.header().entry_type(); + let entry_path: PathBuf = entry + .path() + .map_err(|source| ArtifactError::Extract { + path: archive.to_path_buf(), + source, + })? + .into_owned(); + let display = entry_path.to_string_lossy().into_owned(); + + if !is_safe_relative_path(&entry_path) { + return Err(ArtifactError::UnsafeArchiveEntry(display)); + } + let target = dest.join(&entry_path); + if !target.starts_with(dest) { + return Err(ArtifactError::UnsafeArchiveEntry(display)); + } + + match entry_kind { + tar::EntryType::Directory => { + fs::create_dir_all(&target).map_err(|source| ArtifactError::Io { + path: target.clone(), + source, + })?; + } + tar::EntryType::Regular => { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|source| ArtifactError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let mut out = File::create(&target).map_err(|source| ArtifactError::Io { + path: target.clone(), + source, + })?; + // Cap the read at one byte over the per-entry + // limit so a successful copy of exactly the limit + // is distinguishable from an overflow. + let mut limited = (&mut entry).take(max_entry_bytes + 1); + let written = + io::copy(&mut limited, &mut out).map_err(|source| ArtifactError::Io { + path: target.clone(), + source, + })?; + if written > max_entry_bytes { + // Drop the half-written file before + // surfacing the error so a bomb does not + // leave a max-size carcass behind. + drop(out); + let _ = fs::remove_file(&target); + return Err(ArtifactError::ArchiveEntryTooLarge { + path: display, + limit: max_entry_bytes, + }); + } + total_bytes = total_bytes.saturating_add(written); + if total_bytes > max_total_bytes { + drop(out); + let _ = fs::remove_file(&target); + return Err(ArtifactError::ArchiveTooLarge { + limit: max_total_bytes, + }); + } + } + // Reject every other entry type by design (symlinks, + // hard links, char/block devices, fifos, sparse, GNU + // long names, pax extensions, etc.). Cabin source + // archives only need regular files and directories. + _ => return Err(ArtifactError::UnsupportedArchiveEntry(display)), + } + } + Ok(()) +} + +/// Validate that an extracted source tree at `source_dir` matches the +/// resolved package's `name` and `version`. +pub(crate) fn validate_extracted( + source_dir: &Path, + name: &PackageName, + version: &semver::Version, +) -> Result<(), ArtifactError> { + let manifest_path = source_dir.join("cabin.toml"); + if !manifest_path.is_file() { + return Err(ArtifactError::MissingArchiveManifest { + name: name.as_str().to_owned(), + version: version.to_string(), + }); + } + let parsed = cabin_manifest::load_manifest(&manifest_path).map_err(|source| { + ArtifactError::Manifest { + path: manifest_path.clone(), + source: Box::new(source), + } + })?; + let package = parsed + .package + .ok_or_else(|| ArtifactError::MissingArchiveManifest { + name: name.as_str().to_owned(), + version: version.to_string(), + })?; + if package.name != *name || package.version != *version { + return Err(ArtifactError::ManifestMismatch { + name: name.as_str().to_owned(), + version: version.to_string(), + actual_name: package.name.as_str().to_owned(), + actual_version: package.version.to_string(), + }); + } + Ok(()) +} + +/// A path is safe-to-extract when every component is normal or `.` and +/// the path is relative. +fn is_safe_relative_path(path: &Path) -> bool { + if path.is_absolute() { + return false; + } + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false, + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use std::io::Write; + use tempfile::TempDir; + + fn pkg(name: &str) -> PackageName { + PackageName::new(name).unwrap() + } + + fn ver(s: &str) -> semver::Version { + semver::Version::parse(s).unwrap() + } + + /// Build a `.tar.gz` containing a regular file at `path` whose body + /// is `body`. + fn make_archive(archive_path: &Path, entries: &[(&str, &str)]) { + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let f = File::create(archive_path).unwrap(); + let enc = GzEncoder::new(f, Compression::default()); + let mut builder = tar::Builder::new(enc); + for (rel_path, body) in entries { + let bytes = body.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::Regular); + header.set_cksum(); + builder + .append_data(&mut header, rel_path, &mut std::io::Cursor::new(bytes)) + .unwrap(); + } + let enc = builder.into_inner().unwrap(); + enc.finish().unwrap().flush().unwrap(); + } + + /// Build a `.tar.gz` whose first entry has its `name` field written + /// directly. This bypasses `Header::set_path`'s validation, which + /// would reject `..` and absolute paths. + fn make_archive_with_raw_name( + archive_path: &Path, + raw_name: &str, + entry_type: tar::EntryType, + link_name: Option<&str>, + body: &[u8], + ) { + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let f = File::create(archive_path).unwrap(); + let enc = GzEncoder::new(f, Compression::default()); + let mut builder = tar::Builder::new(enc); + + let mut header = tar::Header::new_old(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_entry_type(entry_type); + if let Some(target) = link_name { + // `set_link_name` validates and rejects `..` / absolutes, + // so write the bytes directly into the OldHeader's + // `linkname` field. + let bytes = target.as_bytes(); + let old = header.as_old_mut(); + for b in &mut old.linkname[..] { + *b = 0; + } + let n = bytes.len().min(old.linkname.len()); + old.linkname[..n].copy_from_slice(&bytes[..n]); + } + { + // Same trick for the entry name. + let bytes = raw_name.as_bytes(); + let old = header.as_old_mut(); + for b in &mut old.name[..] { + *b = 0; + } + let n = bytes.len().min(old.name.len()); + old.name[..n].copy_from_slice(&bytes[..n]); + } + header.set_cksum(); + builder.append(&header, body).unwrap(); + let enc = builder.into_inner().unwrap(); + enc.finish().unwrap().flush().unwrap(); + } + + #[test] + fn extracts_simple_archive() { + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("ok.tar.gz"); + make_archive( + &archive, + &[ + ( + "cabin.toml", + "[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n", + ), + ("src/main.cc", "int main() { return 0; }\n"), + ], + ); + + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + extract_tar_gz(&archive, &dest).unwrap(); + assert!(dest.join("cabin.toml").is_file()); + assert!(dest.join("src/main.cc").is_file()); + } + + #[test] + fn rejects_parent_dir_entry() { + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("bad.tar.gz"); + make_archive_with_raw_name( + &archive, + "../escape.txt", + tar::EntryType::Regular, + None, + b"evil", + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz(&archive, &dest).unwrap_err(); + match err { + ArtifactError::UnsafeArchiveEntry(p) => assert!(p.contains("..")), + other => panic!("expected UnsafeArchiveEntry, got {other:?}"), + } + // Nothing escaped. + assert!(!dir.path().join("escape.txt").exists()); + } + + #[test] + fn rejects_absolute_path_entry() { + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("bad.tar.gz"); + make_archive_with_raw_name( + &archive, + "/etc/passwd", + tar::EntryType::Regular, + None, + b"evil", + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz(&archive, &dest).unwrap_err(); + assert!(matches!(err, ArtifactError::UnsafeArchiveEntry(_))); + } + + #[test] + fn rejects_symlink_entry() { + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("bad.tar.gz"); + make_archive_with_raw_name( + &archive, + "evil", + tar::EntryType::Symlink, + Some("/etc/passwd"), + b"", + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz(&archive, &dest).unwrap_err(); + assert!(matches!(err, ArtifactError::UnsupportedArchiveEntry(_))); + } + + #[test] + fn rejects_hard_link_entry() { + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("bad.tar.gz"); + make_archive_with_raw_name( + &archive, + "alias", + tar::EntryType::Link, + Some("cabin.toml"), + b"", + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz(&archive, &dest).unwrap_err(); + assert!(matches!(err, ArtifactError::UnsupportedArchiveEntry(_))); + } + + #[test] + fn validate_extracted_accepts_matching_manifest() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("cabin.toml"), + "[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n", + ) + .unwrap(); + validate_extracted(dir.path(), &pkg("fmt"), &ver("10.2.1")).unwrap(); + } + + #[test] + fn validate_extracted_rejects_missing_manifest() { + let dir = TempDir::new().unwrap(); + let err = validate_extracted(dir.path(), &pkg("fmt"), &ver("10.2.1")).unwrap_err(); + assert!(matches!(err, ArtifactError::MissingArchiveManifest { .. })); + } + + #[test] + fn validate_extracted_rejects_name_mismatch() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("cabin.toml"), + "[package]\nname = \"other\"\nversion = \"10.2.1\"\n", + ) + .unwrap(); + let err = validate_extracted(dir.path(), &pkg("fmt"), &ver("10.2.1")).unwrap_err(); + match err { + ArtifactError::ManifestMismatch { + actual_name, + actual_version, + .. + } => { + assert_eq!(actual_name, "other"); + assert_eq!(actual_version, "10.2.1"); + } + other => panic!("expected ManifestMismatch, got {other:?}"), + } + } + + #[test] + fn rejects_archive_entry_exceeding_per_entry_limit() { + // A single entry whose decompressed body would exceed the + // per-entry cap is refused before the bomb is written to + // disk. The half-written file is removed so a bomb does + // not leave a max-size carcass behind. + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("bomb.tar.gz"); + let body = "x".repeat(2048); + make_archive(&archive, &[("cabin.toml", body.as_str())]); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz_with_limits(&archive, &dest, 1024, 1_000_000, 1000).unwrap_err(); + match err { + ArtifactError::ArchiveEntryTooLarge { path, limit } => { + assert_eq!(path, "cabin.toml"); + assert_eq!(limit, 1024); + } + other => panic!("expected ArchiveEntryTooLarge, got {other:?}"), + } + assert!(!dest.join("cabin.toml").exists(), "carcass must be removed"); + } + + #[test] + fn rejects_archive_exceeding_aggregate_size_limit() { + // Each entry fits under the per-entry cap, but the sum + // exceeds the aggregate cap. Refused on the entry whose + // write pushes the running total over. + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("aggregate-bomb.tar.gz"); + let body = "x".repeat(700); + make_archive( + &archive, + &[("a.txt", body.as_str()), ("b.txt", body.as_str())], + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz_with_limits(&archive, &dest, 1024, 1000, 1000).unwrap_err(); + match err { + ArtifactError::ArchiveTooLarge { limit } => assert_eq!(limit, 1000), + other => panic!("expected ArchiveTooLarge, got {other:?}"), + } + assert!(!dest.join("b.txt").exists(), "carcass must be removed"); + } + + #[test] + fn rejects_archive_with_too_many_entries() { + // Headers can be cheap to ship and expensive to + // materialise as inodes; the entry-count cap fires + // independently of byte caps. + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("many.tar.gz"); + make_archive( + &archive, + &[ + ("a.txt", "x"), + ("b.txt", "x"), + ("c.txt", "x"), + ("d.txt", "x"), + ], + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + let err = extract_tar_gz_with_limits(&archive, &dest, 1024, 1_000_000, 3).unwrap_err(); + match err { + ArtifactError::ArchiveTooManyEntries { limit } => assert_eq!(limit, 3), + other => panic!("expected ArchiveTooManyEntries, got {other:?}"), + } + } + + #[test] + fn accepts_archive_just_under_limits() { + // Positive control: the bomb caps must not regress the + // happy path for archives that sit under every limit. + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("ok.tar.gz"); + make_archive( + &archive, + &[ + ( + "cabin.toml", + "[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n", + ), + ("src/main.cc", "int main() { return 0; }\n"), + ], + ); + let dest = dir.path().join("out"); + fs::create_dir_all(&dest).unwrap(); + extract_tar_gz_with_limits(&archive, &dest, 4096, 1_000_000, 1000).unwrap(); + assert!(dest.join("cabin.toml").is_file()); + assert!(dest.join("src/main.cc").is_file()); + } + + #[test] + fn validate_extracted_rejects_version_mismatch() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("cabin.toml"), + "[package]\nname = \"fmt\"\nversion = \"10.1.0\"\n", + ) + .unwrap(); + let err = validate_extracted(dir.path(), &pkg("fmt"), &ver("10.2.1")).unwrap_err(); + assert!(matches!(err, ArtifactError::ManifestMismatch { .. })); + } +} diff --git a/crates/cabin-artifact/src/fetch.rs b/crates/cabin-artifact/src/fetch.rs new file mode 100644 index 000000000..28bf16a1b --- /dev/null +++ b/crates/cabin-artifact/src/fetch.rs @@ -0,0 +1,681 @@ +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use cabin_core::PackageName; +use sha2::{Digest, Sha256}; + +use crate::cache::ArtifactCache; +use crate::error::ArtifactError; +use crate::extract; +use crate::model::ChecksumDigest; + +/// What to materialise into the cache. +#[derive(Debug, Clone)] +pub struct FetchPlan { + pub entries: Vec, +} + +/// One package the caller wants in the cache. Built from a resolved +/// package + the index entry's `source` and `checksum`. +#[derive(Debug, Clone)] +pub struct FetchEntry { + pub name: PackageName, + pub version: semver::Version, + /// Raw `sha256:` checksum carried in the index/lockfile. + pub checksum: String, + /// Where the archive lives at fetch time. Local file index sources + /// hand in a [`FetchSource::LocalArchive`]; the HTTP index source + /// pre-downloads the archive bytes and hands in a + /// [`FetchSource::InMemoryArchive`]. + pub source: FetchSource, +} + +/// Where to read archive bytes from. `cabin-artifact` stays +/// HTTP-free: callers handle any download themselves and pass the +/// resulting bytes via [`FetchSource::InMemoryArchive`]. +#[derive(Debug, Clone)] +pub enum FetchSource { + /// Filesystem path that the caller (file index) already resolved + /// to a ready-to-open archive. + LocalArchive(PathBuf), + /// Archive bytes already in memory (HTTP downloads, custom + /// fetchers, tests). + InMemoryArchive(Vec), +} + +/// Caller-controlled knobs that change how `fetch` interacts with the +/// cache. +#[derive(Debug, Clone, Copy, Default)] +pub struct FetchOptions { + /// `--frozen`: do not populate the cache. If a required archive or + /// extracted source tree is not already cached and valid, fail with + /// [`ArtifactError::FrozenCacheMiss`]. + pub frozen: bool, +} + +/// Fetch result, carrying the materialised cache locations. +#[derive(Debug, Clone)] +pub struct FetchResult { + pub packages: Vec, +} + +/// One fully-materialised package: archive verified, source extracted, +/// `cabin.toml` validated. +#[derive(Debug, Clone)] +pub struct FetchedPackage { + pub name: PackageName, + pub version: semver::Version, + pub checksum: String, + pub archive_path: PathBuf, + pub source_dir: PathBuf, +} + +/// Materialise every entry in `plan` into the cache, observing +/// `options`. +pub fn fetch( + plan: &FetchPlan, + cache: &ArtifactCache, + options: FetchOptions, +) -> Result { + let mut packages = Vec::with_capacity(plan.entries.len()); + for entry in &plan.entries { + packages.push(fetch_one(entry, cache, options)?); + } + Ok(FetchResult { packages }) +} + +fn fetch_one( + entry: &FetchEntry, + cache: &ArtifactCache, + options: FetchOptions, +) -> Result { + let digest = + ChecksumDigest::parse(&entry.checksum).ok_or_else(|| ArtifactError::InvalidChecksum { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + value: entry.checksum.clone(), + })?; + let hex = digest.hex().to_owned(); + let archive_path = cache.archive_path(&hex); + let source_dir = cache.source_dir(&hex); + + ensure_archive(entry, &archive_path, &hex, options.frozen)?; + ensure_source(entry, &archive_path, &source_dir, options.frozen)?; + + Ok(FetchedPackage { + name: entry.name.clone(), + version: entry.version.clone(), + checksum: digest.full(), + archive_path, + source_dir, + }) +} + +/// Make sure the cache archive file exists and matches `expected_hex`. +/// +/// Behaviour: +/// - if the archive is already present and hashes correctly, reuse it; +/// - otherwise (missing, wrong hash, or corrupt) and not frozen, +/// read the archive from [`FetchSource`] while hashing, and fail +/// if the bytes don't match `expected_hex`; +/// - in frozen mode, refuse to populate; only an already-correct +/// cache entry is acceptable. +fn ensure_archive( + entry: &FetchEntry, + archive_path: &Path, + expected_hex: &str, + frozen: bool, +) -> Result<(), ArtifactError> { + if archive_path.is_file() { + let actual = hash_file(archive_path)?; + if actual == expected_hex { + return Ok(()); + } + if frozen { + return Err(ArtifactError::FrozenCacheMiss { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + }); + } + } else if frozen { + return Err(ArtifactError::FrozenCacheMiss { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + }); + } + + if let FetchSource::LocalArchive(path) = &entry.source + && !path.is_file() + { + return Err(ArtifactError::MissingArchive { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + path: path.clone(), + }); + } + + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).map_err(|source| ArtifactError::Io { + path: parent.to_path_buf(), + source, + })?; + } + + let tmp_target = partial_sibling(archive_path); + populate_archive(entry, &tmp_target, archive_path, expected_hex)?; + Ok(()) +} + +fn populate_archive( + entry: &FetchEntry, + tmp_target: &Path, + final_target: &Path, + expected_hex: &str, +) -> Result<(), ArtifactError> { + let actual = match &entry.source { + FetchSource::LocalArchive(path) => stream_local_to_partial(path, tmp_target)?, + FetchSource::InMemoryArchive(bytes) => write_bytes_to_partial(bytes, tmp_target)?, + }; + + if actual != expected_hex { + let _ = fs::remove_file(tmp_target); + return Err(ArtifactError::ChecksumMismatch { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + expected: expected_hex.to_owned(), + actual, + }); + } + fs::rename(tmp_target, final_target).map_err(|source| ArtifactError::Io { + path: final_target.to_path_buf(), + source, + })?; + Ok(()) +} + +/// Stream `source_path` into `tmp_target`, hashing as it goes. +fn stream_local_to_partial(source_path: &Path, tmp_target: &Path) -> Result { + let mut src = File::open(source_path).map_err(|source| ArtifactError::Io { + path: source_path.to_path_buf(), + source, + })?; + let mut dst = File::create(tmp_target).map_err(|source| ArtifactError::Io { + path: tmp_target.to_path_buf(), + source, + })?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = src.read(&mut buf).map_err(|source| ArtifactError::Io { + path: source_path.to_path_buf(), + source, + })?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + dst.write_all(&buf[..n]) + .map_err(|source| ArtifactError::Io { + path: tmp_target.to_path_buf(), + source, + })?; + } + drop(dst); + Ok(format!("{:x}", hasher.finalize())) +} + +/// Write `bytes` into `tmp_target`, hashing as it goes. +fn write_bytes_to_partial(bytes: &[u8], tmp_target: &Path) -> Result { + let mut dst = File::create(tmp_target).map_err(|source| ArtifactError::Io { + path: tmp_target.to_path_buf(), + source, + })?; + dst.write_all(bytes).map_err(|source| ArtifactError::Io { + path: tmp_target.to_path_buf(), + source, + })?; + drop(dst); + let mut hasher = Sha256::new(); + hasher.update(bytes); + Ok(format!("{:x}", hasher.finalize())) +} + +/// Build the completion-marker path for an extraction. +/// +/// The marker lives as a SIBLING of `source_dir`, not inside it. +/// `extract::extract_tar_gz` writes every tarball entry under +/// `source_dir`, and `source_dir.starts_with(dest)` is enforced +/// per entry, so no published tarball can forge this marker no +/// matter what filenames it includes. `fs::remove_dir_all` on +/// `source_dir` does not remove the sibling marker either, so +/// the caller must delete the marker explicitly before +/// re-extracting — captured in `ensure_source` below. +fn extraction_marker_path(source_dir: &Path) -> PathBuf { + let mut s: OsString = source_dir.as_os_str().to_owned(); + s.push(".ok"); + PathBuf::from(s) +} + +fn ensure_source( + entry: &FetchEntry, + archive_path: &Path, + source_dir: &Path, + frozen: bool, +) -> Result<(), ArtifactError> { + let marker = extraction_marker_path(source_dir); + if marker.is_file() + && extract::validate_extracted(source_dir, &entry.name, &entry.version).is_ok() + { + return Ok(()); + } + if frozen { + return Err(ArtifactError::FrozenCacheMiss { + name: entry.name.as_str().to_owned(), + version: entry.version.to_string(), + }); + } + + // Drop a stale marker first so a crash before the new one is + // written can never leave the previous run's "complete" flag + // pointing at a freshly re-extracted (or in-progress) tree. + if marker.exists() { + fs::remove_file(&marker).map_err(|source| ArtifactError::Io { + path: marker.clone(), + source, + })?; + } + if source_dir.exists() { + fs::remove_dir_all(source_dir).map_err(|source| ArtifactError::Io { + path: source_dir.to_path_buf(), + source, + })?; + } + fs::create_dir_all(source_dir).map_err(|source| ArtifactError::Io { + path: source_dir.to_path_buf(), + source, + })?; + extract::extract_tar_gz(archive_path, source_dir)?; + extract::validate_extracted(source_dir, &entry.name, &entry.version)?; + // Write the marker only after extraction and validation + // succeed. A crash between extract_tar_gz and this write + // leaves the marker absent, so the next run treats the + // directory as interrupted and re-extracts. + File::create(&marker).map_err(|source| ArtifactError::Io { + path: marker.clone(), + source, + })?; + Ok(()) +} + +/// `archive_path.with_extension("partial")` would clobber `.gz`, so +/// build the sibling path by hand. +fn partial_sibling(archive_path: &Path) -> PathBuf { + let mut s: OsString = archive_path.as_os_str().to_owned(); + s.push(".partial"); + PathBuf::from(s) +} + +fn hash_file(path: &Path) -> Result { + let mut f = File::open(path).map_err(|source| ArtifactError::Io { + path: path.to_path_buf(), + source, + })?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = f.read(&mut buf).map_err(|source| ArtifactError::Io { + path: path.to_path_buf(), + source, + })?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use std::io::Write; + use tempfile::TempDir; + + fn pkg(name: &str) -> PackageName { + PackageName::new(name).unwrap() + } + + fn ver(s: &str) -> semver::Version { + semver::Version::parse(s).unwrap() + } + + /// Assemble a tiny `.tar.gz` with the given file contents. Returns + /// the archive path and its `sha256` hex digest. + fn make_archive( + dir: &std::path::Path, + name: &str, + files: &[(&str, &str)], + ) -> (PathBuf, String) { + let path = dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let f = File::create(&path).unwrap(); + let enc = GzEncoder::new(f, Compression::default()); + let mut builder = tar::Builder::new(enc); + for (rel, body) in files { + let bytes = body.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::Regular); + header.set_cksum(); + builder + .append_data(&mut header, rel, &mut std::io::Cursor::new(bytes)) + .unwrap(); + } + let enc = builder.into_inner().unwrap(); + enc.finish().unwrap().flush().unwrap(); + let hex = hash_file(&path).unwrap(); + (path, hex) + } + + fn manifest(name: &str, version: &str) -> String { + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n") + } + + fn cache_root(dir: &std::path::Path) -> ArtifactCache { + ArtifactCache::new(dir.join("cache")) + } + + #[test] + fn fetch_copies_archive_into_cache_and_extracts_source() { + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt-10.2.1.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + let result = fetch(&plan, &cache, FetchOptions::default()).unwrap(); + assert_eq!(result.packages.len(), 1); + let pkg_result = &result.packages[0]; + assert_eq!(pkg_result.archive_path, cache.archive_path(&hex)); + assert!(pkg_result.archive_path.is_file()); + assert!(pkg_result.source_dir.join("cabin.toml").is_file()); + } + + #[test] + fn already_cached_archive_is_reused() { + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive.clone()), + }], + }; + fetch(&plan, &cache, FetchOptions::default()).unwrap(); + // Move the source archive away — the cached copy must still + // satisfy a re-run. + fs::remove_file(&archive).unwrap(); + let r2 = fetch(&plan, &cache, FetchOptions::default()).unwrap(); + assert!(r2.packages[0].archive_path.is_file()); + } + + #[test] + fn checksum_mismatch_is_reported() { + let dir = TempDir::new().unwrap(); + let (archive, _hex) = make_archive( + &dir.path().join("artifacts"), + "fmt.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + let bogus = format!("sha256:{}", "0".repeat(64)); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: bogus, + source: FetchSource::LocalArchive(archive), + }], + }; + let err = fetch(&plan, &cache, FetchOptions::default()).unwrap_err(); + match err { + ArtifactError::ChecksumMismatch { .. } => {} + other => panic!("expected ChecksumMismatch, got {other:?}"), + } + } + + #[test] + fn missing_archive_is_reported() { + let dir = TempDir::new().unwrap(); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{}", "a".repeat(64)), + source: FetchSource::LocalArchive(dir.path().join("nope.tar.gz")), + }], + }; + let err = fetch(&plan, &cache, FetchOptions::default()).unwrap_err(); + assert!(matches!(err, ArtifactError::MissingArchive { .. })); + } + + #[test] + fn invalid_checksum_is_reported() { + let dir = TempDir::new().unwrap(); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: "sha256:not-hex".to_owned(), + source: FetchSource::LocalArchive(dir.path().join("any.tar.gz")), + }], + }; + let err = fetch(&plan, &cache, FetchOptions::default()).unwrap_err(); + assert!(matches!(err, ArtifactError::InvalidChecksum { .. })); + } + + #[test] + fn frozen_uses_existing_cache() { + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + // Populate first. + fetch(&plan, &cache, FetchOptions::default()).unwrap(); + // Now run with frozen — cache hit should succeed. + fetch(&plan, &cache, FetchOptions { frozen: true }).unwrap(); + } + + #[test] + fn frozen_fails_on_cache_miss() { + let dir = TempDir::new().unwrap(); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{}", "b".repeat(64)), + source: FetchSource::LocalArchive(dir.path().join("ignored.tar.gz")), + }], + }; + let err = fetch(&plan, &cache, FetchOptions { frozen: true }).unwrap_err(); + assert!(matches!(err, ArtifactError::FrozenCacheMiss { .. })); + } + + #[test] + fn re_extracts_when_existing_source_dir_is_incomplete() { + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + fetch(&plan, &cache, FetchOptions::default()).unwrap(); + // Corrupt the extracted manifest; next run should re-extract. + let extracted = cache.source_dir(&hex); + fs::write(extracted.join("cabin.toml"), "garbage").unwrap(); + fetch(&plan, &cache, FetchOptions::default()).unwrap(); + let body = fs::read_to_string(extracted.join("cabin.toml")).unwrap(); + assert!(body.contains("fmt")); + } + + #[test] + fn re_extracts_when_marker_missing_even_if_manifest_present() { + // Simulates an interrupted previous run that wrote + // `cabin.toml` (tar archives put the manifest at the + // head) before crashing without finishing the rest of + // the source tree. The next fetch must re-extract rather + // than treat the directory as a complete cache hit. + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[ + ("cabin.toml", &manifest("fmt", "10.2.1")), + ("src/main.cc", "int main() { return 0; }\n"), + ], + ); + let cache = cache_root(dir.path()); + let extracted = cache.source_dir(&hex); + let marker = extraction_marker_path(&extracted); + // Pretend a previous run extracted just the manifest and + // crashed. No completion marker is written. + fs::create_dir_all(&extracted).unwrap(); + fs::write(extracted.join("cabin.toml"), manifest("fmt", "10.2.1")).unwrap(); + assert!(!marker.is_file()); + assert!(!extracted.join("src/main.cc").is_file()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + fetch(&plan, &cache, FetchOptions::default()).unwrap(); + assert!(marker.is_file()); + assert!(extracted.join("src/main.cc").is_file()); + } + + #[test] + fn marker_sibling_path_resists_tarball_forgery() { + // The completion marker is a sibling of `source_dir`, + // not inside it. Even if a published tarball were named + // to look like the marker, `extract_tar_gz` would only + // place it under `source_dir` and our check would still + // miss. Confirm the marker path does not start with + // `source_dir` so the invariant is visible to readers. + let dir = TempDir::new().unwrap(); + let cache = cache_root(dir.path()); + let extracted = cache.source_dir(&"a".repeat(64)); + let marker = extraction_marker_path(&extracted); + assert!(!marker.starts_with(&extracted)); + assert_eq!(marker.parent(), extracted.parent()); + } + + #[test] + fn frozen_fails_when_marker_missing_even_if_manifest_present() { + // Same setup as the marker-missing test above, but in + // frozen mode. The incomplete cache must surface as a + // FrozenCacheMiss rather than being silently treated as + // valid. + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[("cabin.toml", &manifest("fmt", "10.2.1"))], + ); + let cache = cache_root(dir.path()); + // Also lay down the archive so `ensure_archive` passes + // and we exercise the source path. + let dest_archive = cache.archive_path(&hex); + fs::create_dir_all(dest_archive.parent().unwrap()).unwrap(); + fs::copy(&archive, &dest_archive).unwrap(); + let extracted = cache.source_dir(&hex); + fs::create_dir_all(&extracted).unwrap(); + fs::write(extracted.join("cabin.toml"), manifest("fmt", "10.2.1")).unwrap(); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + let err = fetch(&plan, &cache, FetchOptions { frozen: true }).unwrap_err(); + assert!(matches!(err, ArtifactError::FrozenCacheMiss { .. })); + } + + #[test] + fn rejects_archive_without_root_cabin_toml() { + let dir = TempDir::new().unwrap(); + let archives = dir.path().join("artifacts"); + let (archive, hex) = make_archive( + &archives, + "fmt.tar.gz", + &[("src/main.cc", "int main() { return 0; }\n")], + ); + let cache = cache_root(dir.path()); + let plan = FetchPlan { + entries: vec![FetchEntry { + name: pkg("fmt"), + version: ver("10.2.1"), + checksum: format!("sha256:{hex}"), + source: FetchSource::LocalArchive(archive), + }], + }; + let err = fetch(&plan, &cache, FetchOptions::default()).unwrap_err(); + assert!(matches!(err, ArtifactError::MissingArchiveManifest { .. })); + } +} diff --git a/crates/cabin-artifact/src/lib.rs b/crates/cabin-artifact/src/lib.rs new file mode 100644 index 000000000..e9326e07f --- /dev/null +++ b/crates/cabin-artifact/src/lib.rs @@ -0,0 +1,38 @@ +//! Local source-archive layer for Cabin. +//! +//! The artifact layer turns a resolved registry package set into +//! on-disk source trees. The crate owns: +//! +//! - cache layout ([`cache`]), +//! - SHA-256 verification and `.tar.gz` extraction ([`mod@fetch`], [`extract`]), +//! - the small typed surface in [`model`]. +//! +//! Crate boundaries: +//! - this crate must not run the resolver, write Ninja, or invoke +//! Compilers; +//! - it must not implement networking, publishing, or any server +//! Functionality; +//! - extraction is fail-closed: archive entries that escape the +//! Destination, declare absolute paths, contain `..` components, or +//! Use unsupported tar entry types are rejected. + +// `ArtifactError` aggregates lockfile, fetch, extract, and +// cache errors. The union crosses clippy's default +// `result_large_err` threshold once `cabin_lockfile` (whose +// errors flow in via `?`) gains its own larger variants. +// Boxing the enum at every call site would be churny; we +// accept the larger `Result` instead. +#![allow(clippy::missing_errors_doc, clippy::must_use_candidate)] + +pub mod cache; +pub mod error; +pub mod extract; +pub mod fetch; +pub mod model; + +pub use cache::ArtifactCache; +pub use error::ArtifactError; +pub use fetch::{ + FetchEntry, FetchOptions, FetchPlan, FetchResult, FetchSource, FetchedPackage, fetch, +}; +pub use model::{CHECKSUM_PREFIX, ChecksumDigest}; diff --git a/crates/cabin-artifact/src/model.rs b/crates/cabin-artifact/src/model.rs new file mode 100644 index 000000000..eb4575bbc --- /dev/null +++ b/crates/cabin-artifact/src/model.rs @@ -0,0 +1,72 @@ +/// Prefix every Cabin checksum string carries. +pub const CHECKSUM_PREFIX: &str = "sha256:"; + +/// A parsed `sha256:` digest, lower-cased and validated. +/// +/// Strings are accepted via [`ChecksumDigest::parse`]; anything that does +/// not match `sha256:` followed by exactly 64 hex characters is rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChecksumDigest { + hex: String, +} + +impl ChecksumDigest { + /// Parse a `sha256:` checksum. Returns `None` if the prefix or + /// the hex body is malformed. + pub fn parse(value: &str) -> Option { + let rest = value.strip_prefix(CHECKSUM_PREFIX)?; + if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + Some(Self { + hex: rest.to_ascii_lowercase(), + }) + } + + /// The 64-character lower-case hex body. + pub fn hex(&self) -> &str { + &self.hex + } + + /// Re-render as `sha256:`. + pub fn full(&self) -> String { + format!("{CHECKSUM_PREFIX}{}", self.hex) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_checksum() { + let body = "a".repeat(64); + let value = format!("sha256:{body}"); + let parsed = ChecksumDigest::parse(&value).unwrap(); + assert_eq!(parsed.hex(), body); + assert_eq!(parsed.full(), value); + } + + #[test] + fn lowercases_uppercase_hex() { + let value = format!("sha256:{}", "A".repeat(64)); + let parsed = ChecksumDigest::parse(&value).unwrap(); + assert_eq!(parsed.hex(), "a".repeat(64)); + } + + #[test] + fn rejects_wrong_prefix() { + assert!(ChecksumDigest::parse(&format!("md5:{}", "a".repeat(64))).is_none()); + } + + #[test] + fn rejects_wrong_length() { + assert!(ChecksumDigest::parse("sha256:abc").is_none()); + } + + #[test] + fn rejects_non_hex() { + let body = "z".repeat(64); + assert!(ChecksumDigest::parse(&format!("sha256:{body}")).is_none()); + } +} diff --git a/crates/cabin-build/Cargo.toml b/crates/cabin-build/Cargo.toml new file mode 100644 index 000000000..d556543b1 --- /dev/null +++ b/crates/cabin-build/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cabin-build" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Backend-independent build graph planner for Cabin." + +[dependencies] +cabin-core = { workspace = true } +cabin-workspace = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +semver = { workspace = true } +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/cabin-build/src/clean.rs b/crates/cabin-build/src/clean.rs new file mode 100644 index 000000000..4fdb8d4c7 --- /dev/null +++ b/crates/cabin-build/src/clean.rs @@ -0,0 +1,538 @@ +//! Plan and execute the deletion list for `cabin clean`. +//! +//! The on-disk layout this module operates against must stay in +//! sync with [`crate::planner`]: +//! +//! ```text +//! //build.ninja +//! //compile_commands.json +//! //packages//... +//! //cargo///... +//! ``` +//! +//! Safety contract: +//! +//! - every safety check runs before the deletion plan is even +//! computed, so an unsafe build directory never reaches the +//! filesystem step; +//! - the plan only contains paths inside the resolved +//! `build_dir`; +//! - paths are sorted so dry-run output is deterministic; +//! - `remove_dir_all` does not follow symlinks for entries +//! inside the tree — it removes the link, not the target — and +//! the build directory itself is rejected up-front when it is a +//! symlink, so this module never traverses through a symlink. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use cabin_core::{PackageName, ProfileName}; + +/// What `cabin clean` should remove. +#[derive(Debug, Clone)] +pub enum CleanScope { + /// Remove the entire build directory. Used by the no-flag + /// invocation `cabin clean`. + Whole, + /// Remove a single profile sub-tree + /// (`//`). + Profile(ProfileName), + /// Remove the per-package output for one or more packages + /// across one or more profiles. + Packages { + profiles: Vec, + packages: Vec, + }, +} + +/// Inputs to [`plan_clean`]. +#[derive(Debug, Clone)] +pub struct CleanRequest<'a> { + /// Resolved absolute build directory. + pub build_dir: &'a Path, + /// Workspace root directory (manifest's parent). Used by + /// the safety check that refuses to clean the workspace + /// itself. + pub workspace_root: &'a Path, + /// Manifest directories of every loaded package — single + /// package or every workspace member. Used to refuse a + /// build directory that points at a package source tree. + pub package_roots: &'a [PathBuf], + /// Source files and source-owned directories that must not + /// be contained by the build directory. This lets in-tree + /// build dirs like `/build` keep working while rejecting + /// dangerous settings such as `--build-dir src`. + pub protected_source_paths: &'a [PathBuf], + /// What to clean. + pub scope: CleanScope, +} + +/// Deterministic deletion plan: a sorted, deduplicated list of +/// existing paths inside `build_dir` that the executor will +/// remove. +#[derive(Debug, Clone)] +pub struct CleanPlan { + /// Resolved build directory the plan operates against. + pub build_dir: PathBuf, + /// Sorted, existing paths to remove. Each entry lives + /// inside `build_dir` (see [`plan_clean`]'s contract). + pub removals: Vec, +} + +/// Result of an [`execute_clean`] call. +#[derive(Debug, Clone, Default)] +pub struct CleanReport { + /// Paths the executor actually removed. May be a strict + /// subset of [`CleanPlan::removals`] if a concurrent process + /// removed an entry between planning and execution. + pub removed: Vec, +} + +/// Errors produced while validating a clean request, planning +/// the deletion, or removing files. +#[derive(Debug, Error)] +pub enum CleanError { + #[error("build directory path is empty")] + EmptyBuildDir, + + #[error("refusing to clean root path {}", .0.display())] + RootBuildDir(PathBuf), + + #[error("refusing to clean home directory {}", .0.display())] + HomeBuildDir(PathBuf), + + #[error("refusing to clean workspace root {}; the build directory must point at a separate output directory", .0.display())] + WorkspaceRootBuildDir(PathBuf), + + #[error("refusing to clean package source directory {}; the build directory must point at a separate output directory", .0.display())] + PackageRootBuildDir(PathBuf), + + #[error("refusing to clean build directory {} because it overlaps source file or directory {}", build_dir.display(), source_path.display())] + SourcePathBuildDir { + build_dir: PathBuf, + source_path: PathBuf, + }, + + #[error("refusing to clean symlink {}; replace it with a real directory before re-running `cabin clean`", .0.display())] + SymlinkBuildDir(PathBuf), + + #[error("computed deletion path {} is not inside build directory {}", path.display(), build_dir.display())] + PathEscapesBuildDir { path: PathBuf, build_dir: PathBuf }, + + #[error("failed to remove {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Validate the request's safety guards and return a sorted, +/// deduplicated deletion plan. +/// +/// Every path in the returned plan is an existing entry inside +/// `req.build_dir`. The function never touches the filesystem +/// beyond `symlink_metadata` (safety check) and `Path::exists` +/// (filtering candidates to those that actually live on disk). +pub fn plan_clean(req: &CleanRequest<'_>) -> Result { + validate_safe_build_dir( + req.build_dir, + req.workspace_root, + req.package_roots, + req.protected_source_paths, + )?; + + let candidates = match &req.scope { + CleanScope::Whole => vec![req.build_dir.to_path_buf()], + CleanScope::Profile(profile) => vec![req.build_dir.join(profile.as_str())], + CleanScope::Packages { profiles, packages } => { + let mut out = Vec::with_capacity(profiles.len().saturating_mul(packages.len()) * 2); + for profile in profiles { + let profile_root = req.build_dir.join(profile.as_str()); + for pkg in packages { + out.push(profile_root.join("packages").join(pkg.as_str())); + out.push(profile_root.join("cargo").join(pkg.as_str())); + } + } + out + } + }; + + for candidate in &candidates { + if !is_within(candidate, req.build_dir) { + return Err(CleanError::PathEscapesBuildDir { + path: candidate.clone(), + build_dir: req.build_dir.to_path_buf(), + }); + } + } + + let mut existing: Vec = candidates.into_iter().filter(|p| p.exists()).collect(); + existing.sort(); + existing.dedup(); + + Ok(CleanPlan { + build_dir: req.build_dir.to_path_buf(), + removals: existing, + }) +} + +/// Remove every path in `plan.removals`. +/// +/// Paths that disappeared between planning and execution +/// (concurrent removal by another process) are silently skipped: +/// the goal state — the path no longer existing — is already +/// satisfied. Symbolic links inside the build tree are removed +/// as links rather than recursively followed. +pub fn execute_clean(plan: &CleanPlan) -> Result { + let mut removed = Vec::with_capacity(plan.removals.len()); + for path in &plan.removals { + let metadata = match std::fs::symlink_metadata(path) { + Ok(m) => m, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => { + return Err(CleanError::Io { + path: path.clone(), + source, + }); + } + }; + let file_type = metadata.file_type(); + if file_type.is_dir() { + std::fs::remove_dir_all(path).map_err(|source| CleanError::Io { + path: path.clone(), + source, + })?; + } else { + std::fs::remove_file(path).map_err(|source| CleanError::Io { + path: path.clone(), + source, + })?; + } + removed.push(path.clone()); + } + Ok(CleanReport { removed }) +} + +fn validate_safe_build_dir( + build_dir: &Path, + workspace_root: &Path, + package_roots: &[PathBuf], + protected_source_paths: &[PathBuf], +) -> Result<(), CleanError> { + if build_dir.as_os_str().is_empty() { + return Err(CleanError::EmptyBuildDir); + } + if build_dir.parent().is_none() { + return Err(CleanError::RootBuildDir(build_dir.to_path_buf())); + } + if let Some(home) = home_dir() + && same_path(build_dir, &home) + { + return Err(CleanError::HomeBuildDir(build_dir.to_path_buf())); + } + if same_path(build_dir, workspace_root) { + return Err(CleanError::WorkspaceRootBuildDir(build_dir.to_path_buf())); + } + for root in package_roots { + if same_path(build_dir, root) { + return Err(CleanError::PackageRootBuildDir(build_dir.to_path_buf())); + } + } + for source_path in protected_source_paths { + if overlaps_source_path(build_dir, source_path) { + return Err(CleanError::SourcePathBuildDir { + build_dir: build_dir.to_path_buf(), + source_path: source_path.clone(), + }); + } + } + if let Ok(meta) = std::fs::symlink_metadata(build_dir) + && meta.file_type().is_symlink() + { + return Err(CleanError::SymlinkBuildDir(build_dir.to_path_buf())); + } + Ok(()) +} + +/// Equality test for paths that tolerates symlink-only spelling +/// differences (e.g. macOS exposes `/tmp/foo` as `/private/tmp/foo`). +/// Falls back to literal equality when canonicalisation fails so a +/// non-existent build dir still matches a non-existent workspace +/// root entered by the same path string. +fn same_path(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(ca), Ok(cb)) => ca == cb, + _ => false, + } +} + +/// Whether `candidate` is `base` itself or lives underneath +/// `base`. Performed by component-wise matching so a sibling +/// directory whose name is a string prefix of `base` does not +/// accidentally pass the check. +fn is_within(candidate: &Path, base: &Path) -> bool { + candidate.starts_with(base) +} + +fn overlaps_source_path(build_dir: &Path, source_path: &Path) -> bool { + build_dir.starts_with(source_path) || source_path.starts_with(build_dir) +} + +fn home_dir() -> Option { + let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(key).map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn profile(name: &str) -> ProfileName { + ProfileName::new(name.to_owned()).unwrap() + } + + fn package(name: &str) -> PackageName { + PackageName::new(name.to_owned()).unwrap() + } + + fn write(path: &Path) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, b"x").unwrap(); + } + + fn populate_layout(build_dir: &Path) { + // dev profile. + write(&build_dir.join("dev").join("build.ninja")); + write( + &build_dir + .join("dev") + .join("packages") + .join("hello") + .join("hello"), + ); + write( + &build_dir + .join("dev") + .join("packages") + .join("util") + .join("libutil.a"), + ); + write( + &build_dir + .join("dev") + .join("cargo") + .join("hello") + .join("rust") + .join("artifact"), + ); + // release profile. + write(&build_dir.join("release").join("build.ninja")); + write( + &build_dir + .join("release") + .join("packages") + .join("hello") + .join("hello"), + ); + } + + fn req<'a>( + build_dir: &'a Path, + workspace_root: &'a Path, + scope: CleanScope, + ) -> CleanRequest<'a> { + CleanRequest { + build_dir, + workspace_root, + package_roots: &[], + protected_source_paths: &[], + scope, + } + } + + #[test] + fn plan_whole_lists_build_dir() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req(&build_dir, &workspace, CleanScope::Whole)).unwrap(); + assert_eq!(plan.removals, vec![build_dir]); + } + + #[test] + fn plan_profile_lists_only_that_profile() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req( + &build_dir, + &workspace, + CleanScope::Profile(profile("dev")), + )) + .unwrap(); + assert_eq!(plan.removals, vec![build_dir.join("dev")]); + } + + #[test] + fn plan_packages_includes_each_existing_path() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req( + &build_dir, + &workspace, + CleanScope::Packages { + profiles: vec![profile("dev"), profile("release")], + packages: vec![package("hello")], + }, + )) + .unwrap(); + let expected = { + let mut v = vec![ + build_dir.join("dev").join("cargo").join("hello"), + build_dir.join("dev").join("packages").join("hello"), + build_dir.join("release").join("packages").join("hello"), + ]; + v.sort(); + v + }; + assert_eq!(plan.removals, expected); + } + + #[test] + fn plan_skips_missing_candidates() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + // build dir does not exist. + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req(&build_dir, &workspace, CleanScope::Whole)).unwrap(); + assert!(plan.removals.is_empty()); + } + + #[test] + fn plan_is_deterministic_and_deduplicated() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req( + &build_dir, + &workspace, + CleanScope::Packages { + profiles: vec![profile("release"), profile("dev"), profile("dev")], + packages: vec![package("hello"), package("hello")], + }, + )) + .unwrap(); + let mut sorted = plan.removals.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(plan.removals, sorted); + } + + #[test] + fn execute_removes_planned_paths() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req(&build_dir, &workspace, CleanScope::Whole)).unwrap(); + let report = execute_clean(&plan).unwrap(); + assert_eq!(report.removed, vec![build_dir.clone()]); + assert!(!build_dir.exists()); + } + + #[test] + fn execute_tolerates_concurrent_removal() { + let tmp = TempDir::new().unwrap(); + let build_dir = tmp.path().join("build"); + populate_layout(&build_dir); + let workspace = tmp.path().to_path_buf(); + let plan = plan_clean(&req(&build_dir, &workspace, CleanScope::Whole)).unwrap(); + std::fs::remove_dir_all(&build_dir).unwrap(); + let report = execute_clean(&plan).unwrap(); + assert!(report.removed.is_empty()); + } + + #[test] + fn rejects_root_build_dir() { + let workspace = PathBuf::from("/tmp/x"); + let err = plan_clean(&req(Path::new("/"), &workspace, CleanScope::Whole)).unwrap_err(); + assert!(matches!(err, CleanError::RootBuildDir(_))); + } + + #[test] + fn rejects_empty_build_dir() { + let workspace = PathBuf::from("/tmp/x"); + let err = plan_clean(&req(Path::new(""), &workspace, CleanScope::Whole)).unwrap_err(); + assert!(matches!(err, CleanError::EmptyBuildDir)); + } + + #[test] + fn rejects_workspace_root_build_dir() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().to_path_buf(); + let err = plan_clean(&req(&workspace, &workspace, CleanScope::Whole)).unwrap_err(); + assert!(matches!(err, CleanError::WorkspaceRootBuildDir(_))); + } + + #[test] + fn rejects_package_root_build_dir() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().to_path_buf(); + let pkg = tmp.path().join("pkg"); + std::fs::create_dir_all(&pkg).unwrap(); + let request = CleanRequest { + build_dir: &pkg, + workspace_root: &workspace, + package_roots: std::slice::from_ref(&pkg), + protected_source_paths: &[], + scope: CleanScope::Whole, + }; + let err = plan_clean(&request).unwrap_err(); + assert!(matches!(err, CleanError::PackageRootBuildDir(_))); + } + + #[test] + fn rejects_build_dir_that_contains_source_path() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().to_path_buf(); + let build_dir = tmp.path().join("pkg").join("src"); + let source = build_dir.join("main.cc"); + std::fs::create_dir_all(&build_dir).unwrap(); + std::fs::write(&source, "int main(){return 0;}").unwrap(); + let request = CleanRequest { + build_dir: &build_dir, + workspace_root: &workspace, + package_roots: &[], + protected_source_paths: std::slice::from_ref(&source), + scope: CleanScope::Whole, + }; + let err = plan_clean(&request).unwrap_err(); + assert!(matches!(err, CleanError::SourcePathBuildDir { .. })); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_build_dir() { + let tmp = TempDir::new().unwrap(); + let target = tmp.path().join("real"); + std::fs::create_dir(&target).unwrap(); + let link = tmp.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let workspace = tmp.path().to_path_buf(); + let err = plan_clean(&req(&link, &workspace, CleanScope::Whole)).unwrap_err(); + assert!(matches!(err, CleanError::SymlinkBuildDir(_))); + } +} diff --git a/crates/cabin-build/src/error.rs b/crates/cabin-build/src/error.rs new file mode 100644 index 000000000..913540544 --- /dev/null +++ b/crates/cabin-build/src/error.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors produced while planning a build graph. +#[derive(Debug, Error)] +pub enum BuildError { + #[error("target dependency cycle detected: {}", format_cycle(.0))] + DependencyCycle(Vec), + + #[error("no target named {0:?} is defined in the package graph")] + UnknownTargetReference(String), + + #[error("target {0:?} is ambiguous; use `package:target` (candidates: {})", format_candidates(.1))] + AmbiguousTarget(String, Vec), + + #[error("unknown package {package:?} in target selector {selector:?}")] + UnknownPackageInTargetSelector { package: String, selector: String }, + + #[error("package {package:?} has no target {target:?}")] + UnknownTargetInPackage { package: String, target: String }, + + #[error( + "dependency {dep:?} resolves to package {package:?} which has no cpp_library target; use `{package}:` to pick a specific target" + )] + DependencyHasNoLibrary { dep: String, package: String }, + + #[error( + "dependency {dep:?} resolves to package {package:?} which has multiple cpp_library targets; disambiguate with `{package}:`" + )] + AmbiguousDefaultLibrary { dep: String, package: String }, + + #[error("target {0:?} has no source files; nothing to build")] + EmptyTargetSources(String), + + #[error("source path {} for target {target:?} is not supported: {reason}", path.display())] + InvalidSourcePath { + target: String, + path: PathBuf, + reason: String, + }, + + #[error("path {} is not valid UTF-8 and cannot be used in build commands", .0.display())] + NonUtf8Path(PathBuf), + + #[error( + "selected workspace packages declare no C/C++ targets to build; pick a package with at least one cpp_library or cpp_executable" + )] + EmptySelectedPackages, + + /// The detected toolchain cannot run the commands the C++ + /// backend emits. The wrapped error carries the specific + /// missing capability or unsupported compiler family. + #[error(transparent)] + UnsupportedToolchain(#[from] cabin_core::ToolDetectionError), + + /// A target carries a source whose extension does not match + /// any of Cabin's recognised C / C++ extensions. + #[error( + "target {target:?} has source `{}` with an unrecognised extension; supported extensions are .c (C) and .cc / .cpp / .cxx / .c++ / .C (C++)", + path.display() + )] + UnrecognisedSourceExtension { target: String, path: PathBuf }, + + /// A target carries `.c` source(s) but no C compiler is + /// available. Set `CC`, pass `--cc`, or add `cc = ...` to + /// `[toolchain]` so Cabin can compile C translation units. + #[error( + "target {target:?} has C source `{}` but no C compiler is available; set the `CC` environment variable, pass `--cc `, or add `cc = ...` under [toolchain]", + path.display() + )] + MissingCCompiler { target: String, path: PathBuf }, +} + +fn format_cycle(cycle: &[String]) -> String { + cycle.join(" -> ") +} + +fn format_candidates(candidates: &[String]) -> String { + candidates.join(", ") +} diff --git a/crates/cabin-build/src/graph.rs b/crates/cabin-build/src/graph.rs new file mode 100644 index 000000000..357b39b4f --- /dev/null +++ b/crates/cabin-build/src/graph.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +/// Backend-independent description of everything that needs to happen to +/// realise a build. A backend (currently `cabin-ninja`) walks this graph and +/// emits the equivalent build-system-specific representation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuildGraph { + /// All actions to execute, in topological order. Earlier actions in the + /// vector never depend on later actions. + pub actions: Vec, + /// Output paths that should be marked as default targets. + pub default_outputs: Vec, + /// One entry per C / C++ source compile, used to emit + /// `compile_commands.json`. Both languages contribute entries + /// with their language-appropriate compiler driver and flags + /// recorded in `arguments`. + pub compile_commands: Vec, +} + +/// A single buildable step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub kind: ActionKind, + /// Inputs that participate in the command (e.g. `.cc` for compile, + /// `.o`/`.a` for link). + pub inputs: Vec, + /// Inputs the action implicitly depends on but that are not arguments. + /// Used by C/C++ compiles whose source is generated by an + /// upstream action. + pub implicit_inputs: Vec, + /// Files this action produces. + pub outputs: Vec, + /// Optional Makefile-style depfile path; populated for C and C++ + /// compiles so Ninja can wire `-MMD -MF $depfile` into its + /// `deps = gcc` machinery. + pub depfile: Option, + /// Argv-style command, ready to be shell-quoted by the backend. + pub command: Vec, + /// Short, human-readable description for build output (`CXX foo.o`). + pub description: String, +} + +/// Categorisation of a build action. Currently a closed set; new variants +/// require explicit handling by every backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionKind { + /// Compile a single C translation unit (`.c`) to an object + /// file. Driven by the C compiler with `-std=c11` plus the + /// language-neutral profile flags. + CompileC, + /// Compile a single C++ translation unit (`.cc` / `.cpp` / + /// `.cxx` / `.c++` / `.C`) to an object file. Driven by the + /// C++ compiler with `-std=c++17` plus the language-neutral + /// profile flags. + CompileCpp, + /// Archive a set of object files into a static library. + ArchiveStaticLibrary, + /// Link object files plus static archives into an executable. + /// The link-driver language (C vs. C++) is encoded in the + /// action's `command` (the first argument is the chosen + /// compiler driver), so backends do not need to consult any + /// extra metadata to render the action. + LinkExecutable, +} + +/// One entry of a Clang JSON Compilation Database. +/// +/// `arguments` is kept as a list so each backend / consumer can render it +/// however the format requires (LLVM accepts both `command` and +/// `arguments` keys; `cabin-ninja` emits `command`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileCommand { + pub directory: PathBuf, + pub file: PathBuf, + pub arguments: Vec, + pub output: PathBuf, +} diff --git a/crates/cabin-build/src/lib.rs b/crates/cabin-build/src/lib.rs new file mode 100644 index 000000000..656743f9f --- /dev/null +++ b/crates/cabin-build/src/lib.rs @@ -0,0 +1,35 @@ +//! Backend-independent build planning for Cabin. +//! +//! This crate consumes a validated [`cabin_core::Package`] plus a +//! resolved C/C++ toolchain (from `cabin-toolchain`) and produces a +//! [`BuildGraph`] — a list of compile, archive, and link actions +//! plus the metadata needed to write a Clang-style compilation +//! database. +//! +//! The graph is intentionally backend-agnostic. `cabin-ninja` knows how to +//! serialise it as `build.ninja` + `compile_commands.json`; future backends +//! (e.g. a Bazel exporter, or a direct in-process executor) could consume +//! the same structure. + +#![allow( + clippy::missing_errors_doc, + clippy::must_use_candidate, + clippy::single_match_else, + clippy::items_after_statements, + clippy::default_trait_access, + clippy::too_many_lines, + clippy::map_unwrap_or, + clippy::manual_let_else, + clippy::redundant_closure_for_method_calls +)] + +pub mod clean; +pub mod error; +pub mod graph; +pub mod planner; +pub mod validate; + +pub use error::BuildError; +pub use graph::{Action, ActionKind, BuildGraph, CompileCommand}; +pub use planner::{ManifestTargetSelector, PlanRequest, plan, select_targets_of_kind}; +pub use validate::validate_toolchain_for_backend; diff --git a/crates/cabin-build/src/planner.rs b/crates/cabin-build/src/planner.rs new file mode 100644 index 000000000..ac46b6174 --- /dev/null +++ b/crates/cabin-build/src/planner.rs @@ -0,0 +1,2197 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +use cabin_core::{ + ResolvedCompilerWrapper, ResolvedProfile, ResolvedProfileFlags, ResolvedToolchain, + SourceLanguage, Target, TargetKind, classify_source, link_driver_language, +}; +use cabin_workspace::PackageGraph; + +use crate::error::BuildError; +use crate::graph::{Action, ActionKind, BuildGraph, CompileCommand}; + +/// Cabin's built-in C++ standard. Hardcoded for now; users +/// override via `[profile].cxxflags`. +pub const DEFAULT_CXX_STANDARD: &str = "-std=c++17"; + +/// Cabin's built-in C standard. Hardcoded for now; users +/// override via `[profile].cflags`. +/// +/// Kept distinct from [`DEFAULT_CXX_STANDARD`] so the two flag +/// spaces never share state. A change here must not silently +/// alter C++ compile lines. +pub const DEFAULT_C_STANDARD: &str = "-std=c11"; + +/// Compose the deterministic compile flags for `profile`, +/// prefixed with the supplied language-specific `standard` flag. +/// +/// The optimisation / debug-info / `NDEBUG` flags +/// ([`ResolvedProfile::cxx_flags`]) are language-neutral and +/// apply to both C and C++ compiles; the `standard` argument is +/// the only language-specific contribution. Pulling the two +/// `*_flags_for_profile` paths through one helper keeps the +/// per-language flag composition byte-identical except for the +/// standard flag itself, so `compile_commands.json` and +/// `build.ninja` stay deterministic. +pub(crate) fn flags_for_profile(standard: &str, profile: &ResolvedProfile) -> Vec { + let optim = profile.compile_flags(); + let mut out: Vec = Vec::with_capacity(optim.len() + 1); + out.push(standard.to_owned()); + for flag in optim { + out.push((*flag).to_owned()); + } + out +} + +/// Convenience: the C++ standard flag plus profile flags. +pub fn cxx_flags_for_profile(profile: &ResolvedProfile) -> Vec { + flags_for_profile(DEFAULT_CXX_STANDARD, profile) +} + +/// Convenience: the C standard flag plus profile flags. +pub(crate) fn c_flags_for_profile(profile: &ResolvedProfile) -> Vec { + flags_for_profile(DEFAULT_C_STANDARD, profile) +} + +/// Reference to a manifest target — one of the `[target.]` +/// declarations in a package's `cabin.toml`. May be qualified +/// `package:target` or unqualified `target`. Resolution against a +/// [`PackageGraph`] happens in the planner. +/// +/// This is the *manifest-target* selector. It has nothing to do +/// with a platform / toolchain target (e.g. an +/// `x86_64-unknown-linux-gnu` triple); Cabin does not yet model +/// the latter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestTargetSelector { + pub package: Option, + pub name: String, +} + +impl ManifestTargetSelector { + /// Parse a `package:target` or bare `target` string. Unknown formats + /// (multiple `:`s) are accepted and surfaced later by resolution + /// errors. + pub fn parse(s: &str) -> Self { + match s.split_once(':') { + Some((pkg, tgt)) => Self { + package: Some(pkg.to_owned()), + name: tgt.to_owned(), + }, + None => Self { + package: None, + name: s.to_owned(), + }, + } + } +} + +/// Inputs to the build planner. +#[derive(Debug)] +pub struct PlanRequest<'a> { + pub graph: &'a PackageGraph, + /// Resolved C/C++ toolchain. The planner reads + /// `toolchain.cxx.path()` for compile / link commands and + /// `toolchain.ar.path()` for archive commands. + pub toolchain: &'a ResolvedToolchain, + /// Per-package resolved build flags. Missing entries fall + /// back to an empty [`ResolvedProfileFlags`]; the planner does + /// not require every package to be present so consumers can + /// resolve flags lazily for the selected closure only. + pub build_flags: &'a HashMap, + /// Absolute path under which all build outputs are placed. + pub build_dir: PathBuf, + /// Resolved build profile. Drives compile flags and the per- + /// profile output directory. + pub profile: ResolvedProfile, + /// Specific manifest targets to build, plus their transitive + /// deps. `None` means "every C/C++ target in every primary + /// package". + pub selected: Option>, + /// Resolved root-package configuration. Carried through + /// the planner so future cache logic and any planner-level + /// fingerprint comparisons see the same selection the build + /// script and metadata observed. The planner does not yet + /// change C++ flags based on this value. + pub configuration: Option<&'a cabin_core::BuildConfiguration>, + /// Indices of `graph.packages` that the user picked + /// through workspace package-selection flags. `None` means + /// "use the graph's primary set" (the documented default). + /// When `Some`, default-target enumeration narrows to the + /// supplied indices and any manifest-target selectors in + /// `selected` are validated against them so an unrelated + /// package never sneaks into the build. + pub selected_packages: Option<&'a [usize]>, + /// Optional compiler-cache wrapper applied to every C++ + /// compile command. The Ninja `command` field is prefixed with + /// the wrapper executable; the matching `compile_commands.json` + /// `arguments` array stays *unwrapped* so clangd / IDE tooling + /// keeps seeing the underlying compiler. Link and archive + /// commands are never wrapped. + pub compiler_wrapper: Option<&'a ResolvedCompilerWrapper>, +} + +/// Plan a build for the requested package graph. +pub fn plan(req: &PlanRequest<'_>) -> Result { + path_to_str(&req.build_dir)?; + + let selected = match &req.selected { + Some(sel) => resolve_selection(sel, req.graph, req.selected_packages)?, + None => { + let chosen = default_selection(req.graph, req.selected_packages); + if chosen.is_empty() { + return Err(BuildError::EmptySelectedPackages); + } + chosen + } + }; + + // Walk the target dep graph, resolving each raw `deps` entry to a + // concrete (package, target) ID and recording the edges. + let mut resolved_deps: HashMap> = HashMap::new(); + let mut reachable: HashSet = HashSet::new(); + let mut to_visit: Vec = selected.clone(); + + while let Some(tid) = to_visit.pop() { + if !reachable.insert(tid.clone()) { + continue; + } + let target = lookup_target(&tid, req.graph)?; + let mut resolved = Vec::with_capacity(target.deps.len()); + for raw in &target.deps { + let dep = resolve_target_dep(raw.as_str(), tid.0, req.graph)?; + to_visit.push(dep.clone()); + resolved.push(dep); + } + resolved_deps.insert(tid, resolved); + } + + let topo = topo_sort_targets(&reachable, &resolved_deps, req.graph)?; + + let mut actions: Vec = Vec::new(); + let mut compile_commands: Vec = Vec::new(); + let mut output_for_target: HashMap = HashMap::new(); + // Per-target source-language manifest, including transitive + // contributions through `target.deps`. Used to pick the + // link-driver language deterministically: a target with any + // direct or transitive C++ object link-drives through the C++ + // compiler, every other target link-drives through the C + // compiler. Populated in topo order so dependents inherit + // their dependencies' contributions. + let mut target_languages: HashMap> = HashMap::new(); + + for tid in &topo { + let target = lookup_target(tid, req.graph)?; + + let pkg = &req.graph.packages[tid.0]; + let pkg_name = pkg.package.name.as_str(); + // Per-profile output root keeps `dev` and `release` + // builds from overwriting each other and gives custom + // profiles a deterministic, non-colliding output tree. + let pkg_build_dir = req + .build_dir + .join(req.profile.name.as_str()) + .join("packages") + .join(pkg_name); + let manifest_dir = &pkg.manifest_dir; + + // Header-only libraries declare include dirs but no + // translation units. Skip every action — `collect_link_libs` + // and `collect_include_dirs` already walk dep targets by + // their declared `include_dirs`, so consumers still pick up + // the headers; they just see no `.a` to link against. + if matches!(target.kind, TargetKind::CppHeaderOnly) { + target_languages.insert(tid.clone(), Default::default()); + continue; + } + + // Build the per-source list. Each manifest-declared source + // resolves to an absolute path under the manifest directory + // and a per-target object path. + struct PreparedSource { + abs_source: PathBuf, + object: PathBuf, + language: SourceLanguage, + } + let mut prepared: Vec = Vec::with_capacity(target.sources.len()); + for source in &target.sources { + let language = + classify_source(source).ok_or_else(|| BuildError::UnrecognisedSourceExtension { + target: format_target_id(tid, req.graph), + path: source.clone(), + })?; + let object = + object_path(&pkg_build_dir, target.name.as_str(), source).map_err(|reason| { + BuildError::InvalidSourcePath { + target: format_target_id(tid, req.graph), + path: source.clone(), + reason, + } + })?; + prepared.push(PreparedSource { + abs_source: manifest_dir.join(source), + object, + language, + }); + } + if prepared.is_empty() { + return Err(BuildError::EmptyTargetSources(format_target_id( + tid, req.graph, + ))); + } + + // Per-package resolved build flags from the manifest's + // `[profile]`, `[target.'cfg(...)'.profile]`, and the active + // `[profile.]` table. Layered on top of per-target + // defines / include dirs. + let pkg_flags = req.build_flags.get(&tid.0); + + // Compose include_dirs and defines: existing target + + // per-package build flags. + let mut include_dirs = collect_include_dirs(tid, target, &resolved_deps, req.graph); + if let Some(flags) = pkg_flags { + for inc in &flags.include_dirs { + let absolute = if inc.is_absolute() { + inc.clone() + } else { + manifest_dir.join(inc) + }; + if !include_dirs.contains(&absolute) { + include_dirs.push(absolute); + } + } + } + let mut defines: Vec = target.defines.clone(); + if let Some(flags) = pkg_flags { + for def in &flags.defines { + if !defines.contains(def) { + defines.push(def.clone()); + } + } + } + let extra_compile_args: &[String] = pkg_flags + .map(|f| f.extra_compile_args.as_slice()) + .unwrap_or(&[]); + let cflags: &[String] = pkg_flags.map(|f| f.cflags.as_slice()).unwrap_or(&[]); + let cxxflags: &[String] = pkg_flags.map(|f| f.cxxflags.as_slice()).unwrap_or(&[]); + let ldflags: &[String] = pkg_flags.map(|f| f.ldflags.as_slice()).unwrap_or(&[]); + + let mut objects: Vec = Vec::with_capacity(prepared.len()); + for ps in &prepared { + let depfile = depfile_path(&ps.object); + // Pick the language-appropriate compiler driver, the + // language-appropriate standard / profile flags, the + // matching escape-hatch arg list, the action kind, + // and the human-readable tag. Naming the components + // here is the single point that enforces "C and C++ + // compile lines never share argv space". + let dispatch = compile_dispatch(ps.language, req) + .map_err(|err| err.attach_target_path(tid, req.graph, &ps.abs_source))?; + let cmd = build_compile_command(&CompileCommandInput { + driver: dispatch.driver, + language_flags: &dispatch.language_flags, + source: &ps.abs_source, + object: &ps.object, + depfile: &depfile, + include_dirs: &include_dirs, + defines: &defines, + extra_compile_args, + extra_language_compile_args: match ps.language { + SourceLanguage::C => cflags, + SourceLanguage::Cxx => cxxflags, + }, + })?; + // Ninja sees the wrapped command (`ccache cxx ...`) + // for C++ compiles when a compiler-cache wrapper is + // selected; C compiles stay unwrapped because the public + // wrapper contract is C++-only today. The matching + // `compile_commands.json` entry keeps the unwrapped + // command so clangd / IDE tooling still sees the + // underlying compiler. Link and archive commands are + // deliberately never wrapped. + let ninja_cmd = match (req.compiler_wrapper, ps.language) { + (Some(wrapper), SourceLanguage::Cxx) => prepend_wrapper(&cmd, wrapper)?, + _ => cmd.clone(), + }; + + actions.push(Action { + kind: dispatch.action_kind, + inputs: vec![ps.abs_source.clone()], + implicit_inputs: vec![], + outputs: vec![ps.object.clone()], + depfile: Some(depfile), + command: ninja_cmd, + description: format!("{} {}", dispatch.description_tag, display(&ps.object)?), + }); + compile_commands.push(CompileCommand { + directory: req.build_dir.clone(), + file: ps.abs_source.clone(), + arguments: cmd, + output: ps.object.clone(), + }); + objects.push(ps.object.clone()); + } + + // Per-target language manifest: own sources' languages + // unioned with every direct target dep's manifest. The + // topo iteration guarantees dependencies are populated + // before we visit the dependent. + let mut languages_here: BTreeSet = + prepared.iter().map(|p| p.language).collect(); + if let Some(deps) = resolved_deps.get(tid) { + for dep in deps { + if let Some(dep_langs) = target_languages.get(dep) { + languages_here.extend(dep_langs.iter().copied()); + } + } + } + + match target.kind { + TargetKind::CppLibrary => { + let lib_path = pkg_build_dir.join(format!("lib{}.a", target.name.as_str())); + let mut cmd = vec![ + path_to_str(req.toolchain.ar.path())?.to_owned(), + "crs".to_owned(), + path_to_str(&lib_path)?.to_owned(), + ]; + for o in &objects { + cmd.push(path_to_str(o)?.to_owned()); + } + actions.push(Action { + kind: ActionKind::ArchiveStaticLibrary, + inputs: objects.clone(), + implicit_inputs: vec![], + outputs: vec![lib_path.clone()], + depfile: None, + command: cmd, + description: format!("AR {}", display(&lib_path)?), + }); + output_for_target.insert(tid.clone(), lib_path); + } + // Every executable C++ kind takes the same link path: + // `cpp_executable` (production binaries), `cpp_test` + // (run by `cabin test`), and `cpp_example`. The build + // planner does not distinguish between them here because + // the link/compile semantics are identical; the kind + // difference is only consulted when deciding *which* + // targets to select (default-buildable vs. dev-only) and + // which targets `cabin test` runs. + TargetKind::CppExecutable | TargetKind::CppTest | TargetKind::CppExample => { + let exe_path = pkg_build_dir.join(target.name.as_str()); + let lib_paths = + collect_link_libs(tid, &resolved_deps, req.graph, &output_for_target); + + let mut inputs: Vec = objects.clone(); + inputs.extend(lib_paths.iter().cloned()); + + // Link-driver pick: C++ if any of this target's + // own objects came from a C++ source, or if any + // transitively reachable object did. Otherwise + // the C compiler drives the link, which keeps + // pure-C executables off the C++ runtime. + let languages_slice: Vec = languages_here.iter().copied().collect(); + let driver_language = link_driver_language(&languages_slice); + let driver_path = match driver_language { + SourceLanguage::Cxx => req.toolchain.cxx.path(), + SourceLanguage::C => { + req.toolchain.cc.as_ref().map(|t| t.path()).ok_or_else(|| { + BuildError::MissingCCompiler { + target: format_target_id(tid, req.graph), + // Pick a representative source for the + // diagnostic; pure-C link errors + // always have at least one C source on + // this target. + path: prepared + .iter() + .find(|p| p.language == SourceLanguage::C) + .map(|p| p.abs_source.clone()) + .unwrap_or_else(|| exe_path.clone()), + } + })? + } + }; + let mut cmd = vec![path_to_str(driver_path)?.to_owned()]; + for inp in &inputs { + cmd.push(path_to_str(inp)?.to_owned()); + } + for arg in ldflags { + cmd.push(arg.clone()); + } + cmd.push("-o".to_owned()); + cmd.push(path_to_str(&exe_path)?.to_owned()); + + actions.push(Action { + kind: ActionKind::LinkExecutable, + inputs, + implicit_inputs: vec![], + outputs: vec![exe_path.clone()], + depfile: None, + command: cmd, + description: format!("LINK {}", display(&exe_path)?), + }); + output_for_target.insert(tid.clone(), exe_path); + } + TargetKind::CppHeaderOnly => { + unreachable!("header-only targets are skipped before action generation") + } + } + target_languages.insert(tid.clone(), languages_here); + } + + let default_outputs: Vec = selected + .iter() + .filter_map(|tid| output_for_target.get(tid).cloned()) + .collect(); + + Ok(BuildGraph { + actions, + default_outputs, + compile_commands, + }) +} + +// --------------------------------------------------------------------------- +// internal: target IDs and lookups +// --------------------------------------------------------------------------- + +/// Stable identifier for a target within a [`PackageGraph`]: the index of +/// its package in `graph.packages` and its target name. +type TargetId = (usize, String); + +fn lookup_target<'a>(tid: &TargetId, graph: &'a PackageGraph) -> Result<&'a Target, BuildError> { + let pkg = &graph.packages[tid.0]; + pkg.package + .targets + .iter() + .find(|t| t.name.as_str() == tid.1) + .ok_or_else(|| BuildError::UnknownTargetInPackage { + package: pkg.package.name.as_str().to_owned(), + target: tid.1.clone(), + }) +} + +fn format_target_id(tid: &TargetId, graph: &PackageGraph) -> String { + format!("{}:{}", graph.packages[tid.0].package.name.as_str(), tid.1) +} + +// --------------------------------------------------------------------------- +// internal: manifest-target selector resolution +// --------------------------------------------------------------------------- + +fn resolve_selection( + selectors: &[ManifestTargetSelector], + graph: &PackageGraph, + selected_packages: Option<&[usize]>, +) -> Result, BuildError> { + let mut out: Vec = Vec::with_capacity(selectors.len()); + for sel in selectors { + out.push(resolve_top_level_selector(sel, graph, selected_packages)?); + } + Ok(out) +} + +fn resolve_top_level_selector( + sel: &ManifestTargetSelector, + graph: &PackageGraph, + selected_packages: Option<&[usize]>, +) -> Result { + if let Some(pkg_name) = &sel.package { + let pkg_idx = + graph + .index_of(pkg_name) + .ok_or_else(|| BuildError::UnknownPackageInTargetSelector { + package: pkg_name.clone(), + selector: format!("{}:{}", pkg_name, sel.name), + })?; + let pkg = &graph.packages[pkg_idx]; + if !pkg + .package + .targets + .iter() + .any(|t| t.name.as_str() == sel.name) + { + return Err(BuildError::UnknownTargetInPackage { + package: pkg_name.clone(), + target: sel.name.clone(), + }); + } + return Ok((pkg_idx, sel.name.clone())); + } + + // unqualified selectors search the selected + // package set (or the primary set when no selection is + // active). We no longer fall back to the root package when it + // is outside the selected set — that would silently build + // something the user did not ask for. + let candidates: Vec = match selected_packages { + Some(s) => s.to_vec(), + None => { + // Unqualified selector with no workspace selection + // active: walk the root first, then every primary. + let mut root_match: Option = None; + if let Some(root_idx) = graph.root_package { + let root = &graph.packages[root_idx]; + if root + .package + .targets + .iter() + .any(|t| t.name.as_str() == sel.name) + { + root_match = Some((root_idx, sel.name.clone())); + } + } + if let Some(tid) = root_match { + return Ok(tid); + } + graph.primary_packages.clone() + } + }; + + let mut matches: Vec = Vec::new(); + for idx in candidates { + let pkg = &graph.packages[idx]; + if pkg + .package + .targets + .iter() + .any(|t| t.name.as_str() == sel.name) + { + matches.push((idx, sel.name.clone())); + } + } + match matches.len() { + 0 => Err(BuildError::UnknownTargetReference(sel.name.clone())), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => Err(BuildError::AmbiguousTarget( + sel.name.clone(), + matches + .iter() + .map(|(i, t)| format!("{}:{}", graph.packages[*i].package.name.as_str(), t)) + .collect(), + )), + } +} + +fn default_selection(graph: &PackageGraph, selected_packages: Option<&[usize]>) -> Vec { + let mut out = Vec::new(); + let pkg_indices: &[usize] = match selected_packages { + Some(s) => s, + None => graph.primary_packages.as_slice(), + }; + for &pkg_idx in pkg_indices { + let pkg = &graph.packages[pkg_idx]; + for target in &pkg.package.targets { + if target.kind.is_default_buildable() { + out.push((pkg_idx, target.name.as_str().to_owned())); + } + } + } + out +} + +/// Build-time selector for `cabin test`: expand a package +/// selection into the set of targets of a specific +/// development-only kind (`cpp_test` today). Returns +/// deterministic `(package_index, target_name)` tuples in the same +/// order as the planner consumes selectors. Useful for callers that +/// want every dev-only target of a given kind without naming each +/// one explicitly. +pub fn select_targets_of_kind( + graph: &PackageGraph, + selected_packages: Option<&[usize]>, + kind: TargetKind, +) -> Vec { + let pkg_indices: &[usize] = match selected_packages { + Some(s) => s, + None => graph.primary_packages.as_slice(), + }; + let mut out = Vec::new(); + for &pkg_idx in pkg_indices { + let pkg = &graph.packages[pkg_idx]; + for target in &pkg.package.targets { + if target.kind == kind { + out.push(ManifestTargetSelector { + package: Some(pkg.package.name.as_str().to_owned()), + name: target.name.as_str().to_owned(), + }); + } + } + } + out +} + +// --------------------------------------------------------------------------- +// internal: target.deps resolution +// --------------------------------------------------------------------------- + +fn resolve_target_dep( + raw: &str, + pkg_idx: usize, + graph: &PackageGraph, +) -> Result { + let pkg = &graph.packages[pkg_idx]; + + // Cross-package target lookups must only see Normal-kind + // dependency edges. Dev dependencies are declaration-only as + // far as ordinary `target..deps` resolution is concerned. + if let Some((p_name, t_name)) = raw.split_once(':') { + // Qualified `package:target`. The package must be either this + // package itself or one of its declared *normal* + // dependencies. + let dep_idx = if p_name == pkg.package.name.as_str() { + pkg_idx + } else { + pkg.deps_of_kind(cabin_core::DependencyKind::Normal) + .find(|&di| graph.packages[di].package.name.as_str() == p_name) + .ok_or_else(|| BuildError::UnknownPackageInTargetSelector { + package: p_name.to_owned(), + selector: raw.to_owned(), + })? + }; + let dep_pkg = &graph.packages[dep_idx]; + if !dep_pkg + .package + .targets + .iter() + .any(|t| t.name.as_str() == t_name) + { + return Err(BuildError::UnknownTargetInPackage { + package: p_name.to_owned(), + target: t_name.to_owned(), + }); + } + return Ok((dep_idx, t_name.to_owned())); + } + + // Unqualified. Same-package match wins. + if pkg.package.targets.iter().any(|t| t.name.as_str() == raw) { + return Ok((pkg_idx, raw.to_owned())); + } + + // Then, *normal-kind* package dependency name → its default + // cpp_library. Build / tool / dev deps are intentionally + // skipped here so they cannot auto-link into ordinary targets. + if let Some(dep_idx) = pkg + .deps_of_kind(cabin_core::DependencyKind::Normal) + .find(|&di| graph.packages[di].package.name.as_str() == raw) + { + let dep_pkg = &graph.packages[dep_idx]; + let libs: Vec<&Target> = dep_pkg + .package + .targets + .iter() + .filter(|t| matches!(t.kind, TargetKind::CppLibrary | TargetKind::CppHeaderOnly)) + .collect(); + return match libs.len() { + 0 => Err(BuildError::DependencyHasNoLibrary { + dep: raw.to_owned(), + package: dep_pkg.package.name.as_str().to_owned(), + }), + 1 => Ok((dep_idx, libs[0].name.as_str().to_owned())), + _ => Err(BuildError::AmbiguousDefaultLibrary { + dep: raw.to_owned(), + package: dep_pkg.package.name.as_str().to_owned(), + }), + }; + } + + Err(BuildError::UnknownTargetReference(raw.to_owned())) +} + +// --------------------------------------------------------------------------- +// internal: target topological sort +// --------------------------------------------------------------------------- + +fn topo_sort_targets( + reachable: &HashSet, + resolved: &HashMap>, + graph: &PackageGraph, +) -> Result, BuildError> { + #[derive(Clone, Copy)] + enum Color { + Visiting, + Done, + } + + fn visit( + node: &TargetId, + resolved: &HashMap>, + graph: &PackageGraph, + state: &mut HashMap, + path: &mut Vec, + order: &mut Vec, + ) -> Result<(), BuildError> { + match state.get(node) { + Some(Color::Done) => return Ok(()), + Some(Color::Visiting) => { + let start = path.iter().position(|n| n == node).unwrap_or(0); + let mut cycle: Vec = path[start..] + .iter() + .map(|t| format_target_id(t, graph)) + .collect(); + cycle.push(format_target_id(node, graph)); + return Err(BuildError::DependencyCycle(cycle)); + } + None => {} + } + state.insert(node.clone(), Color::Visiting); + path.push(node.clone()); + if let Some(deps) = resolved.get(node) { + for d in deps { + visit(d, resolved, graph, state, path, order)?; + } + } + path.pop(); + state.insert(node.clone(), Color::Done); + order.push(node.clone()); + Ok(()) + } + + let mut state: HashMap = HashMap::new(); + let mut order = Vec::new(); + let mut path = Vec::new(); + + let mut sorted: Vec = reachable.iter().cloned().collect(); + sorted.sort(); + for tid in sorted { + visit(&tid, resolved, graph, &mut state, &mut path, &mut order)?; + } + Ok(order) +} + +// --------------------------------------------------------------------------- +// internal: include dir + link lib collection +// --------------------------------------------------------------------------- + +fn collect_include_dirs( + start: &TargetId, + target: &Target, + resolved: &HashMap>, + graph: &PackageGraph, +) -> Vec { + let manifest_dir = &graph.packages[start.0].manifest_dir; + let mut result: Vec = target + .include_dirs + .iter() + .map(|d| manifest_dir.join(d)) + .collect(); + + let mut seen: HashSet = HashSet::new(); + let empty: Vec = Vec::new(); + let mut stack: Vec<&TargetId> = resolved.get(start).unwrap_or(&empty).iter().collect(); + while let Some(tid) = stack.pop() { + if !seen.insert(tid.clone()) { + continue; + } + let dep_target = match graph.packages[tid.0] + .package + .targets + .iter() + .find(|t| t.name.as_str() == tid.1) + { + Some(t) => t, + None => continue, + }; + if matches!( + dep_target.kind, + TargetKind::CppLibrary | TargetKind::CppHeaderOnly + ) { + let dep_manifest = &graph.packages[tid.0].manifest_dir; + for inc in &dep_target.include_dirs { + let abs = dep_manifest.join(inc); + if !result.contains(&abs) { + result.push(abs); + } + } + } + if let Some(deps) = resolved.get(tid) { + for d in deps { + stack.push(d); + } + } + } + + result +} + +fn collect_link_libs( + start: &TargetId, + resolved: &HashMap>, + graph: &PackageGraph, + output_for_target: &HashMap, +) -> Vec { + fn visit( + node: &TargetId, + resolved: &HashMap>, + graph: &PackageGraph, + seen: &mut HashSet, + post: &mut Vec, + ) { + if !seen.insert(node.clone()) { + return; + } + if let Some(deps) = resolved.get(node) { + for d in deps { + visit(d, resolved, graph, seen, post); + } + } + let target = match graph.packages[node.0] + .package + .targets + .iter() + .find(|t| t.name.as_str() == node.1) + { + Some(t) => t, + None => return, + }; + if matches!(target.kind, TargetKind::CppLibrary) { + post.push(node.clone()); + } + } + + let mut seen: HashSet = HashSet::new(); + let mut post: Vec = Vec::new(); + if let Some(deps) = resolved.get(start) { + for d in deps { + visit(d, resolved, graph, &mut seen, &mut post); + } + } + post.iter() + .rev() + .filter_map(|tid| output_for_target.get(tid).cloned()) + .collect() +} + +/// One per-source compile decision. Naming the components +/// (driver, flags, action kind, human tag) keeps the planner's +/// per-source loop legible: the dispatch table is *the* place +/// where a future language addition would go, and changes here +/// are mechanically reviewable. +struct CompileDispatch<'a> { + /// Driver executable (the compiler binary). + driver: &'a Path, + /// Language-specific standard + profile flags. + language_flags: Vec, + /// Build-graph action kind to record on the emitted + /// [`Action`]. + action_kind: ActionKind, + /// Short human-readable tag (`CC` or `CXX`) used in Ninja + /// `description = ...` lines. + description_tag: &'static str, +} + +/// Failure modes for [`compile_dispatch`]. Carry only the +/// language-level reason; the planner attaches target / source +/// context via [`CompileDispatchError::attach_target_path`]. +enum CompileDispatchError { + MissingCCompiler, +} + +impl CompileDispatchError { + fn attach_target_path(self, tid: &TargetId, graph: &PackageGraph, path: &Path) -> BuildError { + match self { + Self::MissingCCompiler => BuildError::MissingCCompiler { + target: format_target_id(tid, graph), + path: path.to_path_buf(), + }, + } + } +} + +/// Choose driver / flags / kind for a single compile, given the +/// classified source language and the resolved toolchain. +fn compile_dispatch<'a>( + language: SourceLanguage, + req: &'a PlanRequest<'a>, +) -> Result, CompileDispatchError> { + match language { + SourceLanguage::Cxx => Ok(CompileDispatch { + driver: req.toolchain.cxx.path(), + language_flags: cxx_flags_for_profile(&req.profile), + action_kind: ActionKind::CompileCpp, + description_tag: "CXX", + }), + SourceLanguage::C => { + let cc = req + .toolchain + .cc + .as_ref() + .ok_or(CompileDispatchError::MissingCCompiler)?; + Ok(CompileDispatch { + driver: cc.path(), + language_flags: c_flags_for_profile(&req.profile), + action_kind: ActionKind::CompileC, + description_tag: "CC", + }) + } + } +} + +fn object_path(pkg_build_dir: &Path, target: &str, source: &Path) -> Result { + let mut sanitized = PathBuf::new(); + for component in source.components() { + match component { + Component::Normal(name) => sanitized.push(name), + Component::CurDir => {} + Component::ParentDir => { + return Err("parent directory components ('..') are not supported".to_owned()); + } + Component::RootDir | Component::Prefix(_) => { + return Err("absolute source paths are not supported".to_owned()); + } + } + } + if sanitized.as_os_str().is_empty() { + return Err("source path is empty".to_owned()); + } + let parent = sanitized + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + let mut name: OsString = sanitized.file_name().unwrap().to_owned(); + name.push(".o"); + Ok(pkg_build_dir + .join("obj") + .join(target) + .join(parent) + .join(name)) +} + +fn depfile_path(object: &Path) -> PathBuf { + let mut s: OsString = object.as_os_str().to_owned(); + s.push(".d"); + PathBuf::from(s) +} + +/// Prefix `cmd` with the wrapper executable. Used only on the +/// Ninja command path; `compile_commands.json` keeps the unwrapped +/// argument list so IDE tooling keeps seeing the underlying +/// compiler. +fn prepend_wrapper( + cmd: &[String], + wrapper: &ResolvedCompilerWrapper, +) -> Result, BuildError> { + let mut out = Vec::with_capacity(cmd.len() + 1); + out.push(path_to_str(wrapper.path.as_path())?.to_owned()); + out.extend(cmd.iter().cloned()); + Ok(out) +} + +/// Build a single compile command. The caller picks the +/// language-appropriate driver, profile flags, and language +/// escape-hatch args; `extra_compile_args` carries the +/// language-neutral escape-hatch args (applied to both C and +/// C++ compiles). The argv shape is identical across languages +/// so backends can render a single rule per language without +/// re-deriving the layout. +struct CompileCommandInput<'a> { + driver: &'a Path, + language_flags: &'a [String], + source: &'a Path, + object: &'a Path, + depfile: &'a Path, + include_dirs: &'a [PathBuf], + defines: &'a [String], + extra_compile_args: &'a [String], + extra_language_compile_args: &'a [String], +} + +fn build_compile_command(input: &CompileCommandInput<'_>) -> Result, BuildError> { + let &CompileCommandInput { + driver, + language_flags, + source, + object, + depfile, + include_dirs, + defines, + extra_compile_args, + extra_language_compile_args, + } = input; + let mut cmd = Vec::new(); + cmd.push(path_to_str(driver)?.to_owned()); + for f in language_flags { + cmd.push(f.clone()); + } + cmd.push("-MMD".to_owned()); + cmd.push("-MF".to_owned()); + cmd.push(path_to_str(depfile)?.to_owned()); + for d in defines { + cmd.push(format!("-D{d}")); + } + for i in include_dirs { + cmd.push("-I".to_owned()); + cmd.push(path_to_str(i)?.to_owned()); + } + // Language-neutral escape-hatch first, then the + // language-specific list — so a per-language override always + // appears later in argv where the compiler treats it as the + // final word. + for arg in extra_compile_args { + cmd.push(arg.clone()); + } + for arg in extra_language_compile_args { + cmd.push(arg.clone()); + } + cmd.push("-c".to_owned()); + cmd.push(path_to_str(source)?.to_owned()); + cmd.push("-o".to_owned()); + cmd.push(path_to_str(object)?.to_owned()); + Ok(cmd) +} + +fn path_to_str(p: &Path) -> Result<&str, BuildError> { + p.to_str() + .ok_or_else(|| BuildError::NonUtf8Path(p.to_path_buf())) +} + +fn display(p: &Path) -> Result { + Ok(path_to_str(p)?.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cabin_core::{ + Dependency, DependencySource, Package, PackageName, ProfileDefinition, ProfileName, + ProfileSelection, ResolvedProfile, Target as CoreTarget, TargetName, resolve_profile, + }; + use cabin_workspace::{PackageGraph, PackageKind, WorkspacePackage}; + use std::collections::BTreeMap; + + fn dev_profile() -> ResolvedProfile { + resolve_profile( + &ProfileSelection::default_dev(), + &BTreeMap::::new(), + ) + .expect("built-in dev resolves") + } + + fn release_profile() -> ResolvedProfile { + resolve_profile( + &ProfileSelection::release_alias(), + &BTreeMap::::new(), + ) + .expect("built-in release resolves") + } + + fn version() -> semver::Version { + semver::Version::parse("0.1.0").unwrap() + } + + fn pkg_name(name: &str) -> PackageName { + PackageName::new(name).unwrap() + } + + fn target_name(name: &str) -> TargetName { + TargetName::new(name).unwrap() + } + + fn target(name: &str, kind: TargetKind, sources: &[&str], deps: &[&str]) -> CoreTarget { + CoreTarget { + name: target_name(name), + kind, + sources: sources.iter().map(PathBuf::from).collect(), + include_dirs: Vec::new(), + defines: Vec::new(), + deps: deps.iter().map(|d| (*d).to_owned()).collect(), + } + } + + fn target_with_includes( + name: &str, + kind: TargetKind, + sources: &[&str], + includes: &[&str], + deps: &[&str], + ) -> CoreTarget { + CoreTarget { + name: target_name(name), + kind, + sources: sources.iter().map(PathBuf::from).collect(), + include_dirs: includes.iter().map(PathBuf::from).collect(), + defines: Vec::new(), + deps: deps.iter().map(|d| (*d).to_owned()).collect(), + } + } + + fn dep(name: &str, path: &str) -> Dependency { + Dependency { + name: pkg_name(name), + source: DependencySource::Path(PathBuf::from(path)), + kind: cabin_core::DependencyKind::Normal, + optional: false, + features: Vec::new(), + default_features: true, + condition: None, + } + } + + fn toolchain() -> ResolvedToolchain { + use cabin_core::{ResolvedTool, ToolKind, ToolSource, ToolSpec}; + ResolvedToolchain { + cxx: ResolvedTool { + kind: ToolKind::CxxCompiler, + path: PathBuf::from("/usr/bin/g++"), + spec: ToolSpec::Name("g++".into()), + source: ToolSource::Default, + }, + ar: ResolvedTool { + kind: ToolKind::Archiver, + path: PathBuf::from("/usr/bin/ar"), + spec: ToolSpec::Name("ar".into()), + source: ToolSource::Default, + }, + cc: None, + } + } + + /// Toolchain with both compilers resolved. Used by tests that + /// exercise the C compile path or the link-driver pick. + fn toolchain_with_cc() -> ResolvedToolchain { + use cabin_core::{ResolvedTool, ToolKind, ToolSource, ToolSpec}; + let mut tc = toolchain(); + tc.cc = Some(ResolvedTool { + kind: ToolKind::CCompiler, + path: PathBuf::from("/usr/bin/cc"), + spec: ToolSpec::Name("cc".into()), + source: ToolSource::Default, + }); + tc + } + + fn empty_build_flags() -> HashMap { + HashMap::new() + } + + fn make_pkg( + _name: &str, + manifest_dir: &str, + package: Package, + deps: Vec, + ) -> WorkspacePackage { + let manifest_dir = PathBuf::from(manifest_dir); + let manifest_path = manifest_dir.join("cabin.toml"); + WorkspacePackage { + package, + manifest_path, + manifest_dir, + deps: deps + .into_iter() + .map(|index| cabin_workspace::DependencyEdge { + index, + kind: cabin_core::DependencyKind::Normal, + condition: None, + }) + .collect(), + kind: PackageKind::Local, + } + } + + fn graph_with( + packages: Vec, + primaries: Vec, + root: Option, + ) -> PackageGraph { + let root_dir = packages + .first() + .map(|p| p.manifest_dir.clone()) + .unwrap_or_else(|| PathBuf::from("/abs")); + let root_manifest = root_dir.join("cabin.toml"); + PackageGraph { + root_manifest_path: root_manifest, + root_dir, + is_workspace_root: false, + root_package: root, + root_settings: Default::default(), + primary_packages: primaries, + default_members: Vec::new(), + excluded_members: Vec::new(), + packages, + } + } + + fn single_package_graph(package: Package, manifest_dir: &str) -> PackageGraph { + let name = package.name.as_str().to_owned(); + let pkg = make_pkg(&name, manifest_dir, package, vec![]); + graph_with(vec![pkg], vec![0], Some(0)) + } + + #[test] + fn plans_single_executable() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let req = PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/proj/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }; + let bg = plan(&req).unwrap(); + assert_eq!(bg.actions.len(), 2); + assert_eq!(bg.actions[0].kind, ActionKind::CompileCpp); + assert_eq!(bg.actions[1].kind, ActionKind::LinkExecutable); + assert_eq!( + bg.default_outputs, + vec![PathBuf::from("/abs/proj/build/dev/packages/hello/hello")] + ); + let cc = &bg.compile_commands[0]; + assert_eq!( + cc.output, + PathBuf::from("/abs/proj/build/dev/packages/hello/obj/hello/src/main.cc.o") + ); + assert!(cc.arguments.iter().any(|a| a == "-std=c++17")); + } + + #[test] + fn compiler_wrapper_prefixes_only_the_ninja_command() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let wrapper = ResolvedCompilerWrapper { + kind: cabin_core::CompilerWrapperKind::Ccache, + path: PathBuf::from("/usr/local/bin/ccache"), + spec: "ccache".into(), + source: cabin_core::CompilerWrapperSource::Cli, + identity: None, + }; + let req = PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/proj/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: Some(&wrapper), + }; + let bg = plan(&req).unwrap(); + let compile = bg + .actions + .iter() + .find(|a| a.kind == ActionKind::CompileCpp) + .expect("compile action present"); + assert_eq!(compile.command[0], "/usr/local/bin/ccache"); + assert_eq!(compile.command[1], "/usr/bin/g++"); + let cc = &bg.compile_commands[0]; + // compile_commands.json must keep the underlying compiler + // first so clangd / IDE tooling continues to recognise the + // command shape. + assert_eq!(cc.arguments[0], "/usr/bin/g++"); + // Link / archive paths are never wrapped. + let link = bg + .actions + .iter() + .find(|a| a.kind == ActionKind::LinkExecutable) + .expect("link action present"); + assert_eq!(link.command[0], "/usr/bin/g++"); + assert!( + !link.command.iter().any(|a| a == "/usr/local/bin/ccache"), + "wrapper must not appear in link command" + ); + } + + #[test] + fn compiler_wrapper_does_not_prefix_c_compile_commands() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.c"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain_with_cc(); + let wrapper = ResolvedCompilerWrapper { + kind: cabin_core::CompilerWrapperKind::Ccache, + path: PathBuf::from("/usr/local/bin/ccache"), + spec: "ccache".into(), + source: cabin_core::CompilerWrapperSource::Cli, + identity: None, + }; + let req = PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/proj/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: Some(&wrapper), + }; + let bg = plan(&req).unwrap(); + let compile = bg + .actions + .iter() + .find(|a| a.kind == ActionKind::CompileC) + .expect("C compile action present"); + assert_eq!(compile.command[0], "/usr/bin/cc"); + assert!( + !compile.command.iter().any(|a| a == "/usr/local/bin/ccache"), + "wrapper must not appear in C compile command" + ); + } + + #[test] + fn release_profile_uses_release_flags() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/proj/build"), + profile: release_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let cc = &bg.compile_commands[0]; + assert!(cc.arguments.iter().any(|a| a == "-O3")); + assert!(cc.arguments.iter().any(|a| a == "-DNDEBUG")); + assert!(!cc.arguments.iter().any(|a| a == "-O0")); + } + + #[test] + fn plans_library_then_executable_within_one_package() { + let package = Package::new( + pkg_name("multi"), + version(), + vec![ + target_with_includes( + "greet", + TargetKind::CppLibrary, + &["src/greet.cc"], + &["include"], + &[], + ), + target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &["greet"], + ), + ], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/proj/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let kinds: Vec = bg.actions.iter().map(|a| a.kind).collect(); + assert_eq!( + kinds, + vec![ + ActionKind::CompileCpp, + ActionKind::ArchiveStaticLibrary, + ActionKind::CompileCpp, + ActionKind::LinkExecutable, + ] + ); + let link = &bg.actions[3]; + assert!(link.inputs.contains(&PathBuf::from( + "/abs/proj/build/dev/packages/multi/libgreet.a" + ))); + let hello_compile = &bg.actions[2]; + assert!( + hello_compile + .command + .iter() + .any(|a| a == "/abs/proj/include") + ); + } + + #[test] + fn cross_package_path_dep_links_library() { + // greet at /abs/greet, app at /abs/app depending on greet. + let greet_proj = Package::new( + pkg_name("greet"), + version(), + vec![target_with_includes( + "greet", + TargetKind::CppLibrary, + &["src/greet.cc"], + &["include"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let app_proj = Package::new( + pkg_name("app"), + version(), + vec![target( + "app", + TargetKind::CppExecutable, + &["src/main.cc"], + &["greet"], + )], + vec![dep("greet", "../greet")], + ) + .unwrap(); + let greet_pkg = make_pkg("greet", "/abs/greet", greet_proj, vec![]); + let app_pkg = make_pkg("app", "/abs/app", app_proj, vec![0]); + let graph = graph_with(vec![greet_pkg, app_pkg], vec![1], Some(1)); + let tc = toolchain(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + + // Outputs should be namespaced by package. + let greet_lib = PathBuf::from("/abs/build/dev/packages/greet/libgreet.a"); + let app_exe = PathBuf::from("/abs/build/dev/packages/app/app"); + // app's link action must include greet's static archive. + let link = bg + .actions + .iter() + .find(|a| a.kind == ActionKind::LinkExecutable) + .unwrap(); + assert!(link.inputs.contains(&greet_lib)); + assert_eq!(link.outputs, vec![app_exe.clone()]); + + // Default outputs are only the primary package's targets (app). + assert_eq!(bg.default_outputs, vec![app_exe]); + + // greet's include dir should propagate into app's compile command. + let app_compile = bg + .actions + .iter() + .find(|a| { + a.kind == ActionKind::CompileCpp && a.outputs[0].to_string_lossy().contains("/app/") + }) + .unwrap(); + assert!( + app_compile + .command + .iter() + .any(|a| a == "/abs/greet/include") + ); + } + + #[test] + fn qualified_target_selector_picks_specific_target() { + let greet_proj = Package::new( + pkg_name("greet"), + version(), + vec![target( + "greet", + TargetKind::CppLibrary, + &["src/greet.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let app_proj = Package::new( + pkg_name("app"), + version(), + vec![ + target( + "app", + TargetKind::CppExecutable, + &["src/main.cc"], + &["greet"], + ), + target("other", TargetKind::CppExecutable, &["src/other.cc"], &[]), + ], + vec![dep("greet", "../greet")], + ) + .unwrap(); + let greet_pkg = make_pkg("greet", "/abs/greet", greet_proj, vec![]); + let app_pkg = make_pkg("app", "/abs/app", app_proj, vec![0]); + let graph = graph_with(vec![greet_pkg, app_pkg], vec![1], Some(1)); + let tc = toolchain(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("app:app")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + // Only app:app and greet:greet should appear; not app:other. + let outs: Vec = bg + .actions + .iter() + .map(|a| a.outputs[0].display().to_string()) + .collect(); + assert!(outs.iter().any(|o| o.ends_with("/packages/app/app"))); + assert!(!outs.iter().any(|o| o.contains("/packages/app/other"))); + } + + #[test] + fn ambiguous_unqualified_target_errors() { + // Workspace with two member packages each having a target "build". + let a = Package::new( + pkg_name("a"), + version(), + vec![target("build", TargetKind::CppExecutable, &["a.cc"], &[])], + Vec::new(), + ) + .unwrap(); + let b = Package::new( + pkg_name("b"), + version(), + vec![target("build", TargetKind::CppExecutable, &["b.cc"], &[])], + Vec::new(), + ) + .unwrap(); + let pkg_a = make_pkg("a", "/abs/a", a, vec![]); + let pkg_b = make_pkg("b", "/abs/b", b, vec![]); + let mut graph = graph_with(vec![pkg_a, pkg_b], vec![0, 1], None); + graph.is_workspace_root = true; + let tc = toolchain(); + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("build")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + assert!(matches!(err, BuildError::AmbiguousTarget(_, _))); + } + + #[test] + fn unknown_package_in_qualified_selector_errors() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("nope:thing")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + assert!(matches!( + err, + BuildError::UnknownPackageInTargetSelector { .. } + )); + } + + #[test] + fn target_dep_cycle_within_package_is_reported() { + let package = Package { + name: pkg_name("cyc"), + version: version(), + targets: vec![ + target("a", TargetKind::CppLibrary, &["a.cc"], &["b"]), + target("b", TargetKind::CppLibrary, &["b.cc"], &["a"]), + ], + dependencies: Vec::new(), + system_dependencies: Vec::new(), + features: Default::default(), + profiles: Default::default(), + toolchain: Default::default(), + build: Default::default(), + compiler_wrapper: Default::default(), + patches: Default::default(), + }; + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + match err { + BuildError::DependencyCycle(cycle) => { + assert_eq!(cycle.first(), cycle.last()); + assert!(cycle.iter().any(|s| s == "cyc:a")); + assert!(cycle.iter().any(|s| s == "cyc:b")); + } + other => panic!("expected DependencyCycle, got {other:?}"), + } + } + + #[test] + fn unknown_target_in_qualified_selector_errors() { + let package = Package::new( + pkg_name("hello"), + version(), + vec![target( + "hello", + TargetKind::CppExecutable, + &["src/main.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/proj"); + let tc = toolchain(); + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("hello:missing")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + assert!(matches!(err, BuildError::UnknownTargetInPackage { .. })); + } + + /// Helper: extract the link-action command from a planned + /// graph. Returns the `Vec` argv of the first + /// `LinkExecutable` action so tests can assert on `command[0]` + /// (the chosen driver). Panics if no link action is present. + fn link_command(bg: &BuildGraph) -> &Vec { + &bg.actions + .iter() + .find(|a| a.kind == ActionKind::LinkExecutable) + .expect("link action present") + .command + } + + #[test] + fn link_driver_is_c_when_target_has_only_c_sources() { + // A pure-C executable must link through the C driver so + // the C++ runtime is not pulled in. + let package = Package::new( + pkg_name("cdemo"), + version(), + vec![target( + "cdemo_exe", + TargetKind::CppExecutable, + &["src/main.c"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/cdemo"); + let tc = toolchain_with_cc(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/cdemo/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let link = link_command(&bg); + assert_eq!(link[0], "/usr/bin/cc"); + } + + #[test] + fn link_driver_is_cxx_when_target_has_any_cpp_source() { + // Mixed C / C++ executable in a single target must link + // through the C++ driver because the closure has C++ + // objects. + let package = Package::new( + pkg_name("mixed"), + version(), + vec![target( + "mixed_exe", + TargetKind::CppExecutable, + &["src/c_part.c", "src/cpp_part.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/mixed"); + let tc = toolchain_with_cc(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/mixed/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let link = link_command(&bg); + assert_eq!(link[0], "/usr/bin/g++"); + } + + #[test] + fn link_driver_is_cxx_when_dependency_has_cpp_objects() { + // Pure-C executable that links a C++ static library + // must use the C++ driver — the runtime is required + // because the library carries C++ objects. + let cpp_lib = target("cppcore", TargetKind::CppLibrary, &["src/cpp_part.cc"], &[]); + let c_exe = target( + "c_runner", + TargetKind::CppExecutable, + &["src/main.c"], + &["cppcore"], + ); + let package = Package::new( + pkg_name("interop"), + version(), + vec![cpp_lib, c_exe], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/interop"); + let tc = toolchain_with_cc(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/interop/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("c_runner")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let link = link_command(&bg); + assert_eq!(link[0], "/usr/bin/g++"); + } + + #[test] + fn link_driver_stays_c_when_dependency_is_also_pure_c() { + // C executable + C library: still link through the C + // driver because the closure has no C++ objects. + let c_lib = target("ccore", TargetKind::CppLibrary, &["src/util.c"], &[]); + let c_exe = target( + "c_runner", + TargetKind::CppExecutable, + &["src/main.c"], + &["ccore"], + ); + let package = Package::new( + pkg_name("clib_only"), + version(), + vec![c_lib, c_exe], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/clib_only"); + let tc = toolchain_with_cc(); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/clib_only/build"), + profile: dev_profile(), + selected: Some(vec![ManifestTargetSelector::parse("c_runner")]), + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let link = link_command(&bg); + assert_eq!(link[0], "/usr/bin/cc"); + } + + #[test] + fn missing_c_compiler_yields_actionable_error_with_target_id() { + // C source + no `cc` resolved → MissingCCompiler error + // that names both the package and the target so a + // monorepo user can map the failure to the right + // manifest. + let package = Package::new( + pkg_name("cdemo"), + version(), + vec![target( + "cdemo_exe", + TargetKind::CppExecutable, + &["src/main.c"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/cdemo"); + let tc = toolchain(); // no cc populated + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/cdemo/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + let rendered = err.to_string(); + assert!( + rendered.contains("cdemo:cdemo_exe"), + "error should name the package:target, got: {rendered}" + ); + assert!( + rendered.contains("CC") || rendered.contains("`--cc"), + "error should suggest how to set the C compiler, got: {rendered}" + ); + } + + #[test] + fn unrecognised_source_extension_yields_actionable_error() { + let package = Package::new( + pkg_name("broken"), + version(), + vec![target( + "broken", + TargetKind::CppLibrary, + &["src/file.txt"], + &[], + )], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/broken"); + let tc = toolchain_with_cc(); + let err = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &empty_build_flags(), + build_dir: PathBuf::from("/abs/broken/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap_err(); + let rendered = err.to_string(); + assert!( + rendered.contains("broken:broken"), + "error should name package:target, got: {rendered}" + ); + assert!( + rendered.contains(".c") && rendered.contains(".cc"), + "error should enumerate the supported extensions, got: {rendered}" + ); + } + + #[test] + fn flags_for_profile_returns_only_standard_and_optimisation_flags() { + // The shared helper threads the standard flag in front + // of the language-neutral optimisation flags. Anchoring + // the assertion on the helper rather than on the + // language-specific wrappers gives one place to update + // if the default profile flags change. + let dev = dev_profile(); + let c = flags_for_profile(DEFAULT_C_STANDARD, &dev); + let cxx = flags_for_profile(DEFAULT_CXX_STANDARD, &dev); + assert_eq!(c[0], "-std=c11"); + assert_eq!(cxx[0], "-std=c++17"); + // Optimisation flags appear in the same order on both + // languages — that is the language-neutral postfix. + assert_eq!(&c[1..], &cxx[1..]); + // The C standard never sneaks into the C++ flag list + // and vice versa. + assert!(!cxx.iter().any(|f| f == "-std=c11")); + assert!(!c.iter().any(|f| f == "-std=c++17")); + } + + /// Build a single-package graph with a `mixed` library + /// target carrying one C source and one C++ source. Used by + /// the per-language flag-routing tests below. + fn graph_with_mixed_sources() -> PackageGraph { + let package = Package::new( + pkg_name("mixed"), + version(), + vec![target( + "mixed", + TargetKind::CppLibrary, + &["src/c_part.c", "src/cpp_part.cc"], + &[], + )], + Vec::new(), + ) + .unwrap(); + single_package_graph(package, "/abs/mixed") + } + + /// Build per-package flag map carrying a single + /// `ResolvedProfileFlags` for the mixed package. + fn build_flags_map(flags: ResolvedProfileFlags) -> HashMap { + let mut out = HashMap::new(); + out.insert(0usize, flags); + out + } + + /// Plan and return the compile actions for the mixed + /// fixture under the supplied build flags. Used by every + /// per-language flag-routing test below to keep the + /// boilerplate to one place. + fn plan_compile_actions(flags: ResolvedProfileFlags) -> Vec { + let graph = graph_with_mixed_sources(); + let tc = toolchain_with_cc(); + let map = build_flags_map(flags); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &map, + build_dir: PathBuf::from("/abs/mixed/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + bg.actions + .into_iter() + .filter(|a| matches!(a.kind, ActionKind::CompileC | ActionKind::CompileCpp)) + .collect() + } + + fn compile_action_for(actions: &[Action], kind: ActionKind) -> &Action { + actions + .iter() + .find(|a| a.kind == kind) + .unwrap_or_else(|| panic!("expected a {kind:?} compile action")) + } + + #[test] + fn cflags_route_to_c_compile_only() { + // The C-only escape-hatch reaches every C compile + // command and never reaches a C++ compile. Without this + // routing, a flag that is invalid for C++ (`-std=c99`, + // `-Wno-pointer-sign`) would break C++ builds. + let flags = ResolvedProfileFlags { + cflags: vec!["-DC_ONLY_FLAG=1".to_owned()], + ..ResolvedProfileFlags::default() + }; + let actions = plan_compile_actions(flags); + let c = compile_action_for(&actions, ActionKind::CompileC); + let cxx = compile_action_for(&actions, ActionKind::CompileCpp); + assert!( + c.command.iter().any(|a| a == "-DC_ONLY_FLAG=1"), + "C compile must include the C-only define, got: {:?}", + c.command + ); + assert!( + !cxx.command.iter().any(|a| a == "-DC_ONLY_FLAG=1"), + "C-only define must NOT leak into the C++ compile, got: {:?}", + cxx.command + ); + } + + #[test] + fn cxxflags_route_to_cxx_compile_only() { + // Mirror of the C-only test: a C++-only flag never + // reaches the C compile command. Required so a flag + // that is invalid for C (`-fno-rtti`, `-std=c++20`) + // does not break C builds. + let flags = ResolvedProfileFlags { + cxxflags: vec!["-DCXX_ONLY_FLAG=1".to_owned()], + ..ResolvedProfileFlags::default() + }; + let actions = plan_compile_actions(flags); + let c = compile_action_for(&actions, ActionKind::CompileC); + let cxx = compile_action_for(&actions, ActionKind::CompileCpp); + assert!( + cxx.command.iter().any(|a| a == "-DCXX_ONLY_FLAG=1"), + "C++ compile must include the C++-only define, got: {:?}", + cxx.command + ); + assert!( + !c.command.iter().any(|a| a == "-DCXX_ONLY_FLAG=1"), + "C++-only define must NOT leak into the C compile, got: {:?}", + c.command + ); + } + + #[test] + fn language_neutral_extra_compile_args_reach_both_compile_kinds() { + // The language-neutral slot is the documented home for + // flags that are valid for both C and C++. It must + // appear on every compile command. + let flags = ResolvedProfileFlags { + extra_compile_args: vec!["-Wall".to_owned()], + ..ResolvedProfileFlags::default() + }; + let actions = plan_compile_actions(flags); + let c = compile_action_for(&actions, ActionKind::CompileC); + let cxx = compile_action_for(&actions, ActionKind::CompileCpp); + assert!( + c.command.iter().any(|a| a == "-Wall"), + "C compile must include the language-neutral flag, got: {:?}", + c.command + ); + assert!( + cxx.command.iter().any(|a| a == "-Wall"), + "C++ compile must include the language-neutral flag, got: {:?}", + cxx.command + ); + } + + #[test] + fn ldflags_appear_on_link_command_only() { + // Link-only flags must reach every link command and + // never appear on a compile command. + let package = Package::new( + pkg_name("mixed"), + version(), + vec![ + target( + "mixedlib", + TargetKind::CppLibrary, + &["src/c_part.c", "src/cpp_part.cc"], + &[], + ), + target( + "app", + TargetKind::CppExecutable, + &["src/main.cc"], + &["mixedlib"], + ), + ], + Vec::new(), + ) + .unwrap(); + let graph = single_package_graph(package, "/abs/mixed"); + let tc = toolchain_with_cc(); + let mut map = HashMap::new(); + let flags = ResolvedProfileFlags { + ldflags: vec!["-Wl,--as-needed".to_owned()], + ..ResolvedProfileFlags::default() + }; + map.insert(0usize, flags); + let bg = plan(&PlanRequest { + graph: &graph, + toolchain: &tc, + build_flags: &map, + build_dir: PathBuf::from("/abs/mixed/build"), + profile: dev_profile(), + selected: None, + configuration: None, + selected_packages: None, + compiler_wrapper: None, + }) + .unwrap(); + let link = bg + .actions + .iter() + .find(|a| a.kind == ActionKind::LinkExecutable) + .expect("link action present"); + assert!( + link.command.iter().any(|a| a == "-Wl,--as-needed"), + "link command must include the link-only flag, got: {:?}", + link.command + ); + for compile in bg + .actions + .iter() + .filter(|a| matches!(a.kind, ActionKind::CompileC | ActionKind::CompileCpp)) + { + assert!( + !compile.command.iter().any(|a| a == "-Wl,--as-needed"), + "link-only flag must NOT appear on compile, got: {:?}", + compile.command + ); + } + } +} diff --git a/crates/cabin-build/src/validate.rs b/crates/cabin-build/src/validate.rs new file mode 100644 index 000000000..c488c457a --- /dev/null +++ b/crates/cabin-build/src/validate.rs @@ -0,0 +1,182 @@ +//! Validate that a detected toolchain can run the commands the +//! C++ backend emits. +//! +//! The planner currently emits GCC/Clang-style commands: +//! +//! - C++ compile: `cxx -std=c++17 -Oâ€Ļ [-g] [-DNDEBUG] -MMD -MF +//! -D -I [extra-args] -c -o `. +//! - Static-library archive: `ar crs `. +//! - Link: `cxx [extra-args] -o `. +//! +//! Any compiler / archiver that cannot run those exact shapes is +//! rejected up front rather than left to fail with a confusing +//! Ninja error. + +use cabin_core::{ + ResolvedToolchain, ToolchainDetectionReport, validate_ar_for_backend, validate_cc_for_backend, + validate_cxx_for_backend, +}; + +use crate::error::BuildError; + +/// Validate that every populated tool in `report` can execute the +/// command shapes emitted by the current backend. Returns the +/// first problem encountered so users see one actionable error, +/// not a wall of unrelated failures. +/// +/// The C++ compiler is held to the full C++-backend contract +/// (GCC-style flags, depfile, `-std=c++17`). The C compiler is +/// held to the *C-side* contract (GCC-style flags, depfile) — +/// this is laxer because a pure-C driver may not accept C++ mode +/// at all. The archiver gets its own narrow contract. +/// +/// `toolchain` is the matching [`ResolvedToolchain`] — we use it +/// to recover the user-visible spec strings (`clang++`, +/// `/opt/llvm/bin/clang++`) for the error messages. +pub fn validate_toolchain_for_backend( + toolchain: &ResolvedToolchain, + report: &ToolchainDetectionReport, +) -> Result<(), BuildError> { + let cxx_spec = toolchain.cxx.spec.display(); + validate_cxx_for_backend(&cxx_spec, &report.cxx.identity, &report.cxx.capabilities)?; + if let (Some(cc_tool), Some(cc_detection)) = (toolchain.cc.as_ref(), report.cc.as_ref()) { + let cc_spec = cc_tool.spec.display(); + validate_cc_for_backend(&cc_spec, &cc_detection.identity, &cc_detection.capabilities)?; + } + let ar_spec = toolchain.ar.spec.display(); + validate_ar_for_backend(&ar_spec, &report.ar.identity, &report.ar.capabilities)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cabin_core::{ + ArchiverIdentity, ArchiverKind, CompilerIdentity, CompilerKind, CompilerVersion, + ResolvedTool, ResolvedToolchain, ToolDetection, ToolKind, ToolSource, ToolSpec, + derive_ar_capabilities, derive_cxx_capabilities, + }; + use std::path::PathBuf; + + fn make_toolchain(cxx_spec: &str, ar_spec: &str) -> ResolvedToolchain { + ResolvedToolchain { + cxx: ResolvedTool { + kind: ToolKind::CxxCompiler, + path: PathBuf::from("/bin").join(cxx_spec), + spec: ToolSpec::Name(cxx_spec.into()), + source: ToolSource::Default, + }, + ar: ResolvedTool { + kind: ToolKind::Archiver, + path: PathBuf::from("/bin").join(ar_spec), + spec: ToolSpec::Name(ar_spec.into()), + source: ToolSource::Default, + }, + cc: None, + } + } + + fn report_for(cxx: CompilerIdentity, ar: ArchiverIdentity) -> ToolchainDetectionReport { + let cxx_caps = derive_cxx_capabilities(&cxx); + let ar_caps = derive_ar_capabilities(&ar); + ToolchainDetectionReport { + cxx: ToolDetection { + path: PathBuf::from("/bin/cxx"), + identity: cxx, + capabilities: cxx_caps, + }, + cc: None, + ar: ToolDetection { + path: PathBuf::from("/bin/ar"), + identity: ar, + capabilities: ar_caps, + }, + } + } + + #[test] + fn accepts_clang_with_gnu_ar() { + let toolchain = make_toolchain("clang++", "ar"); + let report = report_for( + CompilerIdentity { + kind: CompilerKind::Clang, + version: CompilerVersion::parse("17.0.6"), + target: None, + raw_version_line: "clang version 17.0.6".into(), + }, + ArchiverIdentity { + kind: ArchiverKind::Ar, + version: CompilerVersion::parse("2.40"), + raw_version_line: "GNU ar".into(), + }, + ); + validate_toolchain_for_backend(&toolchain, &report).unwrap(); + } + + #[test] + fn rejects_msvc_compiler_clearly() { + let toolchain = make_toolchain("cl.exe", "ar"); + let report = report_for( + CompilerIdentity { + kind: CompilerKind::Msvc, + version: None, + target: None, + raw_version_line: "Microsoft Optimizing Compiler".into(), + }, + ArchiverIdentity { + kind: ArchiverKind::Ar, + version: None, + raw_version_line: "GNU ar".into(), + }, + ); + let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("MSVC") || message.contains("GCC- or Clang-like"), + "expected MSVC rejection, got: {message}" + ); + } + + #[test] + fn rejects_msvc_archiver_clearly() { + let toolchain = make_toolchain("clang++", "lib.exe"); + let report = report_for( + CompilerIdentity { + kind: CompilerKind::Clang, + version: CompilerVersion::parse("17.0.6"), + target: None, + raw_version_line: "clang version 17.0.6".into(), + }, + ArchiverIdentity { + kind: ArchiverKind::Lib, + version: None, + raw_version_line: "Microsoft Library Manager".into(), + }, + ); + let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("ar-compatible") || message.contains("not supported"), + "expected unsupported archiver, got: {message}" + ); + } + + #[test] + fn rejects_unknown_compiler_clearly() { + let toolchain = make_toolchain("custom-cxx", "ar"); + let report = report_for( + CompilerIdentity::unknown("???"), + ArchiverIdentity { + kind: ArchiverKind::Ar, + version: None, + raw_version_line: "GNU ar".into(), + }, + ); + let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("could not be identified"), + "expected unknown-compiler error, got: {message}" + ); + } +} diff --git a/crates/cabin-cli/Cargo.toml b/crates/cabin-cli/Cargo.toml new file mode 100644 index 000000000..c78f7712a --- /dev/null +++ b/crates/cabin-cli/Cargo.toml @@ -0,0 +1,73 @@ +[package] +name = "cabin-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Cabin command-line interface." + +[lib] +# The library half exposes the typed parser (`Cli`), the +# subcommand dispatcher (`run`), and the small testable +# helpers (version-info model, command-list formatter, â€Ļ). +# Integration tests consume the library so they never +# hard-code the subcommand list: they walk +# `Cli::command().get_subcommands()` directly. +path = "src/lib.rs" + +[[bin]] +name = "cabin" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +cabin-artifact = { workspace = true } +cabin-build = { workspace = true } +cabin-config = { workspace = true } +cabin-core = { workspace = true } +cabin-diagnostics = { workspace = true } +cabin-env = { workspace = true } +cabin-explain = { workspace = true } +cabin-feature = { workspace = true } +cabin-fmt = { workspace = true } +cabin-index = { workspace = true } +cabin-index-http = { workspace = true } +cabin-lockfile = { workspace = true } +cabin-manifest = { workspace = true } +cabin-ninja = { workspace = true } +cabin-package = { workspace = true } +cabin-publish = { workspace = true } +cabin-resolver = { workspace = true } +cabin-source-discovery = { workspace = true } +cabin-system-deps = { workspace = true } +cabin-test = { workspace = true } +cabin-tidy = { workspace = true } +cabin-toolchain = { workspace = true } +cabin-vendor = { workspace = true } +cabin-workspace = { workspace = true } +clap = { workspace = true } +clap_complete = { workspace = true } +clap_mangen = { workspace = true } +os_info = { version = "3", default-features = false } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +termcolor = { workspace = true } + +[dev-dependencies] +assert_cmd = "2" +cabin-fmt = { workspace = true, features = ["test-fake-formatter"] } +cabin-ninja = { workspace = true, features = ["test-fake-ninja"] } +cabin-system-deps = { workspace = true, features = ["test-fake-pkg-config"] } +cabin-tidy = { workspace = true, features = ["test-fake-tidy"] } +flate2 = { workspace = true } +predicates = "3" +serde_json = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +tempfile = "3" +tiny_http = { workspace = true } + +[lints] +workspace = true diff --git a/crates/cabin-cli/build.rs b/crates/cabin-cli/build.rs new file mode 100644 index 000000000..b5f74da1e --- /dev/null +++ b/crates/cabin-cli/build.rs @@ -0,0 +1,142 @@ +//! Capture stable, privacy-safe build metadata for `cabin +//! version --verbose`. +//! +//! Every value collected here is either: +//! - already public and stable (the git commit hash, ISO commit +//! date, the rustc identity, the build profile, the target +//! triple), or +//! - intentionally omitted when the underlying source is +//! unavailable (a published crate tarball without `.git`, a +//! compiler without a parseable `-vV` output, â€Ļ). +//! +//! Deliberate exclusions: absolute paths, usernames, hostnames, +//! local checkout paths, Git working-tree status, build +//! timestamps. Builds without git or without a working `rustc +//! -vV` succeed normally — the missing fields render as +//! `unknown` at runtime in the verbose version output. + +use std::path::Path; +use std::process::Command; + +fn main() { + // The metadata captured here is read through `option_env!` + // in `src/version_info.rs`. Every emitted variable is + // typed as a `String`, so the runtime layer never has to + // parse build-script output. + emit_git_commit(); + emit_git_commit_date(); + emit_target_triple(); + emit_rerun_directives(); +} + +/// Capture the full git commit hash if a usable `.git` is +/// present. Missing git, a shallow tarball, or a `git` binary +/// without HEAD access all gracefully fall through; the runtime +/// layer omits the `commit-hash:` line in that case. The +/// formatter derives the short prefix shown in the header from +/// the same value, so a single source of truth is captured. +fn emit_git_commit() { + if !workspace_has_git_dir() { + return; + } + if let Some(full) = run_git(&["rev-parse", "HEAD"]) { + println!("cargo:rustc-env=CABIN_BUILD_COMMIT={full}"); + } +} + +/// Capture the ISO-8601 date (`YYYY-MM-DD`) of the HEAD commit. +/// Authored date in UTC keeps the value reproducible across +/// machines with different local timezones. +fn emit_git_commit_date() { + if !workspace_has_git_dir() { + return; + } + if let Some(raw) = run_git(&[ + "-c", + "log.showSignature=false", + "log", + "-1", + "--date=short", + "--pretty=%cd", + ]) { + let date = raw.trim(); + if !date.is_empty() { + println!("cargo:rustc-env=CABIN_BUILD_COMMIT_DATE={date}"); + } + } +} + +/// Cargo always sets `TARGET` for build scripts — re-emit it +/// for runtime visibility without a separate probe. The +/// formatter renders the value behind a `host:` label so the +/// verbose output matches cargo's own version block. +fn emit_target_triple() { + if let Ok(target) = std::env::var("TARGET") + && !target.is_empty() + { + println!("cargo:rustc-env=CABIN_BUILD_HOST={target}"); + } +} + +/// Tell cargo to rerun the build script when the workspace +/// switches commits. Watch three files because each one +/// flips for a different kind of git operation: +/// - `.git/HEAD` — branch switch / detached HEAD; +/// - `.git/packed-refs` — refs repack; +/// - `.git/logs/HEAD` — every commit, checkout, and reflog +/// write on the current branch (so a fresh `git commit` +/// that does not touch a tracked source file still +/// refreshes the captured short hash). +fn emit_rerun_directives() { + // Cargo's own rebuild rules already cover source changes — + // these directives only matter when the git metadata + // changes without a touch to a tracked source file. + println!("cargo:rerun-if-changed=build.rs"); + if workspace_has_git_dir() { + for relative in [".git/HEAD", ".git/packed-refs", ".git/logs/HEAD"] { + let path = workspace_root().join(relative); + if path.is_file() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + // If `.git` is itself a file (worktree pointer) rather + // than a directory, skip; the linked git dir is private + // to the worktree and not stable to watch from here. + } +} + +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git") + .args(args) + .current_dir(workspace_root()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let raw = String::from_utf8(output.stdout).ok()?; + let trimmed = raw.trim().to_owned(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Workspace root resolved from `CARGO_MANIFEST_DIR/../..`. +/// `cabin-cli` lives in `crates/cabin-cli`; the workspace root +/// is two parents up. Falls back to the manifest dir itself if +/// the layout ever changes, so a developer rename does not +/// turn into a hard build failure. +fn workspace_root() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_owned()); + let manifest_path = Path::new(&manifest_dir); + manifest_path + .parent() + .and_then(Path::parent) + .map_or_else(|| manifest_path.to_path_buf(), Path::to_path_buf) +} + +fn workspace_has_git_dir() -> bool { + workspace_root().join(".git").exists() +} diff --git a/crates/cabin-cli/src/cli.rs b/crates/cabin-cli/src/cli.rs new file mode 100644 index 000000000..544c36107 --- /dev/null +++ b/crates/cabin-cli/src/cli.rs @@ -0,0 +1,3724 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand}; + +use cabin_artifact::{ArtifactCache, FetchEntry, FetchOptions, FetchPlan, FetchedPackage}; +use cabin_build::{PlanRequest, plan}; +use cabin_core::PackageName; +use cabin_index::PackageIndex; +use cabin_lockfile::{LockedPackage, LockedSource, Lockfile}; +use cabin_package::scaffold; +use cabin_resolver::{ + LockedVersion, ResolveInput, ResolveMode, ResolveOutput, ResolvedPackage, ResolvedSource, +}; +use cabin_workspace::{PackageGraph, RegistryPackageSource, collect_patched_versioned_deps}; + +use crate::completions::CompgenArgs; +use crate::fetch_output_glue::emit_fetch_output; +use crate::manpages::MangenArgs; +use crate::metadata_glue::{MetadataInputs, MetadataView}; +use crate::term_color_glue::CliColorChoice; +use crate::term_verbosity_glue::Reporter; + +/// Cargo-style colour palette for clap's help / error +/// rendering. Mirrors the ANSI sequences `cargo --help +/// --color always` emits today: bold + bright green for the +/// section headings and the `Usage:` line, bold + bright cyan +/// for literal tokens (the binary name, flag and subcommand +/// names), plain cyan for value placeholders such as +/// `` / `[OPTIONS]`, bold + bright red for `error:` +/// labels, and bold + yellow for the highlighted-invalid +/// token inside diagnostic messages. +fn cli_styles() -> clap::builder::Styles { + use clap::builder::styling::{AnsiColor, Color, Style}; + + let header_usage = Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))); + let literal = Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))); + let placeholder = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))); + let error = Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightRed))); + let invalid = Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))); + let valid = Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))); + + clap::builder::Styles::styled() + .usage(header_usage) + .header(header_usage) + .literal(literal) + .placeholder(placeholder) + .error(error) + .invalid(invalid) + .valid(valid) +} + +/// Top-level help template — mirrors `cargo --help`: +/// +/// - the `Options:` block comes before the `Commands:` block +/// so the short list of global flags is on screen first; +/// - the section headings (`Options:`, `Commands:`) carry the +/// same bold + bright-green styling clap applies to +/// `Usage:`. The embedded ANSI escapes are stripped by +/// anstream when colour is disabled (`--color never`, +/// `NO_COLOR`, or a non-TTY stdout). +/// +/// `{options}` renders the options block body only. The +/// subcommand block is omitted because the default `[aliases: +/// x]` rendering does not match cargo's `name, alias` style; +/// the dispatcher in `lib.rs::run` rebuilds the subcommand +/// rows manually and feeds them in via `after_help`. +const HELP_TEMPLATE: &str = concat!( + "{about-with-newline}\n", + "{usage-heading} {usage}\n", + "\n", + // Bold + bright green, like clap's auto `Usage:` style. + "\x1b[1m\x1b[92mOptions:\x1b[0m\n", + "{options}", + "{after-help}", +); + +/// Top-level Cabin CLI parser. +#[derive(Debug, Parser)] +#[command( + name = "cabin", + about = "A package manager and build system for C and C++", + disable_version_flag = true, + styles = cli_styles(), + help_template = HELP_TEMPLATE, + // Compact, cargo-style option rows: keep the description + // inline with the flag name rather than dropping it to + // its own line for every entry. + next_line_help = false, +)] +pub struct Cli { + /// Use verbose output (-vv very verbose output). + // + // `ArgAction::Count` collects repeated `-v` occurrences; + // counts of two or more clamp to `Verbosity::VeryVerbose`. + #[arg( + short = 'v', + long = "verbose", + global = true, + action = clap::ArgAction::Count, + conflicts_with = "quiet", + display_order = 1, + )] + pub(crate) verbose: u8, + + /// Do not print cabin log messages. + #[arg( + short = 'q', + long = "quiet", + global = true, + conflicts_with = "verbose", + display_order = 2 + )] + pub(crate) quiet: bool, + + /// Coloring: auto, always, never [default: auto] + // + // Single-line rustdoc keeps `cabin --help` compact. The + // literal "[default: auto]" is part of the description + // because clap does not render a `default_value` for + // `Option<...>` enum flags. + // + // Precedence is `--color` > `CABIN_TERM_COLOR` > + // `[term] color` config > `auto`; see + // `docs/environment-variables.md` for the full table. + #[arg( + long, + value_name = "WHEN", + value_enum, + global = true, + hide_possible_values = true, + display_order = 3 + )] + pub(crate) color: Option, + + /// List installed commands. + // + // The dispatcher short-circuits on this flag before + // touching `cli.command`, so combining it with a + // subcommand silently ignores the subcommand. The flag + // intentionally co-exists with global flags like + // `--color` so `cabin --color always --list` renders the + // listing with the requested colour treatment. + #[arg(long, display_order = 4)] + pub(crate) list: bool, + + /// Print version info and exit. + // + // Replaces clap's auto `--version` so the flag can route + // through `cabin version`'s dispatcher: `cabin --version` + // prints the concise line and `cabin --version --verbose` + // prints the same key/value block `cabin version -v` + // emits. Display order keeps the `-h, -V` pair adjacent. + #[arg( + short = 'V', + long = "version", + global = true, + action = clap::ArgAction::SetTrue, + display_order = 6, + )] + pub(crate) version: bool, + + // The subcommand is `Option<...>` so `cabin --list` and + // `cabin --version` keep working without one. The + // dispatcher prints the curated help and exits cleanly when + // both `--list` is unset and `command` is `None`. + #[command(subcommand)] + pub(crate) command: Option, +} + +// `cabin --help` is the curated, day-to-day surface and +// closely mirrors `cargo --help`. Subcommands tagged +// `#[command(hide = true)]` below stay fully functional but +// surface only through `cabin --list`, `cabin --help`, +// shell completions, and per-subcommand man pages. +// +// Curation pattern (matching cargo --help): +// - hide inspection-only commands (`metadata`, `tree`, +// `explain`) — useful for scripts / CI, rarely typed +// day-to-day; +// - hide low-level / scripting commands (`resolve`) — +// `cabin metadata` and `cabin update` are the user-facing +// paths; +// - hide offline / networking helpers (`fetch`, `vendor`) — +// triggered automatically when needed; +// - hide pre-publish packaging (`package`) — `publish` is +// the user-facing entry; +// - hide distribution helpers (`compgen`, `mangen`) — aimed +// at downstream packagers. +// +// `version` stays visible because it is a direct user-facing +// command; `cabin --version` and `cabin version` +// agree on the concise wording. +// Each subcommand's rustdoc has two paragraphs: the first is +// the short summary clap renders in `cabin --help` / `cabin +// --list`, and the rest becomes the long help shown by `cabin +// --help`. The split keeps the top-level surface +// skimmable while preserving the existing detailed prose. +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Create a new cabin package in an existing directory. + Init(InitArgs), + /// Create a new cabin package. + /// + /// Scaffolds a new package at ``. The directory must + /// not already exist. + New(NewArgs), + /// Output workspace metadata as JSON. + /// + /// Prints the loaded workspace graph, selected build + /// configuration view, and lockfile state (if any) in + /// machine-readable form. Use this for tooling / scripts; + /// the human-facing inspection commands are `cabin tree` + /// and `cabin explain`. + #[command(hide = true)] + Metadata(ManifestArgs), + /// Compile a local package and all of its dependencies. + /// + /// Plans the build, writes `build.ninja` plus a + /// Clang-compatible `compile_commands.json`, and invokes + /// Ninja. + // + // `visible_alias = "b"` matches cargo's `build, b` + // rendering: clap auto-renders the alias next to the + // canonical name in `cabin --help` / `cabin --list`, and + // `cabin b` is parsed identically to `cabin build`. + #[command(visible_alias = "b")] + Build(BuildArgs), + /// Remove the built directory. + /// + /// Deletes Cabin-generated build artifacts under the + /// resolved `--build-dir`. Source files are never + /// touched. + Clean(CleanArgs), + /// Run a binary of the local package. + /// + /// Builds the selected `cpp_executable` target and executes + /// it. Arguments after `--` are forwarded verbatim to the + /// executed program. + #[command(visible_alias = "r")] + Run(crate::run_glue::RunArgs), + /// Run the tests of a local package. + /// + /// Builds the workspace's `cpp_test` targets and executes + /// each one with a deterministic per-test `CABIN_*` + /// environment overlay. + #[command(visible_alias = "t")] + Test(crate::test_glue::TestArgs), + /// Resolve versioned dependencies. + /// + /// Resolves the manifest's versioned dependencies against + /// a local JSON package index and prints the result. + /// Most users prefer `cabin metadata` or `cabin update`. + #[command(hide = true)] + Resolve(ResolveArgs), + /// Update dependencies as recorded in `cabin.lock`. + Update(UpdateArgs), + /// Fetch registry dependencies into the artifact cache. + /// + /// Fetches, verifies, and extracts the source archives of + /// resolved registry dependencies. Triggered + /// automatically by `cabin build`, `cabin run`, and + /// `cabin test`; use this command to warm the cache. + #[command(hide = true)] + Fetch(FetchArgs), + /// Vendor external versioned dependencies locally. + /// + /// Materialises the selected external registry dependency + /// closure into a deterministic local file-registry directory + /// for offline use. Local path dependencies stay local. + /// Combine with `--offline --index-path ` on + /// subsequent commands. + #[command(hide = true)] + Vendor(crate::vendor_glue::VendorArgs), + /// Display the dependency tree. + /// + /// Renders the loaded workspace / local-path dependency + /// graph as a tree (human or JSON). Workspace, feature, + /// kind-filter, and patch flags affect this view; option and + /// variant selectors are build-configuration inputs and do + /// not change the tree. + #[command(hide = true)] + Tree(crate::tree_glue::TreeArgs), + /// Explain a loaded package, target, source, or feature. + /// + /// Package, target, source, and feature subcommands map to + /// the typed explanation model in `cabin-explain`. + /// `build-config` reuses the same resolved configuration + /// shape as `cabin metadata`. + #[command(hide = true)] + Explain(crate::explain_glue::ExplainArgs), + /// Assemble the local package into a distributable archive. + /// + /// Builds a deterministic source archive plus canonical + /// metadata for the package at `--manifest-path`. + /// Typically driven by `cabin publish`. + #[command(hide = true)] + Package(PackageArgs), + /// Publish a package to a local file registry. + /// + /// With `--registry-dir `, writes the archive plus + /// canonical metadata into a Cabin file registry. With + /// `--dry-run` alone, stages the same artefacts under + /// `--output-dir` without touching any registry. Remote + /// registry protocols are not supported. + Publish(PublishArgs), + /// Format codes using clang-format. + /// + /// Walks the workspace's C and C++ sources and rewrites + /// them in place using the user's `clang-format`. + Fmt(crate::fmt_glue::FmtArgs), + /// Run clang-tidy. + /// + /// Drives `run-clang-tidy` over the workspace's C and C++ + /// sources using the generated `compile_commands.json`. + Tidy(crate::tidy_glue::TidyArgs), + /// Generate shell completion scripts for the `cabin` CLI. + #[command(hide = true)] + Compgen(CompgenArgs), + /// Generate man pages for the `cabin` CLI. + #[command(hide = true)] + Mangen(MangenArgs), + /// Show version information. + /// + /// Without flags, prints the concise release name (same + /// wording as `cabin --version`). With `-v` / + /// `--verbose`, prints a stable key/value block describing + /// the build (`release`, `commit-hash`, `commit-date`, + /// `host`, `os`); rows whose underlying value is unknown + /// are omitted. + Version(crate::version_glue::VersionArgs), +} + +#[derive(Debug, Args)] +pub(crate) struct InitArgs { + /// Package name. Defaults to the current directory name. + #[arg(long)] + pub name: Option, + + /// Use a binary (application) template [default]. + /// + /// Conflicts with `--lib`. + #[arg(short = 'b', long, group = "init_scaffold_kind")] + pub bin: bool, + + /// Use a library template. + /// + /// Conflicts with `--bin`. + #[arg(short = 'l', long, group = "init_scaffold_kind")] + pub lib: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct NewArgs { + /// Path of the new package directory. The directory must not already exist. + #[arg(value_name = "PATH")] + pub path: PathBuf, + + /// Package name. Defaults to the final component of ``. + #[arg(long)] + pub name: Option, + + /// Use a binary (application) template [default]. + /// + /// Conflicts with `--lib`. + #[arg(short = 'b', long, group = "new_scaffold_kind")] + pub bin: bool, + + /// Use a library template. + /// + /// Conflicts with `--bin`. + #[arg(short = 'l', long, group = "new_scaffold_kind")] + pub lib: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct CleanArgs { + /// Path to the cabin.toml manifest. Same precedence rules + /// as `cabin build`. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Build output directory. Same precedence rules as + /// `cabin build`: `--build-dir` > `CABIN_BUILD_DIR` > + /// `[paths] build-dir` config setting > built-in default + /// `build`. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Compatibility alias for `--profile release`. Cannot be + /// used together with `--profile`. + #[arg(long, conflicts_with = "profile")] + pub release: bool, + + /// Limit the clean to the named build profile. Without this + /// flag every known profile sub-tree is in scope. + #[arg(long, value_name = "NAME")] + pub profile: Option, + + /// Print the deletion plan without removing anything. Output + /// lists the paths that would be removed in deterministic + /// order. + #[arg(long)] + pub dry_run: bool, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, +} + +#[derive(Debug, Args)] +pub(crate) struct ManifestArgs { + /// Path to the cabin.toml manifest. May be a single-package manifest + /// or a workspace root. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Feature selection flags. Empty by default. When any + /// selection flag is passed, `cabin metadata --format json` + /// adds a `configuration` block to each primary package + /// describing the resolved configuration. + #[command(flatten)] + pub selection: ConfigSelectionArgs, + + /// Workspace package-selection flags. The metadata view + /// always reports every loaded package; selection flags only + /// narrow the `selected_packages` list. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Output format. `human` is a readable summary; `json` + /// produces a machine-parseable document. Defaults to `json` + /// for back-compat with scripts that pipe the metadata output + /// into `jq`. + #[arg(long, value_name = "FORMAT", default_value = "json")] + pub format: ResolveFormat, + + /// Profile to evaluate for the metadata view. Defaults to + /// `dev`. The view always lists every available profile in + /// the `profiles.available` array regardless of which one is + /// selected. + #[arg(long, value_name = "NAME")] + pub profile: Option, + + /// Toolchain-selection flags. Same precedence rules as + /// `cabin build` so the metadata view reflects exactly the + /// toolchain a build would use. + #[command(flatten)] + pub toolchain: ToolchainSelectionArgs, + + /// Disable every active patch and source-replacement entry + /// for this invocation. Manifest `[patch]` tables and + /// config `[patch]` / `[source-replacement]` declarations + /// are ignored; ordinary `path = "..."` dependency edges + /// and dependency declarations stay active. + #[arg(long)] + pub no_patches: bool, + + /// Forbid network access. `cabin metadata` rejects an HTTP + /// `--index-url` (or a `[registry] index-url` in the active + /// config) when this flag is set so the metadata view stays + /// fully local. + #[arg(long)] + pub offline: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct BuildArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Directory for build outputs (build.ninja, object files, binaries). + /// Defaults to `build/`; a config-provided `paths.build-dir` + /// overrides this default. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Build with optimizations. + /// + /// Use release flags (-O3 -DNDEBUG) instead of debug flags + /// (-g -O0). Compatibility alias for `--profile release`; + /// cannot be used together with `--profile`. + #[arg(short = 'r', long, conflicts_with = "profile")] + pub release: bool, + + /// Select the build profile (`dev`, `release`, or any custom + /// profile declared in `[profile.]`). Defaults to `dev`. + /// Mutually exclusive with `--release`. + #[arg(long, value_name = "NAME")] + pub profile: Option, + + /// Path to a directory containing the local JSON package index. + /// Required when the manifest declares any versioned dependencies + /// and `--index-url` is not given. Mutually exclusive with + /// `--index-url`. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL to read package metadata from. + /// Mutually exclusive with `--index-path`. Static sparse HTTP + /// serving of the file-registry layout is supported + /// (`/config.json`, `/packages/.json`). + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Override the default artifact cache directory. + #[arg(long, value_name = "PATH")] + pub cache_dir: Option, + + /// Require an existing, current `cabin.lock`. Resolution is not + /// allowed to choose any version that differs from the lockfile. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects state-writing side effects: + /// The lockfile must not change and the artifact cache will not be + /// populated. Already-cached artifacts may be reused. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. Cabin refuses to use an HTTP index URL + /// (`--index-url` or a `[registry] index-url` config setting) and + /// expects every needed artifact to be available from a local + /// index (`--index-path`) or already in the artifact cache. + /// Combine with `cabin vendor` to consume a self-contained vendor + /// directory. + #[arg(long)] + pub offline: bool, + + /// Enable named features. May be passed multiple times; values + /// may also be comma-separated (`--features simd,ssl`). The + /// selection applies to the root package being built. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every feature declared by the root package. Combines + /// with `--features` (the union is the same as `--all-features`) + /// and overrides `--no-default-features`. + #[arg(long)] + pub all_features: bool, + + /// Disable the package's default features. Without this flag, the + /// names listed under `[features].default` are enabled. + #[arg(long)] + pub no_default_features: bool, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Toolchain-selection flags. Each flag (when supplied) + /// overrides any `CC`/`CXX`/`AR` environment variable and + /// any `[toolchain]` table in the workspace root manifest. + #[command(flatten)] + pub toolchain: ToolchainSelectionArgs, + + /// Disable every active patch and source-replacement entry + /// for this invocation. See `docs/patch-overrides.md`. + #[arg(long)] + pub no_patches: bool, + + /// Number of parallel jobs to use for building. + /// + /// Precedence: this flag > `CABIN_BUILD_JOBS` env var > + /// `[build] jobs` config setting > backend default. The + /// value must be a positive integer; `0` is rejected. + #[arg(short = 'j', long = "jobs", value_name = "N")] + pub jobs: Option, +} + +/// Toolchain-selection flag bundle shared by `cabin build` and +/// `cabin metadata`. Each flag accepts either a bare command name +/// (`clang++`, resolved against `PATH`) or an explicit path +/// (`/opt/llvm/bin/clang++`). +#[derive(Debug, Args, Default)] +pub(crate) struct ToolchainSelectionArgs { + /// Override the C compiler. Accepts a bare command name or a + /// path. Highest precedence — also overrides `CC` and + /// `[toolchain].cc`. + #[arg(long, value_name = "PATH-OR-NAME")] + pub cc: Option, + + /// Override the C++ compiler. Accepts a bare command name or + /// a path. Highest precedence — also overrides `CXX` and + /// `[toolchain].cxx`. + #[arg(long, value_name = "PATH-OR-NAME")] + pub cxx: Option, + + /// Override the static-library archiver. Accepts a bare + /// command name or a path. Highest precedence — also + /// overrides `AR` and `[toolchain].ar`. + #[arg(long, value_name = "PATH-OR-NAME")] + pub ar: Option, + + /// Select a compiler-cache wrapper that prefixes every C++ + /// compile command. Accepts `none`, `ccache`, or `sccache`. + /// Highest precedence — also overrides + /// `CABIN_COMPILER_WRAPPER`, config `[build.cache]`, and + /// any manifest `[profile.cache]` or + /// `[target.'cfg(...)'.profile.cache]` declaration. + /// Mutually exclusive with `--no-compiler-wrapper`. + #[arg(long, value_name = "WRAPPER", conflicts_with = "no_compiler_wrapper")] + pub compiler_wrapper: Option, + + /// Disable the compiler-cache wrapper for this invocation, + /// regardless of any environment variable or manifest + /// declaration. Equivalent to `--compiler-wrapper none` but + /// shorter to type. Mutually exclusive with + /// `--compiler-wrapper`. + #[arg(long)] + pub no_compiler_wrapper: bool, +} + +/// Selection-flag bundle shared by `cabin build` and `cabin metadata`. +#[derive(Debug, Args, Default)] +pub(crate) struct ConfigSelectionArgs { + /// Enable named features. May be repeated and/or comma-separated. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every declared feature. + #[arg(long)] + pub all_features: bool, + + /// Disable default features. + #[arg(long)] + pub no_default_features: bool, +} + +/// Workspace selection flags for `cabin update`. +/// +/// `cabin update` reserves `--package ` for its +/// "refresh just this direct registry dep" semantic, so this +/// bundle deliberately omits `-p / --package`. Members can still +/// be scoped by `--workspace`, `--default-members`, and +/// `--exclude`. Adding a separate long flag (e.g. +/// `--scope-package`) for member-name selection is a deferred +/// improvement. +#[derive(Debug, Args, Default)] +pub(crate) struct WorkspaceSelectionArgsForUpdate { + /// Operate on every workspace member, then apply `--exclude`. + #[arg(long, conflicts_with = "default_members")] + pub workspace: bool, + + /// Operate on `[workspace.default-members]`. Errors when the + /// Workspace declares no default-members. + #[arg(long, conflicts_with = "workspace")] + pub default_members: bool, + + /// Drop the named package from the selection. Only valid in + /// combination with `--workspace` or `--default-members`. + #[arg(long, value_name = "PACKAGE")] + pub exclude: Vec, +} + +/// Workspace package-selection flags shared across the commands +/// that operate on a (possibly multi-member) workspace. +/// +/// Empty by default, in which case the documented "current +/// package" fallback applies (single-package builds keep working +/// unchanged; workspace builds use `[workspace.default-members]` +/// if declared, otherwise every member). +#[derive(Debug, Args, Default)] +pub(crate) struct WorkspaceSelectionArgs { + /// Operate on every workspace member, then apply `--exclude`. + /// Mutually exclusive with `--package` / `--default-members`. + #[arg( + long, + conflicts_with_all = &["package", "default_members"], + )] + pub workspace: bool, + + /// Operate on the named workspace package. Repeat the flag to + /// select multiple packages. Errors when a name is not a workspace + /// member or appears twice in the workspace. + #[arg(long = "package", short = 'p', value_name = "PACKAGE")] + pub package: Vec, + + /// Operate on `[workspace.default-members]`. Errors when the + /// workspace declares no default-members. + #[arg(long, conflicts_with_all = &["workspace", "package"])] + pub default_members: bool, + + /// Drop the named package from the selection. Only valid in + /// combination with `--workspace` or `--default-members`, or with + /// the no-flag default-member fallback. + #[arg(long, value_name = "PACKAGE")] + pub exclude: Vec, +} + +#[derive(Debug, Args)] +pub(crate) struct FetchArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Path to a directory containing the local JSON package index. + /// Required when the manifest declares any versioned dependencies + /// and `--index-url` is not given. Mutually exclusive with + /// `--index-url`. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL to read package metadata from. + /// Mutually exclusive with `--index-path`. + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Override the default artifact cache directory. + #[arg(long, value_name = "PATH")] + pub cache_dir: Option, + + /// Require an existing, current `cabin.lock`. Resolution is not + /// allowed to choose any version that differs from the lockfile. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects state-writing side effects. + /// The lockfile is not written and the artifact cache will not be + /// populated. Already-cached artifacts may be reused. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. Cabin refuses to use an HTTP index + /// URL (`--index-url` or a `[registry] index-url` config setting) + /// and expects every needed input to be local or already cached. + #[arg(long)] + pub offline: bool, + + /// Output format. `human` is a readable summary; `json` produces a + /// machine-parseable document. Defaults to `human`. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Disable every active patch and source-replacement entry + /// for this invocation. See `docs/patch-overrides.md`. + #[arg(long)] + pub no_patches: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct PackageArgs { + /// Path to the cabin.toml manifest. Must point at a single + /// package; pure-workspace roots are rejected unless the + /// Workspace selects exactly one member with `--package`. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Directory for the generated archive and metadata. + #[arg(long, default_value = "dist")] + pub output_dir: PathBuf, + + /// Output format. `human` is a readable summary; `json` produces + /// A machine-parseable document. Defaults to `human`. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Workspace package-selection flags. In a workspace with + /// multiple members, `cabin package` requires a single + /// `--package ` selection. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, +} + +#[derive(Debug, Args)] +pub(crate) struct PublishArgs { + /// Path to the cabin.toml manifest. Must point at a single + /// package; pure-workspace roots are rejected. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Directory for the dry-run's archive and metadata when + /// `--registry-dir` is not given. Defaults to `dist/`. Mutually + /// exclusive with `--registry-dir`. + #[arg(long, value_name = "PATH")] + pub output_dir: Option, + + /// Run a publish dry-run only. With `--registry-dir`, validates + /// what would happen against the registry without mutating it. + /// Without `--registry-dir`, runs the staging-only dry-run that + /// writes the archive + metadata to `--output-dir`. + #[arg(long)] + pub dry_run: bool, + + /// Local file-registry root to publish into. Without + /// `--dry-run`, the registry is mutated; with `--dry-run`, every + /// pre-write check runs but the registry is left untouched. + #[arg(long, value_name = "PATH")] + pub registry_dir: Option, + + /// Output format for the publish or dry-run report. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Workspace package-selection flags. In a workspace with + /// multiple members, `cabin publish` requires a single + /// `--package ` selection. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, +} + +#[derive(Debug, Args)] +pub(crate) struct ResolveArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Path to a directory containing the local JSON package index. + /// Required when the manifest declares any versioned dependencies + /// and `--index-url` is not given. Mutually exclusive with + /// `--index-url`. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL to read package metadata from. + /// Mutually exclusive with `--index-path`. + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Output format. `human` is a readable summary; `json` produces a + /// machine-parseable document. Defaults to `human`. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Require an existing, current `cabin.lock`. Resolution is not + /// allowed to choose any version that differs from the lockfile. + /// Implies that `cabin.lock` will not be written. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects any state-writing side + /// effects. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. Cabin refuses to use an HTTP index + /// URL (`--index-url` or a `[registry] index-url` config setting) + /// and expects every needed input to be local or already cached. + #[arg(long)] + pub offline: bool, + + /// Workspace package-selection flags. The resolver is + /// workspace-flat (every member shares one resolution), so + /// selection only narrows the diagnostic output, not the + /// resolution itself. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Feature names to enable on selected root packages. + /// Repeatable; values may also be comma-separated. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every declared feature on selected root packages. + /// Combines with `--features` (the union is requested). + #[arg(long)] + pub all_features: bool, + + /// Disable selected root packages' `default` feature. + #[arg(long)] + pub no_default_features: bool, + + /// Disable every active patch and source-replacement entry + /// for this invocation. See `docs/patch-overrides.md`. + #[arg(long)] + pub no_patches: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct UpdateArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Path to a directory containing the local JSON package index. + /// Required when the manifest declares any versioned dependencies + /// and `--index-url` is not given. Mutually exclusive with + /// `--index-url`. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL to read package metadata from. + /// Mutually exclusive with `--index-path`. + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Update only the named **dependency** (and any of its + /// transitive deps that must change to satisfy the new + /// constraints). Without this flag every locked package is + /// re-resolved. + /// + /// `--package` here means "refresh this direct versioned + /// dependency", *not* "scope to this workspace member". + /// Workspace members can still be scoped through + /// `--workspace`, `--default-members`, and `--exclude`; the + /// workspace-selection bundle on `cabin update` deliberately + /// omits `-p` / `--package` to avoid the name collision. + #[arg(long, value_name = "NAME")] + pub package: Option, + + /// Output format for the resulting resolution. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Forbid network access. Cabin refuses to use an HTTP index + /// URL (`--index-url` or a `[registry] index-url` config setting) + /// and expects every needed input to be local or already cached. + #[arg(long)] + pub offline: bool, + + /// Workspace package-selection flags scoped to + /// `cabin update`'s flag space (no `-p / --package`; see the + /// docstring on `package` above). + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgsForUpdate, + + /// Disable every active patch and source-replacement entry + /// for this invocation. See `docs/patch-overrides.md`. + #[arg(long)] + pub no_patches: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub(crate) enum ResolveFormat { + Human, + Json, +} + +/// Default manifest filename used by every command. +const MANIFEST_FILENAME: &str = scaffold::MANIFEST_FILENAME; + +/// Dispatch a parsed CLI invocation. Returns the exit code the +/// process should propagate. Most commands return +/// `ExitCode::SUCCESS` on the happy path; `cabin run` forwards +/// the spawned program's exit status so a non-zero exit from the +/// program becomes Cabin's own exit status. +/// +/// The `cli.color` field carries the user's `--color` choice; +/// the resolved [`cabin_core::ColorChoice`] for top-level +/// error rendering is computed in `main.rs` against the env +/// and the user-level config. Subcommands today produce +/// uncolored status output and so do not consume the resolved +/// color; when a subcommand learns to emit styled output, it +/// should accept the resolved choice as an explicit argument +/// rather than re-deriving it here. +pub(crate) fn run( + cli: Cli, + reporter: Reporter, + color: cabin_core::ColorChoice, +) -> Result { + use std::process::ExitCode; + // `--version` (and the short `-V`) routes through the same + // formatter `cabin version` uses, so `cabin --version -v` + // produces the verbose key/value block instead of the + // concise single line clap's auto-flag would emit. The + // flag wins over any subcommand and over `--list`, matching + // cargo's precedence. + if cli.version { + crate::version_glue::version(crate::version_glue::VersionArgs {}, reporter.verbosity())?; + return Ok(ExitCode::SUCCESS); + } + // `--list` is mutually exclusive with every other input; + // clap rejects `cabin --list ` for us. Print + // the full subcommand list and exit successfully. The + // listing is written through a `termcolor::StandardStream` + // tuned to the caller-resolved colour choice so the + // cargo-style palette (green heading, cyan subcommand + // names) appears whenever `--color` says it should. + if cli.list { + let mut stdout = + termcolor::StandardStream::stdout(cabin_diagnostics::termcolor_choice(color)); + crate::command_list::print_list(&mut stdout)?; + return Ok(ExitCode::SUCCESS); + } + let Some(command) = cli.command else { + // `cabin` with no subcommand prints the curated help + // and exits zero, matching the prior implicit behavior + // (clap's auto help) but routed through the dispatcher + // so the exit code is documented here. + let mut cmd = ::command(); + cmd.print_help().context("failed to print top-level help")?; + // Cargo prints help and exits 0 when invoked with no + // arguments. Cabin matches that. + return Ok(ExitCode::SUCCESS); + }; + match command { + Command::Init(args) => init(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::New(args) => new(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Metadata(args) => metadata(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Build(args) => build(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Clean(args) => clean(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Run(args) => crate::run_glue::run(&args, reporter), + Command::Test(args) => crate::test_glue::test(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Resolve(args) => resolve(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Update(args) => update(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Fetch(args) => fetch(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Vendor(args) => { + crate::vendor_glue::vendor(&args, reporter).map(|()| ExitCode::SUCCESS) + } + Command::Tree(args) => crate::tree_glue::tree(&args).map(|()| ExitCode::SUCCESS), + Command::Explain(args) => { + crate::explain_glue::explain(&args, reporter).map(|()| ExitCode::SUCCESS) + } + Command::Package(args) => package(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Publish(args) => publish(&args, reporter).map(|()| ExitCode::SUCCESS), + Command::Fmt(args) => crate::fmt_glue::fmt(&args, reporter), + Command::Tidy(args) => crate::tidy_glue::tidy(&args, reporter), + Command::Compgen(args) => crate::completions::run(&args).map(|()| ExitCode::SUCCESS), + Command::Mangen(args) => crate::manpages::run(&args).map(|()| ExitCode::SUCCESS), + Command::Version(args) => { + crate::version_glue::version(args, reporter.verbosity()).map(|()| ExitCode::SUCCESS) + } + } +} + +fn scaffold_kind_from_flags(_bin: bool, lib: bool) -> scaffold::ScaffoldKind { + // clap's `group` constraint already rejected the `--bin + // --lib` combination, so `_bin` is only observed for + // symmetry with the CLI surface; binary is the default + // whether `--bin` was explicit or absent. + if lib { + scaffold::ScaffoldKind::Library + } else { + scaffold::ScaffoldKind::Binary + } +} + +fn report_scaffold(reporter: Reporter, verb: &str, report: &scaffold::ScaffoldReport, dest: &Path) { + // Cargo-style aligned status line: the verb (`Created` / + // `Initialized`) is right-padded to column 12 by + // `Reporter::cargo_status`, which keeps the banner aligned + // with `Compiling` and `Finished` and styles the verb in + // bright green + bold when colour is enabled. The rendered + // shape is: + // + // Created binary (application) `` package + // Created library `` package + reporter.cargo_status( + verb, + format_args!( + "{kind} `{name}` package", + kind = report.kind.label(), + name = report.name.as_str(), + ), + ); + for created in &report.files_created { + let relative = created.strip_prefix(dest).unwrap_or(created); + reporter.verbose(format_args!( + "cabin: wrote {}", + relative.display().to_string().replace('\\', "/") + )); + } +} + +fn init(args: &InitArgs, reporter: Reporter) -> Result<()> { + let cwd = std::env::current_dir().context("failed to determine current directory")?; + let kind = scaffold_kind_from_flags(args.bin, args.lib); + let request = scaffold::ScaffoldRequest::new(&cwd) + .with_name(args.name.as_deref()) + .with_kind(kind) + .with_gitignore(true); + let report = scaffold::scaffold(request)?; + // `cabin init` and `cabin new` share the same `Created â€Ļ` + // status line so scripts can parse either path uniformly. + report_scaffold(reporter, "Created", &report, &cwd); + Ok(()) +} + +fn new(args: &NewArgs, reporter: Reporter) -> Result<()> { + let target = args.path.clone(); + if target.as_os_str().is_empty() { + bail!("destination path must not be empty"); + } + if target.exists() { + bail!( + "destination {} already exists; use `cabin init` to initialise an existing directory", + target.display() + ); + } + if let Some(parent) = target.parent() + && !parent.as_os_str().is_empty() + && !parent.is_dir() + { + bail!( + "parent directory {} does not exist; create it first or pass a path under an existing directory", + parent.display() + ); + } + + std::fs::create_dir(&target) + .with_context(|| format!("failed to create directory {}", target.display()))?; + + let kind = scaffold_kind_from_flags(args.bin, args.lib); + let request = scaffold::ScaffoldRequest::new(&target) + .with_name(args.name.as_deref()) + .with_kind(kind) + .with_gitignore(true); + match scaffold::scaffold(request) { + Ok(report) => { + report_scaffold(reporter, "Created", &report, &target); + Ok(()) + } + Err(err) => { + // Best-effort cleanup of the directory we just + // created; surface the scaffold error regardless of + // whether removal succeeds. + let _ = std::fs::remove_dir_all(&target); + Err(err.into()) + } + } +} + +fn metadata(args: &ManifestArgs, reporter: Reporter) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let initial_graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + // `cabin metadata` never reaches the network, but reject + // `--offline` paired with a URL registry source so the + // metadata view documents the same offline contract the + // build / fetch / resolve commands enforce. + let resolved_index_for_offline_check = + crate::config_glue::resolve_index_source(None, None, &effective_config)?; + let metadata_offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source( + metadata_offline, + resolved_index_for_offline_check.as_ref(), + )?; + // Resolve patch policy before the rest of the pipeline. + // Validation surfaces invalid / stale patches up-front. + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_sources = active_patches.workspace_sources(); + let graph = if patched_sources.is_empty() { + initial_graph + } else { + let strict_packages: BTreeSet = BTreeSet::new(); + cabin_workspace::load_workspace_with_options( + &manifest_path, + &cabin_workspace::WorkspaceLoadOptions { + registry: &[], + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &BTreeSet::new(), + }, + )? + }; + let lockfile_path = lockfile_path_for(&manifest_path); + let lockfile = if lockfile_path.is_file() { + Some( + cabin_lockfile::read_lockfile(&lockfile_path) + .with_context(|| format!("failed to read {}", lockfile_path.display()))?, + ) + } else { + None + }; + let request = build_selection_request( + &args.selection.features, + args.selection.all_features, + args.selection.no_default_features, + ); + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + // Run the cross-package feature resolver so unknown features, + // `dep:` entries on non-optional deps, and other feature-graph + // errors surface here too — not only in `cabin build`. + let _feature_resolution = compute_feature_resolution(&graph, &resolved_selection, &request)?; + let manifest_profiles = workspace_profile_definitions(&graph); + let profile_selection = + profile_selection_for_metadata(args.profile.as_deref(), &effective_config)?; + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?; + let toolchain = resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + // Capability detection runs against the resolved tools. + // `cabin metadata` is fail-soft so a misbehaving compiler + // does not block users from inspecting the rest of the + // workspace; the typed report is reported to the JSON view + // as `null` when subprocess detection fails. + let detection_report = + match cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner) { + Ok(report) => Some(report), + Err(err) => { + reporter.warning(format_args!("toolchain detection failed: {err}")); + None + } + }; + // Resolve the compiler-cache wrapper. `cabin metadata` mirrors + // the build-side resolution but fails soft on subprocess + // errors so a missing wrapper executable cannot block + // inspection of the rest of the workspace. + let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph); + let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?; + let mut wrapper_inputs = cabin_toolchain::WrapperInputs::from_process( + cli_compiler_wrapper, + &manifest_compiler_wrapper, + &host_platform, + ); + if let Some(layer) = crate::config_glue::wrapper_layer(&effective_config) { + wrapper_inputs = wrapper_inputs.with_config(layer); + } + let compiler_wrapper = match cabin_toolchain::resolve_compiler_wrapper( + &wrapper_inputs, + Some(&cabin_toolchain::ProcessRunner), + ) { + Ok(w) => w, + Err(err) => { + reporter.warning(format_args!("compiler-wrapper resolution failed: {err}")); + None + } + }; + let toolchain_summary = + cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, compiler_wrapper.as_ref()); + let profile_build = profile.build.as_ref(); + let build_flags = resolve_per_package_build_flags(&graph, profile_build, &host_platform); + // `cabin metadata` does not opt into dev-dep activation; + // dev-kind system deps stay declaration-only here so the + // probe step matches the Cabin-package activation rule. + let dev_for: BTreeSet = BTreeSet::new(); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + let configurations = resolve_build_configurations( + &graph, + &request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let view = MetadataView::from_graph_and_lock(&MetadataInputs { + graph: &graph, + lockfile: lockfile.as_ref(), + lockfile_path: &lockfile_path, + configurations: &configurations, + selection: &resolved_selection, + profile: &profile, + manifest_profiles: &manifest_profiles, + toolchain: &toolchain, + build_flags: &build_flags, + detection: detection_report.as_ref(), + compiler_wrapper: compiler_wrapper.as_ref(), + config: &effective_config, + active_patches: &active_patches, + no_patches: args.no_patches, + }); + match args.format { + ResolveFormat::Json => { + let json = serde_json::to_string_pretty(&view) + .context("failed to serialize metadata as JSON")?; + println!("{json}"); + } + ResolveFormat::Human => { + // Human form is intentionally minimal — JSON is the + // contract for tooling; this branch is here so users who + // pass `--format human` get something readable. + for pkg in &view.packages { + println!( + "{} {} ({})", + pkg.name, + pkg.version, + if pkg.is_root { + "root" + } else if pkg.is_primary { + "primary" + } else { + "dep" + } + ); + } + } + } + Ok(()) +} + +fn build(args: &BuildArgs, reporter: Reporter) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + + // First-pass load: needed to detect versioned dependencies + // before we know whether we have to fetch anything. This load + // also surfaces manifest / workspace errors before we touch + // the index. + let initial_graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + // Resolve patch policy before we look at the index. Patched + // names are excluded from the closure / artifact pipeline + // because they ship from a local working copy. + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + let resolved_index_source = crate::config_glue::resolve_index_source( + args.index_path.as_deref(), + args.index_url.as_deref(), + &effective_config, + )?; + let build_offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source( + build_offline, + resolved_index_source.as_ref(), + )?; + let resolved_cache_dir = + crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config); + + // only the *selected closure* drives the index + // requirement. An unrelated workspace member's versioned dep + // must not force the user to pass `--index-path` when + // `cabin build -p selected` is run on a C/C++-only selection. + let workspace_selection_for_pipeline = build_workspace_selection(&args.workspace_selection); + let initial_resolved_selection = cabin_workspace::resolve_package_selection( + &initial_graph, + &workspace_selection_for_pipeline, + )?; + let initial_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let initial_features = compute_feature_resolution( + &initial_graph, + &initial_resolved_selection, + &initial_request, + )?; + let dev_for: BTreeSet = BTreeSet::new(); + let patched_root_deps_preview = + collect_patched_versioned_deps(&active_patches, &patched_names)?; + let has_versioned = !patched_root_deps_preview.is_empty() + || closure_has_versioned_deps_excluding_patches( + &initial_graph, + &initial_resolved_selection, + &initial_features, + &patched_names, + &dev_for, + ); + + let registry: Vec = if has_versioned { + let Some(index_source) = resolved_index_source.as_ref() else { + bail!( + "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting" + ); + }; + let mode = lock_mode_for_flags(args.locked, args.frozen); + let allow_write = !(args.locked || args.frozen); + let cache_dir = match resolved_cache_dir.as_ref() { + Some((path, _)) => path.clone(), + None => cache_dir_for(&manifest_path, args.cache_dir.as_deref())?, + }; + let initial_locator = match &index_source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved_locator = crate::patch_glue::apply_source_replacement( + initial_locator, + &effective_config, + args.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement(build_offline, &resolved_locator)?; + let (replaced_path, replaced_url) = + crate::patch_glue::locator_to_index_inputs(&resolved_locator.resolved); + let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest { + manifest_path: &manifest_path, + initial_graph: &initial_graph, + index_path: replaced_path.as_deref(), + index_url: replaced_url.as_deref(), + mode, + allow_write, + frozen: args.frozen, + cache_dir: &cache_dir, + reporter, + selection: workspace_selection_for_pipeline, + selection_request: &initial_request, + patched_names: &patched_names, + active_patches: &active_patches, + source_replacements: &effective_config.source_replacements, + no_patches: args.no_patches, + dev_for: &dev_for, + })?; + pipeline + .fetched + .iter() + .map(|p| RegistryPackageSource { + name: p.name.clone(), + version: p.version.clone(), + manifest_path: p.source_dir.join("cabin.toml"), + }) + .collect() + } else { + Vec::new() + }; + + // Re-load the workspace, this time stitching in the resolved + // registry packages plus active patches. When both lists are + // empty this is identical to the first-pass load. + // + // `strict_packages` controls which packages require their + // versioned deps to be present in `registry`. Patched + // packages live outside `initial_graph`, so the closure walk + // never names them; without an explicit add the loader's + // `requires_registry_for` returns false for patched packages + // and silently skips a missing-from-registry edge they + // declare. Adding the patched names enforces the same + // strictness for them. + let mut strict_packages: BTreeSet = initial_resolved_selection + .closure(&initial_graph) + .into_iter() + .map(|i| initial_graph.packages[i].package.name.as_str().to_owned()) + .collect(); + strict_packages.extend(patched_names.iter().cloned()); + let patched_sources = active_patches.workspace_sources(); + let graph = cabin_workspace::load_workspace_with_options( + &manifest_path, + &cabin_workspace::WorkspaceLoadOptions { + registry: ®istry, + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &BTreeSet::new(), + }, + )?; + + // Resolve the build directory. Precedence: + // `--build-dir` > `CABIN_BUILD_DIR` env var + // > `[paths] build-dir` config setting > built-in default. + let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutise(&build_dir_input) + .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?; + + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?; + let toolchain = resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + // Detect compiler / archiver identity and validate that the + // backend's required capabilities (GCC-style flags, depfile + // emission, `-std=c++17`, ar-compatible archiving) are + // available before any Ninja file is written. Fail fast and + // clear here rather than letting Ninja produce a confusing + // error from a broken command line. + let detection_report = + cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + cabin_build::validate_toolchain_for_backend(&toolchain, &detection_report)?; + let ninja = cabin_toolchain::locate_ninja()?; + + let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph); + let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?; + + // Translate `--profile` / `--release` into a typed selection + // (clap's `conflicts_with` already rejects the two-flag form). + // The workspace root manifest's `[profile.]` tables are + // the only source of profile definitions; a `build.profile` + // setting in any active config file slots between the CLI + // flag and the built-in `dev` default. + let profile_selection = profile_selection_for_build(args, &effective_config)?; + let manifest_profiles = workspace_profile_definitions(&graph); + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + // Per-package resolved build flags. Each package's own + // `[profile]` / `[target.'cfg(...)'.profile]` plus the active + // profile's `[profile.]` block compose into a + // `ResolvedProfileFlags`. Computed up-front so the planner + // and metadata view see the same values. + let profile_build = profile.build.as_ref(); + let build_flags = resolve_per_package_build_flags(&graph, profile_build, &host_platform); + // `cabin metadata` does not opt into dev-dep activation; + // dev-kind system deps stay declaration-only here so the + // probe step matches the Cabin-package activation rule. + let dev_for: BTreeSet = BTreeSet::new(); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + + // Resolve the compiler-cache wrapper. Production runs detect + // the wrapper version through the same `ProcessRunner` + // toolchain detection used for the compilers; failures here + // are fatal so a misbehaving wrapper never silently bypasses + // caching. + let compiler_wrapper = resolve_compiler_wrapper_layered( + cli_compiler_wrapper, + &manifest_compiler_wrapper, + &effective_config, + &host_platform, + )?; + let toolchain_summary = + cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, compiler_wrapper.as_ref()); + + // resolve the workspace package selection up-front. + // The planner consumes the selected indices through + // `PlanRequest::selected_packages` so default-target enumeration + // narrows to the picked packages instead of every primary. + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + + // resolve features for the root package before doing anything + // else, so the planner observes the selected configuration. + let selection_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let configurations = resolve_build_configurations( + &graph, + &selection_request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let _feature_resolution = + compute_feature_resolution(&graph, &resolved_selection, &selection_request)?; + + let root_configuration = graph + .root_package + .and_then(|i| configurations.get(&i)) + .cloned(); + let plan_graph = plan(&PlanRequest { + graph: &graph, + toolchain: &toolchain, + build_flags: &build_flags, + build_dir: build_dir.clone(), + profile: profile.clone(), + selected: None, + configuration: root_configuration.as_ref(), + selected_packages: Some(&resolved_selection.packages), + compiler_wrapper: compiler_wrapper.as_ref(), + })?; + + // Profile-aware Ninja root: `build//build.ninja` + // and `build//compile_commands.json`. Keeps dev / + // release / custom builds from overwriting each other and + // matches the per-package output tree the planner emits. + let profile_build_root = build_dir.join(profile.name.as_str()); + std::fs::create_dir_all(&profile_build_root).with_context(|| { + format!( + "failed to create build directory {}", + profile_build_root.display() + ) + })?; + + let ninja_file = profile_build_root.join("build.ninja"); + cabin_ninja::write_build_ninja(&ninja_file, &plan_graph)?; + + let ccmd_file = profile_build_root.join("compile_commands.json"); + cabin_ninja::write_compile_commands(&ccmd_file, &plan_graph)?; + + reporter.verbose(format_args!("cabin: profile = {}", profile.name.as_str())); + reporter.verbose(format_args!("cabin: build dir = {}", build_dir.display())); + reporter.verbose(format_args!( + "cabin: c++ compiler = {}", + toolchain.cxx.path.display() + )); + if let Some(cc) = &toolchain.cc { + reporter.very_verbose(format_args!("cabin: c compiler = {}", cc.path.display())); + } + reporter.very_verbose(format_args!( + "cabin: archiver = {}", + toolchain.ar.path.display() + )); + // Implementation-detail status (which files Cabin wrote + // before handing the build off to Ninja, the exact Ninja + // argv) is verbose-only so the default surface stays terse. + reporter.verbose(format_args!("cabin: wrote {}", ninja_file.display())); + reporter.verbose(format_args!("cabin: wrote {}", ccmd_file.display())); + let jobs = crate::config_glue::resolve_build_jobs(args.jobs, &effective_config)?; + reporter.verbose(format_args!( + "cabin: invoking {} {}-C {}", + ninja.display(), + ninja_jobs_echo(jobs), + profile_build_root.display() + )); + + let mut ninja_cmd = std::process::Command::new(&ninja); + if let Some(jobs) = jobs { + ninja_cmd.arg(jobs.as_ninja_arg()); + } + let build_started = std::time::Instant::now(); + let status = run_ninja( + ninja_cmd.arg("-C").arg(&profile_build_root), + reporter, + &graph, + ) + .with_context(|| format!("failed to invoke ninja at {}", ninja.display()))?; + + if !status.success() { + bail!("ninja exited with {status}"); + } + + // Cargo-style `Finished` summary: profile name, the resolved + // optimisation / debuginfo descriptor, and the wall-clock + // duration the Ninja invocation took. + let elapsed = build_started.elapsed(); + reporter.cargo_status( + "Finished", + format_args!( + "`{}` profile [{}] target(s) in {:.2}s", + profile.name.as_str(), + profile_descriptor(&profile), + elapsed.as_secs_f64(), + ), + ); + + Ok(()) +} + +/// Render the optimisation / debuginfo descriptor that follows +/// the profile name in the `Finished` status line, matching +/// cargo's own banner: +/// +/// - `unoptimized + debuginfo` for `dev` and any other `O0` + +/// debug build, +/// - `optimized` for `release` and other non-zero opt levels, +/// - `optimized + debuginfo` when both flags are on. +fn profile_descriptor(profile: &cabin_core::ResolvedProfile) -> String { + let opt = if matches!(profile.opt_level, cabin_core::OptLevel::O0) { + "unoptimized" + } else { + "optimized" + }; + if profile.debug { + format!("{opt} + debuginfo") + } else { + opt.to_owned() + } +} + +/// Run Ninja and filter its housekeeping lines (`ninja: Entering +/// directory â€Ļ`, `ninja: no work to do.`, `[N/M] â€Ļ` progress) +/// from stdout so the default surface stays terse. Compiler +/// warnings, errors, and any non-housekeeping line from Ninja or +/// the toolchain pass through unchanged on stderr. +/// +/// Verbose mode (`-v`) restores the full Ninja output so users +/// who want to inspect the backend's progress have a knob. +pub(crate) fn run_ninja( + cmd: &mut std::process::Command, + reporter: Reporter, + graph: &cabin_workspace::PackageGraph, +) -> std::io::Result { + use HashMap; + use std::io::{BufRead, BufReader, Write as _}; + use std::process::Stdio; + + // Verbose modes (`-v` / `-vv`) keep every line Ninja + // emits — the `[N/M] â€Ļ` progress prefix, the `Entering + // directory` banner, the `no work to do.` reassurance — so + // raising verbosity never makes the surface smaller. The + // `Compiling` banner is still printed in those modes from + // the same per-package detection used at the default + // verbosity; users opting into `-v` see the cargo-style + // headers interleaved with the raw Ninja output. + let keep_ninja_chatter = reporter.verbosity().shows_verbose(); + + // Lookup table from package name to the workspace `WorkspacePackage` + // entry. We resolve each `[N/M] â€Ļ` progress line back to its + // owning package by the `/packages//` segment the + // planner embeds in every output path, then announce the + // `Compiling` banner the first time a given package shows + // up. Tying announcements to Ninja's own progress keeps the + // banner temporally accurate — header-only libraries that + // contribute no actions never get a `Compiling` line because + // they never appear in Ninja's output. + let pkg_by_name: HashMap<&str, &cabin_workspace::WorkspacePackage> = graph + .packages + .iter() + .map(|pkg| (pkg.package.name.as_str(), pkg)) + .collect(); + let mut announced: HashSet = HashSet::new(); + + let mut child = cmd + .stdout(Stdio::piped()) + // `stderr` is left attached to the inherited stream so + // compiler diagnostics (which go to stderr) reach the + // user untouched. + .spawn()?; + if let Some(stdout) = child.stdout.take() { + let stdout_handle = std::io::stdout(); + let mut sink = stdout_handle.lock(); + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some(path) = ninja_progress_path(&line) { + if let Some(pkg_name) = package_segment_from_path(path) + && announced.insert(pkg_name.to_owned()) + && let Some(pkg) = pkg_by_name.get(pkg_name) + { + announce_compiling(reporter, pkg); + } + if keep_ninja_chatter { + let _ = writeln!(sink, "{line}"); + } + continue; + } + if is_ninja_chatter(&line) { + if keep_ninja_chatter { + let _ = writeln!(sink, "{line}"); + } + continue; + } + let _ = writeln!(sink, "{line}"); + } + } + child.wait() +} + +/// Emit the cargo-style `Compiling v ()` +/// header for a single package. Local packages render their +/// manifest directory in parentheses; registry packages drop +/// the path because the workspace user did not bring them in +/// by hand. +fn announce_compiling(reporter: Reporter, pkg: &cabin_workspace::WorkspacePackage) { + let name = pkg.package.name.as_str(); + let version = &pkg.package.version; + match pkg.kind { + cabin_workspace::PackageKind::Local => { + reporter.cargo_status( + "Compiling", + format_args!("{} v{} ({})", name, version, pkg.manifest_dir.display()), + ); + } + cabin_workspace::PackageKind::Registry => { + reporter.cargo_status("Compiling", format_args!("{} v{}", name, version)); + } + } +} + +/// Return true for lines that are pure Ninja housekeeping: +/// `Entering directory` and `no work to do.`. Any other line +/// (including compiler-emitted diagnostics, blank lines, and +/// Ninja error reports) returns `false` so it passes through +/// unchanged. Progress lines (`[N/M] â€Ļ`) are handled separately +/// — `run_ninja` extracts the package name from them before +/// dropping the line. +fn is_ninja_chatter(line: &str) -> bool { + line == "ninja: no work to do." || line.starts_with("ninja: Entering directory") +} + +/// Parse a Ninja `[/] ` progress +/// line. Returns the trailing `` slice on a successful +/// match, or `None` for any other input. Both progress numbers +/// must be non-empty decimal integers; both are decimal positive +/// integers in practice. +fn ninja_progress_path(line: &str) -> Option<&str> { + let rest = line.strip_prefix('[')?; + let (finished, rest) = rest.split_once('/')?; + if finished.is_empty() || !finished.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let (total, after) = rest.split_once("] ")?; + if total.is_empty() || !total.chars().all(|c| c.is_ascii_digit()) { + return None; + } + // `after` is ` `; the action token is the + // single word the planner records as the description prefix + // (`CXX`, `AR`, `LINK`, â€Ļ), so the path starts immediately + // after the first space. + let (_action, path) = after.split_once(' ')?; + Some(path) +} + +/// Extract the package name from a planner-emitted build path. +/// Every per-package artifact lives under `/ +/// /packages//â€Ļ`, so locating the first `/packages/` +/// segment and taking the next path component yields the +/// owning package's name. Returns `None` when the path lacks +/// the segment (a custom-command output the planner did not +/// route through the per-package tree). +fn package_segment_from_path(path: &str) -> Option<&str> { + const SEGMENT: &str = "/packages/"; + let after = path.find(SEGMENT)?; + let tail = &path[after + SEGMENT.len()..]; + tail.split('/').next().filter(|s| !s.is_empty()) +} + +/// Render the optional `-jN` token plus a trailing space for +/// the status line. Empty when jobs is unset so the message +/// `cabin: invoking ninja -C ` stays byte-identical to +/// the pre-jobs default. +pub(crate) fn ninja_jobs_echo(jobs: Option) -> String { + match jobs { + Some(j) => format!("-j{} ", j), + None => String::new(), + } +} + +fn clean(args: &CleanArgs, reporter: Reporter) -> Result<()> { + use cabin_build::clean::{CleanRequest, CleanScope, execute_clean, plan_clean}; + + // Manifest discovery, build-dir resolution, and profile + // selection share helpers with `cabin build` so the user + // sees the same precedence rules across both commands. + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&graph)?; + + let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutise(&build_dir_input) + .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?; + + let workspace_root = graph.root_dir.clone(); + let package_roots: Vec = graph + .packages + .iter() + .map(|pkg| pkg.manifest_dir.clone()) + .collect(); + let protected_source_paths = clean_protected_source_paths(&graph); + + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + let selected_explicitly = !args.workspace_selection.package.is_empty() + || !args.workspace_selection.exclude.is_empty(); + + let profile_selection = + profile_selection_from_flags(args.profile.as_deref(), args.release, &effective_config)?; + let manifest_profiles = workspace_profile_definitions(&graph); + let resolved_profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + let profile_was_chosen = args.profile.is_some() || args.release; + + let scope = if selected_explicitly { + let packages: Vec = resolved_selection + .packages + .iter() + .map(|&idx| graph.packages[idx].package.name.clone()) + .collect(); + let profiles = if profile_was_chosen { + vec![resolved_profile.name] + } else { + known_profile_names(&manifest_profiles) + }; + CleanScope::Packages { profiles, packages } + } else if profile_was_chosen { + CleanScope::Profile(resolved_profile.name) + } else { + CleanScope::Whole + }; + + let plan = plan_clean(&CleanRequest { + build_dir: &build_dir, + workspace_root: &workspace_root, + package_roots: &package_roots, + protected_source_paths: &protected_source_paths, + scope, + }) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + if plan.removals.is_empty() { + if args.dry_run { + reporter.status(format_args!( + "cabin: dry run; build directory {} contains nothing to clean", + build_dir.display() + )); + } else { + reporter.status(format_args!( + "cabin: build directory {} does not exist; nothing to clean", + build_dir.display() + )); + } + return Ok(()); + } + + if args.dry_run { + reporter.status(format_args!("cabin: dry run; would remove:")); + print_plan_paths(&plan, reporter); + return Ok(()); + } + + let report = execute_clean(&plan).map_err(|err| anyhow::anyhow!(err.to_string()))?; + if report.removed.is_empty() { + reporter.status(format_args!( + "cabin: build directory {} contained nothing to clean", + build_dir.display() + )); + } else { + reporter.status(format_args!( + "cabin: removed {} path{} under {}", + report.removed.len(), + crate::plural(report.removed.len()), + build_dir.display() + )); + } + Ok(()) +} + +fn clean_protected_source_paths(graph: &cabin_workspace::PackageGraph) -> Vec { + let mut paths = Vec::new(); + for pkg in &graph.packages { + for target in &pkg.package.targets { + paths.extend( + target + .sources + .iter() + .map(|source| pkg.manifest_dir.join(source)), + ); + paths.extend( + target + .include_dirs + .iter() + .map(|include_dir| pkg.manifest_dir.join(include_dir)), + ); + } + } + paths.sort(); + paths.dedup(); + paths +} + +fn print_plan_paths(plan: &cabin_build::clean::CleanPlan, reporter: Reporter) { + for path in &plan.removals { + reporter.status(format_args!(" {}", path.display())); + } +} + +/// Names of every profile this workspace knows about: the two +/// built-ins (`dev`, `release`) plus every user-declared +/// `[profile.]` table on the workspace root manifest. +/// The set is sorted and deduplicated so the resulting clean +/// scope is stable across invocations. +fn known_profile_names( + manifest_profiles: &BTreeMap, +) -> Vec { + let mut out: BTreeSet = BTreeSet::new(); + for builtin in cabin_core::BuiltinProfile::all() { + out.insert(cabin_core::ProfileName::builtin(builtin)); + } + for name in manifest_profiles.keys() { + out.insert(name.clone()); + } + out.into_iter().collect() +} + +fn resolve(args: &ResolveArgs, reporter: Reporter) -> Result<()> { + let mode = lock_mode_for_flags(args.locked, args.frozen); + // Both --locked and --frozen forbid writing the lockfile. The + // distinction becomes meaningful once a fetcher / cache exists for + // `--frozen` to refuse to populate; today they behave the same. + let allow_write = !(args.locked || args.frozen); + if args.frozen && args.index_url.is_some() { + bail!( + "cannot use --index-url with --frozen: there is no persistent HTTP index metadata cache, so a frozen run would have to perform network fetches it is not allowed to perform" + ); + } + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let selection_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + run_resolution( + &ResolutionRequest { + manifest_path: &manifest_path, + index_path: args.index_path.as_deref(), + index_url: args.index_url.as_deref(), + format: args.format, + mode, + allow_write, + frozen: args.frozen, + update_package: None, + selection: workspace_selection, + selection_request, + no_patches: args.no_patches, + offline: args.offline, + }, + reporter, + ) +} + +fn update(args: &UpdateArgs, reporter: Reporter) -> Result<()> { + let mode = match &args.package { + Some(name) => LockMode::UpdatePackage(name.clone()), + None => LockMode::UpdateAll, + }; + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + // `cabin update` keeps its `--package ` flag for the + // dep-targeted-update meaning. Workspace member scoping uses + // the dedicated bundle without `-p`. + let workspace_selection = build_update_workspace_selection(&args.workspace_selection); + run_resolution( + &ResolutionRequest { + manifest_path: &manifest_path, + index_path: args.index_path.as_deref(), + index_url: args.index_url.as_deref(), + format: args.format, + mode, + allow_write: true, + frozen: false, + update_package: args.package.as_deref(), + selection: workspace_selection, + selection_request: cabin_core::SelectionRequest::default(), + no_patches: args.no_patches, + offline: args.offline, + }, + reporter, + ) +} + +/// Convert `WorkspaceSelectionArgsForUpdate` (the +/// `cabin update`-specific bundle without `-p / --package`) into +/// the same `PackageSelection` shape every other workspace-aware +/// command consumes. +fn build_update_workspace_selection( + args: &WorkspaceSelectionArgsForUpdate, +) -> cabin_workspace::PackageSelection { + use cabin_workspace::SelectionMode; + let mode = if args.workspace { + SelectionMode::WholeWorkspace + } else if args.default_members { + SelectionMode::DefaultMembers + } else { + SelectionMode::CurrentPackage + }; + cabin_workspace::PackageSelection { + mode, + exclude: args.exclude.clone(), + } +} + +fn fetch(args: &FetchArgs, reporter: Reporter) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let initial_graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + // validate the workspace selection up-front so a typo + // like `--package missing` fails even when there are no + // versioned deps to fetch. + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&initial_graph, &workspace_selection)?; + // `cabin fetch` does not currently expose feature flags, + // so feature resolution runs with the documented defaults + // (each selected root's `default` feature, no extras). This + // still excludes disabled optional dependencies from the + // index-requirement check below — the user opts into them + // via `cabin build --features ...` / `cabin resolve + // --features ...`. + let initial_features = compute_feature_resolution( + &initial_graph, + &resolved_selection, + &cabin_core::SelectionRequest::default(), + )?; + + // scope the index requirement to the selected + // closure. Unrelated members' versioned deps no longer force a + // user who passed `--package ` to also pass + // `--index-path`. Patched manifests contribute their own + // versioned deps too, so a workspace whose only versioned + // edge comes from `[patch]` still needs the index. + let dev_for: BTreeSet = BTreeSet::new(); + let patched_root_deps_preview = + collect_patched_versioned_deps(&active_patches, &patched_names)?; + if patched_root_deps_preview.is_empty() + && !closure_has_versioned_deps_excluding_patches( + &initial_graph, + &resolved_selection, + &initial_features, + &patched_names, + &dev_for, + ) + { + emit_fetch_output( + &[], + args.format, + &cache_dir_for(&manifest_path, args.cache_dir.as_deref()).unwrap_or_default(), + &manifest_path, + )?; + return Ok(()); + } + + let resolved_index_source = crate::config_glue::resolve_index_source( + args.index_path.as_deref(), + args.index_url.as_deref(), + &effective_config, + )?; + let fetch_offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source( + fetch_offline, + resolved_index_source.as_ref(), + )?; + let resolved_cache_dir = + crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config); + let Some(index_source) = resolved_index_source.as_ref() else { + bail!( + "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting" + ); + }; + let mode = lock_mode_for_flags(args.locked, args.frozen); + let allow_write = !(args.locked || args.frozen); + let cache_dir = match resolved_cache_dir.as_ref() { + Some((path, _)) => path.clone(), + None => cache_dir_for(&manifest_path, args.cache_dir.as_deref())?, + }; + let initial_locator = match &index_source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved_locator = crate::patch_glue::apply_source_replacement( + initial_locator, + &effective_config, + args.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement(fetch_offline, &resolved_locator)?; + let (replaced_path, replaced_url) = + crate::patch_glue::locator_to_index_inputs(&resolved_locator.resolved); + + let fetch_request = cabin_core::SelectionRequest::default(); + let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest { + manifest_path: &manifest_path, + initial_graph: &initial_graph, + index_path: replaced_path.as_deref(), + index_url: replaced_url.as_deref(), + mode, + allow_write, + frozen: args.frozen, + cache_dir: &cache_dir, + reporter, + selection: workspace_selection, + selection_request: &fetch_request, + patched_names: &patched_names, + active_patches: &active_patches, + source_replacements: &effective_config.source_replacements, + no_patches: args.no_patches, + dev_for: &dev_for, + })?; + + emit_fetch_output(&pipeline.fetched, args.format, &cache_dir, &manifest_path)?; + Ok(()) +} + +fn package(args: &PackageArgs, _reporter: Reporter) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let target = + select_single_package_manifest(&manifest_path, &args.workspace_selection, "package")?; + let output_dir = absolutise(&args.output_dir) + .with_context(|| format!("failed to resolve {}", args.output_dir.display()))?; + let artifact = cabin_package::package_with_project( + cabin_package::PackageRequest { + manifest_path: &target.manifest_path, + output_dir: &output_dir, + }, + target.resolved_project, + )?; + emit_package_output(&artifact, args.format)?; + Ok(()) +} + +fn publish(args: &PublishArgs, _reporter: Reporter) -> Result<()> { + // `--output-dir` is for the staging-only `dist/` flow; combining + // it with `--registry-dir` is meaningless and almost always + // means the user picked the wrong flag, so refuse loudly. + if args.output_dir.is_some() && args.registry_dir.is_some() { + bail!("--output-dir is not compatible with --registry-dir; pick one"); + } + + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let target = + select_single_package_manifest(&manifest_path, &args.workspace_selection, "publish")?; + + match (args.registry_dir.as_deref(), args.dry_run) { + (Some(registry_dir), true) => { + let registry_dir = absolutise(registry_dir) + .with_context(|| format!("failed to resolve {}", registry_dir.display()))?; + let report = cabin_publish::dry_run_against_file_registry( + cabin_publish::RegistryPublishWorkflow { + manifest_path: &target.manifest_path, + registry_dir: ®istry_dir, + resolved_project: target.resolved_project.clone(), + }, + )?; + emit_registry_publish_output(&report, args.format)?; + } + (Some(registry_dir), false) => { + let registry_dir = absolutise(registry_dir) + .with_context(|| format!("failed to resolve {}", registry_dir.display()))?; + let report = + cabin_publish::publish_to_file_registry(cabin_publish::RegistryPublishWorkflow { + manifest_path: &target.manifest_path, + registry_dir: ®istry_dir, + resolved_project: target.resolved_project.clone(), + })?; + emit_registry_publish_output(&report, args.format)?; + } + (None, true) => { + let output_dir = args + .output_dir + .clone() + .unwrap_or_else(|| PathBuf::from("dist")); + let output_dir = absolutise(&output_dir) + .with_context(|| format!("failed to resolve {}", output_dir.display()))?; + let report = cabin_publish::dry_run(cabin_publish::DryRunRequest { + manifest_path: &target.manifest_path, + output_dir: &output_dir, + resolved_project: target.resolved_project.clone(), + })?; + emit_dry_run_output(&report, args.format)?; + } + (None, false) => { + return Err(cabin_publish::PublishError::DryRunRequired.into()); + } + } + Ok(()) +} + +fn emit_package_output( + artifact: &cabin_package::PackagedArtifact, + format: ResolveFormat, +) -> Result<()> { + match format { + ResolveFormat::Human => { + print_package_human(artifact); + Ok(()) + } + ResolveFormat::Json => print_package_json(artifact), + } +} + +fn print_package_human(artifact: &cabin_package::PackagedArtifact) { + println!("Packaged {} {}", artifact.name.as_str(), artifact.version); + println!(" archive: {}", artifact.archive_path.display()); + println!(" metadata: {}", artifact.metadata_path.display()); + println!(" checksum: {}", artifact.checksum); +} + +fn print_package_json(artifact: &cabin_package::PackagedArtifact) -> Result<()> { + let value = serde_json::json!({ + "name": artifact.name.as_str(), + "version": artifact.version.to_string(), + "archive_path": artifact.archive_path, + "metadata_path": artifact.metadata_path, + "checksum": artifact.checksum, + }); + let body = serde_json::to_string_pretty(&value) + .context("failed to serialize package output as JSON")?; + println!("{body}"); + Ok(()) +} + +fn emit_dry_run_output(report: &cabin_publish::DryRunReport, format: ResolveFormat) -> Result<()> { + match format { + ResolveFormat::Human => { + print_dry_run_human(report); + Ok(()) + } + ResolveFormat::Json => print_dry_run_json(report), + } +} + +fn print_dry_run_human(report: &cabin_publish::DryRunReport) { + println!( + "Publish dry-run for {} {}", + report.name.as_str(), + report.version + ); + println!(); + println!("Generated:"); + println!(" archive: {}", report.archive_path.display()); + println!(" metadata: {}", report.metadata_path.display()); + println!(" checksum: {}", report.checksum); + println!(); + println!("This was a dry run. No registry was modified."); +} + +fn print_dry_run_json(report: &cabin_publish::DryRunReport) -> Result<()> { + let value = serde_json::json!({ + "dry_run": true, + "name": report.name.as_str(), + "version": report.version.to_string(), + "archive_path": report.archive_path, + "metadata_path": report.metadata_path, + "checksum": report.checksum, + "registry_modified": report.registry_modified, + }); + let body = serde_json::to_string_pretty(&value) + .context("failed to serialize publish dry-run output as JSON")?; + println!("{body}"); + Ok(()) +} + +fn emit_registry_publish_output( + report: &cabin_publish::RegistryPublishReport, + format: ResolveFormat, +) -> Result<()> { + match format { + ResolveFormat::Human => { + print_registry_publish_human(report); + Ok(()) + } + ResolveFormat::Json => print_registry_publish_json(report), + } +} + +fn print_registry_publish_human(report: &cabin_publish::RegistryPublishReport) { + if report.dry_run { + println!( + "Publish dry-run for {} {} against file registry", + report.name.as_str(), + report.version + ); + } else { + println!( + "Published {} {} to file registry", + report.name.as_str(), + report.version + ); + } + println!(" registry: {}", report.registry_dir.display()); + println!(" package index: {}", report.package_index_path.display()); + println!(" artifact: {}", report.artifact_path.display()); + println!(" checksum: {}", report.checksum); + if report.dry_run { + println!(); + if report.registry_initialised { + println!("Registry would be initialised at this path."); + } + println!("This was a dry run. No registry was modified."); + } else if report.registry_initialised { + println!(); + println!("Registry was initialised at this path."); + } +} + +fn print_registry_publish_json(report: &cabin_publish::RegistryPublishReport) -> Result<()> { + let value = serde_json::json!({ + "published": !report.dry_run, + "dry_run": report.dry_run, + "name": report.name.as_str(), + "version": report.version.to_string(), + "registry_dir": report.registry_dir, + "package_index_path": report.package_index_path, + "artifact_path": report.artifact_path, + "checksum": report.checksum, + "source_path": report.source_path, + "registry_modified": report.registry_modified, + "registry_initialised": report.registry_initialised, + }); + let body = serde_json::to_string_pretty(&value) + .context("failed to serialize publish output as JSON")?; + println!("{body}"); + Ok(()) +} + +/// Translate `cabin build`'s `--profile` / `--release` flags into +/// a typed [`cabin_core::ProfileSelection`]. +/// +/// `--release` is preserved as a compatibility alias for +/// `--profile release`. clap's `conflicts_with` already rejects +/// the both-set combination so this helper only sees one of the +/// three possible inputs. +fn profile_selection_for_build( + args: &BuildArgs, + config: &cabin_config::EffectiveConfig, +) -> Result { + profile_selection_from_flags(args.profile.as_deref(), args.release, config) +} + +/// Shared profile-selection precedence: explicit `--profile NAME` +/// wins, then the legacy `--release` alias, then any config- +/// supplied default, then the built-in `dev` profile. Used by +/// `cabin build` and `cabin test`. +pub(crate) fn profile_selection_from_flags( + profile: Option<&str>, + release: bool, + config: &cabin_config::EffectiveConfig, +) -> Result { + if let Some(name) = profile { + let pname = cabin_core::ProfileName::new(name.to_owned()) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + return Ok(cabin_core::ProfileSelection::from_name(pname)); + } + if release { + return Ok(cabin_core::ProfileSelection::release_alias()); + } + if let Some((selection, _source)) = crate::config_glue::config_profile_selection(config)? { + return Ok(selection); + } + Ok(cabin_core::ProfileSelection::default_dev()) +} + +/// `cabin metadata` accepts a `--profile` flag but no `--release` +/// alias (metadata is read-only and doesn't need the legacy spelling). +/// Falls back to a config-supplied default when the user did not +/// pass `--profile`; otherwise the built-in `dev` profile applies. +pub(crate) fn profile_selection_for_metadata( + name: Option<&str>, + config: &cabin_config::EffectiveConfig, +) -> Result { + if let Some(n) = name { + let pname = cabin_core::ProfileName::new(n.to_owned()) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + return Ok(cabin_core::ProfileSelection::from_name(pname)); + } + if let Some((selection, _source)) = crate::config_glue::config_profile_selection(config)? { + return Ok(selection); + } + Ok(cabin_core::ProfileSelection::default_dev()) +} + +/// Look up the profile-definition table that should drive +/// resolution. Profiles are workspace-wide: only the entry-point +/// manifest's `[profile.*]` tables count, so we read them off the +/// graph's root package (workspace root or single-package root). +pub(crate) fn workspace_profile_definitions( + graph: &PackageGraph, +) -> BTreeMap { + graph.root_settings.profiles.clone() +} + +/// Workspace-root manifest's `[toolchain]` plus any +/// `[target.'cfg(...)'.toolchain]` overrides. Workspace member +/// manifests cannot declare a `[toolchain]` table — the workspace +/// loader rejects them — so reading off the root is sufficient. +pub(crate) fn workspace_toolchain_settings(graph: &PackageGraph) -> cabin_core::ToolchainSettings { + graph.root_settings.toolchain.clone() +} + +/// Translate `cabin build`'s / `cabin metadata`'s tool-selection +/// CLI flags into a typed [`cabin_core::ToolchainSelection`]. +pub(crate) fn toolchain_selection_from_args( + args: &ToolchainSelectionArgs, +) -> Result { + let mut sel = cabin_core::ToolchainSelection::default(); + if let Some(raw) = &args.cc { + sel = sel.with_cli(cabin_core::ToolKind::CCompiler, parse_cli_tool(raw)?); + } + if let Some(raw) = &args.cxx { + sel = sel.with_cli(cabin_core::ToolKind::CxxCompiler, parse_cli_tool(raw)?); + } + if let Some(raw) = &args.ar { + sel = sel.with_cli(cabin_core::ToolKind::Archiver, parse_cli_tool(raw)?); + } + Ok(sel) +} + +fn parse_cli_tool(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + bail!("tool argument must be a non-empty path or command name"); + } + Ok(cabin_core::ToolSpec::parse(trimmed.to_owned())) +} + +/// Resolve a toolchain by layering manifest settings, the +/// optional `[toolchain]` config layer, and process-discovered +/// defaults on top of `selection` (already-parsed CLI overrides +/// or `ToolchainSelection::default()`). +pub(crate) fn resolve_toolchain_layered( + graph: &PackageGraph, + selection: &cabin_core::ToolchainSelection, + effective_config: &cabin_config::EffectiveConfig, + host_platform: &cabin_core::TargetPlatform, +) -> Result { + let manifest_toolchain_settings = workspace_toolchain_settings(graph); + let config_toolchain_layer = crate::config_glue::toolchain_layer(effective_config); + let mut toolchain_inputs = cabin_toolchain::ResolveInputs::from_process( + selection, + &manifest_toolchain_settings, + host_platform, + ); + if let Some(layer) = config_toolchain_layer.as_ref() { + toolchain_inputs = toolchain_inputs.with_config(layer); + } + Ok(cabin_toolchain::resolve_toolchain(&toolchain_inputs)?) +} + +/// Translate the `--compiler-wrapper` / `--no-compiler-wrapper` +/// CLI flag pair into a typed +/// [`cabin_core::CompilerWrapperRequest`] override. Clap already +/// rejects passing both flags simultaneously; this helper only +/// validates the value passed to `--compiler-wrapper`. +pub(crate) fn compiler_wrapper_override_from_args( + args: &ToolchainSelectionArgs, +) -> Result> { + if args.no_compiler_wrapper { + return Ok(Some(cabin_core::CompilerWrapperRequest::Disabled)); + } + let Some(raw) = args.compiler_wrapper.as_deref() else { + return Ok(None); + }; + let parsed = cabin_core::CompilerWrapperRequest::parse(raw) + .with_context(|| format!("invalid --compiler-wrapper value `{raw}`"))?; + Ok(Some(parsed)) +} + +/// Resolve the compiler-cache wrapper by layering the CLI +/// override (`--compiler-wrapper` / `--no-compiler-wrapper`), the +/// manifest's `[build.cache]` settings, the optional config +/// `[build.cache.compiler-wrapper]` layer, and process-detected +/// version metadata. Returns the typed resolution on success; +/// callers that want fail-soft behaviour (e.g. `cabin metadata`) +/// call `resolve_compiler_wrapper` directly. +pub(crate) fn resolve_compiler_wrapper_layered( + cli_override: Option, + manifest_settings: &cabin_core::CompilerWrapperManifestSettings, + effective_config: &cabin_config::EffectiveConfig, + host_platform: &cabin_core::TargetPlatform, +) -> Result> { + let mut wrapper_inputs = cabin_toolchain::WrapperInputs::from_process( + cli_override, + manifest_settings, + host_platform, + ); + if let Some(layer) = crate::config_glue::wrapper_layer(effective_config) { + wrapper_inputs = wrapper_inputs.with_config(layer); + } + cabin_toolchain::resolve_compiler_wrapper( + &wrapper_inputs, + Some(&cabin_toolchain::ProcessRunner), + ) + .map_err(|err| anyhow::anyhow!(err.to_string())) +} + +/// Workspace-root manifest's compiler-wrapper settings. Mirrors +/// [`workspace_toolchain_settings`] — the workspace loader rejects +/// non-empty declarations on member manifests so reading the root +/// is sufficient. +pub(crate) fn workspace_compiler_wrapper_settings( + graph: &PackageGraph, +) -> cabin_core::CompilerWrapperManifestSettings { + graph.root_settings.compiler_wrapper.clone() +} + +/// Compute per-package `ResolvedProfileFlags` for every package in +/// the graph. The result is keyed by package index so callers +/// (planner, metadata view) can read them without rerunning the +/// merge per package. +pub(crate) fn resolve_per_package_build_flags( + graph: &PackageGraph, + profile_build: Option<&cabin_core::ProfileFlags>, + host_platform: &cabin_core::TargetPlatform, +) -> HashMap { + let mut out = HashMap::with_capacity(graph.packages.len()); + for (idx, pkg) in graph.packages.iter().enumerate() { + let resolved = + cabin_core::resolve_build_flags(&pkg.package.build, profile_build, host_platform); + out.insert(idx, resolved); + } + out +} + +/// Convert raw `--features` flag values into a `SelectionRequest`. +/// Validation against package declarations happens later in +/// `BuildConfiguration::resolve`. +pub(crate) fn build_selection_request( + feature_args: &[String], + all_features: bool, + no_default_features: bool, +) -> cabin_core::SelectionRequest { + let mut features: BTreeSet = BTreeSet::new(); + for raw in feature_args { + for token in raw.split(',') { + let trimmed = token.trim(); + if trimmed.is_empty() { + continue; + } + features.insert(trimmed.to_owned()); + } + } + cabin_core::SelectionRequest { + features, + all_features, + no_default_features, + } +} + +/// Resolve a `BuildConfiguration` for every package in the graph. +/// CLI feature selection requests apply to primary packages only — +/// non-primary packages (transitive path / registry deps) fall back +/// to their declared defaults until per-dependency feature requests +/// land. +pub(crate) fn resolve_build_configurations( + graph: &PackageGraph, + request: &cabin_core::SelectionRequest, + selected: &[usize], + profile: &cabin_core::ResolvedProfile, + toolchain: &cabin_core::ToolchainSummary, + build_flags: &HashMap, +) -> Result> { + use HashMap; + let selected_set: HashSet = selected.iter().copied().collect(); + let mut out: HashMap = HashMap::new(); + for (idx, pkg) in graph.packages.iter().enumerate() { + // CLI feature requests apply only to *selected* packages. + // Non-selected packages — including workspace siblings the + // user did not pick — fall back to their declared defaults + // so an unrelated package's missing feature does not fail + // an unrelated build. + let pkg_request = if selected_set.contains(&idx) { + request.clone() + } else { + cabin_core::SelectionRequest::default() + }; + let pkg_flags = build_flags.get(&idx).cloned().unwrap_or_default(); + let cfg = cabin_core::BuildConfiguration::resolve(cabin_core::BuildConfigurationInput { + package: pkg.package.name.as_str(), + features: &pkg.package.features, + request: &pkg_request, + profile: profile.clone(), + toolchain: toolchain.clone(), + build_flags: pkg_flags, + }) + .with_context(|| { + format!( + "invalid configuration selection for package `{}`", + pkg.package.name.as_str() + ) + })?; + out.insert(idx, cfg); + } + Ok(out) +} + +/// Resolve the manifest the user is operating on. When the +/// user did not pass `--manifest-path` (the option is `None`), walk +/// upward from the current directory looking for a workspace root +/// and prefer it. When the user passed `--manifest-path` +/// Explicitly — even with the value `cabin.toml` — the supplied +/// Path is honoured as-is so callers can intentionally target a +/// specific manifest from any directory. +pub(crate) fn resolve_invocation_manifest(args_path: Option<&Path>) -> Result { + let cwd = std::env::current_dir().context("failed to determine current directory")?; + match args_path { + Some(path) => { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(cwd.join(path)) + } + } + None => { + if let Some(found) = cabin_workspace::discover_workspace_root(&cwd)? { + Ok(found.manifest_path) + } else { + Ok(cwd.join(MANIFEST_FILENAME)) + } + } + } +} + +/// Convert CLI workspace-selection flags into a +/// `cabin_workspace::PackageSelection`. The mode mirrors the order +/// of `WorkspaceSelectionArgs`'s field comments. +pub(crate) fn build_workspace_selection( + args: &WorkspaceSelectionArgs, +) -> cabin_workspace::PackageSelection { + use cabin_workspace::SelectionMode; + let mode = if args.workspace { + SelectionMode::WholeWorkspace + } else if !args.package.is_empty() { + SelectionMode::ExplicitPackages(args.package.clone()) + } else if args.default_members { + SelectionMode::DefaultMembers + } else { + SelectionMode::CurrentPackage + }; + cabin_workspace::PackageSelection { + mode, + exclude: args.exclude.clone(), + } +} + +/// Collect every versioned dependency reachable from `selection` +/// after dropping patched names. Thin shim around the typed API +/// in `cabin-workspace` that builds the closure once and adapts +/// the [`cabin_feature::FeatureResolution`] handle into the +/// closure-style filter the workspace layer consumes. +pub(crate) fn collect_closure_versioned_deps_excluding_patches( + graph: &PackageGraph, + selection: &cabin_workspace::ResolvedSelection, + features: &cabin_feature::FeatureResolution, + patched_names: &BTreeSet, + dev_for: &BTreeSet, +) -> Result> { + let closure = selection.closure(graph); + cabin_workspace::collect_closure_versioned_deps_excluding_with_dev( + graph, + &closure, + |idx, name| features.is_optional_dep_enabled(idx, name), + patched_names, + dev_for, + ) + .map_err(Into::into) +} + +/// Merge `extra` into `into`, joining version requirements for +/// names that appear in both so the resolver sees a single +/// requirement per package. Mirrors the join-and-reparse pattern +/// the workspace closure walker uses. +fn merge_versioned_deps( + into: &mut BTreeMap, + extra: BTreeMap, +) -> Result<()> { + for (name, req) in extra { + match into.entry(name.clone()) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(req); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + let joined = format!("{}, {}", slot.get(), req); + let parsed = semver::VersionReq::parse(&joined).map_err(|err| { + anyhow::anyhow!( + "conflicting dependency requirements for {}: {}: {}", + name.as_str(), + joined, + err + ) + })?; + slot.insert(parsed); + } + } + } + Ok(()) +} + +/// Whether the selected closure carries any versioned +/// (registry-bound) dependency that the artifact pipeline would +/// need to fetch. Thin shim around the typed API in +/// `cabin-workspace`. +pub(crate) fn closure_has_versioned_deps_excluding_patches( + graph: &PackageGraph, + selection: &cabin_workspace::ResolvedSelection, + features: &cabin_feature::FeatureResolution, + patched_names: &BTreeSet, + dev_for: &BTreeSet, +) -> bool { + let closure = selection.closure(graph); + cabin_workspace::closure_has_versioned_deps_excluding_with_dev( + graph, + &closure, + |idx, name| features.is_optional_dep_enabled(idx, name), + patched_names, + dev_for, + ) +} + +/// Build a [`cabin_feature::RootFeatureRequest`] from a +/// [`cabin_core::SelectionRequest`]. The conversion is direct: +/// `--features` → explicit list, `--all-features` flips the +/// `all_features` flag, `--no-default-features` flips +/// `include_defaults` to `false`. +/// +/// Combined CLI flags policy (documented in +/// `docs/features.md`): +/// +/// - `--features` is **additive** with `--all-features`. Both +/// may be passed; the union is requested. +/// - `--no-default-features` and `--all-features` may both be +/// passed; the resolver simply omits the `default` group while +/// still enabling every declared feature. +fn root_feature_request_from_selection( + request: &cabin_core::SelectionRequest, +) -> cabin_feature::RootFeatureRequest { + cabin_feature::RootFeatureRequest { + include_defaults: !request.no_default_features, + all_features: request.all_features, + explicit_features: request.features.clone(), + } +} + +/// Resolve features for the selected closure. Roots receive the +/// caller-provided request; non-root reachable packages inherit +/// requests through dependency edges per the documented feature +/// model. The returned [`cabin_feature::FeatureResolution`] is +/// then threaded into the dependency-iteration helpers so +/// disabled optional dependencies disappear from the resolver / +/// fetch / build planning. +pub(crate) fn compute_feature_resolution( + graph: &PackageGraph, + selection: &cabin_workspace::ResolvedSelection, + request: &cabin_core::SelectionRequest, +) -> Result { + let root_request = root_feature_request_from_selection(request); + let platform = cabin_core::TargetPlatform::current(); + cabin_feature::resolve_features(graph, &selection.packages, &root_request, &platform) + .map_err(|e| anyhow::anyhow!(e.to_string())) +} + +/// Synthesise a root identity for resolving over a +/// pure-workspace root (no `[package]`). The name is a deterministic +/// `__workspace_` value the resolver uses for diagnostic +/// output only; nothing else relies on it being canonical. +fn synthetic_workspace_root_identity(graph: &PackageGraph) -> (PackageName, semver::Version) { + let dirname = graph + .root_dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("workspace"); + let mut sanitized = String::with_capacity(dirname.len() + 12); + sanitized.push_str("__workspace_"); + for c in dirname.chars() { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') { + sanitized.push(c); + } else { + sanitized.push('_'); + } + } + let name = PackageName::new(sanitized).expect("synthesised name is non-empty and ASCII"); + let version = semver::Version::new(0, 0, 0); + (name, version) +} + +/// Pick the primary packages that contribute versioned +/// deps to a resolve / fetch / update run. When the user passed +/// workspace-selection flags, only their selected packages +/// contribute. Otherwise the documented default applies (root +/// package or every primary). +fn selected_resolution_packages( + graph: &PackageGraph, + selection: &cabin_workspace::PackageSelection, +) -> Result { + cabin_workspace::resolve_package_selection(graph, selection).map_err(|e| e.into()) +} + +/// Pick the single package manifest path that +/// `cabin package` / `cabin publish` should operate on. When the +/// invocation manifest is a workspace root, the user must supply +/// exactly one explicit `--package ` selection. Otherwise we +/// honour the existing single-package contract. +/// Result of selecting a single package manifest for a +/// workspace-aware `cabin package` / `cabin publish` invocation. +/// Carries both the manifest path and the pre-resolved `Package`, +/// so member manifests with `dep = { workspace = true }` reach +/// `cabin-package` after the workspace loader has substituted the +/// inherited requirement. +struct SinglePackageSelection { + manifest_path: PathBuf, + /// `Some` when the manifest was loaded through a workspace + /// (so `cabin-workspace` resolved any `workspace = true` deps). + /// `None` when the user passed a standalone manifest path; in + /// that case `cabin-package`'s own validator decides what to do + /// with any unresolved workspace dep it sees. + resolved_project: Option, +} + +fn select_single_package_manifest( + invocation: &Path, + selection: &WorkspaceSelectionArgs, + command: &'static str, +) -> Result { + let parsed = cabin_manifest::load_manifest(invocation) + .with_context(|| format!("failed to load manifest at {}", invocation.display()))?; + if parsed.workspace.is_none() { + // Single-package manifest: the existing behaviour applies + // unchanged. Reject workspace-selection flags so the user + // never gets the impression Cabin honoured them silently. + if selection.workspace + || selection.default_members + || !selection.package.is_empty() + || !selection.exclude.is_empty() + { + bail!( + "workspace package-selection flags are not valid for `cabin {command}` against a non-workspace manifest" + ); + } + return Ok(SinglePackageSelection { + manifest_path: invocation.to_path_buf(), + resolved_project: None, + }); + } + if selection.package.len() != 1 || selection.workspace || selection.default_members { + bail!( + "`cabin {command}` requires a single `--package ` selection inside a workspace; use `--package ` to pick the package to {command}" + ); + } + if !selection.exclude.is_empty() { + bail!( + "`--exclude` is not valid for `cabin {command}`; pass exactly one `--package `" + ); + } + let graph = cabin_workspace::load_workspace(invocation)?; + let name = &selection.package[0]; + let idx = graph + .index_of(name) + .ok_or_else(|| anyhow::anyhow!("package `{name}` is not a member of this workspace"))?; + if !graph.primary_packages.contains(&idx) { + bail!("package `{name}` is not a member of this workspace"); + } + Ok(SinglePackageSelection { + manifest_path: graph.packages[idx].manifest_path.clone(), + resolved_project: Some(graph.packages[idx].package.clone()), + }) +} + +pub(crate) fn lock_mode_for_flags(locked: bool, frozen: bool) -> LockMode { + if locked || frozen { + LockMode::Locked + } else { + LockMode::PreferLocked + } +} + +/// Resolve the cache directory using --cache-dir, $CABIN_CACHE_DIR, or +/// the package-local default `/.cabin/cache`. +pub(crate) fn cache_dir_for(manifest_path: &Path, override_dir: Option<&Path>) -> Result { + if let Some(p) = override_dir { + return absolutise(p) + .with_context(|| format!("failed to resolve cache dir {}", p.display())); + } + if let Some(env) = std::env::var_os("CABIN_CACHE_DIR") { + let p = PathBuf::from(env); + return absolutise(&p) + .with_context(|| format!("failed to resolve cache dir {}", p.display())); + } + let root_dir = manifest_path + .parent() + .ok_or_else(|| anyhow::anyhow!("manifest path has no parent directory"))?; + Ok(root_dir.join(".cabin").join("cache")) +} + +pub(crate) struct ArtifactPipelineRequest<'a> { + pub(crate) manifest_path: &'a Path, + pub(crate) initial_graph: &'a PackageGraph, + pub(crate) index_path: Option<&'a Path>, + pub(crate) index_url: Option<&'a str>, + pub(crate) mode: LockMode, + pub(crate) allow_write: bool, + pub(crate) frozen: bool, + pub(crate) cache_dir: &'a Path, + pub(crate) reporter: Reporter, + /// Workspace selection that contributes versioned deps + /// to the resolution. Defaults to every primary package when + /// the user passes no selection flags. + pub(crate) selection: cabin_workspace::PackageSelection, + /// Feature flags from the CLI. Drives optional-dependency + /// inclusion. + pub(crate) selection_request: &'a cabin_core::SelectionRequest, + /// Names of patched packages — the pipeline must skip them + /// because they ship from a local working copy and never need + /// to be fetched from the index. + pub(crate) patched_names: &'a BTreeSet, + /// Active patches recorded into the new lockfile and + /// compared against the existing lockfile under `--locked`. + pub(crate) active_patches: &'a cabin_workspace::ActivePatchSet, + /// Active source-replacement entries (post-merge) recorded + /// into the new lockfile. + pub(crate) source_replacements: &'a cabin_core::SourceReplacementSettings, + /// Whether `--no-patches` was supplied — suppresses + /// source-replacement records on the lockfile to match the + /// "no local override policy" semantics. + pub(crate) no_patches: bool, + /// Names of packages whose `[dev-dependencies]` should be + /// activated for this invocation. Empty for `cabin build`; + /// `cabin test` passes the selected primary packages' names + /// so the resolver / fetch path picks up dev-deps the test + /// executables need. + pub(crate) dev_for: &'a BTreeSet, +} + +pub(crate) struct ArtifactPipeline { + pub(crate) fetched: Vec, +} + +/// Resolved index access: either a directory on disk we already +/// turned into a [`PackageIndex`], or a live HTTP client we will use +/// to download artifacts. +enum IndexAccess { + Local, + Http(cabin_index_http::HttpClient), +} + +/// Run the resolve → lockfile → fetch pipeline used by both +/// `cabin fetch` and `cabin build`. +pub(crate) fn run_artifact_pipeline( + request: &ArtifactPipelineRequest<'_>, +) -> Result { + let manifest_path = request.manifest_path; + let graph = request.initial_graph; + let resolved_selection = selected_resolution_packages(graph, &request.selection)?; + let features = + compute_feature_resolution(graph, &resolved_selection, request.selection_request)?; + let mut root_deps = collect_closure_versioned_deps_excluding_patches( + graph, + &resolved_selection, + &features, + request.patched_names, + request.dev_for, + )?; + // Patched manifests are not part of the workspace graph at + // this point, so their own `[dependencies]` never appeared + // in the closure walk. Fold them in so a workspace whose only + // versioned dep is patched still resolves and fetches the + // patched manifest's transitive registry edges. + let patched_root_deps = + collect_patched_versioned_deps(request.active_patches, request.patched_names)?; + merge_versioned_deps(&mut root_deps, patched_root_deps)?; + // short-circuit when neither the selected closure nor the + // active patch set introduces a versioned dependency. + // Loading an index, walking the lockfile, and downloading + // artifacts are all unnecessary in that case. + if root_deps.is_empty() { + return Ok(ArtifactPipeline { + fetched: Vec::new(), + }); + } + // pick a stable synthetic root identity for pure + // workspace roots; fall back to the [package] root otherwise. + let (root_name, root_version) = match graph.root_package { + Some(idx) => ( + graph.packages[idx].package.name.clone(), + graph.packages[idx].package.version.clone(), + ), + None => synthetic_workspace_root_identity(graph), + }; + + let lockfile_path = lockfile_path_for(manifest_path); + + let existing_lockfile: Option = if lockfile_path.is_file() { + Some( + cabin_lockfile::read_lockfile(&lockfile_path) + .with_context(|| format!("failed to read {}", lockfile_path.display()))?, + ) + } else { + if matches!(request.mode, LockMode::Locked) { + bail!( + "cannot resolve with --locked because {} does not exist", + lockfile_path.display() + ); + } + None + }; + + let (index, access) = load_index_for_pipeline( + request.index_path, + request.index_url, + request.frozen, + &root_deps, + )?; + + let resolver_mode = match &request.mode { + LockMode::PreferLocked => ResolveMode::PreferLocked, + LockMode::Locked => ResolveMode::Locked, + LockMode::UpdateAll => ResolveMode::UpdateAll, + LockMode::UpdatePackage(name) => ResolveMode::UpdatePackage( + PackageName::new(name.clone()) + .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?, + ), + }; + + let mut input = ResolveInput::new(root_name, root_version, root_deps); + if let Some(lock) = &existing_lockfile { + for pkg in &lock.packages { + input.locked.insert( + pkg.name.clone(), + LockedVersion { + version: pkg.version.clone(), + checksum: pkg.checksum.clone(), + }, + ); + } + } + input.mode = resolver_mode; + + // Patch / source-replacement state recorded into the new + // lockfile and compared against the existing lockfile under + // `--locked`. + let active_patch_records = crate::patch_glue::lockfile_patches(request.active_patches); + let active_replacement_records = crate::patch_glue::lockfile_source_replacements( + request.source_replacements, + request.no_patches, + ); + if matches!(request.mode, LockMode::Locked) + && let Some(prev) = &existing_lockfile + && !prev.matches_patch_state(&active_patch_records, &active_replacement_records) + { + bail!( + "--locked cannot be used because active patch / source-replacement policy differs from {}; re-run without --locked to refresh the lockfile", + lockfile_path.display() + ); + } + + let output = cabin_resolver::resolve(&input, &index).context("dependency resolution failed")?; + + let mut new_lockfile = lockfile_from_resolution(&output, &index); + new_lockfile.patches = active_patch_records; + new_lockfile.source_replacements = active_replacement_records; + + if request.allow_write { + let needs_write = match &existing_lockfile { + Some(prev) => prev != &new_lockfile, + None => true, + }; + if needs_write { + cabin_lockfile::write_lockfile(&lockfile_path, &new_lockfile) + .with_context(|| format!("failed to write {}", lockfile_path.display()))?; + request + .reporter + .aux_status(format_args!("cabin: wrote {}", lockfile_path.display())); + } else { + request.reporter.aux_status(format_args!( + "cabin: {} is up to date", + lockfile_path.display() + )); + } + } + + let plan = build_fetch_plan(&output, &index, &access)?; + let cache = ArtifactCache::new(request.cache_dir); + let result = cabin_artifact::fetch( + &plan, + &cache, + FetchOptions { + frozen: request.frozen, + }, + )?; + Ok(ArtifactPipeline { + fetched: result.packages, + }) +} + +/// Pick the right index source for a fetch / build run, validate +/// CLI flag combinations, and return both the [`PackageIndex`] the +/// Resolver consumes and a tag describing which access mode the +/// fetch plan should use. +fn load_index_for_pipeline( + index_path: Option<&Path>, + index_url: Option<&str>, + frozen: bool, + root_deps: &BTreeMap, +) -> Result<(PackageIndex, IndexAccess)> { + match (index_path, index_url) { + (Some(_), Some(_)) => bail!("use either --index-path or --index-url, not both"), + (None, None) => { + bail!("versioned dependencies require --index-path or --index-url") + } + (Some(path), None) => { + let index_path = absolutise(path) + .with_context(|| format!("failed to resolve {}", path.display()))?; + let index = cabin_index::load_index(&index_path) + .with_context(|| format!("failed to load index at {}", index_path.display()))?; + Ok((index, IndexAccess::Local)) + } + (None, Some(url)) => { + if frozen { + bail!( + "cannot use --index-url with --frozen: there is no persistent HTTP index metadata cache, so a frozen run would have to perform network fetches it is not allowed to perform" + ); + } + let client = cabin_index_http::HttpClient::new(); + let http_index = cabin_index_http::HttpIndex::open(url, client.clone())?; + let names: Vec = root_deps.keys().cloned().collect(); + let index = http_index.load_package_index(&names)?; + Ok((index, IndexAccess::Http(client))) + } + } +} + +/// Build a [`FetchPlan`] from a resolver output and the index it ran +/// against. Each resolved registry package contributes exactly one +/// fetch entry; the index is the source of truth for `source` and +/// `checksum`. +/// +/// `access` decides whether HTTP-resolved sources get downloaded +/// here (so `cabin-artifact` stays HTTP-free) or whether the source +/// Path is handed straight through as a local file. +fn build_fetch_plan( + output: &ResolveOutput, + index: &PackageIndex, + access: &IndexAccess, +) -> Result { + let mut entries = Vec::new(); + for resolved in &output.packages { + if resolved.source != ResolvedSource::Index { + continue; + } + let entry = index.package(&resolved.name).ok_or_else(|| { + anyhow::anyhow!( + "resolver chose `{} {}`, but it is not in the index", + resolved.name.as_str(), + resolved.version + ) + })?; + let meta = entry.versions.get(&resolved.version).ok_or_else(|| { + anyhow::anyhow!( + "resolver chose `{} {}`, but the index has no entry for this version", + resolved.name.as_str(), + resolved.version + ) + })?; + let source = meta.source.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "package `{} {}` has no source artifact in the index", + resolved.name.as_str(), + resolved.version + ) + })?; + let checksum = meta.checksum.clone().ok_or_else(|| { + anyhow::anyhow!( + "missing checksum for `{} {}`; cabin fetch requires a sha256: entry in the index", + resolved.name.as_str(), + resolved.version + ) + })?; + let fetch_source = match (&source.location, access) { + (cabin_index::SourceLocation::LocalPath(p), _) => { + cabin_artifact::FetchSource::LocalArchive(p.clone()) + } + (cabin_index::SourceLocation::HttpUrl(url), IndexAccess::Http(client)) => { + let label = format!("{} {}", resolved.name.as_str(), resolved.version); + let bytes = client.download(url, &label).with_context(|| { + format!( + "failed to download source archive for `{} {}`", + resolved.name.as_str(), + resolved.version + ) + })?; + cabin_artifact::FetchSource::InMemoryArchive(bytes) + } + (cabin_index::SourceLocation::HttpUrl(_), IndexAccess::Local) => { + bail!( + "package `{} {}` has an HTTP source URL but the run is using a local index", + resolved.name.as_str(), + resolved.version + ); + } + }; + entries.push(FetchEntry { + name: resolved.name.clone(), + version: resolved.version.clone(), + checksum, + source: fetch_source, + }); + } + Ok(FetchPlan { entries }) +} + +/// What kind of resolution the CLI is asking for. +#[derive(Debug, Clone)] +pub(crate) enum LockMode { + PreferLocked, + Locked, + UpdateAll, + UpdatePackage(String), +} + +struct ResolutionRequest<'a> { + manifest_path: &'a Path, + index_path: Option<&'a Path>, + index_url: Option<&'a str>, + format: ResolveFormat, + mode: LockMode, + allow_write: bool, + /// Whether the original invocation was `cabin resolve --frozen`. + /// `LockMode::Locked` intentionally covers both `--locked` and + /// `--frozen`, so keep this bit to enforce frozen-only network + /// restrictions after config and source replacement are applied. + frozen: bool, + /// Used only by `cabin update --package ` to validate that the + /// named package actually exists in the manifest's dependency + /// graph. + update_package: Option<&'a str>, + /// Workspace selection that contributes versioned deps + /// to the resolution. + selection: cabin_workspace::PackageSelection, + /// Feature flags from the CLI. Drives optional-dependency + /// inclusion. + selection_request: cabin_core::SelectionRequest, + /// Whether `--no-patches` was supplied for this command. + no_patches: bool, + /// Whether `--offline` was supplied for this command. + offline: bool, +} + +fn run_resolution(request: &ResolutionRequest<'_>, reporter: Reporter) -> Result<()> { + let manifest_path = absolutise(request.manifest_path) + .with_context(|| format!("failed to resolve {}", request.manifest_path.display()))?; + let graph = cabin_workspace::load_workspace(&manifest_path)?; + // CLI flags win; otherwise consult the merged effective + // config for a `[registry]` default. The orchestration layer + // owns the final reconciliation; cabin-resolver / cabin-index + // see only a concrete index source. + let effective_config = crate::config_glue::load_effective_config(&graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&graph, &effective_config, request.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + let resolved_index_source = crate::config_glue::resolve_index_source( + request.index_path, + request.index_url, + &effective_config, + )?; + let resolution_offline = crate::config_glue::effective_offline(request.offline)?; + crate::config_glue::enforce_offline_index_source( + resolution_offline, + resolved_index_source.as_ref(), + )?; + let (config_index_path, config_index_url): (Option, Option) = + match resolved_index_source.as_ref() { + Some(source) => { + let initial = match &source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved = crate::patch_glue::apply_source_replacement( + initial, + &effective_config, + request.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement( + resolution_offline, + &resolved, + )?; + crate::patch_glue::locator_to_index_inputs(&resolved.resolved) + } + None => (None, None), + }; + let effective_index_path = config_index_path.as_deref(); + let effective_index_url = config_index_url.as_deref(); + if request.frozen && effective_index_url.is_some() { + bail!( + "cannot use --index-url with --frozen: there is no persistent HTTP index metadata cache, so a frozen run would have to perform network fetches it is not allowed to perform" + ); + } + + // gather versioned deps from the selected primary + // packages, not just the workspace root. Pure-workspace roots + // (no `[package]`) work too — they take a synthetic identity. + let resolved_selection = selected_resolution_packages(&graph, &request.selection)?; + let features = + compute_feature_resolution(&graph, &resolved_selection, &request.selection_request)?; + let dev_for: BTreeSet = BTreeSet::new(); + let mut root_deps = collect_closure_versioned_deps_excluding_patches( + &graph, + &resolved_selection, + &features, + &patched_names, + &dev_for, + )?; + // Patched manifests live outside the workspace graph, so + // their own versioned deps never reached the closure walk. + // Fold them in so `cabin resolve` (and `--package` validation + // below) sees the same root set the artifact pipeline does. + let patched_root_deps = collect_patched_versioned_deps(&active_patches, &patched_names)?; + merge_versioned_deps(&mut root_deps, patched_root_deps)?; + let (root_name, root_version) = match graph.root_package { + Some(idx) => ( + graph.packages[idx].package.name.clone(), + graph.packages[idx].package.version.clone(), + ), + None => synthetic_workspace_root_identity(&graph), + }; + + let lockfile_path = lockfile_path_for(&manifest_path); + + // validate `--package` (the dep-targeted-update + // flag on `cabin update`) before short-circuiting on an + // empty resolution. Otherwise an unknown name like + // `cabin update --package missing` silently succeeds when + // the workspace happens to have no versioned deps. + if let Some(name) = request.update_package + && !root_deps.contains_key( + &PackageName::new(name) + .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?, + ) + { + // `cabin update --package ` targets a *direct* + // versioned dependency only. The matching set is the + // resolver's input — any name declared under + // `[dependencies]` (the + // kinds that participate in ordinary resolution). + // Refreshing a transitive locked package requires + // re-running `cabin update` without `--package`, or + // scoping with `--workspace` / `--default-members`. + bail!( + "package {name:?} is not a direct versioned dependency of `{}`; `cabin update --package` only refreshes direct dependencies declared under `[dependencies]`", + root_name.as_str(), + ); + } + + // Read the lockfile up-front so the patch / source-replacement + // staleness check below can apply even when the active patch + // set covers every versioned dep (and the resolver itself has + // nothing to do). + let existing_lockfile: Option = if lockfile_path.is_file() { + Some( + cabin_lockfile::read_lockfile(&lockfile_path) + .with_context(|| format!("failed to read {}", lockfile_path.display()))?, + ) + } else { + None + }; + + // Patch / source-replacement state recorded into the new + // lockfile and compared against the existing lockfile under + // `--locked`. Computed early so the no-versioned-deps fast + // path below can still enforce the staleness check: if the + // user added or removed a patch since the lockfile was + // written, `--locked` must refuse, even though the resolver + // itself would otherwise have nothing to do. + let active_patch_records = crate::patch_glue::lockfile_patches(&active_patches); + let active_replacement_records = crate::patch_glue::lockfile_source_replacements( + &effective_config.source_replacements, + request.no_patches, + ); + if matches!(request.mode, LockMode::Locked) + && let Some(prev) = &existing_lockfile + && !prev.matches_patch_state(&active_patch_records, &active_replacement_records) + { + bail!( + "--locked cannot be used because active patch / source-replacement policy differs from {}; re-run without --locked to refresh the lockfile", + lockfile_path.display() + ); + } + + if root_deps.is_empty() { + // No versioned deps to resolve. Print a clear empty result + // and never touch the lockfile. The patch-staleness check + // above already ran, so `--locked` will already have bailed + // if the patch set diverged from the lockfile's record. + let output = ResolveOutput { + packages: vec![ResolvedPackage { + name: root_name, + version: root_version, + source: ResolvedSource::Root, + }], + }; + emit_resolve_output(&output, request.format)?; + return Ok(()); + } + + // Locked mode (with versioned deps) still requires an existing + // lockfile — the staleness check above is a no-op when one is + // missing. + if existing_lockfile.is_none() && matches!(request.mode, LockMode::Locked) { + bail!( + "cannot resolve with --locked because {} does not exist", + lockfile_path.display() + ); + } + + let index = match (effective_index_path, effective_index_url) { + (None, None) => { + bail!( + "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting" + ) + } + (Some(path), None) => { + let index_path = absolutise(path) + .with_context(|| format!("failed to resolve {}", path.display()))?; + cabin_index::load_index(&index_path) + .with_context(|| format!("failed to load index at {}", index_path.display()))? + } + (None, Some(url)) => { + let client = cabin_index_http::HttpClient::new(); + let http_index = cabin_index_http::HttpIndex::open(url, client)?; + let names: Vec = root_deps.keys().cloned().collect(); + http_index.load_package_index(&names)? + } + (Some(_), Some(_)) => { + unreachable!("config_glue::resolve_index_source guarantees only one variant is set") + } + }; + + let resolver_mode = match &request.mode { + LockMode::PreferLocked => ResolveMode::PreferLocked, + LockMode::Locked => ResolveMode::Locked, + LockMode::UpdateAll => ResolveMode::UpdateAll, + LockMode::UpdatePackage(name) => ResolveMode::UpdatePackage( + PackageName::new(name.clone()) + .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?, + ), + }; + + let mut input = ResolveInput::new(root_name, root_version, root_deps); + if let Some(lock) = &existing_lockfile { + for pkg in &lock.packages { + input.locked.insert( + pkg.name.clone(), + LockedVersion { + version: pkg.version.clone(), + checksum: pkg.checksum.clone(), + }, + ); + } + } + input.mode = resolver_mode; + + let output = cabin_resolver::resolve(&input, &index).context("dependency resolution failed")?; + + let mut new_lockfile = lockfile_from_resolution(&output, &index); + new_lockfile.patches = active_patch_records; + new_lockfile.source_replacements = active_replacement_records; + + if request.allow_write { + let needs_write = match &existing_lockfile { + Some(prev) => prev != &new_lockfile, + None => true, + }; + if needs_write { + cabin_lockfile::write_lockfile(&lockfile_path, &new_lockfile) + .with_context(|| format!("failed to write {}", lockfile_path.display()))?; + reporter.aux_status(format_args!("cabin: wrote {}", lockfile_path.display())); + } else { + reporter.aux_status(format_args!( + "cabin: {} is up to date", + lockfile_path.display() + )); + } + } else if matches!(request.mode, LockMode::Locked) + && let Some(prev) = &existing_lockfile + && prev != &new_lockfile + { + // We allowed PreferLocked-style search inside the + // resolver but Locked mode forces selection to come + // from the lockfile; this branch is a defensive + // fallback if a future change loosens that. + bail!( + "{} is stale; run `cabin resolve` or `cabin update` to refresh it", + lockfile_path.display() + ); + } + + emit_resolve_output(&output, request.format)?; + Ok(()) +} + +pub(crate) fn lockfile_path_for(manifest_path: &Path) -> PathBuf { + manifest_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + .join("cabin.lock") +} + +fn lockfile_from_resolution(output: &ResolveOutput, index: &cabin_index::PackageIndex) -> Lockfile { + // We need each resolved package's transitive deps to write the + // lockfile's `dependencies = [...]` field. The resolver doesn't + // surface the dep edges directly, so we read them off the index + // entry for the chosen version. + let resolved_names: BTreeSet<&str> = output + .packages + .iter() + .filter(|p| p.source == ResolvedSource::Index) + .map(|p| p.name.as_str()) + .collect(); + let mut packages: Vec = Vec::new(); + for pkg in &output.packages { + if pkg.source != ResolvedSource::Index { + continue; + } + let entry = index + .package(&pkg.name) + .expect("index has every resolved package"); + let meta = entry + .versions + .get(&pkg.version) + .expect("index has the resolved version"); + // Filter to only dep names that are also resolved (defensive). + let mut deps: Vec = meta + .dependencies + .keys() + .filter(|n| resolved_names.contains(n.as_str())) + .cloned() + .collect(); + deps.sort(); + packages.push(LockedPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + source: LockedSource::Index, + checksum: meta.checksum.clone(), + dependencies: deps, + }); + } + packages.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); + Lockfile { + version: cabin_lockfile::LOCKFILE_VERSION, + packages, + patches: Vec::new(), + source_replacements: Vec::new(), + } +} + +fn emit_resolve_output(output: &ResolveOutput, format: ResolveFormat) -> Result<()> { + match format { + ResolveFormat::Human => print_resolve_human(output), + ResolveFormat::Json => print_resolve_json(output), + } +} + +fn print_resolve_human(output: &ResolveOutput) -> Result<()> { + let root = output + .packages + .iter() + .find(|p| p.source == ResolvedSource::Root) + .ok_or_else(|| anyhow::anyhow!("resolver output is missing a root package"))?; + println!( + "Resolved dependencies for {} {}:", + root.name.as_str(), + root.version + ); + let mut others: Vec<&cabin_resolver::ResolvedPackage> = output + .packages + .iter() + .filter(|p| p.source != ResolvedSource::Root) + .collect(); + others.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); + if others.is_empty() { + println!(" (no versioned dependencies)"); + } else { + for pkg in others { + println!(" {} {}", pkg.name.as_str(), pkg.version); + } + } + Ok(()) +} + +fn print_resolve_json(output: &ResolveOutput) -> Result<()> { + let root = output + .packages + .iter() + .find(|p| p.source == ResolvedSource::Root) + .ok_or_else(|| anyhow::anyhow!("resolver output is missing a root package"))?; + let json_root = serde_json::json!({ + "name": root.name.as_str(), + "version": root.version.to_string(), + }); + let json_packages: Vec<_> = output + .packages + .iter() + .filter(|p| p.source != ResolvedSource::Root) + .map(|p| { + serde_json::json!({ + "name": p.name.as_str(), + "version": p.version.to_string(), + "source": p.source.as_str(), + }) + }) + .collect(); + let value = serde_json::json!({ + "root": json_root, + "packages": json_packages, + }); + let body = serde_json::to_string_pretty(&value) + .context("failed to serialize resolve output as JSON")?; + println!("{body}"); + Ok(()) +} + +/// Resolve a path to an absolute one without requiring it to exist. +pub(crate) fn absolutise(path: &Path) -> std::io::Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendered_binary_template_round_trips_through_parser() { + let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Binary); + let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap(); + let package = parsed.package.expect("template should parse as a package"); + assert_eq!(package.name.as_str(), "hello"); + assert_eq!(package.targets.len(), 1); + assert_eq!(package.targets[0].name.as_str(), "hello"); + } + + #[test] + fn rendered_library_template_round_trips_through_parser() { + let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Library); + let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap(); + let package = parsed.package.expect("template should parse as a package"); + assert_eq!(package.name.as_str(), "hello"); + assert_eq!(package.targets.len(), 1); + assert_eq!(package.targets[0].name.as_str(), "hello"); + } +} diff --git a/crates/cabin-cli/src/command_list.rs b/crates/cabin-cli/src/command_list.rs new file mode 100644 index 000000000..c55d5811a --- /dev/null +++ b/crates/cabin-cli/src/command_list.rs @@ -0,0 +1,321 @@ +//! Renderer for `cabin --list`. +//! +//! `cabin --help` shows only the day-to-day commands so the +//! default view is short and easy to skim. Advanced and +//! machine-facing commands are hidden from `--help` by a +//! `#[command(hide = true)]` annotation in [`crate::cli::Cli`]. +//! +//! `cabin --list` is the full directory: it walks the canonical +//! [`clap::Command`] tree, gathers every top-level subcommand +//! (including hidden ones), sorts them alphabetically, and +//! prints a stable name + short-about block. The output is +//! intentionally cargo-style — a `Installed Commands:` heading +//! followed by indented ` ` rows. +//! +//! The module is `pub(crate)`; integration tests run the binary +//! and assert against the printed bytes. The pure +//! [`format_command_list`] helper is exercised by unit tests so +//! the formatter stays decoupled from the process stdout. + +use anyhow::{Context, Result}; +use clap::CommandFactory; +use termcolor::{Color, ColorSpec, WriteColor}; + +use crate::cli::Cli; + +/// Heading printed before the indented command rows. Stable +/// wording so integration tests can pin it. +const LIST_HEADING: &str = "Installed Commands:"; + +/// Indent prefix for each row. Four spaces matches cargo's +/// `cargo --list`. +const ROW_INDENT: &str = " "; + +/// Build the deterministic command-list output for the canonical +/// [`Cli`] command tree and write it to `out`. The writer +/// implements [`WriteColor`] so callers honour the caller- +/// resolved colour choice: a `termcolor::StandardStream` built +/// from Cabin's resolved `--color` value paints the heading and +/// subcommand names in the cargo-style palette, while a +/// no-color writer (`Buffer`, redirected stdout, â€Ļ) emits the +/// same content as plain bytes. +pub(crate) fn print_list(out: &mut W) -> Result<()> { + // `Command::build` materialises clap's auto-injected + // `help` pseudo-subcommand so it appears in the listing. + // Without the explicit build call `Cli::command()` only + // carries the user-declared subcommands; cargo's + // `cargo --list` includes `help`, and so do we. + let mut cmd = Cli::command(); + cmd.build(); + write_command_list(out, &cmd).context("failed to write command list") +} + +/// Render the command list onto a [`WriteColor`] sink, using +/// the cargo-style palette: bright green + bold heading, +/// bright cyan + bold subcommand names and aliases, plain +/// about text and plain `, ` separators. The colour +/// transitions are guarded by `set_color` / `reset` so callers +/// passing a no-color writer see the same plain text the +/// [`format_command_list`] helper produces. +fn write_command_list(out: &mut W, cmd: &clap::Command) -> std::io::Result<()> { + let entries = collect_entries(cmd); + let width = entries + .iter() + .map(|e| e.tokens.join(", ").len()) + .max() + .unwrap_or(0); + + let mut heading_spec = ColorSpec::new(); + heading_spec + .set_fg(Some(Color::Green)) + .set_intense(true) + .set_bold(true); + out.set_color(&heading_spec)?; + write!(out, "{LIST_HEADING}")?; + out.reset()?; + writeln!(out)?; + + let mut name_spec = ColorSpec::new(); + name_spec + .set_fg(Some(Color::Cyan)) + .set_intense(true) + .set_bold(true); + + for entry in &entries { + out.write_all(ROW_INDENT.as_bytes())?; + let plain_width: usize = entry.tokens.join(", ").len(); + for (i, token) in entry.tokens.iter().enumerate() { + if i > 0 { + // The `, ` between name and alias stays plain + // text — same as cargo. + out.write_all(b", ")?; + } + out.set_color(&name_spec)?; + write!(out, "{token}")?; + out.reset()?; + } + if entry.about.is_empty() { + writeln!(out)?; + } else { + let padding = width.saturating_sub(plain_width); + for _ in 0..padding { + out.write_all(b" ")?; + } + writeln!(out, " {about}", about = entry.about)?; + } + } + Ok(()) +} + +/// Test-only convenience that drives [`write_command_list`] +/// against an in-memory uncoloured buffer and returns the +/// rendered text. Wrapping the real renderer (instead of +/// duplicating its formatting code) keeps unit-test +/// expectations honest: any change to the production layout +/// shows up in both surfaces in one place. +#[cfg(test)] +fn format_command_list(cmd: &clap::Command) -> String { + use termcolor::NoColor; + let mut buf = NoColor::new(Vec::::new()); + write_command_list(&mut buf, cmd).expect("Vec writer never fails"); + String::from_utf8(buf.into_inner()).expect("rendered output is utf-8") +} + +#[derive(Debug, Clone)] +struct CommandEntry { + /// The canonical name first, followed by each visible + /// alias. Rendered joined by `, ` to match cargo's + /// `cargo --list` style. + tokens: Vec, + about: String, +} + +fn collect_entries(cmd: &clap::Command) -> Vec { + let mut entries: Vec = cmd + .get_subcommands() + .map(|sub| { + let mut tokens = vec![sub.get_name().to_owned()]; + for alias in sub.get_visible_aliases() { + tokens.push(alias.to_string()); + } + let about = sub + .get_about() + .map(|s| { + // First line of the about block is the + // short summary clap uses in `--help`; long + // help has a separate field we ignore. + s.to_string().lines().next().unwrap_or("").trim().to_owned() + }) + .unwrap_or_default(); + CommandEntry { tokens, about } + }) + .collect(); + entries.sort_by(|a, b| a.tokens[0].cmp(&b.tokens[0])); + entries +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{Args, Parser, Subcommand}; + + #[derive(Parser, Debug)] + #[command(name = "test")] + struct FixtureCli { + #[command(subcommand)] + cmd: FixtureCmd, + } + + #[derive(Subcommand, Debug)] + enum FixtureCmd { + /// Build a thing. + #[command(visible_alias = "b")] + Build(EmptyArgs), + /// Clean output. + Clean(EmptyArgs), + /// Generate completions (advanced). + #[command(hide = true)] + Compgen(EmptyArgs), + } + + #[derive(Args, Debug)] + struct EmptyArgs {} + + fn fixture_cmd() -> clap::Command { + ::command() + } + + #[test] + fn header_is_first_line() { + let out = format_command_list(&fixture_cmd()); + let first = out.lines().next().expect("non-empty output"); + assert_eq!(first, LIST_HEADING); + } + + #[test] + fn output_ends_with_newline() { + let out = format_command_list(&fixture_cmd()); + assert!(out.ends_with('\n'), "expected trailing newline: {out}"); + } + + #[test] + fn entries_are_sorted_alphabetically() { + let out = format_command_list(&fixture_cmd()); + let names: Vec<&str> = out + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "rows must be alphabetically sorted"); + } + + #[test] + fn hidden_commands_are_listed() { + let out = format_command_list(&fixture_cmd()); + // `compgen` was annotated `#[command(hide = true)]`; the + // list view still surfaces it. + assert!( + out.contains("compgen"), + "hidden subcommands must still appear in `--list`: {out}" + ); + } + + #[test] + fn help_pseudo_subcommand_is_listed_when_built() { + // clap auto-injects a `help` pseudo-subcommand only + // after `Command::build`. Once built, the row is + // included in the listing — matching cargo's + // `cargo --list` which also surfaces `help`. + let mut cmd = fixture_cmd(); + cmd.build(); + let out = format_command_list(&cmd); + let names: Vec<&str> = out + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .collect(); + assert!( + names.contains(&"help"), + "`help` should appear in --list once the command tree is built: {names:?}" + ); + } + + #[test] + fn name_about_separator_is_present() { + let out = format_command_list(&fixture_cmd()); + // Each entry has the ` ` shape; spot-check + // one entry rather than over-coupling to the exact + // column width (which depends on the longest name). + // clap strips trailing punctuation from rustdoc-derived + // about lines, so we compare on the leading words only. + let build_line = out + .lines() + .find(|line| line.trim_start().starts_with("build")) + .expect("build row"); + assert!( + build_line.contains("Build a thing"), + "build row should carry its about: {build_line}" + ); + } + + #[test] + fn entries_align_to_longest_name() { + let out = format_command_list(&fixture_cmd()); + // The longest visible name in the fixture is `compgen` + // (7 chars). Build (5 chars) gets right-padded to 7 + // before its about text, so the gap is 2 columns. This + // is a structural assertion: the formatter must compute + // the width once, not per-row. + let build_line = out + .lines() + .find(|line| line.trim_start().starts_with("build")) + .expect("build row"); + let compgen_line = out + .lines() + .find(|line| line.trim_start().starts_with("compgen")) + .expect("compgen row"); + // Both rows have an `about` and the about text starts + // at the same column. + let build_about_col = build_line.find("Build").unwrap(); + let compgen_about_col = compgen_line.find("Generate").unwrap(); + assert_eq!( + build_about_col, compgen_about_col, + "about columns must align across rows" + ); + } + + #[test] + fn visible_aliases_are_rendered_cargo_style() { + let out = format_command_list(&fixture_cmd()); + // Cargo renders aliases comma-separated after the name + // (`build, b`), not in clap's `[aliases: b]` form. The + // fixture's build subcommand has a `b` alias. + assert!( + out.contains("build, b"), + "expected cargo-style `build, b` row: {out}" + ); + assert!( + !out.contains("[aliases:"), + "must not use clap's default `[aliases: ...]` form: {out}" + ); + } + + #[test] + fn empty_about_does_not_emit_separator() { + // Synthesize a command tree where one subcommand has no + // about text; the formatter must still emit the row, + // and the row must not contain the column separator + // spaces that other rows have. + let cmd = clap::Command::new("test") + .subcommand(clap::Command::new("alpha").about("Alpha command.")) + .subcommand(clap::Command::new("beta")); + let out = format_command_list(&cmd); + let beta_line = out + .lines() + .find(|line| line.trim_start().starts_with("beta")) + .expect("beta row"); + assert_eq!(beta_line.trim(), "beta"); + } +} diff --git a/crates/cabin-cli/src/completions.rs b/crates/cabin-cli/src/completions.rs new file mode 100644 index 000000000..8c517f9e7 --- /dev/null +++ b/crates/cabin-cli/src/completions.rs @@ -0,0 +1,160 @@ +//! Shell-completion generation for `cabin compgen`. +//! +//! Completions are derived from the canonical [`clap::Command`] +//! produced by the top-level CLI (`Cli::command()`). The clap +//! definition in [`crate::cli`] is the single source of truth — this +//! module never reaches into command names or argument metadata +//! directly. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use clap::{Args, CommandFactory}; +use clap_complete::Shell; + +use crate::cli::Cli; + +/// Binary name to embed in generated completion scripts. +const BIN_NAME: &str = "cabin"; + +/// Every shell `cabin compgen --all` writes a script for. Other +/// `clap_complete::Shell` variants are still accepted as a positional +/// arg so users can ask for them explicitly. +const ALL_SHELLS: &[Shell] = &[ + Shell::Bash, + Shell::Zsh, + Shell::Fish, + Shell::PowerShell, + Shell::Elvish, +]; + +/// Arguments accepted by `cabin compgen`. +#[derive(Debug, Args)] +pub(crate) struct CompgenArgs { + /// Target shell. Required unless `--all` is given. + #[arg(value_enum, conflicts_with = "all", required_unless_present = "all")] + shell: Option, + + /// Generate completions for every supported shell. Requires + /// `--output-dir`; multiple files cannot be written to stdout + /// cleanly. + #[arg(long)] + all: bool, + + /// Directory to write the completion file(s) into. Created if it + /// does not already exist; existing files are overwritten. + /// Without this flag a single shell's completion is written to + /// stdout. + #[arg(long, value_name = "PATH")] + output_dir: Option, +} + +/// Top-level entry point for `cabin compgen`. +pub(crate) fn run(args: &CompgenArgs) -> Result<()> { + if args.all && args.output_dir.is_none() { + bail!( + "`--all` requires `--output-dir`; multiple completion files cannot be written to stdout cleanly" + ); + } + + let mut cmd = Cli::command(); + + if args.all { + let dir = args.output_dir.as_ref().expect("checked above"); + write_all(&mut cmd, dir)?; + return Ok(()); + } + + let shell = args.shell.expect("clap enforces required-unless-all"); + match args.output_dir.as_ref() { + Some(dir) => write_one_to_dir(&mut cmd, shell, dir)?, + None => write_one_to_stdout(&mut cmd, shell), + } + Ok(()) +} + +fn write_all(cmd: &mut clap::Command, dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("failed to create completion output dir {}", dir.display()))?; + for shell in ALL_SHELLS { + write_one_to_dir(cmd, *shell, dir)?; + } + Ok(()) +} + +fn write_one_to_dir(cmd: &mut clap::Command, shell: Shell, dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("failed to create completion output dir {}", dir.display()))?; + let path = dir.join(filename_for(shell)); + let mut file = + fs::File::create(&path).with_context(|| format!("failed to create {}", path.display()))?; + clap_complete::generate(shell, cmd, BIN_NAME, &mut file); + Ok(()) +} + +fn write_one_to_stdout(cmd: &mut clap::Command, shell: Shell) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + clap_complete::generate(shell, cmd, BIN_NAME, &mut handle); +} + +/// Filename `cabin compgen --output-dir ` writes for each +/// `Shell`. The names match what package managers usually expect on +/// disk (`cabin.bash`, `_cabin`, `cabin.fish`, â€Ļ); deviations from +/// `clap_complete`'s default filenames are intentional and stable. +fn filename_for(shell: Shell) -> String { + match shell { + Shell::Bash => "cabin.bash".to_owned(), + Shell::Zsh => "_cabin".to_owned(), + Shell::Fish => "cabin.fish".to_owned(), + Shell::PowerShell => "cabin.ps1".to_owned(), + Shell::Elvish => "cabin.elv".to_owned(), + // `clap_complete::Shell` is `#[non_exhaustive]`; future + // variants get a generic, deterministic filename. + other => format!("cabin.{}", other.to_string().to_lowercase()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filenames_are_stable_across_shells() { + assert_eq!(filename_for(Shell::Bash), "cabin.bash"); + assert_eq!(filename_for(Shell::Zsh), "_cabin"); + assert_eq!(filename_for(Shell::Fish), "cabin.fish"); + assert_eq!(filename_for(Shell::PowerShell), "cabin.ps1"); + assert_eq!(filename_for(Shell::Elvish), "cabin.elv"); + } + + #[test] + fn all_shells_list_matches_supported_shells() { + // Every entry in ALL_SHELLS must yield a stable filename. + for shell in ALL_SHELLS { + let name = filename_for(*shell); + assert!(!name.is_empty()); + } + } + + #[test] + fn cli_command_includes_compgen_and_mangen() { + // `cabin compgen` and `cabin mangen` are hidden from + // `cabin --help` but must remain registered on the clap + // tree so the binary they wrap into still works and the + // generated completions / man pages reach them. This + // is a focused check on the two subcommands this module + // owns; broader coverage that every subcommand round- + // trips through `--help` / `--list` lives in the + // integration tests. + let cmd = Cli::command(); + let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect(); + for expected in ["compgen", "mangen"] { + assert!( + names.contains(&expected), + "missing subcommand {expected}; got {names:?}" + ); + } + } +} diff --git a/crates/cabin-cli/src/config_glue.rs b/crates/cabin-cli/src/config_glue.rs new file mode 100644 index 000000000..671abb870 --- /dev/null +++ b/crates/cabin-cli/src/config_glue.rs @@ -0,0 +1,709 @@ +//! Glue between [`cabin_config::EffectiveConfig`] and the rest of +//! the CLI's command pipeline. +//! +//! Discovery, parsing, and merging live in `cabin-config`. This +//! module owns the small amount of *orchestration* the CLI needs +//! to thread an [`EffectiveConfig`] into resolvers, paths, and +//! the metadata view — typed helpers in, typed values out, no TOML +//! awareness, no filesystem reads. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use cabin_config::{ + ConfigDiscoveryInputs, ConfigSource, EffectiveCompilerWrapper, EffectiveConfig, + EffectivePathSetting, EffectiveRegistrySource, EffectiveTool, EffectiveToolchain, + WorkspaceLayout, discover_config_files, merge_loaded_files, +}; +use cabin_core::{ + CompilerWrapperSource, ConfigValueSource, ProfileName, ProfileSelection, ToolSource, +}; +use cabin_toolchain::{ConfigToolEntry, ConfigToolchainLayer, ConfigWrapperLayer}; +use cabin_workspace::PackageGraph; + +/// Discover and merge config files for a command running against +/// `graph`. Wraps the pure cabin-config API with the workspace +/// layout pulled out of the loaded graph. +pub(crate) fn load_effective_config(graph: &PackageGraph) -> Result { + let workspace = WorkspaceLayout { + root_dir: graph.root_dir.as_path(), + is_workspace_root: graph.is_workspace_root, + }; + let inputs = ConfigDiscoveryInputs::from_process(Some(workspace)); + let discovery = discover_config_files(&inputs).context("failed to load Cabin config")?; + Ok(merge_loaded_files(discovery.loaded_files)) +} + +/// Build the typed config layer the toolchain resolver consumes. +/// Returns `None` when no config-file values apply. +pub(crate) fn toolchain_layer(config: &EffectiveConfig) -> Option { + let layer = ConfigToolchainLayer { + cc: tool_entry(config.toolchain.cc.as_ref()), + cxx: tool_entry(config.toolchain.cxx.as_ref()), + ar: tool_entry(config.toolchain.ar.as_ref()), + }; + if layer.is_empty() { None } else { Some(layer) } +} + +/// Build the typed config layer the wrapper resolver consumes. +/// `None` when no wrapper choice was declared in any config file. +pub(crate) fn wrapper_layer(config: &EffectiveConfig) -> Option { + let EffectiveCompilerWrapper { request, source } = config.compiler_wrapper.as_ref()?; + Some(ConfigWrapperLayer { + request: *request, + source: wrapper_source_for(*source), + }) +} + +fn tool_entry(value: Option<&EffectiveTool>) -> Option { + let entry = value?; + Some(ConfigToolEntry { + spec: entry.spec.clone(), + source: tool_source_for(entry.source), + }) +} + +fn tool_source_for(source: ConfigSource) -> ToolSource { + match source { + ConfigSource::User => ToolSource::UserConfig, + ConfigSource::Workspace => ToolSource::WorkspaceConfig, + ConfigSource::Package => ToolSource::PackageConfig, + ConfigSource::Explicit => ToolSource::ExplicitConfig, + } +} + +fn wrapper_source_for(source: ConfigSource) -> CompilerWrapperSource { + match source { + ConfigSource::User => CompilerWrapperSource::UserConfig, + ConfigSource::Workspace => CompilerWrapperSource::WorkspaceConfig, + ConfigSource::Package => CompilerWrapperSource::PackageConfig, + ConfigSource::Explicit => CompilerWrapperSource::ExplicitConfig, + } +} + +/// Map a [`ConfigSource`] onto the broader [`ConfigValueSource`] +/// used in metadata reporting. +pub(crate) fn config_value_source(source: ConfigSource) -> ConfigValueSource { + match source { + ConfigSource::User => ConfigValueSource::UserConfig, + ConfigSource::Workspace => ConfigValueSource::WorkspaceConfig, + ConfigSource::Package => ConfigValueSource::PackageConfig, + ConfigSource::Explicit => ConfigValueSource::ExplicitConfig, + } +} + +/// Resolved index source that consumes CLI arguments first and +/// falls back to the merged config. +pub(crate) struct ResolvedIndexSource { + pub kind: IndexSourceKind, +} + +pub(crate) enum IndexSourceKind { + Path(PathBuf), + Url(String), +} + +/// Apply the documented index-source precedence: +/// +/// 1. `--index-path` â–ļ CLI +/// 2. `--index-url` â–ļ CLI +/// 3. config-supplied registry source (highest-priority file's +/// declared variant) +/// 4. unset (caller decides whether the absence is an error) +/// +/// Passing both CLI flags is rejected at the call site (existing +/// behaviour); this helper only reconciles a single CLI choice +/// against the config layer. +pub(crate) fn resolve_index_source( + cli_index_path: Option<&Path>, + cli_index_url: Option<&str>, + config: &EffectiveConfig, +) -> Result> { + if cli_index_path.is_some() && cli_index_url.is_some() { + bail!("use either --index-path or --index-url, not both"); + } + if let Some(path) = cli_index_path { + return Ok(Some(ResolvedIndexSource { + kind: IndexSourceKind::Path(path.to_path_buf()), + })); + } + if let Some(url) = cli_index_url { + if cabin_config::url_contains_credentials(url) { + bail!( + "`--index-url` must not contain credentials (userinfo): `{}`", + cabin_config::redact_userinfo(url) + ); + } + return Ok(Some(ResolvedIndexSource { + kind: IndexSourceKind::Url(url.to_owned()), + })); + } + Ok(config.registry.source.as_ref().map(|src| match src { + EffectiveRegistrySource::Path(value) => ResolvedIndexSource { + kind: IndexSourceKind::Path(value.value.clone()), + }, + EffectiveRegistrySource::Url(value) => ResolvedIndexSource { + kind: IndexSourceKind::Url(value.value.clone()), + }, + })) +} + +/// Apply Cabin's CLI-vs-env precedence for the `--offline` +/// flag. Returns `true` when the user passed `--offline` *or* +/// when [`cabin_env::CABIN_NET_OFFLINE`] is set to a truthy +/// value. The CLI flag short-circuits the env lookup because +/// there is no negative form today; otherwise the env value must +/// use Cabin's documented boolean grammar. +pub(crate) fn effective_offline(cli: bool) -> Result { + if cli { + return Ok(true); + } + if let Some(raw) = std::env::var_os(cabin_env::CABIN_NET_OFFLINE) { + let Some(s) = raw.to_str() else { + bail!( + "invalid {} value: expected valid UTF-8 boolean spelling", + cabin_env::CABIN_NET_OFFLINE + ); + }; + return cabin_env::parse_bool(s).map_err(|err| { + anyhow::anyhow!( + "invalid {} value {:?}: {err}", + cabin_env::CABIN_NET_OFFLINE, + s + ) + }); + } + Ok(false) +} + +/// Reject any resolved-index-source that would require network +/// access when the caller passed `--offline`. The check is the +/// single point where Cabin enforces the offline contract: an +/// HTTP index URL is the only network input the read path +/// recognises today, so refusing one here is sufficient. +/// +/// Returns `Ok(())` when offline is satisfied (no source, or a +/// path source); otherwise returns an actionable error that +/// names the URL and tells the user how to switch to a local +/// index or a vendor directory. +pub(crate) fn enforce_offline_index_source( + offline: bool, + resolved: Option<&ResolvedIndexSource>, +) -> Result<()> { + if !offline { + return Ok(()); + } + if let Some(ResolvedIndexSource { + kind: IndexSourceKind::Url(url), + .. + }) = resolved + { + bail!( + "--offline forbids network access, but the resolved index source is the URL `{url}`; pass `--index-path ` or remove `[registry] index-url` from the active config and re-run with a local index (e.g. a `cabin vendor` output)" + ); + } + Ok(()) +} + +/// Companion to [`enforce_offline_index_source`] that runs *after* +/// `apply_source_replacement`. The pre-check only sees the source +/// the user requested; a `[source-replacement]` entry can still +/// rewrite an `index-path` into an `index-url` later in the +/// pipeline, and the artifact loader would happily open it. This +/// check closes that gap. +/// +/// Takes the typed [`cabin_core::SourceReplacementResolution`] so +/// it can give an accurate error: a non-empty `hops` list means +/// replacement actually fired, and the message can name the +/// `[source-replacement]` config the user needs to revisit. +pub(crate) fn enforce_offline_post_replacement( + offline: bool, + resolution: &cabin_core::SourceReplacementResolution, +) -> Result<()> { + if !offline { + return Ok(()); + } + let cabin_core::SourceLocator::IndexUrl { url } = &resolution.resolved else { + return Ok(()); + }; + if resolution.hops.is_empty() { + bail!( + "--offline forbids network access, but the resolved index source is the URL `{url}`; pass `--index-path ` or remove `[registry] index-url` from the active config and re-run with a local index (e.g. a `cabin vendor` output)" + ); + } + bail!( + "--offline forbids network access, but `[source-replacement]` redirected the index to the URL `{url}`; remove the offending source-replacement entry, pass `--no-patches`, or drop `--offline`" + ); +} + +/// Post-`apply_source_replacement` variant of vendor's +/// local-index check. The pre-replacement check at the call site +/// catches direct `[registry] index-url` cases; this one catches +/// the path → URL replacement case the same way +/// [`enforce_offline_post_replacement`] does for `--offline`. +pub(crate) fn enforce_vendor_local_index_post_replacement( + resolution: &cabin_core::SourceReplacementResolution, +) -> Result<()> { + let cabin_core::SourceLocator::IndexUrl { url } = &resolution.resolved else { + return Ok(()); + }; + if resolution.hops.is_empty() { + bail!( + "`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory; the resolved index source is the URL `{url}`" + ); + } + bail!( + "`cabin vendor` requires a local `--index-path` source, but `[source-replacement]` redirected the index to the URL `{url}`; remove the offending source-replacement entry or pass `--no-patches`" + ); +} + +/// Resolve the build directory the CLI should use for a build +/// invocation, consulting CLI flag → env var → config → +/// built-in default in that order. +/// +/// `cli_value` is `Some(p)` only when the user actually passed +/// `--build-dir`; the clap default lives in the helper so an +/// explicit `--build-dir build` is still recognised as a CLI +/// choice and beats the env layer. Precedence: `--build-dir`, +/// then [`cabin_env::CABIN_BUILD_DIR`], then `[paths] build-dir`, +/// then the built-in default (`build`). The returned +/// [`ConfigValueSource`] lets metadata attribute the value. +pub(crate) fn resolve_build_dir_with_env( + cli_value: Option<&Path>, + config: &EffectiveConfig, +) -> (PathBuf, ConfigValueSource) { + resolve_build_dir_layered( + cli_value, + std::env::var_os(cabin_env::CABIN_BUILD_DIR), + config, + ) +} + +fn resolve_build_dir_layered( + cli_value: Option<&Path>, + env_value: Option, + config: &EffectiveConfig, +) -> (PathBuf, ConfigValueSource) { + if let Some(p) = cli_value { + return (p.to_path_buf(), ConfigValueSource::Cli); + } + if let Some(value) = env_value.filter(|v| !v.is_empty()) { + return (PathBuf::from(value), ConfigValueSource::Env); + } + if let Some(setting) = &config.paths.build_dir { + return (setting.absolute(), config_value_source(setting.source)); + } + (PathBuf::from("build"), ConfigValueSource::BuiltinDefault) +} + +/// Resolve the build-jobs setting for a build invocation. +/// +/// Precedence: CLI `--jobs` > [`cabin_env::CABIN_BUILD_JOBS`] +/// env var > `[build] jobs` config setting > backend default +/// (`None` — the Ninja runner omits `-j` and Ninja picks its +/// own default). +/// +/// The env-var parser flows through the same typed +/// [`cabin_core::BuildJobs`] validator the CLI uses so the +/// error wording stays consistent across input sources. +pub(crate) fn resolve_build_jobs( + cli_value: Option, + config: &EffectiveConfig, +) -> Result> { + if let Some(jobs) = cli_value { + return Ok(Some(jobs)); + } + if let Some(raw) = std::env::var_os(cabin_env::CABIN_BUILD_JOBS) { + let raw = raw.to_string_lossy().into_owned(); + if !raw.is_empty() { + let jobs = raw.parse::().map_err(|err| { + anyhow::anyhow!( + "invalid {env} value {raw:?}: {err}", + env = cabin_env::CABIN_BUILD_JOBS + ) + })?; + return Ok(Some(jobs)); + } + } + if let Some(setting) = &config.build.jobs { + return Ok(Some(setting.value)); + } + Ok(None) +} + +/// Resolve the cache directory for a build / fetch invocation. +/// +/// Precedence: CLI `--cache-dir` > [`cabin_env::CABIN_CACHE_DIR`] +/// env var > `[paths] cache-dir` config setting > `None` (the +/// caller keeps its existing default behaviour). Mirrors the +/// sibling helpers [`resolve_build_dir_with_env`] and +/// [`resolve_build_jobs`]. +pub(crate) fn resolve_cache_dir( + cli_value: Option<&Path>, + config: &EffectiveConfig, +) -> Option<(PathBuf, ConfigValueSource)> { + resolve_cache_dir_layered( + cli_value, + std::env::var_os(cabin_env::CABIN_CACHE_DIR), + config, + ) +} + +fn resolve_cache_dir_layered( + cli_value: Option<&Path>, + env_value: Option, + config: &EffectiveConfig, +) -> Option<(PathBuf, ConfigValueSource)> { + if let Some(p) = cli_value { + return Some((p.to_path_buf(), ConfigValueSource::Cli)); + } + if let Some(value) = env_value.filter(|v| !v.is_empty()) { + return Some((PathBuf::from(value), ConfigValueSource::Env)); + } + config + .paths + .cache_dir + .as_ref() + .map(|setting| (setting.absolute(), config_value_source(setting.source))) +} + +/// Apply config-supplied profile defaults. CLI flags (handled +/// upstream of this helper) win; otherwise the config-provided +/// profile name is parsed into a typed [`ProfileSelection`]. +pub(crate) fn config_profile_selection( + config: &EffectiveConfig, +) -> Result> { + let Some(profile) = config.build.profile.as_ref() else { + return Ok(None); + }; + let name = ProfileName::new(profile.name.clone()) + .with_context(|| format!("invalid `build.profile` in config: `{}`", profile.name))?; + Ok(Some(( + ProfileSelection::from_name(name), + config_value_source(profile.source), + ))) +} + +/// JSON view of the loaded config files plus every effective +/// config-derived setting. `None` is rendered as `null` in the +/// metadata view so the field is always present. +pub(crate) fn config_view_json(config: &EffectiveConfig) -> serde_json::Value { + let loaded_files: Vec = config + .loaded_files + .iter() + .map(|file| { + serde_json::json!({ + "source": file.source.as_key(), + "path": file.path.display().to_string(), + }) + }) + .collect(); + + let registry = match &config.registry.source { + Some(EffectiveRegistrySource::Path(value)) => serde_json::json!({ + "kind": "path", + "value": value.value.display().to_string(), + "value_source": config_value_source(value.source).as_key(), + }), + Some(EffectiveRegistrySource::Url(value)) => serde_json::json!({ + "kind": "url", + "value": value.value, + "value_source": config_value_source(value.source).as_key(), + }), + None => serde_json::Value::Null, + }; + + let paths = serde_json::json!({ + "cache_dir": path_setting_view(config.paths.cache_dir.as_ref()), + "build_dir": path_setting_view(config.paths.build_dir.as_ref()), + }); + + let build = serde_json::json!({ + "profile": match &config.build.profile { + Some(profile) => serde_json::json!({ + "name": profile.name, + "value_source": config_value_source(profile.source).as_key(), + }), + None => serde_json::Value::Null, + }, + }); + + let toolchain = toolchain_view_json(&config.toolchain); + + let compiler_wrapper = match &config.compiler_wrapper { + Some(wrapper) => serde_json::json!({ + "request": wrapper.request.as_key(), + "value_source": config_value_source(wrapper.source).as_key(), + }), + None => serde_json::Value::Null, + }; + + serde_json::json!({ + "loaded_files": loaded_files, + "registry": registry, + "paths": paths, + "build": build, + "toolchain": toolchain, + "compiler_wrapper": compiler_wrapper, + }) +} + +fn toolchain_view_json(toolchain: &EffectiveToolchain) -> serde_json::Value { + serde_json::json!({ + "cc": tool_view(toolchain.cc.as_ref()), + "cxx": tool_view(toolchain.cxx.as_ref()), + "ar": tool_view(toolchain.ar.as_ref()), + }) +} + +fn tool_view(value: Option<&EffectiveTool>) -> serde_json::Value { + match value { + Some(tool) => serde_json::json!({ + "spec": tool.spec.display(), + "value_source": config_value_source(tool.source).as_key(), + }), + None => serde_json::Value::Null, + } +} + +fn path_setting_view(setting: Option<&EffectivePathSetting>) -> serde_json::Value { + match setting { + Some(s) => serde_json::json!({ + "value": s.value.display().to_string(), + "absolute": s.absolute().display().to_string(), + "value_source": config_value_source(s.source).as_key(), + }), + None => serde_json::Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cabin_core::{SourceLocator, SourceReplacementResolution}; + + #[test] + fn resolve_index_source_rejects_cli_url_with_credentials() { + let cfg = cabin_config::EffectiveConfig::default(); + let err = match resolve_index_source(None, Some("https://user:pw@bad.example.com/"), &cfg) { + Ok(_) => panic!("expected credential rejection"), + Err(e) => e, + }; + let message = err.to_string(); + assert!( + !message.contains("user:pw"), + "credentials must be redacted from error, got: {message}" + ); + assert!( + message.contains("credentials") || message.contains("userinfo"), + "expected message to mention credentials, got: {message}" + ); + } + + fn path_resolution(path: &str) -> SourceReplacementResolution { + SourceReplacementResolution { + resolved: SourceLocator::IndexPath { + path: PathBuf::from(path), + }, + hops: Vec::new(), + } + } + + fn url_resolution_with_hops( + url: &str, + hops: Vec, + ) -> SourceReplacementResolution { + SourceReplacementResolution { + resolved: SourceLocator::IndexUrl { + url: url.to_owned(), + }, + hops, + } + } + + #[test] + fn enforce_offline_post_replacement_allows_when_not_offline() { + let resolution = url_resolution_with_hops( + "https://example.com/idx", + vec![SourceLocator::IndexPath { + path: PathBuf::from("./mirror"), + }], + ); + enforce_offline_post_replacement(false, &resolution) + .expect("non-offline must always succeed"); + } + + #[test] + fn enforce_offline_post_replacement_allows_path_terminal() { + let resolution = path_resolution("./mirror"); + enforce_offline_post_replacement(true, &resolution) + .expect("offline + path terminal is the supported combination"); + } + + #[test] + fn enforce_offline_post_replacement_blames_source_replacement_when_hops_present() { + let resolution = url_resolution_with_hops( + "https://example.com/idx", + vec![SourceLocator::IndexPath { + path: PathBuf::from("./mirror"), + }], + ); + let err = enforce_offline_post_replacement(true, &resolution) + .expect_err("offline + url-after-replacement must bail"); + let message = err.to_string(); + assert!( + message.contains("source-replacement"), + "message must blame source-replacement, got: {message}" + ); + assert!( + message.contains("https://example.com/idx"), + "message must name the offending URL, got: {message}" + ); + } + + #[test] + fn enforce_offline_post_replacement_falls_back_to_pre_check_wording_without_hops() { + let resolution = url_resolution_with_hops("https://example.com/idx", Vec::new()); + let err = enforce_offline_post_replacement(true, &resolution) + .expect_err("defensive: offline + url terminal still bails"); + let message = err.to_string(); + assert!( + message.contains("--offline"), + "message must reference --offline, got: {message}" + ); + assert!( + message.contains("https://example.com/idx"), + "message must name the offending URL, got: {message}" + ); + } + + #[test] + fn enforce_vendor_local_index_post_replacement_allows_path_terminal() { + let resolution = path_resolution("./mirror"); + enforce_vendor_local_index_post_replacement(&resolution) + .expect("path terminal is acceptable for vendor"); + } + + fn cfg_with_cache_dir(value: &str, source: ConfigSource) -> EffectiveConfig { + let mut cfg = EffectiveConfig::default(); + cfg.paths.cache_dir = Some(EffectivePathSetting { + value: PathBuf::from(value), + source, + base: PathBuf::from("/base"), + }); + cfg + } + + fn cfg_with_build_dir(value: &str, source: ConfigSource) -> EffectiveConfig { + let mut cfg = EffectiveConfig::default(); + cfg.paths.build_dir = Some(EffectivePathSetting { + value: PathBuf::from(value), + source, + base: PathBuf::from("/base"), + }); + cfg + } + + #[test] + fn resolve_build_dir_explicit_cli_wins_even_when_value_equals_default() { + // Regression: an explicit `--build-dir build` (matching the + // built-in default literal) must beat `CABIN_BUILD_DIR`. + let cfg = EffectiveConfig::default(); + let cli = PathBuf::from("build"); + let (path, source) = resolve_build_dir_layered( + Some(cli.as_path()), + Some(OsString::from("/tmp/env-build")), + &cfg, + ); + assert_eq!(path, cli); + assert_eq!(source, ConfigValueSource::Cli); + } + + #[test] + fn resolve_build_dir_env_beats_config() { + let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace); + let (path, source) = + resolve_build_dir_layered(None, Some(OsString::from("/tmp/env-build")), &cfg); + assert_eq!(path, PathBuf::from("/tmp/env-build")); + assert_eq!(source, ConfigValueSource::Env); + } + + #[test] + fn resolve_build_dir_falls_back_to_config() { + let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace); + let (path, source) = resolve_build_dir_layered(None, None, &cfg); + assert_eq!(path, PathBuf::from("/base").join("config-build")); + assert_eq!(source, ConfigValueSource::WorkspaceConfig); + } + + #[test] + fn resolve_build_dir_builtin_default_when_nothing_set() { + let cfg = EffectiveConfig::default(); + let (path, source) = resolve_build_dir_layered(None, None, &cfg); + assert_eq!(path, PathBuf::from("build")); + assert_eq!(source, ConfigValueSource::BuiltinDefault); + } + + #[test] + fn resolve_build_dir_empty_env_falls_through_to_config() { + let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace); + let (path, source) = resolve_build_dir_layered(None, Some(OsString::new()), &cfg); + assert_eq!(path, PathBuf::from("/base").join("config-build")); + assert_eq!(source, ConfigValueSource::WorkspaceConfig); + } + + #[test] + fn resolve_cache_dir_env_beats_config() { + let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace); + let (path, source) = + resolve_cache_dir_layered(None, Some(OsString::from("/tmp/env-cache")), &cfg) + .expect("env value should resolve"); + assert_eq!(path, PathBuf::from("/tmp/env-cache")); + assert_eq!(source, ConfigValueSource::Env); + } + + #[test] + fn resolve_cache_dir_cli_beats_env() { + let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace); + let cli = PathBuf::from("/tmp/cli-cache"); + let (path, source) = resolve_cache_dir_layered( + Some(cli.as_path()), + Some(OsString::from("/tmp/env-cache")), + &cfg, + ) + .expect("cli value should resolve"); + assert_eq!(path, cli); + assert_eq!(source, ConfigValueSource::Cli); + } + + #[test] + fn resolve_cache_dir_empty_env_falls_through_to_config() { + let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace); + let (path, source) = resolve_cache_dir_layered(None, Some(OsString::new()), &cfg) + .expect("config value should resolve"); + assert_eq!(path, PathBuf::from("/base").join("config-cache")); + assert_eq!(source, ConfigValueSource::WorkspaceConfig); + } + + #[test] + fn enforce_vendor_local_index_post_replacement_rejects_url_after_replacement() { + let resolution = url_resolution_with_hops( + "https://example.com/idx", + vec![SourceLocator::IndexPath { + path: PathBuf::from("./mirror"), + }], + ); + let err = enforce_vendor_local_index_post_replacement(&resolution) + .expect_err("vendor must reject URL terminals"); + let message = err.to_string(); + assert!( + message.contains("source-replacement"), + "message must blame source-replacement, got: {message}" + ); + assert!( + message.contains("cabin vendor"), + "message must reference `cabin vendor`, got: {message}" + ); + } +} diff --git a/crates/cabin-cli/src/env_flags_glue.rs b/crates/cabin-cli/src/env_flags_glue.rs new file mode 100644 index 000000000..6c721170f --- /dev/null +++ b/crates/cabin-cli/src/env_flags_glue.rs @@ -0,0 +1,371 @@ +//! Orchestration for the conventional C/C++ build-flag +//! environment variables (`CPPFLAGS`, `CFLAGS`, `CXXFLAGS`, +//! `LDFLAGS`). +//! +//! `cabin-env::build_flags` owns the shell-like word splitter +//! and produces a typed [`EnvBuildFlags`]. This module is the +//! single bridge that: +//! +//! 1. captures the four variables at command start (via a +//! [`Fn(&str) -> Option`] closure so tests stay +//! pure); +//! 2. parses them once, mapping any parse error onto the +//! typed [`EnvBuildFlagsError`]; +//! 3. appends the parsed tokens to every **primary** package's +//! [`ResolvedProfileFlags`] entry, *after* `pkg-config` +//! contributions have already been merged. +//! +//! Calling this helper is the documented merge point for the +//! environment layer; every command that resolves a build +//! configuration (`build`, `run`, `test`, `tidy`, `metadata`, +//! `explain`) must call it directly after +//! `crate::system_deps_glue::augment_build_flags_with_system_deps` +//! so the resulting `BuildConfiguration::fingerprint` observes +//! the user's environment. +//! +//! ## Why primary-only +//! +//! Cabin keeps environment flags scoped to the user's own +//! workspace members for the same reason `pkg-config` +//! contributions do: a stray `-Werror` in `CXXFLAGS` should +//! never break a transitive dependency the user did not write. +//! Registry / path dependencies still observe their own +//! `[profile]` declarations and any flag they own, but the +//! environment is the user's, not the dependency's. + +use std::collections::HashMap; + +use anyhow::Result; + +use cabin_core::ResolvedProfileFlags; +use cabin_env::{EnvBuildFlags, EnvBuildFlagsError, parse_env_build_flags}; +use cabin_workspace::PackageGraph; + +use crate::plural; +use crate::term_verbosity_glue::Reporter; + +/// Read `CPPFLAGS`, `CFLAGS`, `CXXFLAGS`, `LDFLAGS` from the +/// supplied env-lookup closure, parse each value with the +/// shell-like splitter, and merge the result into every primary +/// package's [`ResolvedProfileFlags`] entry. +/// +/// Returns the augmented map (so call sites can chain it with +/// the system-deps step) plus the parsed [`EnvBuildFlags`] for +/// downstream observers (verbose reporting, future metadata +/// view). +/// +/// Empty / whitespace-only / unset variables are no-ops. A +/// malformed value surfaces as an `anyhow::Error` wrapping +/// the typed [`EnvBuildFlagsError`]. +pub(crate) fn augment_build_flags_with_env( + graph: &PackageGraph, + mut build_flags: HashMap, + env: F, + reporter: Reporter, +) -> Result<(HashMap, EnvBuildFlags)> +where + F: Fn(&str) -> Option, +{ + let parsed = match parse_env_build_flags(&env) { + Ok(flags) => flags, + Err(err) => return Err(format_parse_error(err)), + }; + if parsed.is_empty() { + return Ok((build_flags, parsed)); + } + apply_to_primary_packages(graph, &mut build_flags, &parsed); + // Verbose mode acknowledges that an environment layer + // applied without dumping the raw values (they can carry + // local paths or tokens). Very-verbose mode prints the + // parsed argv tokens, matching the policy command-line + // display already follows. + if reporter.verbosity().shows_very_verbose() { + log_very_verbose(&parsed, reporter); + } else if reporter.verbosity().shows_verbose() { + log_verbose(&parsed, reporter); + } + Ok((build_flags, parsed)) +} + +fn format_parse_error(err: EnvBuildFlagsError) -> anyhow::Error { + // The error message already includes the variable name and + // an actionable description ("unterminated quote", + // "trailing escape character", "value is not valid UTF-8"). + // Wrap as `anyhow::Error` so it flows through the standard + // CLI error path. + anyhow::Error::new(err) +} + +fn apply_to_primary_packages( + graph: &PackageGraph, + build_flags: &mut HashMap, + parsed: &EnvBuildFlags, +) { + for &idx in &graph.primary_packages { + let entry = build_flags.entry(idx).or_default(); + // CPPFLAGS apply to both C and C++ compile commands, so + // they land in the language-neutral bucket. + entry + .extra_compile_args + .extend(parsed.cppflags.iter().cloned()); + entry.cflags.extend(parsed.cflags.iter().cloned()); + entry.cxxflags.extend(parsed.cxxflags.iter().cloned()); + entry.ldflags.extend(parsed.ldflags.iter().cloned()); + } +} + +// Verbose chatter routes through the reporter's auxiliary stderr +// path, matching `system_deps_glue`'s pattern. `cabin metadata` +// reserves stdout for its JSON document, so any human-readable +// line emitted from the shared build-orchestration path must use +// stderr or it pollutes the machine-readable contract. +fn log_verbose(parsed: &EnvBuildFlags, reporter: Reporter) { + // One short line per active variable, with arg counts only. + // The full values can carry local include paths or tokens; + // very-verbose mode is the documented place to dump them. + if !parsed.cppflags.is_empty() { + reporter.aux_verbose(format_args!( + "cabin: applying CPPFLAGS ({} arg{})", + parsed.cppflags.len(), + plural(parsed.cppflags.len()), + )); + } + if !parsed.cflags.is_empty() { + reporter.aux_verbose(format_args!( + "cabin: applying CFLAGS ({} arg{})", + parsed.cflags.len(), + plural(parsed.cflags.len()), + )); + } + if !parsed.cxxflags.is_empty() { + reporter.aux_verbose(format_args!( + "cabin: applying CXXFLAGS ({} arg{})", + parsed.cxxflags.len(), + plural(parsed.cxxflags.len()), + )); + } + if !parsed.ldflags.is_empty() { + reporter.aux_verbose(format_args!( + "cabin: applying LDFLAGS ({} arg{})", + parsed.ldflags.len(), + plural(parsed.ldflags.len()), + )); + } +} + +fn log_very_verbose(parsed: &EnvBuildFlags, reporter: Reporter) { + if !parsed.cppflags.is_empty() { + reporter.aux_very_verbose(format_args!("cabin: CPPFLAGS = {}", join(&parsed.cppflags))); + } + if !parsed.cflags.is_empty() { + reporter.aux_very_verbose(format_args!("cabin: CFLAGS = {}", join(&parsed.cflags))); + } + if !parsed.cxxflags.is_empty() { + reporter.aux_very_verbose(format_args!("cabin: CXXFLAGS = {}", join(&parsed.cxxflags))); + } + if !parsed.ldflags.is_empty() { + reporter.aux_very_verbose(format_args!("cabin: LDFLAGS = {}", join(&parsed.ldflags))); + } +} + +fn join(args: &[String]) -> String { + args.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use cabin_core::{Package, PackageName, ResolvedProfileFlags, Target}; + use cabin_workspace::{PackageGraph, PackageKind, WorkspacePackage}; + use std::ffi::OsString; + use std::path::PathBuf; + + fn version() -> semver::Version { + semver::Version::parse("0.1.0").unwrap() + } + + fn make_pkg(name: &str) -> WorkspacePackage { + let package = Package::new( + PackageName::new(name).unwrap(), + version(), + Vec::::new(), + Vec::new(), + ) + .unwrap(); + WorkspacePackage { + package, + manifest_dir: PathBuf::from("/tmp"), + manifest_path: PathBuf::from("/tmp/cabin.toml"), + kind: PackageKind::Local, + deps: Vec::new(), + } + } + + fn one_primary_graph() -> PackageGraph { + let pkg = make_pkg("root"); + PackageGraph { + root_manifest_path: PathBuf::from("/tmp/cabin.toml"), + root_dir: PathBuf::from("/tmp"), + is_workspace_root: false, + root_package: Some(0), + root_settings: Default::default(), + primary_packages: vec![0], + default_members: vec![0], + excluded_members: Vec::new(), + packages: vec![pkg], + } + } + + fn quiet_reporter() -> Reporter { + Reporter::new(cabin_core::Verbosity::Normal) + } + + fn env_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |key: &str| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| OsString::from(*v)) + } + } + + #[test] + fn empty_env_leaves_flags_unchanged() { + let graph = one_primary_graph(); + let mut start = HashMap::new(); + start.insert(0, ResolvedProfileFlags::default()); + let (out, parsed) = + augment_build_flags_with_env(&graph, start.clone(), |_| None, quiet_reporter()) + .unwrap(); + assert!(parsed.is_empty()); + assert_eq!(out, start); + } + + #[test] + fn cppflags_land_in_extra_compile_args() { + let graph = one_primary_graph(); + let mut start: HashMap = HashMap::new(); + start.insert( + 0, + ResolvedProfileFlags { + extra_compile_args: vec!["-fPIC".into()], + ..Default::default() + }, + ); + let env = env_from(&[("CPPFLAGS", "-DFOO=1 -DBAR")]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + let entry = out.get(&0).unwrap(); + assert_eq!( + entry.extra_compile_args, + vec!["-fPIC", "-DFOO=1", "-DBAR"], + "CPPFLAGS append *after* existing language-neutral args" + ); + assert!(entry.cflags.is_empty()); + assert!(entry.cxxflags.is_empty()); + assert!(entry.ldflags.is_empty()); + } + + #[test] + fn cflags_only_reach_c_bucket() { + let graph = one_primary_graph(); + let start: HashMap = + HashMap::from_iter([(0, ResolvedProfileFlags::default())]); + let env = env_from(&[("CFLAGS", "-std=c11 -Wmissing-prototypes")]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + let entry = out.get(&0).unwrap(); + assert!(entry.extra_compile_args.is_empty()); + assert_eq!(entry.cflags, vec!["-std=c11", "-Wmissing-prototypes"],); + assert!(entry.cxxflags.is_empty()); + } + + #[test] + fn cxxflags_only_reach_cxx_bucket() { + let graph = one_primary_graph(); + let start: HashMap = + HashMap::from_iter([(0, ResolvedProfileFlags::default())]); + let env = env_from(&[("CXXFLAGS", "-fno-rtti")]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + let entry = out.get(&0).unwrap(); + assert!(entry.cflags.is_empty()); + assert_eq!(entry.cxxflags, vec!["-fno-rtti".to_owned()]); + } + + #[test] + fn ldflags_only_reach_link_bucket() { + let graph = one_primary_graph(); + let start: HashMap = HashMap::from_iter([( + 0, + ResolvedProfileFlags { + ldflags: vec!["-Wl,--as-needed".into()], + ..Default::default() + }, + )]); + let env = env_from(&[("LDFLAGS", "-L/opt/lib -lfoo")]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + let entry = out.get(&0).unwrap(); + // LDFLAGS append after existing link args; order is + // load-bearing for the link line. + assert_eq!( + entry.ldflags, + vec!["-Wl,--as-needed", "-L/opt/lib", "-lfoo"], + ); + assert!(entry.extra_compile_args.is_empty()); + assert!(entry.cflags.is_empty()); + assert!(entry.cxxflags.is_empty()); + } + + #[test] + fn malformed_quote_error_names_variable() { + let graph = one_primary_graph(); + let start: HashMap = HashMap::new(); + let env = env_from(&[("CFLAGS", "-DFOO='hello")]); + let err = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("CFLAGS"), "{msg}"); + assert!(msg.contains("unterminated"), "{msg}"); + } + + #[test] + fn env_flag_append_preserves_pkg_config_order() { + // pkg-config's contribution is already in + // ResolvedProfileFlags before this helper is called. + // Verify env flags land *after* it, preserving + // pkg-config's link-line order. + let graph = one_primary_graph(); + let start: HashMap = HashMap::from_iter([( + 0, + ResolvedProfileFlags { + extra_compile_args: vec!["-pthread".into()], + ldflags: vec!["-L/usr/local/lib".into(), "-lssl".into()], + ..Default::default() + }, + )]); + let env = env_from(&[ + ("CPPFLAGS", "-DENV_CPP=1"), + ("LDFLAGS", "-L/opt/lib -lextra"), + ]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + let entry = out.get(&0).unwrap(); + assert_eq!(entry.extra_compile_args, vec!["-pthread", "-DENV_CPP=1"],); + assert_eq!( + entry.ldflags, + vec!["-L/usr/local/lib", "-lssl", "-L/opt/lib", "-lextra"], + ); + } + + /// Multi-package primary set; ensures the merge touches + /// every primary index (not just the root). + #[test] + fn merge_touches_every_primary_package() { + let mut graph = one_primary_graph(); + graph.packages.push(make_pkg("worker")); + graph.primary_packages = vec![0, 1]; + let start: HashMap = HashMap::new(); + let env = env_from(&[("CFLAGS", "-DSHARED")]); + let (out, _) = augment_build_flags_with_env(&graph, start, env, quiet_reporter()).unwrap(); + for idx in [0, 1] { + let e = out.get(&idx).unwrap(); + assert_eq!(e.cflags, vec!["-DSHARED".to_owned()]); + } + } +} diff --git a/crates/cabin-cli/src/explain_glue.rs b/crates/cabin-cli/src/explain_glue.rs new file mode 100644 index 000000000..bf35d854f --- /dev/null +++ b/crates/cabin-cli/src/explain_glue.rs @@ -0,0 +1,302 @@ +//! Glue layer for `cabin explain`. +//! +//! Package, target, source, and feature subcommands map onto +//! the typed explanation model in `cabin-explain`. The +//! orchestration layer here is responsible for loading the +//! workspace + lockfile + active patches + source-replacement +//! table + (for `build-config`) the full profile / toolchain / +//! build-config preamble, then handing typed inputs to the +//! owning crates. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; + +use cabin_workspace::{WorkspaceLoadOptions, load_workspace, load_workspace_with_options}; + +use crate::cli::{ + ConfigSelectionArgs, ResolveFormat, ToolchainSelectionArgs, WorkspaceSelectionArgs, + build_selection_request, build_workspace_selection, compiler_wrapper_override_from_args, + compute_feature_resolution, lockfile_path_for, profile_selection_for_metadata, + resolve_build_configurations, resolve_invocation_manifest, resolve_per_package_build_flags, + toolchain_selection_from_args, workspace_compiler_wrapper_settings, + workspace_profile_definitions, +}; + +#[derive(Debug, Args)] +pub(crate) struct ExplainArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Output format. `human` is a concise summary (the + /// default); `json` is a structured document for tooling. + #[arg(long, value_name = "FORMAT", default_value = "human", global = true)] + pub format: ResolveFormat, + + /// Profile to evaluate, when the explanation depends on the + /// build configuration (`build-config`). Defaults to `dev`. + #[arg(long, value_name = "NAME", global = true)] + pub profile: Option, + + /// Toolchain-selection flags. Same precedence rules as + /// `cabin build`. + #[command(flatten)] + pub toolchain: ToolchainSelectionArgs, + + /// Feature selection flags. + #[command(flatten)] + pub selection: ConfigSelectionArgs, + + /// Workspace package-selection flags. Restrict the closure + /// the explanation considers (the same `--package` / + /// `--workspace` flags `cabin metadata` accepts). + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Disable every active patch / source-replacement entry. + #[arg(long, global = true)] + pub no_patches: bool, + + #[command(subcommand)] + pub command: ExplainCommand, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ExplainCommand { + /// Explain why a package is selected and which selected root + /// pulls it in. + Package { + /// Package name to explain. + name: String, + }, + /// Explain a target's owning package, kind, deps, and + /// language summary. + Target { + /// Target name to explain. + name: String, + }, + /// Explain where a package's source bytes come from. + Source { + /// Package name to explain. + name: String, + }, + /// Explain a feature's enablement on the named package. + Feature { + /// Query in the form `package/feature`. + query: String, + }, + /// Explain the resolved [`cabin_core::BuildConfiguration`] + /// for a package (profile, toolchain, flags, options, + /// variants, condition trace, fingerprint). + BuildConfig { + /// Package name to explain. + name: String, + }, +} + +pub(crate) fn explain( + args: &ExplainArgs, + reporter: crate::term_verbosity_glue::Reporter, +) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let initial_graph = load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_sources = active_patches.workspace_sources(); + let graph = if patched_sources.is_empty() { + initial_graph + } else { + let strict_packages: BTreeSet = BTreeSet::new(); + load_workspace_with_options( + &manifest_path, + &WorkspaceLoadOptions { + registry: &[], + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &BTreeSet::new(), + }, + )? + }; + + let lockfile_path = lockfile_path_for(&manifest_path); + let lockfile = if lockfile_path.is_file() { + Some( + cabin_lockfile::read_lockfile(&lockfile_path) + .with_context(|| format!("failed to read {}", lockfile_path.display()))?, + ) + } else { + None + }; + + let request = build_selection_request( + &args.selection.features, + args.selection.all_features, + args.selection.no_default_features, + ); + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + let feature_resolution = compute_feature_resolution(&graph, &resolved_selection, &request)?; + + let explanation = match &args.command { + ExplainCommand::Package { name } => { + let exp = cabin_explain::explain_package( + &graph, + &resolved_selection.packages, + name, + Some(&active_patches), + lockfile.as_ref(), + )?; + cabin_explain::Explanation::Package(exp) + } + ExplainCommand::Target { name } => { + let exp = cabin_explain::explain_target(&graph, &resolved_selection.packages, name)?; + cabin_explain::Explanation::Target(exp) + } + ExplainCommand::Source { name } => { + let exp = cabin_explain::explain_source( + &graph, + name, + Some(&active_patches), + lockfile.as_ref(), + &effective_config.source_replacements, + )?; + cabin_explain::Explanation::Source(exp) + } + ExplainCommand::Feature { query } => { + // Build a per-package feature view limited to the + // package the user named. We look up the package up + // front so we can map its enabled features into the + // typed view the cabin-explain crate consumes. + let pkg_name = query + .split_once('/') + .map(|(p, _)| p.to_owned()) + .unwrap_or_else(|| query.clone()); + let view = if let Some(idx) = graph.index_of(&pkg_name) { + let enabled = feature_resolution.for_package(idx).enabled_features.clone(); + Some(cabin_explain::cabin_feature_per_package_view::FeatureView { enabled }) + } else { + None + }; + let exp = cabin_explain::explain_feature(&graph, view.as_ref(), query)?; + cabin_explain::Explanation::Feature(exp) + } + ExplainCommand::BuildConfig { name } => { + // Build-config explanations need the same preamble + // as `cabin metadata`. We compute it inline rather + // than refactoring `metadata()` itself so the + // existing path stays untouched. + let manifest_profiles = workspace_profile_definitions(&graph); + let profile_selection = + profile_selection_for_metadata(args.profile.as_deref(), &effective_config)?; + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?; + let toolchain = crate::cli::resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph); + let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?; + let compiler_wrapper = crate::cli::resolve_compiler_wrapper_layered( + cli_compiler_wrapper, + &manifest_compiler_wrapper, + &effective_config, + &host_platform, + )?; + let toolchain_summary = cabin_core::ToolchainSummary::from_resolved_parts( + &toolchain, + compiler_wrapper.as_ref(), + ); + let profile_build = profile.build.as_ref(); + let build_flags = + resolve_per_package_build_flags(&graph, profile_build, &host_platform); + // `cabin explain` does not opt into dev-dep + // activation; dev-kind system deps stay + // declaration-only here. + let dev_for: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = + crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + let configurations = resolve_build_configurations( + &graph, + &request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let config = cabin_explain::explain_build_config(&configurations, &graph, name)?; + // BuildConfiguration already has its own JSON shape + // documented by `cabin metadata`. We render it + // directly rather than wrapping it in our `Explanation` + // enum so users see exactly the same shape they see + // in metadata's `configuration` blocks. + return render_build_config(args.format, name, config); + } + }; + + match args.format { + ResolveFormat::Human => { + let rendered = cabin_explain::render_explanation_human(&explanation); + print!("{rendered}"); + } + ResolveFormat::Json => { + let value = cabin_explain::render_explanation_json(&explanation); + let json = serde_json::to_string_pretty(&value) + .context("failed to serialize explanation as JSON")?; + println!("{json}"); + } + } + Ok(()) +} + +fn render_build_config( + format: ResolveFormat, + name: &str, + config: &cabin_core::BuildConfiguration, +) -> Result<()> { + match format { + ResolveFormat::Json => { + let mut map = serde_json::Map::new(); + map.insert( + "kind".to_owned(), + serde_json::Value::String("build-config".to_owned()), + ); + map.insert( + "package".to_owned(), + serde_json::Value::String(name.to_owned()), + ); + map.insert("configuration".to_owned(), config.as_json()); + let json = serde_json::to_string_pretty(&serde_json::Value::Object(map)) + .context("failed to serialize build-config explanation")?; + println!("{json}"); + } + ResolveFormat::Human => { + println!("package: {name}"); + println!("profile: {}", config.profile.name); + println!("fingerprint: {}", config.fingerprint); + // Stay terse — JSON is the contract for tooling. + } + } + Ok(()) +} diff --git a/crates/cabin-cli/src/fetch_output_glue.rs b/crates/cabin-cli/src/fetch_output_glue.rs new file mode 100644 index 000000000..5a908eea5 --- /dev/null +++ b/crates/cabin-cli/src/fetch_output_glue.rs @@ -0,0 +1,73 @@ +//! Human and JSON output rendering for `cabin fetch`. + +use std::path::Path; + +use anyhow::{Context, Result}; +use cabin_artifact::FetchedPackage; + +use crate::cli::ResolveFormat; + +pub(crate) fn emit_fetch_output( + fetched: &[FetchedPackage], + format: ResolveFormat, + cache_dir: &Path, + manifest_path: &Path, +) -> Result<()> { + match format { + ResolveFormat::Human => { + print_fetch_human(fetched, cache_dir, manifest_path); + Ok(()) + } + ResolveFormat::Json => print_fetch_json(fetched, cache_dir, manifest_path), + } +} + +fn print_fetch_human(fetched: &[FetchedPackage], cache_dir: &Path, manifest_path: &Path) { + if fetched.is_empty() { + println!("Fetched artifacts:"); + println!(" (no registry dependencies to fetch)"); + return; + } + println!("Fetched artifacts:"); + for pkg in fetched { + let source = display_relative(&pkg.source_dir, cache_dir, manifest_path); + println!(" {} {} -> {}", pkg.name.as_str(), pkg.version, source); + } +} + +fn print_fetch_json( + fetched: &[FetchedPackage], + cache_dir: &Path, + manifest_path: &Path, +) -> Result<()> { + let packages: Vec<_> = fetched + .iter() + .map(|pkg| { + let source_dir = display_relative(&pkg.source_dir, cache_dir, manifest_path); + serde_json::json!({ + "name": pkg.name.as_str(), + "version": pkg.version.to_string(), + "checksum": pkg.checksum, + "source_dir": source_dir, + }) + }) + .collect(); + let body = serde_json::to_string_pretty(&serde_json::json!({ "packages": packages })) + .context("failed to serialize fetch output as JSON")?; + println!("{body}"); + Ok(()) +} + +/// Best-effort short representation of a cache path for display. We +/// keep absolute paths intact (the source of truth is the cache root) +/// but trim the manifest's package root so output stays readable. +fn display_relative(path: &Path, _cache_dir: &Path, manifest_path: &Path) -> String { + let project_root = manifest_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + if let Ok(stripped) = path.strip_prefix(&project_root) { + return stripped.display().to_string(); + } + path.display().to_string() +} diff --git a/crates/cabin-cli/src/fmt_glue.rs b/crates/cabin-cli/src/fmt_glue.rs new file mode 100644 index 000000000..9b9c171d8 --- /dev/null +++ b/crates/cabin-cli/src/fmt_glue.rs @@ -0,0 +1,213 @@ +//! Orchestration for `cabin fmt`. +//! +//! Translates the CLI flag bundle into the typed inputs the +//! shared crates accept and routes their outcomes back to the +//! reporter. Keeping this glue in a dedicated module preserves +//! the package rule that `cabin-cli` stays thin: arg parsing +//! and reporter wiring live here, but no source-discovery +//! algorithms and no `clang-format` command-line construction +//! live in this file. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::Args; + +use cabin_fmt::{ + FormatMode, FormatReport, FormatRequest, resolve_formatter_executable, run_formatter, +}; +use cabin_source_discovery::{SourceDiscoveryRequest, discover_sources}; + +use crate::plural; +use crate::source_tooling_glue::{ + absolutize, describe_packages, display_workspace_relative, nested_package_excludes, + package_selection_from_flags, +}; +use crate::term_verbosity_glue::Reporter; + +/// `cabin fmt` argument bundle. +/// +/// Field doc-comments are picked up by clap and rendered in +/// `cabin fmt --help`; keep them user-focused. +#[derive(Debug, Args)] +pub(crate) struct FmtArgs { + /// Path to the cabin.toml manifest. Same precedence rules + /// as `cabin build`: when omitted, Cabin walks upward from + /// the current directory to find the nearest manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Build output directory to exclude from source discovery. + /// Same precedence rules as `cabin build`: `--build-dir` > + /// `CABIN_BUILD_DIR` > `[paths] build-dir` config setting > + /// built-in default `build`. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Verify formatting without rewriting any file. Exits + /// non-zero when at least one file would be reformatted. + #[arg(long)] + pub check: bool, + + /// Exclude one file or directory from formatting. May be + /// repeated. Paths are resolved against the current + /// working directory. + #[arg(long, value_name = "PATH")] + pub exclude: Vec, + + /// Disable VCS ignore handling so files that are normally + /// hidden by `.gitignore` are also formatted. Cabin's + /// built-in build / cache / vendor exclusions still apply. + #[arg(long)] + pub no_ignore_vcs: bool, + + /// Format every workspace member. Cannot be combined with + /// `--package` or `--default-members`. + #[arg(long, conflicts_with_all = &["package", "default_members"])] + pub workspace: bool, + + /// Format the named workspace package. Repeat the flag to + /// select multiple packages. Errors when a name is not a + /// workspace member. + #[arg(long = "package", short = 'p', value_name = "PACKAGE")] + pub package: Vec, + + /// Format `[workspace.default-members]`. Errors when the + /// workspace declares no default-members. + #[arg(long, conflicts_with_all = &["workspace", "package"])] + pub default_members: bool, +} + +/// Entry point invoked by the top-level dispatcher. +pub(crate) fn fmt(args: &FmtArgs, reporter: Reporter) -> Result { + let manifest_path = crate::cli::resolve_invocation_manifest(args.manifest_path.as_deref())?; + let graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&graph)?; + + let workspace_selection = + package_selection_from_flags(args.workspace, &args.package, args.default_members); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + + // Effective build directory, honouring `--build-dir` / + // `CABIN_BUILD_DIR` / `[paths] build-dir`. We want the + // walker to exclude exactly the directory `cabin build` + // would have written into. + let (build_dir_input, _) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutize(&graph.root_dir, &build_dir_input); + + let cwd = std::env::current_dir().context("failed to determine current directory")?; + let absolute_excludes: Vec = + args.exclude.iter().map(|p| absolutize(&cwd, p)).collect(); + + let selected_indices: BTreeSet = resolved_selection.packages.iter().copied().collect(); + let roots: Vec = resolved_selection + .packages + .iter() + .map(|&idx| graph.packages[idx].manifest_dir.clone()) + .collect(); + let nested_excludes = nested_package_excludes(&graph, &selected_indices); + + let mut excluded_directories: Vec = nested_excludes; + excluded_directories.push(build_dir); + + let request = SourceDiscoveryRequest { + roots, + excluded_paths: absolute_excludes, + excluded_directories, + respect_vcs_ignore: !args.no_ignore_vcs, + }; + let discovered = discover_sources(&request) + .map_err(|err| anyhow::anyhow!("source discovery failed: {err}"))?; + let files: Vec = discovered.into_iter().map(|f| f.absolute_path).collect(); + + let executable = resolve_formatter_executable(|key| std::env::var_os(key)); + let mode = if args.check { + FormatMode::Check + } else { + FormatMode::Write + }; + + let mut selected_names: Vec = resolved_selection + .packages + .iter() + .map(|&idx| graph.packages[idx].package.name.as_str().to_owned()) + .collect(); + selected_names.sort(); + + if files.is_empty() { + reporter.status(format_args!( + "cabin: no C/C++ sources found in {}", + describe_packages(&selected_names) + )); + return Ok(ExitCode::SUCCESS); + } + + reporter.verbose(format_args!( + "cabin: formatting {} file{} across {}", + files.len(), + plural(files.len()), + describe_packages(&selected_names), + )); + + let mode_args = match mode { + FormatMode::Write => "--style=file -i", + FormatMode::Check => "--style=file --dry-run -Werror", + }; + reporter.very_verbose(format_args!( + "cabin: running `{} {} <{} file{}>`", + executable.to_string_lossy(), + mode_args, + files.len(), + plural(files.len()), + )); + for file in &files { + reporter.very_verbose(format_args!( + " {}", + display_workspace_relative(&graph.root_dir, file), + )); + } + + let request = FormatRequest { + executable, + files, + mode, + }; + + match run_formatter(&request) { + Ok(FormatReport::Wrote { files_processed }) => { + reporter.status(format_args!( + "cabin: formatted {} file{}", + files_processed, + plural(files_processed), + )); + Ok(ExitCode::SUCCESS) + } + Ok(FormatReport::Clean { files_inspected }) => { + reporter.status(format_args!( + "cabin: all {} file{} already formatted", + files_inspected, + plural(files_inspected), + )); + Ok(ExitCode::SUCCESS) + } + Ok(FormatReport::NeedsFormatting { files_inspected }) => { + // Status, not error: the user asked to verify + // formatting and the answer is "no". The non-zero + // exit code is the actionable signal; we don't + // want a noisy `error:` block on top of it. + reporter.status(format_args!( + "cabin: formatting check failed; {} file{} would be reformatted (re-run without --check to apply)", + files_inspected, + plural(files_inspected), + )); + Ok(ExitCode::FAILURE) + } + Err(err) => bail!(err.to_string()), + } +} diff --git a/crates/cabin-cli/src/lib.rs b/crates/cabin-cli/src/lib.rs new file mode 100644 index 000000000..e868871e9 --- /dev/null +++ b/crates/cabin-cli/src/lib.rs @@ -0,0 +1,558 @@ +//! Library half of the `cabin` CLI binary. +//! +//! The bin (`src/main.rs`) is intentionally a thin shim that +//! calls [`run`]; the typed parser ([`Cli`]), the +//! command dispatcher, and every glue module live here so +//! integration tests can re-use the same surface the binary +//! does — `Cli::command()` is the single source of truth for +//! which subcommands exist and which are hidden. + +#![allow( + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::must_use_candidate, + clippy::redundant_closure_for_method_calls, + clippy::struct_excessive_bools, + clippy::stable_sort_primitive, + clippy::uninlined_format_args, + clippy::format_push_string, + clippy::map_unwrap_or, + clippy::manual_let_else, + clippy::too_many_lines, + clippy::doc_markdown, + clippy::single_match_else, + clippy::match_wildcard_for_single_variants, + clippy::if_not_else, + clippy::unused_self, + clippy::semicolon_if_nothing_returned, + clippy::unnecessary_trailing_comma, + clippy::default_trait_access +)] + +use std::process::ExitCode; + +use cabin_core::ColorChoice; +use clap::{CommandFactory, FromArgMatches}; +use termcolor::{StandardStream, WriteColor}; + +pub use crate::cli::Cli; +use crate::term_verbosity_glue::{ + CliVerbosity, Reporter, discover_early_config_verbosity, resolve_verbosity, +}; + +/// Marker name for the cargo-style `...` row that appears at +/// the end of the `cabin --help` Commands block. It points +/// users at `cabin --list` without polluting the Subcommand +/// enum: the row is injected into the clap command tree only +/// for help / parsing, and the dispatcher treats it as an +/// alias for `--list`. `command_list`, `completions`, and +/// `manpages` build their output from the unmodified +/// [`Cli::command()`] tree so the row never leaks into the +/// `--list` view, generated completions, or man pages. +const DOTS_HINT: &str = "..."; + +/// About text rendered next to the [`DOTS_HINT`] row. Matches +/// cargo's wording for the equivalent hint in `cargo --help`. +const DOTS_ABOUT: &str = "See all commands with --list"; + +/// Render the styled `Commands:` block for `cabin --help`, +/// using cargo's `name, alias` rendering instead of clap's +/// default `[aliases: alias]` form. +/// +/// Embedded ANSI escapes paint: +/// - the `Commands:` heading bright green + bold (matching +/// clap's auto styling of `Usage:`); +/// - each `[, ]` cell bright cyan + bold; +/// - the about text stays plain. +/// +/// anstream strips the escapes when the writer disables +/// colour, so `cabin --color never --help` and pipe-redirected +/// output stay clean. Hidden subcommands are skipped because +/// `cabin --help` is the curated view; the full directory lives +/// in `cabin --list`. +fn format_commands_block(cmd: &clap::Command) -> String { + use std::fmt::Write as _; + + /// One subcommand row: the canonical name plus any + /// visible aliases, paired with the short about text. The + /// `tokens` list keeps each name / alias separate so the + /// renderer can style them individually while leaving the + /// `, ` separators unstyled — same as cargo. + struct Row { + tokens: Vec, + about: String, + } + + let rows: Vec = cmd + .get_subcommands() + .filter(|sub| !sub.is_hide_set()) + .map(|sub| { + let mut tokens = vec![sub.get_name().to_owned()]; + for alias in sub.get_visible_aliases() { + tokens.push(alias.to_string()); + } + let about = sub + .get_about() + .map(|s| s.to_string().lines().next().unwrap_or("").trim().to_owned()) + .unwrap_or_default(); + Row { tokens, about } + }) + .collect(); + + // The display width of a row is the length of all tokens + // joined by `, ` (the printed separator). ANSI escapes + // around each token do not add display width because they + // do not advance the cursor, but they do add bytes — we + // compute the visible width from the plain-text join. + let width = rows + .iter() + .map(|row| row.tokens.join(", ").len()) + .max() + .unwrap_or(0); + + // clap prepends a blank line before `{after-help}`, so + // our block starts directly with the styled heading. + let mut out = String::new(); + let _ = writeln!(out, "\x1b[1m\x1b[92mCommands:\x1b[0m"); + for row in &rows { + out.push_str(" "); + let plain_width: usize = row.tokens.join(", ").len(); + for (i, token) in row.tokens.iter().enumerate() { + if i > 0 { + // Cargo emits the `, ` between aliases as plain + // text; only the name / alias tokens get the + // bright-cyan + bold styling. + out.push_str(", "); + } + let _ = write!(out, "\x1b[1m\x1b[96m{token}\x1b[0m"); + } + if row.about.is_empty() { + out.push('\n'); + } else { + // Pad to the column where the about text begins. + let padding = width.saturating_sub(plain_width); + for _ in 0..padding { + out.push(' '); + } + let _ = writeln!(out, " {about}", about = row.about); + } + } + out +} + +// The clap parser stays reachable for this crate's glue modules, +// but `Cli` is re-exported at the crate root for integration +// tests and downstream command-tree generation. +mod cli; +mod command_list; +mod completions; +mod config_glue; +mod env_flags_glue; +mod explain_glue; +mod fetch_output_glue; +mod fmt_glue; +mod manpages; +mod metadata_glue; +mod patch_glue; +mod run_glue; +mod source_tooling_glue; +mod system_deps_glue; +mod term_color_glue; +mod term_verbosity_glue; +mod test_glue; +mod tidy_glue; +mod tree_glue; +mod vendor_glue; +mod version_glue; +mod version_info; + +/// Return the English plural suffix for a count: empty for one, +/// `s` for everything else. Used across the reporter glue files +/// to keep `" file"` / `" files"` consistent. +pub(crate) fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// Run the `cabin` CLI to completion using the given argv +/// iterator. Owns parsing, color/verbosity resolution, +/// dispatch, and top-level error rendering. The binary +/// `main` calls this with the process's own arguments. +pub fn run(args: I) -> ExitCode +where + I: IntoIterator, + T: Into + Clone, +{ + // Build the parser from the typed `Cli` definition and + // append a cargo-style `... See all commands with + // --list` row as the last visible entry in the Commands + // block. Two steps make that ordering work: + // + // 1. `Command::build` forces clap to materialise its + // auto-injected `help` pseudo-subcommand so we can + // address it by name. + // 2. `mut_subcommand("help", â€Ļ)` hides the help row from + // the Commands block — `cabin help ` still + // works, the row just is not advertised, matching + // cargo's `cargo --help` curation. + // + // Then we append the `...` row. Because the auto-help is + // hidden, our row is the visible final entry. + // + // The row is purely visual: the dispatcher treats `cabin + // ...` as a shortcut for `cabin --list` (so the row is + // also a working command), and the canonical + // `Cli::command()` consumed by `command_list`, + // `completions`, and `manpages` never sees the marker. + let mut cmd = Cli::command(); + cmd.build(); + let cmd = cmd.mut_subcommand("help", |sub| sub.hide(true)).subcommand( + clap::Command::new(DOTS_HINT) + .about(DOTS_ABOUT) + .disable_help_subcommand(true), + ); + // Render the Commands block manually so visible aliases + // appear in cargo's `name, alias` style (`build, b`). + // Clap's `{subcommands}` placeholder uses the default + // `[aliases: b]` rendering, which is not what cargo + // emits. See `format_commands_block` for the format. + // + // Append the cargo-style trailer that points users at + // `cabin help ` for per-subcommand detail. + let mut after_help = format_commands_block(&cmd); + after_help.push('\n'); + after_help.push_str("See 'cabin help ' for more information on a specific command.\n"); + let cmd = cmd.after_help(after_help); + let matches = match cmd.try_get_matches_from(args) { + Ok(m) => m, + Err(err) => { + // `clap::Error` already routes `--help` / + // `--version` to stdout and real errors to stderr + // with the correct ANSI handling. Just hand it + // through. + err.exit(); + } + }; + // `cabin ...` is a help-row affordance that doubles as a + // shortcut for `cabin --list`. The unmapped subcommand + // produces `cmd: None` after `from_arg_matches`; promote + // it to `list = true` so the downstream dispatcher renders + // the listing with the same colour-aware code path as the + // real flag. + let dots_shortcut = matches.subcommand_name() == Some(DOTS_HINT); + let mut parsed = match Cli::from_arg_matches(&matches) { + Ok(cli) => cli, + Err(err) => err.exit(), + }; + if dots_shortcut { + parsed.list = true; + } + // Resolve the terminal-color choice as early as possible + // so even errors emitted while loading the workspace honor + // `--color`. The chain is `--color` â–ļ `CABIN_TERM_COLOR` â–ļ + // user-level `[term] color` config â–ļ `auto`. The user-level + // config is the only layer reachable without a workspace, + // which is the right shape: workspace-level overrides + // affect *that* workspace's commands, and we have no + // workspace context yet. + let config_color = term_color_glue::discover_early_config_color(); + let early_color = match term_color_glue::resolve_color_choice( + parsed.color.map(|c| c.into()), + |key| std::env::var(key).ok(), + config_color, + ) { + Ok(choice) => choice, + Err(env_err) => { + // Use `Auto` for the styling of the error itself — + // we cannot trust the value the user gave us. + let mut stderr = + StandardStream::stderr(cabin_diagnostics::termcolor_choice(ColorChoice::Auto)); + let _ = write_plain_error(&mut stderr, &env_err.to_string()); + return ExitCode::FAILURE; + } + }; + + // Resolve verbosity once, against the same user-level + // config the early color resolution observed. Subcommands + // that load their own workspace-level config will see any + // workspace-level `term.verbose` / `term.quiet` overrides + // through their own dispatcher loop; the early resolve is + // sufficient for status output gated through the reporter. + let cli_verbosity = CliVerbosity { + verbose_count: parsed.verbose, + quiet: parsed.quiet, + }; + let early_config_verbosity = discover_early_config_verbosity(); + let verbosity = match resolve_verbosity( + cli_verbosity, + |key| std::env::var(key).ok(), + &early_config_verbosity, + ) { + Ok(level) => level, + Err(env_err) => { + let mut stderr = + StandardStream::stderr(cabin_diagnostics::termcolor_choice(early_color)); + let _ = write_plain_error(&mut stderr, &env_err.to_string()); + return ExitCode::FAILURE; + } + }; + let reporter = Reporter::with_color(verbosity, early_color); + + match cli::run(parsed, reporter, early_color) { + Ok(code) => code, + Err(error) => { + render_error(&error, early_color); + ExitCode::FAILURE + } + } +} + +/// Render a top-level error to stderr, honouring the +/// caller-resolved [`ColorChoice`]. +/// +/// The dispatcher returns `anyhow::Error`; some leaves wrap a +/// typed `miette::Diagnostic` (today: `WorkspaceError` and +/// any future domain error that derives `Diagnostic`). +/// `render_error` peels through the anyhow chain and routes +/// the first `Diagnostic` it finds to +/// [`cabin_diagnostics::render`], so the user sees a stable +/// `error[]: ` block plus the `help:` text the +/// domain author attached. +/// +/// When the chain carries no typed diagnostic, the formatter +/// falls back to anyhow's default `{:#}` rendering, which is +/// what the rest of the CLI has emitted historically. +fn render_error(error: &anyhow::Error, color: ColorChoice) { + // Walk the entire `Error::source` chain and remember the + // deepest typed `Diagnostic` we can recover. The deepest + // one is the most specific (e.g. `ManifestError::TomlAt` + // with source-annotated labels rather than the wrapping + // `WorkspaceError::Manifest`), so the user sees the + // diagnostic that actually carries help text and span info. + let mut current: Option<&(dyn std::error::Error + 'static)> = Some(error.as_ref()); + let mut deepest: Option> = None; + while let Some(err) = current { + if let Some(diag) = downcast_diagnostic(err) { + deepest = Some(diag); + } + current = err.source(); + } + let mut stderr = StandardStream::stderr(cabin_diagnostics::termcolor_choice(color)); + if let Some(candidate) = deepest { + let _ = candidate.render(error, &mut stderr, color); + return; + } + let _ = write_plain_error(&mut stderr, &format!("{error:#}")); +} + +/// Emit a plain `error: ` line, painting only the +/// `error:` prefix. Used by both the env-validation failure +/// path and the anyhow fallback. +fn write_plain_error(stderr: &mut StandardStream, message: &str) -> std::io::Result<()> { + use std::io::Write as _; + + if stderr.supports_color() { + let mut spec = termcolor::ColorSpec::new(); + spec.set_fg(Some(termcolor::Color::Red)).set_bold(true); + stderr.set_color(&spec)?; + stderr.write_all(b"error")?; + stderr.reset()?; + writeln!(stderr, ": {message}") + } else { + writeln!(stderr, "error: {message}") + } +} + +/// A diagnostic recovered from one item in the anyhow source +/// chain. +enum DiagnosticCandidate<'a> { + /// The domain error itself implements `miette::Diagnostic` + /// and may carry source snippets or variant-specific help. + Rich(&'a dyn cabin_diagnostics::miette::Diagnostic), + /// The domain error is typed and user-facing, but only needs + /// an area-level stable code. Wrap it so rendering still goes + /// through `cabin-diagnostics`. + Coded { code: &'static str }, +} + +impl DiagnosticCandidate<'_> { + fn render( + &self, + root: &anyhow::Error, + stderr: &mut StandardStream, + color: ColorChoice, + ) -> std::io::Result<()> { + match self { + Self::Rich(diag) => cabin_diagnostics::render(*diag, stderr, color), + Self::Coded { code } => { + let message = format!("{root:#}"); + let diagnostic = cabin_diagnostics::CodedMessage::new(&message, code); + cabin_diagnostics::render(&diagnostic, stderr, color) + } + } + } +} + +/// Helper that walks the known typed-error roots and yields a +/// diagnostic candidate when one matches. +/// +/// Adding a new diagnostic-bearing error type is a one-line +/// change here. The cost of explicit listing is small relative +/// to the boundary clarity it gives — we never accidentally +/// route a typed error away from the diagnostic renderer +/// because of an unsafe blanket impl. +fn downcast_diagnostic<'a>( + err: &'a (dyn std::error::Error + 'static), +) -> Option> { + use cabin_diagnostics::code; + + // The order matters: try the most specific typed error + // first, then fall through to looser wrappers that share + // the same source chain. ManifestError can hide either + // standalone (e.g. `cabin package`) or behind + // WorkspaceError::Manifest, so we look at the leaf first. + if let Some(e) = err.downcast_ref::() { + return Some(DiagnosticCandidate::Rich(e)); + } + // Workspace / artifact / package errors box their inner + // `ManifestError` (the boxed variant keeps the outer error + // small enough to pass `clippy::result_large_err`); the + // chain walker would otherwise skip the manifest layer. + if let Some(e) = err.downcast_ref::>() { + return Some(DiagnosticCandidate::Rich(e.as_ref())); + } + if let Some(e) = err.downcast_ref::() { + return Some(DiagnosticCandidate::Rich(e)); + } + if let Some(e) = err.downcast_ref::() { + return Some(DiagnosticCandidate::Rich(e)); + } + if let Some(e) = err.downcast_ref::() { + return Some(DiagnosticCandidate::Coded { + code: config_error_code(e), + }); + } + if err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::LOCKFILE_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::RESOLVER_ERROR, + }); + } + if err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::ARTIFACT_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::BUILD_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::PACKAGE_ERROR, + }); + } + if err + .downcast_ref::() + .is_some() + || err + .downcast_ref::() + .is_some() + || err.downcast_ref::().is_some() + || err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::TOOLCHAIN_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::VENDOR_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::INDEX_ERROR, + }); + } + if err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::INDEX_HTTP_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::PUBLISH_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::FMT_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::TIDY_ERROR, + }); + } + if err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::SOURCE_DISCOVERY_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::TEST_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::EXPLAIN_ERROR, + }); + } + if err.downcast_ref::().is_some() { + return Some(DiagnosticCandidate::Coded { + code: code::NINJA_ERROR, + }); + } + if err + .downcast_ref::() + .is_some() + { + return Some(DiagnosticCandidate::Coded { + code: code::FEATURE_ERROR, + }); + } + None +} + +fn config_error_code(error: &cabin_config::ConfigError) -> &'static str { + use cabin_config::{ConfigError, ConfigParseError}; + use cabin_diagnostics::code; + + match error { + ConfigError::Parse { + source: ConfigParseError::InvalidBuildJobs { .. }, + .. + } => code::CONFIG_INVALID_BUILD_JOBS, + _ => code::CONFIG_LOAD_FAILED, + } +} diff --git a/crates/cabin-cli/src/main.rs b/crates/cabin-cli/src/main.rs new file mode 100644 index 000000000..a109aa661 --- /dev/null +++ b/crates/cabin-cli/src/main.rs @@ -0,0 +1,14 @@ +//! Thin entry-point for the `cabin` binary. +//! +//! The library half (`cabin-cli`'s `lib.rs`) owns parsing, +//! dispatch, and error rendering. This shim hands off to +//! [`cabin_cli::run`] with the process's own argv and +//! propagates its exit code so the binary stays trivial to +//! audit and integration tests can call the same entry point +//! the binary uses. + +use std::process::ExitCode; + +fn main() -> ExitCode { + cabin_cli::run(std::env::args_os()) +} diff --git a/crates/cabin-cli/src/manpages.rs b/crates/cabin-cli/src/manpages.rs new file mode 100644 index 000000000..4584b79cf --- /dev/null +++ b/crates/cabin-cli/src/manpages.rs @@ -0,0 +1,154 @@ +//! Man-page generation for `cabin mangen`. +//! +//! Like `compgen`, this module derives every byte of output from the +//! canonical [`clap::Command`] tree exposed by `Cli::command()`. +//! Every top-level subcommand — including ones hidden from +//! `cabin --help` such as `compgen` and `mangen` — gets its own +//! `cabin-.1` page so downstream packagers ship a complete +//! manual set. The root `cabin.1` page mirrors `cabin --help` and +//! therefore omits hidden subcommands from its SUBCOMMANDS section; +//! the per-subcommand pages cover them. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use clap::{Args, CommandFactory}; +use clap_mangen::Man; + +use crate::cli::Cli; + +/// Arguments accepted by `cabin mangen`. +#[derive(Debug, Args)] +pub(crate) struct MangenArgs { + /// Directory to write man pages into. Created if it does not + /// already exist; existing files are overwritten. Without this + /// flag the root `cabin(1)` man page is written to stdout. + #[arg(long, value_name = "PATH")] + output_dir: Option, +} + +/// Top-level entry point for `cabin mangen`. +pub(crate) fn run(args: &MangenArgs) -> Result<()> { + let cmd = Cli::command(); + match args.output_dir.as_deref() { + None => write_root_to_stdout(&cmd)?, + Some(dir) => write_to_dir(&cmd, dir)?, + } + Ok(()) +} + +fn write_root_to_stdout(cmd: &clap::Command) -> Result<()> { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + Man::new(cmd.clone()) + .render(&mut handle) + .context("failed to render cabin(1) man page")?; + Ok(()) +} + +fn write_to_dir(cmd: &clap::Command, dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("failed to create man-page output dir {}", dir.display()))?; + + // Root page first: `cabin.1`. + let root_path = dir.join(filename_for_root()); + let mut file = fs::File::create(&root_path) + .with_context(|| format!("failed to create {}", root_path.display()))?; + Man::new(cmd.clone()) + .render(&mut file) + .with_context(|| format!("failed to render {}", root_path.display()))?; + + // One page per top-level subcommand, including ones hidden + // from `cabin --help`. Hidden commands stay shipped through + // `cabin --list`, shell completions, and these per-command + // pages. Renaming the subcommand to `cabin-` yields a + // man page whose `.TH` and SYNOPSIS show the conventional + // `cabin-build(1)` form; the subcommand's own arguments + // still render correctly because clap_mangen reads them + // from the same Command. clap auto-injects a `help` + // pseudo-subcommand that mirrors `--help`; we skip it + // because the root page already documents `--help`. + for sub in cmd.get_subcommands() { + if sub.get_name() == "help" { + continue; + } + // clap's `Command::name` requires `&'static str`; leak the + // freshly-built display name once per subcommand. The CLI + // process exits right after `mangen` returns, so the leak is + // bounded. + let display_name: &'static str = + Box::leak(format!("cabin-{}", sub.get_name()).into_boxed_str()); + // Clear the hidden flag for the per-page render so the + // page includes the command's arguments and options; the + // root `cabin(1)` page still observes the original hidden + // status and omits the command from its SUBCOMMANDS list + // to match `cabin --help`. + let renamed = sub.clone().name(display_name).hide(false); + let path = dir.join(filename_for_subcommand(display_name)); + let mut file = fs::File::create(&path) + .with_context(|| format!("failed to create {}", path.display()))?; + Man::new(renamed) + .render(&mut file) + .with_context(|| format!("failed to render {}", path.display()))?; + } + Ok(()) +} + +/// Filename for the root `cabin(1)` man page. +fn filename_for_root() -> String { + "cabin.1".to_owned() +} + +/// Filename for the per-subcommand `cabin-(1)` man page, +/// given the already-prefixed display name. +fn filename_for_subcommand(display_name: &str) -> String { + format!("{display_name}.1") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_filename_is_cabin_dot_one() { + assert_eq!(filename_for_root(), "cabin.1"); + } + + #[test] + fn subcommand_filename_uses_dashed_form() { + assert_eq!(filename_for_subcommand("cabin-build"), "cabin-build.1"); + } + + #[test] + fn hidden_subcommands_are_known_and_curated() { + // `cabin --help` curates the day-to-day surface matching + // cargo's `--help` pattern: inspection (`metadata`, + // `tree`, `explain`), low-level (`resolve`), offline / + // networking (`fetch`, `vendor`), pre-publish + // (`package`), and distribution helpers (`compgen`, + // `mangen`) are hidden from the curated view but still + // ship per-command man pages and appear in `cabin + // --list`. This test pins the hidden set so a new + // hidden subcommand is reviewed intentionally rather + // than slipping in by accident. + use std::collections::BTreeSet; + let cmd = Cli::command(); + let hidden: BTreeSet<&str> = cmd + .get_subcommands() + .filter(|s| s.is_hide_set()) + .map(|s| s.get_name()) + .collect(); + let expected: BTreeSet<&str> = [ + "compgen", "explain", "fetch", "mangen", "metadata", "package", "resolve", "tree", + "vendor", + ] + .iter() + .copied() + .collect(); + assert_eq!( + hidden, expected, + "hidden subcommand set drifted; update tests and review --help surface" + ); + } +} diff --git a/crates/cabin-cli/src/metadata_glue.rs b/crates/cabin-cli/src/metadata_glue.rs new file mode 100644 index 000000000..717b34536 --- /dev/null +++ b/crates/cabin-cli/src/metadata_glue.rs @@ -0,0 +1,488 @@ +//! Metadata JSON view construction for `cabin metadata`. +//! +//! The CLI command owns orchestration; this module owns only the +//! serialisable view assembled from already-resolved typed inputs. + +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +use serde::Serialize; + +use cabin_core::{DependencySource, Package}; +use cabin_lockfile::Lockfile; +use cabin_workspace::PackageGraph; + +/// Top-level `cabin metadata --format json` document. +#[derive(Serialize)] +pub(crate) struct MetadataView<'a> { + workspace: Option>, + pub(crate) packages: Vec>, + lockfile: Option>, + /// Platform context used when evaluating + /// `[target.'cfg(...)'.]` predicates. Always populated + /// so consumers of the JSON view can see why a given dep is + /// active or inactive without having to re-derive the host + /// platform themselves. + target_platform: TargetPlatformView, + /// Build-profile context: the resolved `selected` profile + /// plus every available profile name, plus the parsed + /// definitions consumers can use to recompute fields without + /// re-reading the manifest. + profiles: ProfilesView, + /// Resolved C/C++ toolchain plus per-tool source. Always + /// populated so consumers can see which compiler / archiver + /// a build would use without rerunning `cabin build`. + toolchain: serde_json::Value, + /// Loaded config files plus every effective config-derived + /// setting. Always present (even when no files were loaded) + /// so consumers can distinguish "config absent" from "config + /// silent" without re-deriving discovery. + config: serde_json::Value, + /// Active patch entries after manifest+config merging and + /// validation. Empty array when no patches apply. + patches: serde_json::Value, + /// Active source-replacement entries from the merged + /// effective config. Empty array when none apply. + source_replacements: serde_json::Value, +} + +#[derive(Serialize)] +struct ProfilesView { + /// Fully resolved selected profile (built-in or custom). + selected: serde_json::Value, + /// Sorted list of every profile name visible to the user + /// (`dev`, `release`, plus any custom ones declared in the + /// workspace root manifest). + available: Vec, + /// Manifest-declared profile definitions, keyed by profile + /// name in deterministic order. Omitted when the manifest + /// declares none, so packages without `[profile.*]` tables + /// keep their previous JSON shape. + #[serde(skip_serializing_if = "serde_json::Map::is_empty")] + definitions: serde_json::Map, +} + +#[derive(Serialize)] +struct TargetPlatformView { + os: String, + arch: String, + family: String, + env: String, + abi: String, + target: String, +} + +impl TargetPlatformView { + fn from_platform(platform: &cabin_core::TargetPlatform) -> Self { + Self { + os: platform.os.clone(), + arch: platform.arch.clone(), + family: platform.family.clone(), + env: platform.env.clone(), + abi: platform.abi.clone(), + target: platform.target.clone(), + } + } +} + +#[derive(Serialize)] +struct LockfileView<'a> { + path: &'a Path, + version: u32, + packages: Vec>, +} + +#[derive(Serialize)] +struct LockedPackageView<'a> { + name: &'a str, + version: String, + source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + checksum: Option<&'a str>, + dependencies: Vec<&'a str>, +} + +#[derive(Serialize)] +struct WorkspaceView<'a> { + root: &'a Path, + members: Vec<&'a str>, + /// Members listed under `[workspace.default-members]`. + /// Empty when none are declared so the JSON shape stays + /// stable for callers that do not use default-members. + #[serde(skip_serializing_if = "Vec::is_empty")] + default_members: Vec<&'a str>, + /// Directory paths the loader removed via + /// `[workspace.exclude]`, normalised relative to the workspace + /// root. Empty when no excludes are declared. + #[serde(skip_serializing_if = "Vec::is_empty")] + excluded_members: Vec<&'a Path>, + /// Members the user requested via CLI flags (or the + /// documented "current package" fallback). Sorted by package + /// name for deterministic output. + selected_packages: Vec<&'a str>, +} + +#[derive(Serialize)] +pub(crate) struct PackageView<'a> { + pub(crate) name: &'a str, + pub(crate) version: String, + manifest_path: &'a Path, + /// Cabin package dependencies (`[dependencies]`, + /// `[dev-dependencies]`). + /// Every entry carries an explicit + /// `dependency_kind` field so consumers can filter by kind + /// without re-parsing the manifest. The list is sorted by + /// `(dependency_kind, name)` for deterministic output. + dependencies: Vec>, + /// `system = true` declarations. Externally provided + /// (system libraries, SDKs, installed tools) - never resolved + /// through the Cabin registry. Sorted by name. Omitted when + /// no system dependencies are declared so packages without + /// them keep their previous JSON shape. + #[serde(skip_serializing_if = "Vec::is_empty")] + system_dependencies: Vec>, + targets: &'a [cabin_core::Target], + pub(crate) is_root: bool, + pub(crate) is_primary: bool, + /// Declared `[features]`. `None` when the manifest has + /// no features so older callers and tools see the same JSON + /// shape they always have. + #[serde(skip_serializing_if = "Option::is_none")] + features: Option<&'a cabin_core::Features>, + /// Resolved per-package configuration. Always populated + /// (defaults expand even when the user passes no flags); kept + /// optional so packages with zero declarations keep their + /// previous JSON shape. + #[serde(skip_serializing_if = "Option::is_none")] + configuration: Option, +} + +#[derive(Serialize)] +struct DependencyView<'a> { + name: &'a str, + /// Manifest section the dependency was declared in. Always + /// emitted (even for normal dependencies) so consumers do not + /// have to special-case the implicit-default case. + dependency_kind: cabin_core::DependencyKind, + /// Whether the dependency is optional. Omitted when `false` + /// so packages without optional deps keep their previous + /// JSON shape. + #[serde(skip_serializing_if = "is_false_dep")] + optional: bool, + /// Per-edge feature requests on the dependency package. + /// Omitted when empty. + #[serde(skip_serializing_if = "<[String]>::is_empty")] + features: &'a [String], + /// Whether this edge requests the dependency's `default` + /// feature. Omitted when `true` (the documented default) so + /// the JSON shape stays stable for packages that do not opt + /// out. + #[serde(skip_serializing_if = "is_true_dep")] + default_features: bool, + /// Canonical inner-expression form of an optional `cfg(...)` + /// predicate copied from the manifest. Omitted when no + /// `[target.'cfg(...)']` table guarded this dependency. + #[serde(skip_serializing_if = "Option::is_none")] + target: Option, + /// Whether the `target` predicate matches the host platform. + /// `true` for unconditional dependencies (the documented + /// default); `false` when a `cfg(...)` predicate fails on + /// the current host. Always emitted so consumers can decide + /// whether to surface the dependency without re-evaluating + /// the predicate. + active: bool, + #[serde(flatten)] + source: DependencySourceView<'a>, +} + +fn is_false_dep(value: &T) -> bool +where + T: PartialEq + Default, +{ + *value == T::default() +} + +fn is_true_dep(value: &T) -> bool +where + T: PartialEq + Default + std::ops::Not, +{ + *value == !T::default() +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum DependencySourceView<'a> { + Path { + path: &'a Path, + }, + Version { + requirement: String, + }, + /// An unresolved `{ workspace = true }` opt-in. The + /// Workspace loader normally rewrites these into `Path` / + /// `Version` before metadata is serialised, so this variant + /// only surfaces when the user inspects a member manifest in + /// isolation. + Workspace, +} + +#[derive(Serialize)] +struct SystemDependencyView<'a> { + name: &'a str, + /// Manifest section the system dependency was declared in. + dependency_kind: cabin_core::DependencyKind, + version: &'a str, + /// Canonical inner-expression form of an optional `cfg(...)` + /// predicate copied from the manifest. Omitted when absent. + #[serde(skip_serializing_if = "Option::is_none")] + target: Option, + /// Whether the `target` predicate matches the host platform. + /// `true` for unconditional system declarations. + active: bool, +} + +/// Bundle of inputs the metadata JSON view needs. +/// +/// Threading the growing set of build-configuration inputs through +/// a typed struct keeps `MetadataView::from_graph_and_lock`'s +/// surface stable: every consumer reads fields by name instead of +/// remembering positional order. +pub(crate) struct MetadataInputs<'a> { + pub(crate) graph: &'a PackageGraph, + pub(crate) lockfile: Option<&'a Lockfile>, + pub(crate) lockfile_path: &'a Path, + pub(crate) configurations: &'a HashMap, + pub(crate) selection: &'a cabin_workspace::ResolvedSelection, + pub(crate) profile: &'a cabin_core::ResolvedProfile, + pub(crate) manifest_profiles: + &'a BTreeMap, + pub(crate) toolchain: &'a cabin_core::ResolvedToolchain, + pub(crate) build_flags: &'a HashMap, + pub(crate) detection: Option<&'a cabin_core::ToolchainDetectionReport>, + /// Resolved compiler-cache wrapper, if any. `None` is rendered + /// as `toolchain.compiler_wrapper = null` so consumers do not + /// have to special-case the absence. + pub(crate) compiler_wrapper: Option<&'a cabin_core::ResolvedCompilerWrapper>, + /// Merged effective config. Surfaced as a top-level `config` + /// block so consumers can audit which files contributed and + /// which effective values came from the config layer vs. CLI + /// vs. env vs. manifest defaults. + pub(crate) config: &'a cabin_config::EffectiveConfig, + /// Active patch set after manifest+config merging and + /// validation. Empty when no patches apply. + pub(crate) active_patches: &'a cabin_workspace::ActivePatchSet, + /// Whether `--no-patches` was supplied on the CLI. Used to + /// suppress the source-replacement view when the user + /// disabled the local-policy layer entirely. + pub(crate) no_patches: bool, +} + +impl<'a> MetadataView<'a> { + pub(crate) fn from_graph_and_lock(inputs: &MetadataInputs<'a>) -> Self { + let mut view = Self::from_inputs(inputs); + view.lockfile = inputs.lockfile.map(|lock| LockfileView { + path: inputs.lockfile_path, + version: lock.version, + packages: lock + .packages + .iter() + .map(|p| LockedPackageView { + name: p.name.as_str(), + version: p.version.to_string(), + source: p.source.as_str(), + checksum: p.checksum.as_deref(), + dependencies: p.dependencies.iter().map(|d| d.as_str()).collect(), + }) + .collect(), + }); + view + } + + fn from_inputs(inputs: &MetadataInputs<'a>) -> Self { + let graph = inputs.graph; + let configurations = inputs.configurations; + let selection = inputs.selection; + let profile = inputs.profile; + let manifest_profiles = inputs.manifest_profiles; + let toolchain_resolved = inputs.toolchain; + let build_flags = inputs.build_flags; + let detection = inputs.detection; + let host_platform = cabin_core::TargetPlatform::current(); + let workspace = if graph.is_workspace_root { + let mut members: Vec<&str> = graph + .primary_packages + .iter() + .map(|i| graph.packages[*i].package.name.as_str()) + .collect(); + members.sort(); + let mut default_members: Vec<&str> = graph + .default_members + .iter() + .map(|i| graph.packages[*i].package.name.as_str()) + .collect(); + default_members.sort(); + let mut excluded_members: Vec<&Path> = + graph.excluded_members.iter().map(|p| p.as_path()).collect(); + excluded_members.sort(); + let mut selected_packages: Vec<&str> = selection + .packages + .iter() + .map(|i| graph.packages[*i].package.name.as_str()) + .collect(); + selected_packages.sort(); + Some(WorkspaceView { + root: &graph.root_dir, + members, + default_members, + excluded_members, + selected_packages, + }) + } else { + None + }; + + let packages: Vec> = graph + .packages + .iter() + .enumerate() + .map(|(idx, pkg)| { + let package: &Package = &pkg.package; + let features = if package.features.default.is_empty() + && package.features.features.is_empty() + { + None + } else { + Some(&package.features) + }; + let configuration = if features.is_some() { + configurations.get(&idx).map(|cfg| cfg.as_json()) + } else { + None + }; + let system_dependencies: Vec> = package + .system_dependencies + .iter() + .map(|sd| SystemDependencyView { + name: sd.name.as_str(), + dependency_kind: sd.kind, + version: sd.version.as_str(), + target: sd.condition.as_ref().map(ToString::to_string), + active: sd + .condition + .as_ref() + .map(|c| c.evaluate(&host_platform)) + .unwrap_or(true), + }) + .collect(); + PackageView { + name: package.name.as_str(), + version: package.version.to_string(), + manifest_path: &pkg.manifest_path, + dependencies: package + .dependencies + .iter() + .map(|d| DependencyView { + name: d.name.as_str(), + dependency_kind: d.kind, + optional: d.optional, + features: d.features.as_slice(), + default_features: d.default_features, + target: d.condition.as_ref().map(ToString::to_string), + active: d.matches_platform(&host_platform), + source: match &d.source { + DependencySource::Path(p) => DependencySourceView::Path { path: p }, + DependencySource::Version(req) => DependencySourceView::Version { + requirement: req.to_string(), + }, + DependencySource::Workspace => DependencySourceView::Workspace, + }, + }) + .collect(), + system_dependencies, + targets: &package.targets, + is_root: graph.root_package == Some(idx), + is_primary: graph.primary_packages.contains(&idx), + features, + configuration, + } + }) + .collect(); + + // Build the profile section once, deterministically: the + // selected profile's resolved fields, every available + // profile name (built-ins plus manifest-declared customs), + // and the manifest definitions exactly as parsed. + let available: Vec = cabin_core::available_profile_names(manifest_profiles) + .into_iter() + .map(|n| n.as_str().to_owned()) + .collect(); + let mut definitions: serde_json::Map = serde_json::Map::new(); + for (name, def) in manifest_profiles { + let value = serde_json::to_value(def).unwrap_or(serde_json::Value::Null); + definitions.insert(name.as_str().to_owned(), value); + } + let profiles = ProfilesView { + selected: profile.as_json(), + available, + definitions, + }; + + // Toolchain block: resolved tool kind / spec / source plus + // a per-package summary of active build flags. Generated + // here so the metadata view's contract for toolchain / + // build-flag visibility lives next to the rest of the + // build-configuration shape it returns. + let mut per_package_flags: serde_json::Map = + serde_json::Map::new(); + for (idx, _pkg) in graph.packages.iter().enumerate() { + if let Some(flags) = build_flags.get(&idx) + && !flags.is_empty() + { + let name = graph.packages[idx].package.name.as_str().to_owned(); + per_package_flags.insert(name, flags.as_json()); + } + } + // Detected toolchain identity / capabilities. Populated + // when the caller supplied a detection report; absent + // when detection failed (e.g. `cabin metadata` chose to + // continue rather than abort) or when the caller did not + // run detection at all. Always present in the JSON so + // consumers can distinguish "we didn't try" from "we + // ran it" via the `null` value. + let detected_view = match detection { + Some(report) => report.as_json(), + None => serde_json::Value::Null, + }; + // Compiler-cache wrapper sub-block. `null` when no wrapper + // is selected so the field is always present and consumers + // do not need to special-case the absence. + let compiler_wrapper_view = match inputs.compiler_wrapper { + Some(w) => w.as_json(), + None => serde_json::Value::Null, + }; + let toolchain_view = serde_json::json!({ + "tools": toolchain_resolved.as_json(), + "detected": detected_view, + "compiler_wrapper": compiler_wrapper_view, + "build_flags_per_package": serde_json::Value::Object(per_package_flags), + }); + let config_view = crate::config_glue::config_view_json(inputs.config); + let patches_view = crate::patch_glue::patch_view_json(inputs.active_patches); + let source_replacements_view = crate::patch_glue::source_replacement_view_json( + &inputs.config.source_replacements, + inputs.no_patches, + ); + + Self { + workspace, + packages, + lockfile: None, + target_platform: TargetPlatformView::from_platform(&host_platform), + profiles, + toolchain: toolchain_view, + config: config_view, + patches: patches_view, + source_replacements: source_replacements_view, + } + } +} diff --git a/crates/cabin-cli/src/patch_glue.rs b/crates/cabin-cli/src/patch_glue.rs new file mode 100644 index 000000000..2a373e339 --- /dev/null +++ b/crates/cabin-cli/src/patch_glue.rs @@ -0,0 +1,196 @@ +//! Glue between [`cabin_config::EffectiveConfig`], +//! [`cabin_workspace::resolve_active_patches`], and the rest of +//! the CLI's command pipeline. +//! +//! Discovery, parsing, and merging live in `cabin-config` and +//! `cabin-workspace`. This module owns the small amount of +//! *orchestration* the CLI needs: +//! +//! - convert the merged effective config into a typed input for +//! [`cabin_workspace::resolve_active_patches`]; +//! - resolve the source-replacement chain (with cycle detection) +//! for whichever index source the CLI / config picked; +//! - build the lockfile records that capture active patch and +//! source-replacement state for stale-detection; +//! - render a deterministic JSON block for `cabin metadata`. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use cabin_config::EffectiveConfig; +use cabin_core::{ + PackageName, PatchProvenance, SourceLocator, SourceReplacementResolution, + SourceReplacementSettings, +}; +use cabin_lockfile::{ + LockedPatch, LockedPatchKind, LockedSourceLocatorKind, LockedSourceReplacement, +}; +use cabin_workspace::{ + ActivePatchSet, ConfigPatchInput, PackageGraph, PatchResolutionInputs, resolve_active_patches, +}; + +/// Build the patch-resolution input the workspace layer +/// consumes. Returns `None` and an empty active patch set when +/// `--no-patches` is set; otherwise the manifest-declared +/// patches plus the merged config-derived patches feed +/// [`resolve_active_patches`]. +pub(crate) fn load_active_patches( + graph: &PackageGraph, + effective_config: &EffectiveConfig, + no_patches: bool, +) -> Result { + if no_patches { + return Ok(ActivePatchSet::default()); + } + let manifest_patches = graph.root_settings.patches.clone(); + let mut config_patches: BTreeMap = BTreeMap::new(); + for (name, entry) in &effective_config.patches { + config_patches.insert( + name.clone(), + ConfigPatchInput { + source: entry.spec.clone(), + provenance: PatchProvenance::Config(super::config_glue::config_value_source( + entry.source, + )), + declared_in: entry.declared_in.clone(), + }, + ); + } + let inputs = PatchResolutionInputs { + graph, + manifest_patches: &manifest_patches, + config_patches: &config_patches, + }; + let resolved = resolve_active_patches(&inputs).map_err(|err| anyhow!(err.to_string()))?; + Ok(resolved) +} + +/// Apply the source-replacement chain to `initial`. Returns the +/// terminal source plus the chain hops so callers can record +/// them in the lockfile / metadata view. `--no-patches` disables +/// the entire local-policy layer, including source replacement. +pub(crate) fn apply_source_replacement( + initial: SourceLocator, + effective_config: &EffectiveConfig, + no_patches: bool, +) -> Result { + if no_patches { + return Ok(SourceReplacementResolution { + resolved: initial, + hops: Vec::new(), + }); + } + effective_config + .source_replacements + .resolve(&initial) + .map_err(|err| anyhow!(err.to_string())) +} + +/// Render the active patch set as a sorted list of +/// [`LockedPatch`] entries for the lockfile. +pub(crate) fn lockfile_patches(set: &ActivePatchSet) -> Vec { + let mut out: Vec = set + .iter() + .map(|entry| LockedPatch { + package: entry.name.clone(), + version: entry.package.version.clone(), + kind: LockedPatchKind::Path, + provenance: entry.provenance.as_key(), + path: entry.declared_path.clone(), + }) + .collect(); + out.sort_by(|a, b| { + a.package + .as_str() + .cmp(b.package.as_str()) + .then_with(|| a.version.cmp(&b.version)) + }); + out +} + +/// Render every active source-replacement entry as a +/// [`LockedSourceReplacement`] for the lockfile. +pub(crate) fn lockfile_source_replacements( + settings: &SourceReplacementSettings, + no_patches: bool, +) -> Vec { + if no_patches { + return Vec::new(); + } + let mut out: Vec = settings + .entries + .values() + .map(|entry| LockedSourceReplacement { + original: entry.original.display(), + original_kind: locator_to_lock_kind(&entry.original), + replacement: entry.replacement.display(), + replacement_kind: locator_to_lock_kind(&entry.replacement), + provenance: entry.provenance.as_key().to_owned(), + }) + .collect(); + out.sort_by(|a, b| a.original.cmp(&b.original)); + out +} + +fn locator_to_lock_kind(locator: &SourceLocator) -> LockedSourceLocatorKind { + match locator { + SourceLocator::IndexPath { .. } => LockedSourceLocatorKind::IndexPath, + SourceLocator::IndexUrl { .. } => LockedSourceLocatorKind::IndexUrl, + } +} + +/// JSON view of the active patch set. Returned as a sorted +/// array so consumers can rely on stable ordering. +pub(crate) fn patch_view_json(set: &ActivePatchSet) -> serde_json::Value { + let entries: Vec = set + .iter() + .map(|entry| { + serde_json::json!({ + "package": entry.name.as_str(), + "version": entry.package.version.to_string(), + "kind": entry.source.kind().as_key(), + "path": entry.declared_path.display().to_string(), + "provenance": entry.provenance.as_key(), + }) + }) + .collect(); + serde_json::Value::Array(entries) +} + +/// JSON view of the active source-replacement entries. +pub(crate) fn source_replacement_view_json( + settings: &SourceReplacementSettings, + no_patches: bool, +) -> serde_json::Value { + if no_patches { + return serde_json::Value::Array(Vec::new()); + } + let entries: Vec = settings + .entries + .values() + .map(|entry| { + serde_json::json!({ + "original": entry.original.display(), + "original_kind": entry.original.kind_key(), + "replacement": entry.replacement.display(), + "replacement_kind": entry.replacement.kind_key(), + "provenance": entry.provenance.as_key(), + }) + }) + .collect(); + serde_json::Value::Array(entries) +} + +/// Package a typed [`SourceLocator`] back into the +/// `(index_path, index_url)` shape Cabin's existing artifact +/// pipeline expects. The two values are mutually exclusive — at +/// most one is `Some`. +pub(crate) fn locator_to_index_inputs( + locator: &SourceLocator, +) -> (Option, Option) { + match locator { + SourceLocator::IndexPath { path } => (Some(path.clone()), None), + SourceLocator::IndexUrl { url } => (None, Some(url.clone())), + } +} diff --git a/crates/cabin-cli/src/run_glue.rs b/crates/cabin-cli/src/run_glue.rs new file mode 100644 index 000000000..eb3837752 --- /dev/null +++ b/crates/cabin-cli/src/run_glue.rs @@ -0,0 +1,720 @@ +//! Glue layer for `cabin run`. +//! +//! `cabin run` is a thin wrapper over the same build pipeline +//! `cabin build` runs (workspace load → patches → artifact +//! pipeline → planner → Ninja). After Ninja produces the +//! linked executable, this module locates the file that the +//! planner emitted for the selected target, populates a +//! deterministic `CABIN_*` environment, and execs the binary +//! with the user's stdio attached. Arguments after `--` are +//! forwarded verbatim. +//! +//! The typed `CABIN_*` env overlay is built by +//! `cabin_env::package_env`; this module only orchestrates. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::Args; + +use cabin_build::{ManifestTargetSelector, PlanRequest, plan}; +use cabin_core::{Package, TargetKind}; +use cabin_workspace::{ + RegistryPackageSource, WorkspaceLoadOptions, collect_patched_versioned_deps, load_workspace, + load_workspace_with_options, +}; + +use crate::cli::{ + ArtifactPipelineRequest, ToolchainSelectionArgs, WorkspaceSelectionArgs, absolutise, + build_selection_request, build_workspace_selection, + closure_has_versioned_deps_excluding_patches, compiler_wrapper_override_from_args, + compute_feature_resolution, lock_mode_for_flags, profile_selection_from_flags, + resolve_build_configurations, resolve_invocation_manifest, resolve_per_package_build_flags, + run_artifact_pipeline, toolchain_selection_from_args, workspace_compiler_wrapper_settings, + workspace_profile_definitions, +}; + +#[derive(Debug, Args)] +pub(crate) struct RunArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Build output directory. Same precedence rules as + /// `cabin build`: `--build-dir` > `CABIN_BUILD_DIR` > + /// `[paths] build-dir` config setting > built-in default + /// `build`. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Build with optimizations. + /// + /// Compatibility alias for `--profile release`; cannot be + /// used together with `--profile`. + #[arg(short = 'r', long, conflicts_with = "profile")] + pub release: bool, + + /// Build profile (`dev`, `release`, or any custom profile + /// declared in `[profile.]`). Defaults to `dev`. + #[arg(long, value_name = "NAME")] + pub profile: Option, + + /// Build and run the named `cpp_executable` target. + #[arg(long = "bin", value_name = "NAME")] + pub bin: Option, + + /// Path to a directory containing the local JSON package index. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL to read package metadata from. + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Override the default artifact cache directory. + #[arg(long, value_name = "PATH")] + pub cache_dir: Option, + + /// Require an existing, current `cabin.lock`. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects state-writing side effects. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. + #[arg(long)] + pub offline: bool, + + /// Enable named features. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every declared feature. + #[arg(long)] + pub all_features: bool, + + /// Disable default features. + #[arg(long)] + pub no_default_features: bool, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Toolchain-selection flags. + #[command(flatten)] + pub toolchain: ToolchainSelectionArgs, + + /// Disable every active patch / source-replacement entry. + #[arg(long)] + pub no_patches: bool, + + /// Number of parallel jobs to use for the build phase. + /// + /// Precedence: this flag > `CABIN_BUILD_JOBS` env var > + /// `[build] jobs` config setting > backend default. The + /// value must be a positive integer; `0` is rejected. + /// Cabin does not forward `--jobs` to the executed + /// program; arguments after `--` (which may include their + /// own `--jobs`) reach the program verbatim. + #[arg(short = 'j', long = "jobs", value_name = "N")] + pub jobs: Option, + + /// Arguments forwarded to the executed program. Everything + /// after `--` is passed verbatim. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +/// Run `cabin run`: build and execute a selected binary target. +/// +/// Returns an `ExitCode` so the spawned program's exit status +/// becomes Cabin's own exit status. Failing to start the +/// program (or any pipeline error) surfaces as an +/// [`anyhow::Error`] and Cabin exits non-zero from the +/// top-level dispatcher. +pub(crate) fn run( + args: &RunArgs, + reporter: crate::term_verbosity_glue::Reporter, +) -> Result { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + + let initial_graph = load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + + let workspace_selection_for_pipeline = build_workspace_selection(&args.workspace_selection); + let initial_resolved_selection = cabin_workspace::resolve_package_selection( + &initial_graph, + &workspace_selection_for_pipeline, + )?; + + let initial_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let dev_for: BTreeSet = BTreeSet::new(); + let initial_features = compute_feature_resolution( + &initial_graph, + &initial_resolved_selection, + &initial_request, + )?; + + let resolved_index_source = crate::config_glue::resolve_index_source( + args.index_path.as_deref(), + args.index_url.as_deref(), + &effective_config, + )?; + let offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source(offline, resolved_index_source.as_ref())?; + let resolved_cache_dir = + crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config); + + let patched_root_deps_preview = + collect_patched_versioned_deps(&active_patches, &patched_names)?; + let has_versioned = !patched_root_deps_preview.is_empty() + || closure_has_versioned_deps_excluding_patches( + &initial_graph, + &initial_resolved_selection, + &initial_features, + &patched_names, + &dev_for, + ); + + let registry: Vec = if has_versioned { + let Some(index_source) = resolved_index_source.as_ref() else { + bail!( + "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting" + ); + }; + let mode = lock_mode_for_flags(args.locked, args.frozen); + let allow_write = !(args.locked || args.frozen); + let cache_dir = match resolved_cache_dir.as_ref() { + Some((path, _)) => path.clone(), + None => crate::cli::cache_dir_for(&manifest_path, args.cache_dir.as_deref())?, + }; + let initial_locator = match &index_source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved_locator = crate::patch_glue::apply_source_replacement( + initial_locator, + &effective_config, + args.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement(offline, &resolved_locator)?; + let (replaced_path, replaced_url) = + crate::patch_glue::locator_to_index_inputs(&resolved_locator.resolved); + let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest { + manifest_path: &manifest_path, + initial_graph: &initial_graph, + index_path: replaced_path.as_deref(), + index_url: replaced_url.as_deref(), + mode, + allow_write, + frozen: args.frozen, + cache_dir: &cache_dir, + reporter, + selection: workspace_selection_for_pipeline, + selection_request: &initial_request, + patched_names: &patched_names, + active_patches: &active_patches, + source_replacements: &effective_config.source_replacements, + no_patches: args.no_patches, + dev_for: &dev_for, + })?; + pipeline + .fetched + .iter() + .map(|p| RegistryPackageSource { + name: p.name.clone(), + version: p.version.clone(), + manifest_path: p.source_dir.join("cabin.toml"), + }) + .collect() + } else { + Vec::new() + }; + + let strict_packages: BTreeSet = initial_resolved_selection + .closure(&initial_graph) + .into_iter() + .map(|i| initial_graph.packages[i].package.name.as_str().to_owned()) + .collect(); + let patched_sources = active_patches.workspace_sources(); + let graph = load_workspace_with_options( + &manifest_path, + &WorkspaceLoadOptions { + registry: ®istry, + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &dev_for, + }, + )?; + + let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutise(&build_dir_input) + .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?; + + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?; + let toolchain = crate::cli::resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + let detection_report = + cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner)?; + cabin_build::validate_toolchain_for_backend(&toolchain, &detection_report)?; + let ninja = cabin_toolchain::locate_ninja()?; + + let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph); + let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?; + + let profile_selection = + profile_selection_from_flags(args.profile.as_deref(), args.release, &effective_config)?; + let manifest_profiles = workspace_profile_definitions(&graph); + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + let profile_build = profile.build.as_ref(); + let build_flags = resolve_per_package_build_flags(&graph, profile_build, &host_platform); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + + let compiler_wrapper = crate::cli::resolve_compiler_wrapper_layered( + cli_compiler_wrapper, + &manifest_compiler_wrapper, + &effective_config, + &host_platform, + )?; + let toolchain_summary = + cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, compiler_wrapper.as_ref()); + + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + + // Pick the run target. `--bin` narrows the search to a + // named `cpp_executable`; otherwise we look for a single + // `cpp_executable` in the selected closure. + let run_target = pick_run_target(&graph, &resolved_selection.packages, args.bin.as_deref())?; + + let selection_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let configurations = resolve_build_configurations( + &graph, + &selection_request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let _feature_resolution = + compute_feature_resolution(&graph, &resolved_selection, &selection_request)?; + + let root_configuration = graph + .root_package + .and_then(|i| configurations.get(&i)) + .cloned(); + let plan_graph = plan(&PlanRequest { + graph: &graph, + toolchain: &toolchain, + build_flags: &build_flags, + build_dir: build_dir.clone(), + profile: profile.clone(), + selected: Some(vec![ManifestTargetSelector { + package: Some(run_target.package_name.clone()), + name: run_target.target_name.clone(), + }]), + configuration: root_configuration.as_ref(), + selected_packages: Some(&resolved_selection.packages), + compiler_wrapper: compiler_wrapper.as_ref(), + })?; + + let profile_build_root = build_dir.join(profile.name.as_str()); + std::fs::create_dir_all(&profile_build_root).with_context(|| { + format!( + "failed to create build directory {}", + profile_build_root.display() + ) + })?; + + let ninja_file = profile_build_root.join("build.ninja"); + cabin_ninja::write_build_ninja(&ninja_file, &plan_graph)?; + let ccmd_file = profile_build_root.join("compile_commands.json"); + cabin_ninja::write_compile_commands(&ccmd_file, &plan_graph)?; + + let jobs = crate::config_glue::resolve_build_jobs(args.jobs, &effective_config)?; + // Implementation-detail status is verbose-only: under `-v` + // the user sees which files Cabin wrote and how Ninja was + // invoked, alongside Ninja's own raw banner. + reporter.verbose(format_args!("cabin: wrote {}", ninja_file.display())); + reporter.verbose(format_args!("cabin: wrote {}", ccmd_file.display())); + reporter.verbose(format_args!( + "cabin: invoking {} {}-C {}", + ninja.display(), + crate::cli::ninja_jobs_echo(jobs), + profile_build_root.display() + )); + let mut ninja_cmd = std::process::Command::new(&ninja); + if let Some(jobs) = jobs { + ninja_cmd.arg(jobs.as_ninja_arg()); + } + // Route Ninja through the shared runner so `cabin run`'s + // build phase prints the same cargo-style `Compiling â€Ļ` + // banner `cabin build` emits — and so the verbose passthrough + // and the default-mode filtering stay in one place. + let status = crate::cli::run_ninja( + ninja_cmd.arg("-C").arg(&profile_build_root), + reporter, + &graph, + ) + .with_context(|| format!("failed to invoke ninja at {}", ninja.display()))?; + if !status.success() { + bail!("ninja exited with {status}"); + } + + let executable = locate_target_executable(&plan_graph.default_outputs, &run_target) + .ok_or_else(|| { + anyhow::anyhow!( + "build graph did not produce an executable for `{}:{}`", + run_target.package_name, + run_target.target_name, + ) + })?; + + // Build the env. We do not clear the user's environment — + // the spawned program inherits PATH, LANG, etc. — but we + // overlay the deterministic CABIN_* values so the program + // sees consistent package metadata. + let env_overlay = cabin_env::package_env(&cabin_env::PackageEnvInputs { + manifest_dir: &run_target.manifest_dir, + manifest_path: &run_target.manifest_path, + package_name: &run_target.package_name, + package_version: &run_target.package_version, + profile: profile.name.as_str(), + build_dir: &build_dir, + }); + + // Working directory: mirror Cargo by inheriting the user's + // current working directory. Trailing args (`cabin run -- + // a b`) are forwarded to the spawned program verbatim; clap + // strips the `--` separator before we see the vec. + let mut command = std::process::Command::new(&executable); + command.envs(env_overlay.iter().map(|(k, v)| (k.as_str(), v.as_os_str()))); + command.args(args.args.iter()); + let status = command.status().with_context(|| { + format!( + "failed to start `{}` ({}:{})", + executable.display(), + run_target.package_name, + run_target.target_name + ) + })?; + Ok(exit_code_for(status)) +} + +/// Map the spawned program's exit status onto a `process::ExitCode` +/// so `cabin run`'s own exit code is the program's exit code. +/// Signal-terminated children produce exit code `1` because +/// `ExitCode` cannot represent signal kills directly. +fn exit_code_for(status: std::process::ExitStatus) -> ExitCode { + match status.code() { + Some(0) => ExitCode::SUCCESS, + Some(code) => ExitCode::from(u8::try_from(code & 0xff).unwrap_or(1)), + None => ExitCode::from(1), + } +} + +/// Resolved run target. The orchestration layer narrows to +/// exactly one of these before invoking the planner. +#[derive(Debug, Clone)] +struct RunTarget { + package_name: String, + package_version: String, + target_name: String, + manifest_dir: PathBuf, + manifest_path: PathBuf, +} + +fn pick_run_target( + graph: &cabin_workspace::PackageGraph, + selected_packages: &[usize], + bin: Option<&str>, +) -> Result { + let pool: Vec = if selected_packages.is_empty() { + (0..graph.packages.len()).collect() + } else { + selected_packages.to_vec() + }; + if let Some(name) = bin { + return find_target(graph, &pool, name, TargetKind::CppExecutable, "--bin"); + } + // Default: pick a single cpp_executable in the selected + // packages. Ambiguous selections produce a diagnostic + // listing every candidate so users can decide. + let mut candidates: Vec = Vec::new(); + for &idx in &pool { + let pkg = &graph.packages[idx]; + for target in &pkg.package.targets { + if target.kind == TargetKind::CppExecutable { + candidates.push(make_run_target( + &pkg.package, + &graph.packages[idx].manifest_path, + &graph.packages[idx].manifest_dir, + target.name.as_str(), + )); + } + } + } + if candidates.is_empty() { + bail!("no `cpp_executable` target found in the selected packages; declare one to run it"); + } + if candidates.len() > 1 { + let listed: Vec = candidates + .iter() + .map(|t| format!("{}:{}", t.package_name, t.target_name)) + .collect(); + bail!( + "multiple `cpp_executable` targets found in the selected packages; pass `--bin ` to disambiguate. Candidates: {}", + listed.join(", ") + ); + } + Ok(candidates.into_iter().next().expect("len==1 above")) +} + +fn find_target( + graph: &cabin_workspace::PackageGraph, + pool: &[usize], + name: &str, + expected_kind: TargetKind, + flag: &str, +) -> Result { + // Walk every selected package before reporting a kind + // mismatch: a `cpp_library` named `foo` in pkg A must not + // mask a `cpp_executable` named `foo` in pkg B simply because + // A is iterated first. + let mut candidates: Vec = Vec::new(); + let mut other_kind: Option = None; + for &idx in pool { + let pkg = &graph.packages[idx]; + for target in &pkg.package.targets { + if target.name.as_str() != name { + continue; + } + if target.kind != expected_kind { + other_kind.get_or_insert(target.kind); + continue; + } + candidates.push(make_run_target( + &pkg.package, + &graph.packages[idx].manifest_path, + &graph.packages[idx].manifest_dir, + target.name.as_str(), + )); + } + } + if candidates.is_empty() { + if let Some(kind) = other_kind { + bail!( + "{flag} `{name}` matched a target of kind `{}`; expected `{}`", + kind.as_str(), + expected_kind.as_str() + ); + } + bail!("{flag} `{name}` was not found in the selected packages"); + } + if candidates.len() > 1 { + let owners: Vec = candidates.iter().map(|t| t.package_name.clone()).collect(); + bail!( + "{flag} `{name}` is ambiguous; declared by packages: {}", + owners.join(", ") + ); + } + Ok(candidates.into_iter().next().expect("len==1 above")) +} + +fn make_run_target( + package: &Package, + manifest_path: &Path, + manifest_dir: &Path, + target_name: &str, +) -> RunTarget { + RunTarget { + package_name: package.name.as_str().to_owned(), + package_version: package.version.to_string(), + target_name: target_name.to_owned(), + manifest_dir: manifest_dir.to_path_buf(), + manifest_path: manifest_path.to_path_buf(), + } +} + +/// Walk the planner's `default_outputs` looking for the +/// executable produced for `target`. The planner names every +/// `cpp_executable` output +/// `//packages//` (no extension +/// on POSIX; `.exe` on Windows). We scan rather than re-deriving +/// the path so the planner stays the single source of truth. +fn locate_target_executable(default_outputs: &[PathBuf], target: &RunTarget) -> Option { + let needle_tail: PathBuf = [ + "packages", + target.package_name.as_str(), + target.target_name.as_str(), + ] + .iter() + .collect(); + default_outputs + .iter() + .find(|p| p.ends_with(&needle_tail)) + .cloned() + .or_else(|| { + // Windows build output appends `.exe`; the + // unsuffixed needle does not match. Try matching + // the parent directory and last component + // separately. + let parent_tail: PathBuf = ["packages", target.package_name.as_str()].iter().collect(); + default_outputs.iter().find_map(|p| { + let same_parent = p + .parent() + .map(|pp| pp.ends_with(&parent_tail)) + .unwrap_or(false); + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if same_parent && stem == target.target_name { + Some(p.clone()) + } else { + None + } + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cabin_core::{PackageName, Target, TargetName}; + use cabin_workspace::{PackageKind, WorkspacePackage}; + + fn target(name: &str, kind: TargetKind) -> Target { + Target { + name: TargetName::new(name).unwrap(), + kind, + sources: Vec::new(), + include_dirs: Vec::new(), + defines: Vec::new(), + deps: Vec::new(), + } + } + + fn workspace_package(name: &str, targets: Vec) -> WorkspacePackage { + let package = Package::new( + PackageName::new(name).unwrap(), + semver::Version::parse("0.1.0").unwrap(), + targets, + Vec::new(), + ) + .unwrap(); + WorkspacePackage { + package, + manifest_path: PathBuf::from(format!("{name}/cabin.toml")), + manifest_dir: PathBuf::from(name), + deps: Vec::new(), + kind: PackageKind::Local, + } + } + + fn two_pkg_graph(packages: Vec) -> cabin_workspace::PackageGraph { + cabin_workspace::PackageGraph { + root_manifest_path: PathBuf::from("ws/cabin.toml"), + root_dir: PathBuf::from("ws"), + is_workspace_root: true, + root_package: None, + root_settings: Default::default(), + primary_packages: (0..packages.len()).collect(), + default_members: (0..packages.len()).collect(), + excluded_members: Vec::new(), + packages, + } + } + + #[test] + fn find_target_returns_executable_even_when_earlier_package_has_same_name_library() { + // Regression: pkg[0] declares a `cpp_library` named "shared" + // and pkg[1] declares a `cpp_executable` named "shared". + // `find_target` must not bail on pkg[0]'s wrong-kind match + // before reaching pkg[1]. + let graph = two_pkg_graph(vec![ + workspace_package("lib_pkg", vec![target("shared", TargetKind::CppLibrary)]), + workspace_package("exe_pkg", vec![target("shared", TargetKind::CppExecutable)]), + ]); + let chosen = find_target( + &graph, + &[0, 1], + "shared", + TargetKind::CppExecutable, + "--bin", + ) + .expect("an executable candidate exists in pkg[1]"); + assert_eq!(chosen.package_name, "exe_pkg"); + assert_eq!(chosen.target_name, "shared"); + } + + #[test] + fn find_target_reports_kind_mismatch_when_no_executable_candidate_exists() { + let graph = two_pkg_graph(vec![workspace_package( + "lib_pkg", + vec![target("shared", TargetKind::CppLibrary)], + )]); + let err = find_target(&graph, &[0], "shared", TargetKind::CppExecutable, "--bin") + .expect_err("a library-only match must produce a kind-mismatch error"); + let msg = err.to_string(); + assert!( + msg.contains("matched a target of kind") && msg.contains("cpp_library"), + "expected kind-mismatch wording, got: {msg}", + ); + } + + #[test] + fn find_target_reports_not_found_when_name_missing() { + let graph = two_pkg_graph(vec![ + workspace_package("a", vec![target("foo", TargetKind::CppExecutable)]), + workspace_package("b", vec![target("bar", TargetKind::CppExecutable)]), + ]); + let err = find_target( + &graph, + &[0, 1], + "missing", + TargetKind::CppExecutable, + "--bin", + ) + .expect_err("absent target name must produce a not-found error"); + assert!( + err.to_string().contains("not found"), + "expected not-found wording, got: {err}", + ); + } +} diff --git a/crates/cabin-cli/src/source_tooling_glue.rs b/crates/cabin-cli/src/source_tooling_glue.rs new file mode 100644 index 000000000..2dc55aa25 --- /dev/null +++ b/crates/cabin-cli/src/source_tooling_glue.rs @@ -0,0 +1,102 @@ +//! Shared helpers for the source-tooling commands. +//! +//! `cabin fmt` and `cabin tidy` each translate a +//! CLI flag bundle into typed inputs for `cabin-source-discovery` +//! and a downstream runner. Their selection plumbing, exclude +//! handling, and reporter-side rendering are identical; this +//! module owns the shared pieces so the glue files can stay +//! focused on the parts that genuinely differ per command. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use cabin_workspace::{PackageGraph, PackageSelection, SelectionMode}; + +/// Translate the standard `--workspace` / `--package` / +/// `--default-members` trio into a [`PackageSelection`]. The +/// three source-tooling commands all expose the same trio with +/// the same precedence; `--exclude` on each is a *path* +/// exclusion handled by source discovery, so the typed selection +/// carries an empty package-name exclude list. +pub(crate) fn package_selection_from_flags( + workspace: bool, + packages: &[String], + default_members: bool, +) -> PackageSelection { + let mode = if workspace { + SelectionMode::WholeWorkspace + } else if !packages.is_empty() { + SelectionMode::ExplicitPackages(packages.to_vec()) + } else if default_members { + SelectionMode::DefaultMembers + } else { + SelectionMode::CurrentPackage + }; + PackageSelection { + mode, + exclude: Vec::new(), + } +} + +/// Manifest dirs that the source walker should skip when +/// invoked from a selected package's root. The walker emits one +/// entry per root; when a root is package A and a sibling +/// package B's manifest dir lives under it (or the workspace +/// root contains every member), walking would otherwise visit +/// B's sources too. Excluding every non-selected manifest dir +/// prevents that; selected packages remain reachable because +/// their own root is the start point of an independent walk. +/// +/// Only local packages have meaningful manifest dirs on the +/// host filesystem. Extracted-registry entries live in the +/// artifact cache and the walker never reaches them anyway. +pub(crate) fn nested_package_excludes( + graph: &PackageGraph, + selected: &BTreeSet, +) -> Vec { + let mut out: Vec = Vec::new(); + for (idx, pkg) in graph.packages.iter().enumerate() { + if selected.contains(&idx) { + continue; + } + out.push(pkg.manifest_dir.clone()); + } + out +} + +/// Render a list of package names for reporter status / verbose +/// output. Single-package selections emit `package `name``; +/// multi-package selections emit `packages `a`, `b`, `c``. +pub(crate) fn describe_packages(names: &[String]) -> String { + match names.len() { + 0 => "".to_owned(), + 1 => format!("package `{}`", names[0]), + _ => { + let joined = names + .iter() + .map(|n| format!("`{n}`")) + .collect::>() + .join(", "); + format!("packages {joined}") + } + } +} + +/// Resolve `path` against `base` when relative; leave absolute +/// paths untouched. +pub(crate) fn absolutize(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +/// Render `path` relative to the workspace root for reporter +/// output, falling back to the absolute path when it is not a +/// descendant of the workspace. +pub(crate) fn display_workspace_relative(workspace_root: &Path, path: &Path) -> String { + path.strip_prefix(workspace_root) + .map(|rel| rel.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()) +} diff --git a/crates/cabin-cli/src/system_deps_glue.rs b/crates/cabin-cli/src/system_deps_glue.rs new file mode 100644 index 000000000..29f9b3f6d --- /dev/null +++ b/crates/cabin-cli/src/system_deps_glue.rs @@ -0,0 +1,304 @@ +//! Orchestration for `system = true` dependency probing. +//! +//! `cabin-system-deps` owns pkg-config executable resolution, +//! subprocess invocation, and flag classification; this module +//! threads the typed report back into the per-package +//! [`cabin_core::ResolvedProfileFlags`] map every Cabin pipeline +//! consumes. Keeping the orchestration here preserves the +//! package rule that `cabin-cli` stays thin: no probing, +//! parsing, or flag-merge business logic lives in `cli.rs`. +//! +//! The single helper [`augment_build_flags_with_system_deps`] +//! must be called from every command that constructs a build +//! configuration or planner request — `cabin build` / +//! `cabin run` / `cabin test` / `cabin tidy` / +//! `cabin metadata`. The merge point sits *after* +//! `cabin_core::resolve_build_flags` and *before* +//! `BuildConfiguration::resolve`, so the build configuration +//! fingerprint observes the discovered flags. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; + +use anyhow::{Result, bail}; + +use cabin_core::{ResolvedProfileFlags, SystemDependency, TargetPlatform, Verbosity}; +use cabin_system_deps::{ + PkgConfigError, PkgConfigTool, SystemDependencyFlags, SystemDependencyProbeRequest, + SystemDependencyResolution, probe_system_dependency, +}; +use cabin_workspace::PackageGraph; + +use crate::term_verbosity_glue::Reporter; + +/// Per-package map of every successful `pkg-config` probe +/// produced during a workspace probe. Keyed by package index so +/// callers can correlate the resolution back to the originating +/// manifest. +type SystemDependencyReports = BTreeMap>; + +/// Return shape of [`augment_build_flags_with_system_deps`]: +/// the augmented per-package flag map plus the deterministic +/// probe reports. +type AugmentedBuildFlags = ( + HashMap, + SystemDependencyReports, +); + +/// Probe every active system dependency declared by every +/// primary package in the supplied graph and merge the +/// discovered flags into `build_flags`. Returns the same map, +/// augmented, plus a deterministic per-package probe report so +/// the caller can render verbose output or extend a metadata +/// view. +/// +/// The function is a no-op when no system dependency is active: +/// no `pkg-config` subprocess is spawned and the supplied +/// `build_flags` map is returned unchanged. The pkg-config +/// executable is only required when at least one package +/// contributes an active system dependency. +pub(crate) fn augment_build_flags_with_system_deps( + graph: &PackageGraph, + host_platform: &TargetPlatform, + dev_for: &BTreeSet, + mut build_flags: HashMap, + reporter: Reporter, +) -> Result { + let active = collect_active_system_deps(graph, host_platform, dev_for); + if active.is_empty() { + return Ok((build_flags, BTreeMap::new())); + } + + let tool = PkgConfigTool::from_env(|key| std::env::var_os(key)); + let count = total_count(&active); + let noun = if count == 1 { + "dependency" + } else { + "dependencies" + }; + // Probe chatter always lands on stderr because `cabin + // metadata` reserves stdout for its JSON document. + reporter.aux_verbose(format_args!( + "cabin: probing {count} system {noun} via {}", + tool.executable().to_string_lossy(), + )); + + // Check availability up-front so the user gets a single + // actionable diagnostic when pkg-config is missing, + // regardless of which package declares the first system + // dependency. + if let Err(err) = tool.check_available() { + return Err(anyhow::anyhow!(err)); + } + + let mut reports: BTreeMap> = BTreeMap::new(); + for (pkg_idx, deps) in active { + let pkg_name = graph.packages[pkg_idx].package.name.as_str(); + let entry = build_flags.entry(pkg_idx).or_default(); + let mut pkg_reports: Vec = Vec::with_capacity(deps.len()); + for dep in deps { + let resolved = probe_dep(&tool, pkg_name, dep, reporter)?; + merge_flags(entry, &resolved.flags); + pkg_reports.push(resolved); + } + reports.insert(pkg_idx, pkg_reports); + } + Ok((build_flags, reports)) +} + +fn probe_dep( + tool: &PkgConfigTool, + pkg_name: &str, + dep: &SystemDependency, + reporter: Reporter, +) -> Result { + let verbosity = reporter.verbosity(); + if verbosity == Verbosity::VeryVerbose { + reporter.aux_very_verbose(format_args!( + "cabin: probing `{}` for package `{}` (version = {:?})", + dep.name.as_str(), + pkg_name, + dep.version, + )); + } + let request = SystemDependencyProbeRequest { + name: dep.name.as_str(), + version_requirement: &dep.version, + tool, + }; + match probe_system_dependency(&request) { + Ok(resolved) => { + if verbosity.shows_verbose() { + let version_suffix = match resolved.version.as_deref() { + Some(v) => format!(" (version {v})"), + None => String::new(), + }; + reporter.aux_verbose(format_args!( + "cabin: system dependency `{}` ok{}", + resolved.name, version_suffix, + )); + } + Ok(resolved) + } + Err(err) => bail!(format_probe_error(pkg_name, dep, err)), + } +} + +fn format_probe_error( + pkg_name: &str, + dep: &SystemDependency, + err: PkgConfigError, +) -> anyhow::Error { + // Surface a single sentence that identifies the declaring + // package alongside the typed error's own message. The + // typed error keeps its diagnostic code and help text so + // `cabin-diagnostics::render` can pick it up upstream. + let message = format!( + "package `{}` failed to probe system dependency `{}`: {}", + pkg_name, + dep.name.as_str(), + err, + ); + anyhow::Error::new(err).context(message) +} + +fn merge_flags(flags: &mut ResolvedProfileFlags, contrib: &SystemDependencyFlags) { + // Include paths: dedupe by exact value while preserving + // first-seen order, mirroring `cabin_core::resolve_build_flags`. + let mut seen: BTreeSet = flags.include_dirs.iter().cloned().collect(); + for dir in &contrib.include_dirs { + if seen.insert(dir.clone()) { + flags.include_dirs.push(dir.clone()); + } + } + // Extra compile args: append verbatim. pkg-config decides + // the order; we never reshuffle it. + flags + .extra_compile_args + .extend(contrib.extra_compile_args.iter().cloned()); + // Link args: append verbatim. Order is load-bearing for + // C / C++ linking. + flags.ldflags.extend(contrib.ldflags.iter().cloned()); +} + +fn collect_active_system_deps<'a>( + graph: &'a PackageGraph, + host_platform: &TargetPlatform, + dev_for: &BTreeSet, +) -> Vec<(usize, Vec<&'a SystemDependency>)> { + use cabin_core::DependencyKind; + let mut out: Vec<(usize, Vec<&'a SystemDependency>)> = Vec::new(); + // System dependencies are only probed for *primary* + // packages — the local workspace members the user owns. + // Registry / extracted dependencies do not contribute + // system deps; their canonical metadata round-trips + // declarations only. + for &idx in &graph.primary_packages { + let package = &graph.packages[idx].package; + if package.system_dependencies.is_empty() { + continue; + } + let pkg_name = package.name.as_str(); + let mut deps: Vec<&SystemDependency> = Vec::new(); + for dep in &package.system_dependencies { + // Per-kind activation matches Cabin-package deps: + // `Normal` → always active. + // `Dev` → only when the command opted this + // package into dev-dep activation + // (`cabin test`). + match dep.kind { + DependencyKind::Normal => {} + DependencyKind::Dev if dev_for.contains(pkg_name) => {} + DependencyKind::Dev => continue, + } + if let Some(cond) = &dep.condition + && !cond.evaluate(host_platform) + { + continue; + } + deps.push(dep); + } + if !deps.is_empty() { + // Determinism: order by name so identical workspaces + // always probe in the same sequence (and the + // resulting flag append order is stable). + deps.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); + out.push((idx, deps)); + } + } + // Determinism: ascending package index. Iteration order + // shows up in the deterministic flag append sequence and in + // any verbose output we emit, so we pin it here rather than + // relying on the graph's primary set being already sorted. + out.sort_by_key(|(idx, _)| *idx); + out +} + +fn total_count(active: &[(usize, Vec<&SystemDependency>)]) -> usize { + active.iter().map(|(_, v)| v.len()).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use cabin_core::ResolvedProfileFlags; + + fn flags() -> ResolvedProfileFlags { + ResolvedProfileFlags::default() + } + + #[test] + fn merge_appends_include_paths_uniquely() { + let mut f = flags(); + f.include_dirs.push(PathBuf::from("/already")); + let contrib = SystemDependencyFlags { + include_dirs: vec![PathBuf::from("/already"), PathBuf::from("/added")], + ..Default::default() + }; + merge_flags(&mut f, &contrib); + assert_eq!( + f.include_dirs, + vec![PathBuf::from("/already"), PathBuf::from("/added")], + ); + } + + #[test] + fn merge_preserves_link_args_order() { + let mut f = flags(); + f.ldflags.push("-lpriv".into()); + let contrib = SystemDependencyFlags { + ldflags: vec![ + "-L/lib".into(), + "-lssl".into(), + "-lcrypto".into(), + "-lssl".into(), + ], + ..Default::default() + }; + merge_flags(&mut f, &contrib); + assert_eq!( + f.ldflags, + vec![ + "-lpriv".to_owned(), + "-L/lib".to_owned(), + "-lssl".to_owned(), + "-lcrypto".to_owned(), + "-lssl".to_owned(), + ], + ); + } + + #[test] + fn merge_appends_compile_args_in_order() { + let mut f = flags(); + let contrib = SystemDependencyFlags { + extra_compile_args: vec!["-pthread".into(), "-fPIC".into()], + ..Default::default() + }; + merge_flags(&mut f, &contrib); + assert_eq!( + f.extra_compile_args, + vec!["-pthread".to_owned(), "-fPIC".to_owned()], + ); + } +} diff --git a/crates/cabin-cli/src/term_color_glue.rs b/crates/cabin-cli/src/term_color_glue.rs new file mode 100644 index 000000000..f3eaaf1e9 --- /dev/null +++ b/crates/cabin-cli/src/term_color_glue.rs @@ -0,0 +1,247 @@ +//! Glue between Cabin's CLI surface and the typed +//! [`cabin_core::ColorChoice`]. +//! +//! Two pieces live here: +//! - [`CliColorChoice`] is the clap-facing enum used by +//! `--color`. It implements [`clap::ValueEnum`] (via the +//! derive on this side of the orphan rule) and converts +//! into the typed core enum on demand. +//! - [`resolve_color_choice`] applies Cabin's documented +//! precedence rule: CLI > `CABIN_TERM_COLOR` > config +//! `term.color` > default. The function is pure: tests pass +//! a closure for env lookup so they never depend on the host +//! environment. + +use cabin_config::{ + ConfigDiscoveryInputs, EffectiveConfig, discover_config_files, merge_loaded_files, +}; +use cabin_core::{ColorChoice, ColorEnvError}; + +/// Discover the user-level Cabin config (no workspace context) +/// and return its `term.color` value if any. Errors are +/// swallowed: a missing or unparseable config must not block +/// the early `render_error` path. A subcommand that +/// subsequently loads its own [`EffectiveConfig`] (with the +/// proper workspace layout) will surface any parse errors +/// through its normal error chain. +/// +/// This is the production input for the `config` slot of +/// [`resolve_color_choice`] before any subcommand has loaded a +/// workspace. It honours `CABIN_NO_CONFIG`, `CABIN_CONFIG`, and +/// `CABIN_CONFIG_HOME` exactly as discovery does for the rest +/// of Cabin. +pub(crate) fn discover_early_config_color() -> Option { + let inputs = ConfigDiscoveryInputs::from_process(None); + let discovery = discover_config_files(&inputs).ok()?; + let effective: EffectiveConfig = merge_loaded_files(discovery.loaded_files); + effective.term.color.map(|c| c.choice) +} + +/// Clap-facing color-choice enum. Mirrors +/// [`cabin_core::ColorChoice`] one-for-one. Lives on the CLI +/// side so we can derive [`clap::ValueEnum`] without making +/// `cabin-core` depend on `clap`. +/// +/// Variants are intentionally lowercase in their `to_possible_value` +/// rendering — clap's derive uses the `kebab-case` of the variant +/// name, which matches Cabin's accepted spellings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub(crate) enum CliColorChoice { + Auto, + Always, + Never, +} + +impl From for ColorChoice { + fn from(value: CliColorChoice) -> Self { + match value { + CliColorChoice::Auto => ColorChoice::Auto, + CliColorChoice::Always => ColorChoice::Always, + CliColorChoice::Never => ColorChoice::Never, + } + } +} + +/// Apply Cabin's color-choice precedence: +/// 1. `--color` flag (`cli`), +/// 2. `CABIN_TERM_COLOR` env var (looked up via `env`), +/// 3. config `term.color` (`config`), +/// 4. default [`ColorChoice::Auto`]. +/// +/// The function is pure: callers pass an env lookup closure +/// so tests can drive every branch without touching the +/// process environment. An invalid env value bubbles up as a +/// [`ColorEnvError`]; the CLI surfaces that error before +/// dispatching any subcommand. +/// +/// `cli` and `config` are pre-typed; only the env value goes +/// through string parsing because that is the only entry +/// point where a free-form string can reach Cabin from the +/// outside. +pub(crate) fn resolve_color_choice( + cli: Option, + env: F, + config: Option, +) -> Result +where + F: Fn(&str) -> Option, +{ + if let Some(choice) = cli { + return Ok(choice); + } + if let Some(raw) = env(cabin_env::CABIN_TERM_COLOR) { + // An empty `CABIN_TERM_COLOR=` is a common pattern for + // "unset"; treat it as if the variable were absent so + // shell scripts that clear it via `CABIN_TERM_COLOR=` + // do not see a hard error. + if raw.is_empty() { + return Ok(config.unwrap_or_default()); + } + return ColorChoice::from_env_value(&raw); + } + Ok(config.unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_env(_: &str) -> Option { + None + } + + fn env_with<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |key| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| (*v).to_owned()) + } + } + + #[test] + fn defaults_to_auto_with_no_inputs() { + let resolved = resolve_color_choice(None, no_env, None).unwrap(); + assert_eq!(resolved, ColorChoice::Auto); + } + + #[test] + fn cli_always_overrides_env_never() { + let resolved = resolve_color_choice( + Some(ColorChoice::Always), + env_with(&[(cabin_env::CABIN_TERM_COLOR, "never")]), + None, + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Always); + } + + #[test] + fn cli_never_overrides_env_always() { + let resolved = resolve_color_choice( + Some(ColorChoice::Never), + env_with(&[(cabin_env::CABIN_TERM_COLOR, "always")]), + None, + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Never); + } + + #[test] + fn env_always_applies_when_cli_omitted() { + let resolved = resolve_color_choice( + None, + env_with(&[(cabin_env::CABIN_TERM_COLOR, "always")]), + None, + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Always); + } + + #[test] + fn env_never_applies_when_cli_omitted() { + let resolved = resolve_color_choice( + None, + env_with(&[(cabin_env::CABIN_TERM_COLOR, "never")]), + None, + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Never); + } + + #[test] + fn invalid_env_bubbles_up_as_typed_error() { + let err = resolve_color_choice( + None, + env_with(&[(cabin_env::CABIN_TERM_COLOR, "sometimes")]), + None, + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + "invalid CABIN_TERM_COLOR value 'sometimes'; expected one of: auto, always, never" + ); + } + + #[test] + fn cli_value_takes_precedence_over_invalid_env() { + // `--color` parsing happens at the clap layer, so an + // invalid `CABIN_TERM_COLOR` only fails when the CLI + // does not already pin the choice. An explicit CLI + // value short-circuits env validation entirely. + let resolved = resolve_color_choice( + Some(ColorChoice::Auto), + env_with(&[(cabin_env::CABIN_TERM_COLOR, "sometimes")]), + None, + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Auto); + } + + #[test] + fn empty_env_value_is_treated_as_unset() { + let resolved = + resolve_color_choice(None, env_with(&[(cabin_env::CABIN_TERM_COLOR, "")]), None) + .unwrap(); + assert_eq!(resolved, ColorChoice::Auto); + } + + #[test] + fn config_applies_only_when_cli_and_env_silent() { + let resolved = resolve_color_choice(None, no_env, Some(ColorChoice::Always)).unwrap(); + assert_eq!(resolved, ColorChoice::Always); + } + + #[test] + fn env_overrides_config() { + let resolved = resolve_color_choice( + None, + env_with(&[(cabin_env::CABIN_TERM_COLOR, "never")]), + Some(ColorChoice::Always), + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Never); + } + + #[test] + fn cli_overrides_config_too() { + let resolved = + resolve_color_choice(Some(ColorChoice::Always), no_env, Some(ColorChoice::Never)) + .unwrap(); + assert_eq!(resolved, ColorChoice::Always); + } + + #[test] + fn empty_env_falls_through_to_config() { + // An empty `CABIN_TERM_COLOR=` should not erase a + // config-provided `term.color` — Cabin treats the + // empty value as "unset". + let resolved = resolve_color_choice( + None, + env_with(&[(cabin_env::CABIN_TERM_COLOR, "")]), + Some(ColorChoice::Always), + ) + .unwrap(); + assert_eq!(resolved, ColorChoice::Always); + } +} diff --git a/crates/cabin-cli/src/term_verbosity_glue.rs b/crates/cabin-cli/src/term_verbosity_glue.rs new file mode 100644 index 000000000..42a817109 --- /dev/null +++ b/crates/cabin-cli/src/term_verbosity_glue.rs @@ -0,0 +1,486 @@ +//! Glue between Cabin's CLI surface and the typed +//! [`cabin_core::Verbosity`]. +//! +//! Two pieces live here: +//! - [`resolve_verbosity`] applies Cabin's documented precedence +//! rule: CLI > `CABIN_TERM_VERBOSE` / `CABIN_TERM_QUIET` env +//! vars > config `term.verbose` / `term.quiet` > default. The +//! function is pure: tests pass a closure for env lookup so +//! they never depend on the host environment; +//! - [`Reporter`] is the small, typed display context every +//! subcommand uses to emit Cabin-owned status / verbose / +//! very-verbose lines. It honours `--quiet` and `--verbose` +//! so the verbosity check does not have to be re-implemented +//! per call site. +//! +//! Stream policy: human-facing commands (`cabin build`, `cabin +//! run`, `cabin test`, `cabin clean`, `cabin vendor`, `cabin +//! init`, `cabin new`, `cabin package`, `cabin publish`) emit +//! status to **stdout**; JSON-emitting commands (`cabin resolve +//! --format json`, `cabin update --format json`, â€Ļ) emit status +//! to **stderr** so the JSON document on stdout stays +//! machine-parseable. The reporter offers both spellings +//! (`status` / `aux_status`) so callers pick the right stream +//! once and never re-derive the choice. + +use std::fmt; +use std::io::Write; + +use cabin_config::{ + ConfigDiscoveryInputs, EffectiveConfig, discover_config_files, merge_loaded_files, +}; +use cabin_core::{Verbosity, VerbosityEnvError}; + +/// Discover the user-level Cabin config (no workspace context) +/// and return an [`EffectiveConfig`] suitable for passing to +/// [`resolve_verbosity`]. Errors are swallowed and an empty +/// effective config is returned: a missing or unparseable +/// config must not block the early reporter setup. The +/// subcommand-level dispatcher will surface any parse errors +/// later through its normal error chain. +pub(crate) fn discover_early_config_verbosity() -> EffectiveConfig { + let inputs = ConfigDiscoveryInputs::from_process(None); + match discover_config_files(&inputs) { + Ok(discovery) => merge_loaded_files(discovery.loaded_files), + Err(_) => EffectiveConfig::default(), + } +} + +/// Validated verbosity inputs at the CLI boundary. Mirrors the +/// raw `Cli` flags one-for-one so the dispatcher can produce a +/// single typed value without scattering `if quiet ... else if +/// verbose` branches over the call sites. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CliVerbosity { + /// Number of `-v` / `--verbose` occurrences. Clamped at the + /// caller — clap's `ArgAction::Count` already saturates at + /// `u8::MAX`. + pub(crate) verbose_count: u8, + /// Whether `-q` / `--quiet` was passed. + pub(crate) quiet: bool, +} + +impl CliVerbosity { + /// Translate the raw flag pair into a typed [`Verbosity`] if + /// the user explicitly opted in / out, or `None` when neither + /// flag was supplied so the next layer in the precedence + /// chain can take over. + fn into_verbosity(self) -> Option { + if self.quiet { + return Some(Verbosity::Quiet); + } + if self.verbose_count > 0 { + return Some(Verbosity::from_verbose_count(self.verbose_count)); + } + None + } +} + +/// Apply Cabin's verbosity precedence: +/// 1. `--quiet` / `-v` / `--verbose` flags; +/// 2. `CABIN_TERM_QUIET` / `CABIN_TERM_VERBOSE` env vars; +/// 3. config `term.quiet` / `term.verbose`; +/// 4. default [`Verbosity::Normal`]. +/// +/// The function is pure: callers pass an env lookup closure so +/// tests can drive every branch without touching the process +/// environment. An invalid env value bubbles up as a +/// [`VerbosityEnvError`]. Clap already rejects the `--quiet +/// --verbose` combination at parse time, so a CLI-level conflict +/// is never observed here. +pub(crate) fn resolve_verbosity( + cli: CliVerbosity, + env: F, + config: &EffectiveConfig, +) -> Result +where + F: Fn(&str) -> Option, +{ + if let Some(level) = cli.into_verbosity() { + return Ok(level); + } + + let env_quiet = read_bool_env(&env, cabin_env::CABIN_TERM_QUIET)?; + let env_verbose = read_bool_env(&env, cabin_env::CABIN_TERM_VERBOSE)?; + if env_quiet || env_verbose { + // Env vars are independent variables; an explicit truthy + // value on either one wins over the config layer. When + // both are set the typed combiner rejects the pair with + // the same wording the config layer uses. + let combined = + Verbosity::from_config_pair(env_verbose.then_some(true), env_quiet.then_some(true)) + .map_err(|_| VerbosityEnvError { + variable: cabin_env::CABIN_TERM_QUIET, + value: "1".to_owned(), + })?; + if let Some(level) = combined { + return Ok(level); + } + } + + if let Some(setting) = &config.term.verbosity { + return Ok(setting.level); + } + Ok(Verbosity::default()) +} + +fn read_bool_env(env: &F, key: &'static str) -> Result +where + F: Fn(&str) -> Option, +{ + match env(key) { + None => Ok(false), + Some(raw) => Verbosity::parse_bool_env(key, &raw), + } +} + +/// Stream the [`Reporter`] should send a single line to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Stream { + Stdout, + Stderr, +} + +/// Display context every subcommand uses to print Cabin-owned +/// status and verbose / very-verbose context lines. +/// +/// The reporter is intentionally small: it owns a verbosity, not +/// a writer, so call sites stay easy to grep (`reporter.status( +/// ...)`, `reporter.verbose(...)`). The actual writes go through +/// the process's stdout / stderr handles so existing tests that +/// match on either stream keep working. Both `Clone` and `Copy` +/// are derived so the value can flow by-value through the few +/// helper chains that would otherwise need a borrow. +/// Width the cargo-style verb (`Compiling`, `Finished`, +/// `Created`, â€Ļ) is right-aligned to inside `cargo_status`. +/// Matches cargo's own banner layout. +const COLUMN_WIDTH: usize = 12; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Reporter { + verbosity: Verbosity, + /// Resolved at construction time. `true` means every + /// styled write may emit ANSI escape sequences; `false` + /// guarantees plain-text output. The flag captures the + /// `--color` / `CABIN_TERM_COLOR` / config / tty resolution + /// so individual emit sites no longer probe the environment. + styled: bool, +} + +impl Reporter { + /// Build a reporter for the given verbosity, with styled + /// output disabled. Callers that resolve a [`ColorChoice`] + /// should prefer [`Reporter::with_color`] so cargo-style + /// banners (`Compiling foo`, `Finished `dev` profile â€Ļ`) + /// render in colour when the user asked for it. + pub(crate) fn new(verbosity: Verbosity) -> Self { + Self { + verbosity, + styled: false, + } + } + + /// Build a reporter that emits styled status lines when the + /// resolved [`ColorChoice`] says it should. `Auto` is + /// honoured by probing whether the current stdout handle is + /// a terminal — matching what the rest of Cabin's + /// diagnostic renderer does for stderr. + pub(crate) fn with_color(verbosity: Verbosity, color: cabin_core::ColorChoice) -> Self { + let styled = match color { + cabin_core::ColorChoice::Always => true, + cabin_core::ColorChoice::Never => false, + cabin_core::ColorChoice::Auto => std::io::IsTerminal::is_terminal(&std::io::stdout()), + }; + Self { verbosity, styled } + } + + /// Read the resolved verbosity back. Callers that need + /// the typed value read it through this accessor instead + /// of stashing the value alongside the reporter. + pub(crate) fn verbosity(self) -> Verbosity { + self.verbosity + } + + /// Emit a Cabin-owned status line on stdout, suppressed in + /// `Quiet` mode. Status lines describe progress + /// (`cabin: wrote build.ninja`, `cabin: removed N paths`), + /// not user-facing results. Use `aux_status` instead when + /// stdout is reserved for a JSON document or other + /// machine-readable output. + pub(crate) fn status(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_status() { + self.write(Stream::Stdout, args); + } + } + + /// Emit a cargo-style banner: ` `, + /// where the verb (`Compiling`, `Finished`, `Created`, â€Ļ) + /// renders in bright green + bold when the reporter is + /// styled and as plain text otherwise. The verb is + /// right-aligned to column 12 to match cargo's banner + /// layout, so `Compiling` and `Finished` align cleanly even + /// though they differ in length. + pub(crate) fn cargo_status(self, verb: &str, args: fmt::Arguments<'_>) { + if !self.verbosity.shows_status() { + return; + } + // Pad spaces before the styled span so the leading + // alignment is plain text; only the verb itself carries + // the ANSI escape. Both cargo and the C++ Cabin emit + // their banner with this shape. + let padding = COLUMN_WIDTH.saturating_sub(verb.len()); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = if self.styled { + // SGR 1 (bold) + 32 (green foreground), reset + // afterwards so the rest of the line stays plain. + writeln!( + handle, + "{:padding$}\x1b[1;32m{verb}\x1b[0m {args}", + "", + padding = padding, + ) + } else { + writeln!(handle, "{:padding$}{verb} {args}", "", padding = padding) + }; + } + + /// Same as [`Reporter::status`] but routes the line to + /// stderr so JSON-emitting commands (`cabin resolve --format + /// json`, `cabin update --format json`, â€Ļ) keep their stdout + /// document clean. + pub(crate) fn aux_status(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_status() { + self.write(Stream::Stderr, args); + } + } + + /// Emit a user-facing warning on stderr. Warnings are not + /// verbosity-gated: they report a degraded or partial result + /// rather than ordinary progress. + pub(crate) fn warning(self, args: fmt::Arguments<'_>) { + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = writeln!(handle, "cabin: warning: {args}"); + } + + /// Verbose-only status line on stdout. Suppressed below + /// `Verbose`. Used to surface the resolved profile, build + /// directory, and similar Cabin-owned context. + pub(crate) fn verbose(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_verbose() { + self.write(Stream::Stdout, args); + } + } + + /// Same as [`Reporter::verbose`] but routes to stderr for + /// shared orchestration paths that may run under + /// machine-readable stdout commands. + pub(crate) fn aux_verbose(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_verbose() { + self.write(Stream::Stderr, args); + } + } + + /// Very-verbose status line on stdout. Suppressed below + /// `VeryVerbose`. Used for executed command lines and + /// similar local-build diagnostics. + pub(crate) fn very_verbose(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_very_verbose() { + self.write(Stream::Stdout, args); + } + } + + /// Same as [`Reporter::very_verbose`] but routes to stderr for + /// shared orchestration paths that may run under + /// machine-readable stdout commands. + pub(crate) fn aux_very_verbose(self, args: fmt::Arguments<'_>) { + if self.verbosity.shows_very_verbose() { + self.write(Stream::Stderr, args); + } + } + + fn write(self, stream: Stream, args: fmt::Arguments<'_>) { + match stream { + Stream::Stdout => { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = writeln!(handle, "{args}"); + } + Stream::Stderr => { + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = writeln!(handle, "{args}"); + } + } + } +} + +impl Default for Reporter { + fn default() -> Self { + Self::new(Verbosity::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_env(_: &str) -> Option { + None + } + + fn env_with<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |key| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| (*v).to_owned()) + } + } + + fn cfg() -> EffectiveConfig { + EffectiveConfig::default() + } + + fn cfg_with_verbosity(level: Verbosity) -> EffectiveConfig { + let mut effective = EffectiveConfig::default(); + effective.term.verbosity = Some(cabin_config::EffectiveVerbosity { + level, + source: cabin_config::ConfigSource::User, + }); + effective + } + + fn cli(verbose_count: u8, quiet: bool) -> CliVerbosity { + CliVerbosity { + verbose_count, + quiet, + } + } + + #[test] + fn defaults_to_normal_with_no_inputs() { + let resolved = resolve_verbosity(cli(0, false), no_env, &cfg()).unwrap(); + assert_eq!(resolved, Verbosity::Normal); + } + + #[test] + fn cli_verbose_count_one_yields_verbose() { + let resolved = resolve_verbosity(cli(1, false), no_env, &cfg()).unwrap(); + assert_eq!(resolved, Verbosity::Verbose); + } + + #[test] + fn cli_verbose_count_two_or_more_yields_very_verbose() { + let resolved = resolve_verbosity(cli(2, false), no_env, &cfg()).unwrap(); + assert_eq!(resolved, Verbosity::VeryVerbose); + let resolved = resolve_verbosity(cli(7, false), no_env, &cfg()).unwrap(); + assert_eq!(resolved, Verbosity::VeryVerbose); + } + + #[test] + fn cli_quiet_overrides_config_verbose() { + let resolved = resolve_verbosity( + cli(0, true), + no_env, + &cfg_with_verbosity(Verbosity::Verbose), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Quiet); + } + + #[test] + fn cli_verbose_overrides_config_quiet() { + let resolved = + resolve_verbosity(cli(1, false), no_env, &cfg_with_verbosity(Verbosity::Quiet)) + .unwrap(); + assert_eq!(resolved, Verbosity::Verbose); + } + + #[test] + fn env_verbose_applies_when_cli_silent() { + let resolved = resolve_verbosity( + cli(0, false), + env_with(&[(cabin_env::CABIN_TERM_VERBOSE, "1")]), + &cfg(), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Verbose); + } + + #[test] + fn env_quiet_applies_when_cli_silent() { + let resolved = resolve_verbosity( + cli(0, false), + env_with(&[(cabin_env::CABIN_TERM_QUIET, "true")]), + &cfg(), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Quiet); + } + + #[test] + fn env_overrides_config() { + let resolved = resolve_verbosity( + cli(0, false), + env_with(&[(cabin_env::CABIN_TERM_VERBOSE, "1")]), + &cfg_with_verbosity(Verbosity::Quiet), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Verbose); + } + + #[test] + fn env_both_truthy_is_rejected() { + let err = resolve_verbosity( + cli(0, false), + env_with(&[ + (cabin_env::CABIN_TERM_VERBOSE, "1"), + (cabin_env::CABIN_TERM_QUIET, "1"), + ]), + &cfg(), + ) + .unwrap_err(); + // The error names one of the two variables; either is + // an actionable hint for the user to remove the conflict. + assert!( + err.variable == cabin_env::CABIN_TERM_QUIET + || err.variable == cabin_env::CABIN_TERM_VERBOSE + ); + } + + #[test] + fn invalid_env_value_bubbles_up_as_typed_error() { + let err = resolve_verbosity( + cli(0, false), + env_with(&[(cabin_env::CABIN_TERM_VERBOSE, "loud")]), + &cfg(), + ) + .unwrap_err(); + assert_eq!(err.variable, cabin_env::CABIN_TERM_VERBOSE); + assert_eq!(err.value, "loud"); + } + + #[test] + fn config_applies_when_cli_and_env_silent() { + let resolved = resolve_verbosity( + cli(0, false), + no_env, + &cfg_with_verbosity(Verbosity::Verbose), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Verbose); + } + + #[test] + fn empty_env_falls_through_to_config() { + let resolved = resolve_verbosity( + cli(0, false), + env_with(&[(cabin_env::CABIN_TERM_VERBOSE, "")]), + &cfg_with_verbosity(Verbosity::Quiet), + ) + .unwrap(); + assert_eq!(resolved, Verbosity::Quiet); + } +} diff --git a/crates/cabin-cli/src/test_glue.rs b/crates/cabin-cli/src/test_glue.rs new file mode 100644 index 000000000..999fd8c8c --- /dev/null +++ b/crates/cabin-cli/src/test_glue.rs @@ -0,0 +1,582 @@ +//! Glue layer for `cabin test`. +//! +//! `cabin test` builds the selected `cpp_test` targets through +//! the same pipeline as `cabin build` (workspace load → artifact +//! pipeline → planner → Ninja → invoke ninja), then hands the +//! resulting [`cabin_build::BuildGraph`] to +//! [`cabin_test::run_tests`] which spawns each test executable +//! and reports a deterministic summary. +//! +//! This module owns only the orchestration. Test planning and +//! test execution live in the dedicated `cabin-test` crate; +//! workspace loading, dependency resolution, build planning, and +//! Ninja generation live in their respective crates. The CLI +//! layer threads typed values between them. + +use std::collections::BTreeSet; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Args; + +use cabin_build::{ManifestTargetSelector, PlanRequest, plan, select_targets_of_kind}; +use cabin_core::TargetKind; +use cabin_workspace::{ + RegistryPackageSource, WorkspaceLoadOptions, collect_patched_versioned_deps, load_workspace, + load_workspace_with_options, +}; + +use crate::cli::{ + ArtifactPipelineRequest, ToolchainSelectionArgs, WorkspaceSelectionArgs, absolutise, + build_selection_request, build_workspace_selection, cache_dir_for, + closure_has_versioned_deps_excluding_patches, compiler_wrapper_override_from_args, + compute_feature_resolution, lock_mode_for_flags, profile_selection_from_flags, + resolve_build_configurations, resolve_invocation_manifest, resolve_per_package_build_flags, + run_artifact_pipeline, toolchain_selection_from_args, workspace_compiler_wrapper_settings, + workspace_profile_definitions, +}; +use crate::plural; + +/// `cabin test` arguments. Subset of `BuildArgs` plus a few +/// test-specific knobs. Mutually exclusive flags are enforced by +/// `clap`. +#[derive(Debug, Args)] +pub(crate) struct TestArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Directory for build outputs (build.ninja, object files, + /// linked test executables). Defaults to `build`. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Build with optimizations. + /// + /// Compatibility alias for `--profile release`; cannot be + /// used together with `--profile`. + #[arg(short = 'r', long, conflicts_with = "profile")] + pub release: bool, + + /// Build profile (`dev`, `release`, or any custom profile + /// declared in `[profile.]`). Defaults to `dev` — + /// the same default as `cabin build` so test runs match the + /// developer's working profile. + #[arg(long, value_name = "NAME")] + pub profile: Option, + + /// Path to a directory containing the local JSON package + /// index. Required when the test build closure has any + /// versioned dependency and `--index-url` is not given. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Sparse HTTP index URL. + #[arg(long, value_name = "URL")] + pub index_url: Option, + + /// Override the default artifact cache directory. + #[arg(long, value_name = "PATH")] + pub cache_dir: Option, + + /// Require an existing, current `cabin.lock`. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects state-writing side + /// effects. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. Combine with `cabin vendor` to run + /// `cabin test` against a self-contained local index. + #[arg(long)] + pub offline: bool, + + /// Enable named features for the selected packages. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every feature declared by selected packages. + #[arg(long)] + pub all_features: bool, + + /// Disable each selected package's default features. + #[arg(long)] + pub no_default_features: bool, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Toolchain-selection flags. + #[command(flatten)] + pub toolchain: ToolchainSelectionArgs, + + /// Disable every active patch and source-replacement entry + /// for this invocation. + #[arg(long)] + pub no_patches: bool, + + /// Exit successfully when the selected packages declare no + /// `cpp_test` targets. By default, an empty selection errors + /// so CI does not silently pass when tests have not been + /// declared yet. + #[arg(long)] + pub allow_no_tests: bool, +} + +/// Run `cabin test`: build the selected `cpp_test` targets, +/// invoke each linked executable in deterministic order, and +/// print a summary. Exits non-zero on any test failure. +pub(crate) fn test(args: &TestArgs, reporter: crate::term_verbosity_glue::Reporter) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + + // First-pass load with no registry / patches so we can + // resolve config + workspace selection before re-loading + // with the test-aware policy. + let initial_graph = load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + + let workspace_selection_for_pipeline = build_workspace_selection(&args.workspace_selection); + let initial_resolved_selection = cabin_workspace::resolve_package_selection( + &initial_graph, + &workspace_selection_for_pipeline, + )?; + + let initial_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + + // Activate dev-deps for the *selected* primary packages so + // their `[dev-dependencies]` reach the resolver / fetch + // pipeline. Dev-deps never propagate transitively. + let dev_for: BTreeSet = initial_resolved_selection + .packages + .iter() + .map(|i| initial_graph.packages[*i].package.name.as_str().to_owned()) + .collect(); + + let initial_features = compute_feature_resolution( + &initial_graph, + &initial_resolved_selection, + &initial_request, + )?; + + let resolved_index_source = crate::config_glue::resolve_index_source( + args.index_path.as_deref(), + args.index_url.as_deref(), + &effective_config, + )?; + let offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source(offline, resolved_index_source.as_ref())?; + let resolved_cache_dir = + crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config); + + let patched_root_deps_preview = + collect_patched_versioned_deps(&active_patches, &patched_names)?; + let has_versioned = !patched_root_deps_preview.is_empty() + || closure_has_versioned_deps_excluding_patches( + &initial_graph, + &initial_resolved_selection, + &initial_features, + &patched_names, + &dev_for, + ); + + let registry: Vec = if has_versioned { + let Some(index_source) = resolved_index_source.as_ref() else { + bail!( + "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting" + ); + }; + let mode = lock_mode_for_flags(args.locked, args.frozen); + let allow_write = !(args.locked || args.frozen); + let cache_dir = match resolved_cache_dir.as_ref() { + Some((path, _)) => path.clone(), + None => cache_dir_for(&manifest_path, args.cache_dir.as_deref())?, + }; + let initial_locator = match &index_source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved_locator = crate::patch_glue::apply_source_replacement( + initial_locator, + &effective_config, + args.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement(offline, &resolved_locator)?; + let (replaced_path, replaced_url) = + crate::patch_glue::locator_to_index_inputs(&resolved_locator.resolved); + let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest { + manifest_path: &manifest_path, + initial_graph: &initial_graph, + index_path: replaced_path.as_deref(), + index_url: replaced_url.as_deref(), + mode, + allow_write, + frozen: args.frozen, + cache_dir: &cache_dir, + reporter, + selection: workspace_selection_for_pipeline, + selection_request: &initial_request, + patched_names: &patched_names, + active_patches: &active_patches, + source_replacements: &effective_config.source_replacements, + no_patches: args.no_patches, + dev_for: &dev_for, + })?; + pipeline + .fetched + .iter() + .map(|p| RegistryPackageSource { + name: p.name.clone(), + version: p.version.clone(), + manifest_path: p.source_dir.join("cabin.toml"), + }) + .collect() + } else { + Vec::new() + }; + + // Re-load with registry + patches + dev-dep activation so + // the planner sees every test-only dep edge. + let strict_packages: BTreeSet = initial_resolved_selection + .closure(&initial_graph) + .into_iter() + .map(|i| initial_graph.packages[i].package.name.as_str().to_owned()) + .collect(); + let patched_sources = active_patches.workspace_sources(); + let graph = load_workspace_with_options( + &manifest_path, + &WorkspaceLoadOptions { + registry: ®istry, + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &dev_for, + }, + )?; + + let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutise(&build_dir_input) + .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?; + + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?; + let toolchain = crate::cli::resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + let detection_report = + cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner)?; + cabin_build::validate_toolchain_for_backend(&toolchain, &detection_report)?; + let ninja = cabin_toolchain::locate_ninja()?; + + let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph); + let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?; + + let profile_selection = + profile_selection_from_flags(args.profile.as_deref(), args.release, &effective_config)?; + let manifest_profiles = workspace_profile_definitions(&graph); + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + let profile_build = profile.build.as_ref(); + let build_flags = resolve_per_package_build_flags(&graph, profile_build, &host_platform); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + + let compiler_wrapper = crate::cli::resolve_compiler_wrapper_layered( + cli_compiler_wrapper, + &manifest_compiler_wrapper, + &effective_config, + &host_platform, + )?; + let toolchain_summary = + cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, compiler_wrapper.as_ref()); + + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + + // Build every `cpp_test` in the selected packages. + // Single-test selection is reserved for a future explicit-kind + // flag (`--target` is reserved for a platform/toolchain target). + let test_selectors: Vec = select_targets_of_kind( + &graph, + Some(&resolved_selection.packages), + TargetKind::CppTest, + ); + + if test_selectors.is_empty() { + if args.allow_no_tests { + println!("cabin test: no test targets found"); + return Ok(()); + } + bail!( + "no test targets found in the selected packages; declare a `cpp_test` target or pass `--allow-no-tests`" + ); + } + + let selection_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let configurations = resolve_build_configurations( + &graph, + &selection_request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let _feature_resolution = + compute_feature_resolution(&graph, &resolved_selection, &selection_request)?; + + let root_configuration = graph + .root_package + .and_then(|i| configurations.get(&i)) + .cloned(); + let plan_graph = plan(&PlanRequest { + graph: &graph, + toolchain: &toolchain, + build_flags: &build_flags, + build_dir: build_dir.clone(), + profile: profile.clone(), + selected: Some(test_selectors), + configuration: root_configuration.as_ref(), + selected_packages: Some(&resolved_selection.packages), + compiler_wrapper: compiler_wrapper.as_ref(), + })?; + + let profile_build_root = build_dir.join(profile.name.as_str()); + std::fs::create_dir_all(&profile_build_root).with_context(|| { + format!( + "failed to create build directory {}", + profile_build_root.display() + ) + })?; + + let ninja_file = profile_build_root.join("build.ninja"); + cabin_ninja::write_build_ninja(&ninja_file, &plan_graph)?; + let ccmd_file = profile_build_root.join("compile_commands.json"); + cabin_ninja::write_compile_commands(&ccmd_file, &plan_graph)?; + + // Implementation-detail status is verbose-only: under `-v` + // the user sees which files Cabin wrote and how Ninja was + // invoked, alongside Ninja's own raw banner. + reporter.verbose(format_args!("cabin: wrote {}", ninja_file.display())); + reporter.verbose(format_args!("cabin: wrote {}", ccmd_file.display())); + reporter.verbose(format_args!( + "cabin: invoking {} -C {}", + ninja.display(), + profile_build_root.display() + )); + let mut ninja_cmd = std::process::Command::new(&ninja); + // Route Ninja through the shared runner so `cabin test`'s + // build phase prints the same cargo-style `Compiling â€Ļ` + // banner `cabin build` emits — and so the verbose passthrough + // and the default-mode filtering stay in one place. + let status = crate::cli::run_ninja( + ninja_cmd.arg("-C").arg(&profile_build_root), + reporter, + &graph, + ) + .with_context(|| format!("failed to invoke ninja at {}", ninja.display()))?; + if !status.success() { + bail!("ninja exited with {status}"); + } + + // Build → run hand-off. The plan builder reads `cpp_test` + // targets out of the graph and aligns them with the + // `default_outputs` the planner emitted, so empty + // `default_outputs` produce a clear error rather than a + // silent no-op. + let mut test_plan = + cabin_test::plan_tests(&graph, &plan_graph, Some(&resolved_selection.packages)); + populate_test_env_overlay(&mut test_plan, &graph, &profile, &build_dir)?; + if test_plan.is_empty() { + if args.allow_no_tests { + println!("cabin test: no test targets found"); + return Ok(()); + } + bail!("no test targets were produced by the build graph; pass `--allow-no-tests` to skip"); + } + + let mut sink = cabin_test::StreamingSink { + stdout: std::io::stdout().lock(), + stderr: std::io::stderr().lock(), + }; + println!( + "running {} test{}", + test_plan.len(), + plural(test_plan.len()) + ); + for executable in &test_plan { + // Per-test "running" line goes out before output streams + // so multi-test runs are easy to scan. + let _ = writeln!( + sink.stdout, + "{}", + cabin_test::render_running_line(executable) + ); + } + let summary = cabin_test::run_tests(&test_plan, &mut sink)?; + for result in &summary.results { + let _ = writeln!(sink.stdout, "{}", cabin_test::render_result_line(result)); + } + let _ = writeln!(sink.stdout, "{}", cabin_test::render_summary_line(&summary)); + if !summary.all_passed() { + bail!( + "test failures: {} of {} test executables failed", + summary.failed(), + summary.total() + ); + } + Ok(()) +} + +/// Walk every executable in `plan` and attach the typed +/// `CABIN_*` package-execution overlay produced by +/// [`cabin_env::package_env`]. The overlay is layered on top of +/// the inherited environment at runtime; PATH and friends remain +/// intact so test executables can still find shared system +/// tools. The only fallible step is mapping each executable back +/// to its workspace package. +fn populate_test_env_overlay( + plan: &mut cabin_test::TestPlan, + graph: &cabin_workspace::PackageGraph, + profile: &cabin_core::ResolvedProfile, + build_dir: &std::path::Path, +) -> Result<()> { + let mut failure = None; + plan.for_each_executable_mut(|exe| { + if failure.is_some() { + return; + } + let Some(idx) = graph.index_of(exe.package.as_str()) else { + failure = Some(anyhow::anyhow!( + "failed to build test env for `{}:{}`: package is not present in the workspace graph", + exe.package, + exe.target + )); + return; + }; + let pkg = &graph.packages[idx]; + exe.env = cabin_env::package_env(&cabin_env::PackageEnvInputs { + manifest_dir: pkg.manifest_dir.as_path(), + manifest_path: pkg.manifest_path.as_path(), + package_name: pkg.package.name.as_str(), + package_version: &pkg.package.version.to_string(), + profile: profile.name.as_str(), + build_dir, + }); + }); + if let Some(err) = failure { + return Err(err); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + use cabin_build::BuildGraph; + use cabin_core::{ + Package, PackageName, ProfileDefinition, ProfileName, ProfileSelection, Target, TargetKind, + TargetName, resolve_profile, + }; + use cabin_workspace::{PackageGraph, PackageKind, WorkspacePackage}; + + fn dev_profile() -> cabin_core::ResolvedProfile { + resolve_profile( + &ProfileSelection::default_dev(), + &BTreeMap::::new(), + ) + .expect("built-in dev profile resolves") + } + + fn test_graph() -> PackageGraph { + let target = Target { + name: TargetName::new("demo_test").unwrap(), + kind: TargetKind::CppTest, + sources: Vec::new(), + include_dirs: Vec::new(), + defines: Vec::new(), + deps: Vec::new(), + }; + let package = Package::new( + PackageName::new("demo").unwrap(), + semver::Version::parse("0.1.0").unwrap(), + vec![target], + Vec::new(), + ) + .unwrap(); + PackageGraph { + root_manifest_path: PathBuf::from("demo/cabin.toml"), + root_dir: PathBuf::from("demo"), + is_workspace_root: false, + root_package: Some(0), + root_settings: Default::default(), + primary_packages: vec![0], + default_members: vec![0], + excluded_members: Vec::new(), + packages: vec![WorkspacePackage { + package, + manifest_path: PathBuf::from("demo/cabin.toml"), + manifest_dir: PathBuf::from("demo"), + deps: Vec::new(), + kind: PackageKind::Local, + }], + } + } + + #[test] + fn populate_test_env_overlay_errors_when_package_missing_from_graph() { + let graph = test_graph(); + let build_graph = BuildGraph { + actions: Vec::new(), + default_outputs: vec![PathBuf::from("build/dev/packages/demo/demo_test")], + compile_commands: Vec::new(), + }; + let mut plan = cabin_test::plan_tests(&graph, &build_graph, Some(&[0])); + assert_eq!(plan.len(), 1); + // Detach the executable from any workspace package so the + // only remaining fallible step (graph lookup) trips. + plan.for_each_executable_mut(|exe| exe.package.clear()); + + let err = populate_test_env_overlay(&mut plan, &graph, &dev_profile(), Path::new("build")) + .expect_err("an executable with no owning package must be surfaced"); + + assert!( + err.to_string().contains("failed to build test env"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/cabin-cli/src/tidy_glue.rs b/crates/cabin-cli/src/tidy_glue.rs new file mode 100644 index 000000000..66ce0f410 --- /dev/null +++ b/crates/cabin-cli/src/tidy_glue.rs @@ -0,0 +1,552 @@ +//! Orchestration for `cabin tidy`. +//! +//! Translates the CLI flag bundle into the typed inputs the +//! shared crates accept and routes their outcomes back to the +//! reporter. Source discovery is reused verbatim from +//! `cabin-source-discovery`; build planning lives in `cabin-build` +//! and `cabin-ninja` writes the compile database; clang-tidy +//! invocation lives in `cabin-tidy`. No source-walking, +//! compile-database generation, or `run-clang-tidy` command-line +//! construction lives in this file. +//! +//! `cabin tidy` is the only Cabin command that needs a +//! `compile_commands.json` to do its job, so this module is also +//! the only place that calls `cabin_build::plan` outside the +//! build / run / test pipeline. The planner is run *without* +//! invoking Ninja: clang-tidy reads the JSON compilation database +//! directly and a build is unnecessary for analysis. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::Args; + +use cabin_build::{ManifestTargetSelector, PlanRequest, plan, select_targets_of_kind}; +use cabin_core::{DependencySource, SelectionRequest, TargetKind, TargetPlatform, Verbosity}; +use cabin_source_discovery::{SourceDiscoveryRequest, discover_sources}; +use cabin_tidy::{ + ExitStatusKind, TidyMode, TidyReport, TidyRequest, TidyVerbosity, resolve_tidy_executable, + run_tidy, +}; +use cabin_workspace::PackageGraph; + +use crate::plural; +use crate::source_tooling_glue::{ + absolutize, describe_packages, display_workspace_relative, nested_package_excludes, + package_selection_from_flags, +}; +use crate::term_verbosity_glue::Reporter; + +/// `cabin tidy` argument bundle. +/// +/// Field doc-comments are picked up by clap and rendered in +/// `cabin tidy --help`; keep them user-focused. +#[derive(Debug, Args)] +pub(crate) struct TidyArgs { + /// Path to the cabin.toml manifest. Same precedence rules + /// as `cabin build`: when omitted, Cabin walks upward from + /// the current directory to find the nearest manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Build output directory for the generated compile database. + /// Same precedence rules as `cabin build`: `--build-dir` > + /// `CABIN_BUILD_DIR` > `[paths] build-dir` config setting > + /// built-in default `build`. + #[arg(long, value_name = "PATH")] + pub build_dir: Option, + + /// Apply the fixes clang-tidy suggests. Off by default; + /// Cabin never rewrites your sources unless this flag is + /// passed explicitly. + #[arg(long)] + pub fix: bool, + + /// Exclude one file or directory from the analysis. May be + /// repeated. Paths are resolved against the current working + /// directory. + #[arg(long, value_name = "PATH")] + pub exclude: Vec, + + /// Disable VCS ignore handling so files that are normally + /// hidden by `.gitignore` are also analysed. Cabin's + /// built-in build / cache / vendor exclusions still apply. + #[arg(long)] + pub no_ignore_vcs: bool, + + /// Analyse every workspace member. Cannot be combined with + /// `--package` or `--default-members`. + #[arg(long, conflicts_with_all = &["package", "default_members"])] + pub workspace: bool, + + /// Analyse the named workspace package. Repeat the flag to + /// select multiple packages. Errors when a name is not a + /// workspace member. + #[arg(long = "package", short = 'p', value_name = "PACKAGE")] + pub package: Vec, + + /// Analyse `[workspace.default-members]`. Errors when the + /// workspace declares no default-members. + #[arg(long, conflicts_with_all = &["workspace", "package"])] + pub default_members: bool, + + /// Number of parallel `clang-tidy` instances to run. Same + /// precedence chain as `cabin build`: this flag wins over + /// `CABIN_BUILD_JOBS`, then the `[build] jobs` config + /// setting, then the backend's own default. In `--fix` + /// mode Cabin clamps the effective value to `1` so + /// concurrent rewrites cannot race. + #[arg(short = 'j', long = "jobs", value_name = "N")] + pub jobs: Option, +} + +/// Entry point invoked by the top-level dispatcher. +pub(crate) fn tidy(args: &TidyArgs, reporter: Reporter) -> Result { + let manifest_path = crate::cli::resolve_invocation_manifest(args.manifest_path.as_deref())?; + let graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&graph)?; + + let workspace_selection = + package_selection_from_flags(args.workspace, &args.package, args.default_members); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + let selection_request = SelectionRequest::default(); + let feature_resolution = + crate::cli::compute_feature_resolution(&graph, &resolved_selection, &selection_request)?; + + // `cabin tidy` does not run the artifact pipeline or load + // extracted registry manifests, so selected packages with + // versioned dependencies cannot be planned accurately here. + // Scope this check to the selected closure so an unrelated + // workspace member does not block `cabin tidy -p `. + if let Some(name) = first_selected_versioned_dependency_package_name( + &graph, + &resolved_selection, + &feature_resolution, + ) { + bail!( + "package `{name}` declares versioned registry dependencies; `cabin tidy` does not run the artifact pipeline, so registry-backed selections are not supported" + ); + } + + // Match `cabin build` / `cabin fmt`'s build-directory + // resolution so the walker excludes whatever + // directory `cabin build` would have written into and so the + // compile database lands at the same path the user already + // sees in their tree. + let (build_dir_input, _) = crate::config_glue::resolve_build_dir_with_env( + args.build_dir.as_deref(), + &effective_config, + ); + let build_dir = absolutize(&graph.root_dir, &build_dir_input); + + let cwd = std::env::current_dir().context("failed to determine current directory")?; + let absolute_excludes: Vec = + args.exclude.iter().map(|p| absolutize(&cwd, p)).collect(); + + let executable = resolve_tidy_executable(|key| std::env::var_os(key)); + let tidy_verbosity = match reporter.verbosity() { + Verbosity::Quiet | Verbosity::Normal => TidyVerbosity::Normal, + Verbosity::Verbose | Verbosity::VeryVerbose => TidyVerbosity::Verbose, + }; + + let mode = if args.fix { + TidyMode::Fix + } else { + TidyMode::Check + }; + + // Resolve jobs through the same precedence chain `cabin + // build`/`run`/`tidy` honour: CLI wins over CABIN_BUILD_JOBS, + // then the [build] jobs config setting, then the backend's + // own default. In `--fix` mode the effective value is + // clamped to 1: two clang-tidy instances applying overlapping + // rewrites can race, and the safest behaviour is to serialise + // them. When the user explicitly asked for a higher count we + // surface the override in verbose mode rather than silently + // dropping the request. + let requested_jobs = crate::config_glue::resolve_build_jobs(args.jobs, &effective_config)?; + let effective_jobs = if matches!(mode, TidyMode::Fix) { + if requested_jobs.map(|j| j.get() > 1).unwrap_or(false) { + reporter.verbose(format_args!( + "cabin: --fix forces tidy parallelism to 1 (requested -j{})", + requested_jobs.expect("checked above").get(), + )); + } + Some(cabin_core::BuildJobs::new(1).expect("1 is non-zero")) + } else { + requested_jobs + }; + + // Short-circuit before asking the planner to do anything: a + // workspace whose selected packages declare no C/C++ targets + // has nothing to analyse, and `cabin_build::plan` would + // otherwise bail with `EmptySelectedPackages`. Mirrors the + // "no files to check" path used by other source tools. + let selected_indices: BTreeSet = resolved_selection.packages.iter().copied().collect(); + if !any_cpp_targets(&graph, &selected_indices) { + reporter.status(format_args!("cabin: no C/C++ source files to check")); + return Ok(ExitCode::SUCCESS); + } + + // Build the per-target compile-command list by running the + // planner with the dev profile and a process-resolved + // toolchain. + let host_platform = cabin_core::TargetPlatform::current(); + let toolchain_selection = cabin_core::ToolchainSelection::default(); + let toolchain = crate::cli::resolve_toolchain_layered( + &graph, + &toolchain_selection, + &effective_config, + &host_platform, + )?; + + let manifest_profiles = crate::cli::workspace_profile_definitions(&graph); + let profile_selection = cabin_core::ProfileSelection::default_dev(); + let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + let profile_build = profile.build.as_ref(); + let build_flags = + crate::cli::resolve_per_package_build_flags(&graph, profile_build, &host_platform); + // `cabin tidy` does not opt into dev-dep activation; + // dev-kind system deps stay declaration-only here. + let dev_for: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let (build_flags, _system_dep_reports) = + crate::system_deps_glue::augment_build_flags_with_system_deps( + &graph, + &host_platform, + &dev_for, + build_flags, + reporter, + )?; + let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env( + &graph, + build_flags, + |k| std::env::var_os(k), + reporter, + )?; + + // Build configurations are required by the planner so the + // per-package `BuildConfiguration` exists for every selected + // package. We use empty selection requests (no CLI + // features); tidy is workspace-wide analysis, so + // per-feature configuration is not the bar. + let toolchain_summary = cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, None); + let configurations = crate::cli::resolve_build_configurations( + &graph, + &selection_request, + &resolved_selection.packages, + &profile, + &toolchain_summary, + &build_flags, + )?; + let root_configuration = graph + .root_package + .and_then(|i| configurations.get(&i)) + .cloned(); + + // The planner's default selection only emits compile commands + // for default-buildable kinds (library, header-only, executable), + // which silently excludes `cpp_test` / `cpp_example` sources. + // Tidy is asymmetric to fmt without those kinds, so enumerate + // every C/C++ kind explicitly here. + let tidy_selectors: Vec = [ + TargetKind::CppLibrary, + TargetKind::CppHeaderOnly, + TargetKind::CppExecutable, + TargetKind::CppTest, + TargetKind::CppExample, + ] + .into_iter() + .flat_map(|kind| select_targets_of_kind(&graph, Some(&resolved_selection.packages), kind)) + .collect(); + + let plan_graph = plan(&PlanRequest { + graph: &graph, + toolchain: &toolchain, + build_flags: &build_flags, + build_dir: build_dir.clone(), + profile: profile.clone(), + selected: Some(tidy_selectors), + configuration: root_configuration.as_ref(), + selected_packages: Some(&resolved_selection.packages), + compiler_wrapper: None, + })?; + + // Use the per-profile build root so the compile database + // lands at the same path `cabin build` produces. This is + // what the user already sees in their tree and what + // `clang-tidy -p ` expects. + let profile_build_root = build_dir.join(profile.name.as_str()); + std::fs::create_dir_all(&profile_build_root).with_context(|| { + format!( + "failed to create build directory {}", + profile_build_root.display() + ) + })?; + let compile_db_path = profile_build_root.join("compile_commands.json"); + cabin_ninja::write_compile_commands(&compile_db_path, &plan_graph)?; + + // Filter discovered sources to those that have an entry in + // the compile database. `clang-tidy` cannot analyse a file + // without a compile command, and `run-clang-tidy` interprets + // bare filenames as regex patterns matched against the + // database, so passing files with no entry would produce + // confusing "no matches" warnings on stderr. + let mut excluded_directories = nested_package_excludes(&graph, &selected_indices); + excluded_directories.push(build_dir); + let roots: Vec = resolved_selection + .packages + .iter() + .map(|&idx| graph.packages[idx].manifest_dir.clone()) + .collect(); + let request = SourceDiscoveryRequest { + roots, + excluded_paths: absolute_excludes, + excluded_directories, + respect_vcs_ignore: !args.no_ignore_vcs, + }; + let discovered = discover_sources(&request) + .map_err(|err| anyhow::anyhow!("source discovery failed: {err}"))?; + let compile_db_files: BTreeSet = plan_graph + .compile_commands + .iter() + .map(|cc| canonicalize_or_self(&cc.file)) + .collect(); + let files: Vec = discovered + .into_iter() + .map(|f| canonicalize_or_self(&f.absolute_path)) + .filter(|p| compile_db_files.contains(p)) + .collect(); + + let mut selected_names: Vec = resolved_selection + .packages + .iter() + .map(|&idx| graph.packages[idx].package.name.as_str().to_owned()) + .collect(); + selected_names.sort(); + + if files.is_empty() { + reporter.status(format_args!( + "cabin: no C/C++ source files to check in {}", + describe_packages(&selected_names), + )); + return Ok(ExitCode::SUCCESS); + } + + reporter.status(format_args!( + "cabin: running clang-tidy for {}", + describe_packages(&selected_names), + )); + reporter.verbose(format_args!( + "cabin: tidying {} file{} across {}", + files.len(), + plural(files.len()), + describe_packages(&selected_names), + )); + reporter.verbose(format_args!( + "cabin: compile database = {}", + compile_db_path.display(), + )); + if let Some(jobs) = effective_jobs { + reporter.verbose(format_args!("cabin: jobs = {}", jobs.get())); + } + reporter.very_verbose(format_args!( + "cabin: running `{} -p {}{}{} <{} file{}>`", + executable.to_string_lossy(), + profile_build_root.display(), + match mode { + TidyMode::Fix => " -fix", + TidyMode::Check => "", + }, + match (tidy_verbosity, effective_jobs) { + (TidyVerbosity::Normal, Some(j)) => format!(" -quiet -j {}", j.get()), + (TidyVerbosity::Normal, None) => " -quiet".to_owned(), + (TidyVerbosity::Verbose, Some(j)) => format!(" -j {}", j.get()), + (TidyVerbosity::Verbose, None) => String::new(), + }, + files.len(), + plural(files.len()), + )); + for file in &files { + reporter.very_verbose(format_args!( + " {}", + display_workspace_relative(&graph.root_dir, file), + )); + } + + let tidy_request = TidyRequest { + executable, + compile_database_dir: profile_build_root, + files, + mode, + jobs: effective_jobs, + verbosity: tidy_verbosity, + }; + + match run_tidy(&tidy_request) { + Ok(TidyReport::Tidied { files_processed }) => { + reporter.status(format_args!( + "cabin: checked {} file{}", + files_processed, + plural(files_processed), + )); + Ok(ExitCode::SUCCESS) + } + Ok(TidyReport::NoFiles) => { + // Pre-filtered above; the runner's empty-list + // short-circuit is the source of truth. + Ok(ExitCode::SUCCESS) + } + Ok(TidyReport::TidyFailed { + status, + files_processed, + }) => { + reporter.status(format_args!( + "cabin: clang-tidy failed for {} ({}, {} file{})", + describe_packages(&selected_names), + describe_status(&status), + files_processed, + plural(files_processed), + )); + Ok(ExitCode::FAILURE) + } + Err(err) => bail!(err.to_string()), + } +} + +/// Best-effort path canonicalisation. When the file does not +/// exist or canonicalisation otherwise fails, returns the input +/// unchanged so the caller still has a usable absolute path. +/// Source-discovery and the planner both produce absolute paths +/// already; this only normalises symlink resolution so the +/// per-side comparison sees the same bytes. +fn canonicalize_or_self(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// Whether the union of the selected packages contains at least +/// one C/C++ target. The planner's enumeration would otherwise +/// fail with `EmptySelectedPackages` for workspaces that hold +/// only Rust libraries or pure manifest entries. +fn any_cpp_targets(graph: &PackageGraph, selected: &BTreeSet) -> bool { + selected.iter().any(|&idx| { + graph.packages[idx].package.targets.iter().any(|t| { + matches!( + t.kind, + TargetKind::CppLibrary + | TargetKind::CppHeaderOnly + | TargetKind::CppExecutable + | TargetKind::CppTest + | TargetKind::CppExample + ) + }) + }) +} + +/// Find the first selected-closure package that declares an active +/// versioned registry dependency. Used to surface an actionable +/// error instead of letting `cabin_build::plan` fail with a +/// confusing downstream message. +fn first_selected_versioned_dependency_package_name( + graph: &PackageGraph, + selection: &cabin_workspace::ResolvedSelection, + features: &cabin_feature::FeatureResolution, +) -> Option { + let closure = selection.closure(graph); + let host_platform = TargetPlatform::current(); + let mut hits: BTreeSet<&str> = BTreeSet::new(); + for idx in closure { + let pkg = &graph.packages[idx]; + for dep in &pkg.package.dependencies { + if dep.kind.is_resolved_by_default() + && dep.matches_platform(&host_platform) + && (!dep.optional || features.is_optional_dep_enabled(idx, dep.name.as_str())) + && matches!(dep.source, DependencySource::Version(_)) + { + hits.insert(pkg.package.name.as_str()); + } + } + } + hits.into_iter().next().map(str::to_owned) +} + +fn describe_status(status: &ExitStatusKind) -> String { + match status { + ExitStatusKind::Code(c) => format!("exit code {c}"), + ExitStatusKind::Signal(s) => format!("signal {s}"), + ExitStatusKind::Unknown => "unknown status".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use cabin_core::{Package, PackageName, Target, TargetName}; + use cabin_workspace::{PackageKind, WorkspacePackage}; + + fn graph_with_single_target(kind: TargetKind) -> PackageGraph { + let target = Target { + name: TargetName::new("only").unwrap(), + kind, + sources: Vec::new(), + include_dirs: Vec::new(), + defines: Vec::new(), + deps: Vec::new(), + }; + let package = Package::new( + PackageName::new("demo").unwrap(), + semver::Version::parse("0.1.0").unwrap(), + vec![target], + Vec::new(), + ) + .unwrap(); + PackageGraph { + root_manifest_path: PathBuf::from("demo/cabin.toml"), + root_dir: PathBuf::from("demo"), + is_workspace_root: false, + root_package: Some(0), + root_settings: Default::default(), + primary_packages: vec![0], + default_members: vec![0], + excluded_members: Vec::new(), + packages: vec![WorkspacePackage { + package, + manifest_path: PathBuf::from("demo/cabin.toml"), + manifest_dir: PathBuf::from("demo"), + deps: Vec::new(), + kind: PackageKind::Local, + }], + } + } + + #[test] + fn any_cpp_targets_detects_test_only_package() { + let graph = graph_with_single_target(TargetKind::CppTest); + let selected: BTreeSet = [0].into_iter().collect(); + assert!( + any_cpp_targets(&graph, &selected), + "cpp_test must count as a C/C++ target for `cabin tidy`", + ); + } + + #[test] + fn any_cpp_targets_detects_example_only_package() { + let graph = graph_with_single_target(TargetKind::CppExample); + let selected: BTreeSet = [0].into_iter().collect(); + assert!( + any_cpp_targets(&graph, &selected), + "cpp_example must count as a C/C++ target for `cabin tidy`", + ); + } + + #[test] + fn any_cpp_targets_still_detects_library() { + let graph = graph_with_single_target(TargetKind::CppLibrary); + let selected: BTreeSet = [0].into_iter().collect(); + assert!(any_cpp_targets(&graph, &selected)); + } +} diff --git a/crates/cabin-cli/src/tree_glue.rs b/crates/cabin-cli/src/tree_glue.rs new file mode 100644 index 000000000..d20fe485f --- /dev/null +++ b/crates/cabin-cli/src/tree_glue.rs @@ -0,0 +1,147 @@ +//! Glue layer for `cabin tree`. +//! +//! `cabin tree` walks the same resolved [`cabin_workspace::PackageGraph`] + +//! lockfile + active-patch state that `cabin metadata` already +//! exposes, and renders it either as a Unicode-drawing tree +//! (`--format human`, the default) or as a JSON document +//! (`--format json`). All domain logic lives in `cabin-explain`; +//! this module orchestrates the typed inputs. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Args, ValueEnum}; + +use cabin_core::DependencyKind; +use cabin_workspace::{WorkspaceLoadOptions, load_workspace, load_workspace_with_options}; + +use crate::cli::{ + ConfigSelectionArgs, ResolveFormat, WorkspaceSelectionArgs, build_selection_request, + build_workspace_selection, compute_feature_resolution, lockfile_path_for, + resolve_invocation_manifest, +}; + +/// Dependency-kind filter used by the `--kind` flag. +// +// Dev edges are intentionally not exposed here: tree/explain build their +// view through the ordinary workspace loader, which keeps dev deps +// declaration-only — only `cabin run` / `cabin test` opt them into the +// graph. A `--kind dev` filter would walk an empty edge set. +#[derive(Debug, Clone, Copy, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub(crate) enum TreeKindFilter { + /// Walk every kind (default). + All, + /// `dependencies` edges only. + Normal, +} + +impl TreeKindFilter { + fn to_filter(self) -> Option { + match self { + Self::All => None, + Self::Normal => Some(DependencyKind::Normal), + } + } +} + +#[derive(Debug, Args)] +pub(crate) struct TreeArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Output format. `human` is a Unicode-drawing tree (the + /// default); `json` is a structured document for tooling. + #[arg(long, value_name = "FORMAT", default_value = "human")] + pub format: ResolveFormat, + + /// Restrict the walk to one dependency kind. Defaults to + /// every kind. + #[arg(long, value_name = "KIND", default_value = "all")] + pub kind: TreeKindFilter, + + /// Workspace package-selection flags. Same semantics as + /// `cabin metadata` and `cabin build`. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Feature selection flags. + #[command(flatten)] + pub selection: ConfigSelectionArgs, + + /// Disable every active patch and source-replacement entry + /// for this invocation, mirroring `cabin metadata + /// --no-patches`. + #[arg(long)] + pub no_patches: bool, +} + +pub(crate) fn tree(args: &TreeArgs) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let initial_graph = load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_sources = active_patches.workspace_sources(); + let graph = if patched_sources.is_empty() { + initial_graph + } else { + let strict_packages: BTreeSet = BTreeSet::new(); + load_workspace_with_options( + &manifest_path, + &WorkspaceLoadOptions { + registry: &[], + patches: &patched_sources, + strict_packages: &strict_packages, + include_dev_for: &BTreeSet::new(), + }, + )? + }; + + let lockfile_path = lockfile_path_for(&manifest_path); + let lockfile = if lockfile_path.is_file() { + Some( + cabin_lockfile::read_lockfile(&lockfile_path) + .with_context(|| format!("failed to read {}", lockfile_path.display()))?, + ) + } else { + None + }; + + // Run the same selection / feature resolver `cabin metadata` + // runs so unknown features / `dep:` errors surface here too. + let request = build_selection_request( + &args.selection.features, + args.selection.all_features, + args.selection.no_default_features, + ); + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?; + let _feature_resolution = compute_feature_resolution(&graph, &resolved_selection, &request)?; + + let inputs = cabin_explain::TreeInputs { + graph: &graph, + roots: &resolved_selection.packages, + lockfile: lockfile.as_ref(), + active_patches: Some(&active_patches), + kind_filter: args.kind.to_filter(), + }; + let forest = cabin_explain::build_tree(&inputs); + + match args.format { + ResolveFormat::Human => { + let rendered = cabin_explain::render_tree_human(&forest); + print!("{rendered}"); + } + ResolveFormat::Json => { + let value = cabin_explain::render_tree_json(&forest); + let json = + serde_json::to_string_pretty(&value).context("failed to serialize tree as JSON")?; + println!("{json}"); + } + } + Ok(()) +} diff --git a/crates/cabin-cli/src/vendor_glue.rs b/crates/cabin-cli/src/vendor_glue.rs new file mode 100644 index 000000000..55d0d7960 --- /dev/null +++ b/crates/cabin-cli/src/vendor_glue.rs @@ -0,0 +1,374 @@ +//! Orchestration glue for `cabin vendor`. +//! +//! The command resolves the selected external registry +//! dependency closure through the existing artifact pipeline +//! (workspace load → patch / source-replacement → resolver → +//! fetch into the artifact cache), then asks `cabin-vendor` to +//! materialise a +//! deterministic file-registry directory at `--vendor-dir`. +//! +//! The output is a Cabin file registry whose layout the rest of +//! the read path already understands. To consume it offline, +//! point any subsequent command at the directory: +//! +//! ```text +//! cabin vendor # populate ./vendor +//! cabin build --offline --index-path ./vendor +//! ``` +//! +//! `cabin test --offline --index-path ./vendor` is valid only +//! when the selected tests do not introduce additional +//! registry-backed dev dependencies; `cabin vendor` currently +//! mirrors the ordinary build closure. +//! +//! This module is orchestration only. Resolution lives in +//! `cabin-resolver`, the artifact pipeline lives in `cabin-cli`'s +//! existing `run_artifact_pipeline` helper, and the deterministic +//! write is owned by `cabin-vendor`. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Args; + +use cabin_artifact::FetchedPackage; +use cabin_vendor::{ + DEFAULT_VENDOR_DIRNAME, VendorEntry, VendorOptions, VendorPlan, + materialise as vendor_materialise, +}; +use cabin_workspace::collect_patched_versioned_deps; + +use crate::cli::{ + ArtifactPipelineRequest, WorkspaceSelectionArgs, absolutise, build_selection_request, + build_workspace_selection, cache_dir_for, closure_has_versioned_deps_excluding_patches, + compute_feature_resolution, lock_mode_for_flags, resolve_invocation_manifest, + run_artifact_pipeline, +}; +use crate::plural; + +/// `cabin vendor` arguments. Mirrors the flag surface of +/// `cabin fetch` because the two commands share the workspace / +/// patch / index / cache preamble. +#[derive(Debug, Args)] +pub(crate) struct VendorArgs { + /// Path to the cabin.toml manifest. + #[arg(long, value_name = "PATH")] + pub manifest_path: Option, + + /// Output directory for the vendored file registry. + /// Defaults to `vendor` next to the workspace root. + #[arg(long, value_name = "PATH")] + pub vendor_dir: Option, + + /// Path to a directory containing the local JSON package + /// index. Required when the manifest declares any versioned + /// dependencies. `cabin vendor` reads per-package metadata + /// directly off disk to build a byte-stable vendor directory, + /// so the index source must be local. + #[arg(long, value_name = "PATH")] + pub index_path: Option, + + /// Override the default artifact cache directory. + #[arg(long, value_name = "PATH")] + pub cache_dir: Option, + + /// Require an existing, current `cabin.lock`. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Like `--locked`, but also rejects state-writing side + /// effects on the lockfile and the artifact cache. The + /// vendor directory is the explicit user-requested output + /// of the command and is still written under `--frozen`. + #[arg(long)] + pub frozen: bool, + + /// Forbid network access. Cabin refuses to use an HTTP + /// index URL (`--index-url` or a `[registry] index-url` + /// config setting) and expects every needed artifact to + /// already be available in the artifact cache. + #[arg(long)] + pub offline: bool, + + /// Workspace package-selection flags. + #[command(flatten)] + pub workspace_selection: WorkspaceSelectionArgs, + + /// Enable named features for the selected packages. + #[arg(long, value_name = "FEATURES")] + pub features: Vec, + + /// Enable every declared feature on selected packages. + #[arg(long)] + pub all_features: bool, + + /// Disable each selected package's default features. + #[arg(long)] + pub no_default_features: bool, + + /// Disable every active patch and source-replacement entry + /// for this invocation. + #[arg(long)] + pub no_patches: bool, +} + +/// Run `cabin vendor`: resolve the selected external registry +/// dependency closure, fetch its archives into the artifact +/// cache, then materialise a deterministic file-registry +/// directory at `--vendor-dir`. +pub(crate) fn vendor( + args: &VendorArgs, + reporter: crate::term_verbosity_glue::Reporter, +) -> Result<()> { + let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?; + let initial_graph = cabin_workspace::load_workspace(&manifest_path)?; + let effective_config = crate::config_glue::load_effective_config(&initial_graph)?; + let active_patches = + crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?; + let patched_names = active_patches.owned_patched_names(); + + // Compute the resolved selection so we can scope the index + // requirement to the user's chosen packages, exactly like + // `cabin fetch` does. + let workspace_selection = build_workspace_selection(&args.workspace_selection); + let resolved_selection = + cabin_workspace::resolve_package_selection(&initial_graph, &workspace_selection)?; + let selection_request = + build_selection_request(&args.features, args.all_features, args.no_default_features); + let initial_features = + compute_feature_resolution(&initial_graph, &resolved_selection, &selection_request)?; + + let dev_for: BTreeSet = BTreeSet::new(); + let patched_root_deps_preview = + collect_patched_versioned_deps(&active_patches, &patched_names)?; + let has_versioned = !patched_root_deps_preview.is_empty() + || closure_has_versioned_deps_excluding_patches( + &initial_graph, + &resolved_selection, + &initial_features, + &patched_names, + &dev_for, + ); + + let vendor_dir = resolve_vendor_dir(args, &manifest_path)?; + + if !has_versioned { + // Empty plan: still write the file-registry skeleton + // and the summary so a follow-up `cabin build + // --offline --index-path ./vendor` has a valid target. + let plan = VendorPlan::default(); + let report = vendor_materialise( + &plan, + &vendor_dir, + &VendorOptions { + frozen: args.frozen, + }, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + emit_vendor_summary(&report, reporter); + return Ok(()); + } + + let resolved_index_source = crate::config_glue::resolve_index_source( + args.index_path.as_deref(), + None, + &effective_config, + )?; + let offline = crate::config_glue::effective_offline(args.offline)?; + crate::config_glue::enforce_offline_index_source(offline, resolved_index_source.as_ref())?; + let resolved_cache_dir = + crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config); + let Some(index_source) = resolved_index_source.as_ref() else { + bail!( + "versioned dependencies require --index-path or a `[registry] index-path` config setting" + ); + }; + // Vendoring reads per-package metadata directly off disk so + // the vendor directory ends up byte-stable. The only index + // source shape that satisfies that requirement is a local + // file index — reject a URL terminal up front instead of + // letting the artifact pipeline reach for the network and + // surface a less specific error. + if matches!( + index_source.kind, + crate::config_glue::IndexSourceKind::Url(_) + ) { + bail!( + "`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory" + ); + } + + let mode = lock_mode_for_flags(args.locked, args.frozen); + let allow_write = !(args.locked || args.frozen); + let cache_dir = match resolved_cache_dir.as_ref() { + Some((path, _)) => path.clone(), + None => cache_dir_for(&manifest_path, args.cache_dir.as_deref())?, + }; + let initial_locator = match &index_source.kind { + crate::config_glue::IndexSourceKind::Path(p) => { + cabin_core::SourceLocator::IndexPath { path: p.clone() } + } + crate::config_glue::IndexSourceKind::Url(u) => { + cabin_core::SourceLocator::IndexUrl { url: u.clone() } + } + }; + let resolved_locator = crate::patch_glue::apply_source_replacement( + initial_locator, + &effective_config, + args.no_patches, + )?; + crate::config_glue::enforce_offline_post_replacement(offline, &resolved_locator)?; + crate::config_glue::enforce_vendor_local_index_post_replacement(&resolved_locator)?; + let (replaced_path, replaced_url) = + crate::patch_glue::locator_to_index_inputs(&resolved_locator.resolved); + + let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest { + manifest_path: &manifest_path, + initial_graph: &initial_graph, + index_path: replaced_path.as_deref(), + index_url: replaced_url.as_deref(), + mode, + allow_write, + frozen: args.frozen, + cache_dir: &cache_dir, + reporter, + selection: workspace_selection, + selection_request: &selection_request, + patched_names: &patched_names, + active_patches: &active_patches, + source_replacements: &effective_config.source_replacements, + no_patches: args.no_patches, + dev_for: &dev_for, + })?; + + // Vendoring copies `packages/.json` files verbatim + // into the output, so the index source must be a local + // directory the vendor crate can read off disk. + let index_dir = match replaced_path.as_deref() { + Some(p) => p.to_path_buf(), + None => bail!( + "`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory" + ), + }; + + let plan = build_vendor_plan(&pipeline.fetched, &index_dir)?; + let report = vendor_materialise( + &plan, + &vendor_dir, + &VendorOptions { + frozen: args.frozen, + }, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + emit_vendor_summary(&report, reporter); + Ok(()) +} + +fn resolve_vendor_dir(args: &VendorArgs, manifest_path: &std::path::Path) -> Result { + let candidate = match args.vendor_dir.as_deref() { + Some(p) => p.to_path_buf(), + None => manifest_path + .parent() + .map(|p| p.join(DEFAULT_VENDOR_DIRNAME)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_VENDOR_DIRNAME)), + }; + absolutise(&candidate) + .with_context(|| format!("failed to resolve vendor dir {}", candidate.display())) +} + +/// Build a [`VendorPlan`] from the pipeline's fetched packages +/// plus the source index's per-package JSON files. The function +/// reads each `/packages/.json` once, picks the +/// resolved version's entry, and pairs it with the verified +/// archive in the cache. +fn build_vendor_plan( + fetched: &[FetchedPackage], + index_dir: &std::path::Path, +) -> Result { + let mut entries: Vec = Vec::with_capacity(fetched.len()); + // Cache index reads so a workspace that resolves multiple + // versions of the same name does not re-parse the same file. + let mut by_name: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for pkg in fetched { + let name = pkg.name.as_str().to_owned(); + let parsed = match by_name.get(&name) { + Some(v) => v.clone(), + None => { + let path = index_dir.join("packages").join(format!("{name}.json")); + let body = std::fs::read_to_string(&path).with_context(|| { + format!( + "vendoring requires the source index to expose `packages/{name}.json` at `{}`", + path.display() + ) + })?; + let parsed: serde_json::Value = serde_json::from_str(&body) + .with_context(|| format!("failed to parse {}", path.display()))?; + by_name.insert(name.clone(), parsed.clone()); + parsed + } + }; + let version_entry = parsed + .get("versions") + .and_then(|v| v.get(pkg.version.to_string())) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "source index has no `{name}` version `{}` to vendor; the index file may be stale", + pkg.version + ) + })?; + entries.push(VendorEntry { + name: pkg.name.clone(), + version: pkg.version.clone(), + checksum: pkg.checksum.clone(), + archive_source: pkg.archive_path.clone(), + index_entry: version_entry, + }); + } + VendorPlan::new(entries).map_err(|err| anyhow::anyhow!(err.to_string())) +} + +fn emit_vendor_summary( + report: &cabin_vendor::VendorReport, + reporter: crate::term_verbosity_glue::Reporter, +) { + reporter.status(format_args!( + "cabin: vendored to {}", + report.vendor_dir.display() + )); + if report.written.is_empty() { + reporter.status(format_args!( + "cabin: no versioned dependencies in the selected closure" + )); + } else { + reporter.status(format_args!( + "cabin: wrote {} package{}", + report.written.len(), + plural(report.written.len()) + )); + for entry in &report.written { + let action = if entry.artifact_was_written { + "wrote" + } else { + "verified" + }; + reporter.status(format_args!( + " {action} {} {} -> {}", + entry.name.as_str(), + entry.version, + entry.artifact_relative_path + )); + } + } + reporter.status(format_args!( + "cabin: build offline with `cabin build --offline --index-path {}`", + report.vendor_dir.display() + )); + if report.frozen { + reporter.status(format_args!( + "cabin: --frozen: lockfile and artifact cache were not modified" + )); + } +} diff --git a/crates/cabin-cli/src/version_glue.rs b/crates/cabin-cli/src/version_glue.rs new file mode 100644 index 000000000..588348947 --- /dev/null +++ b/crates/cabin-cli/src/version_glue.rs @@ -0,0 +1,99 @@ +//! Glue between the CLI's `version` subcommand and the typed +//! [`crate::version_info::VersionInfo`]. +//! +//! `cabin version` is the dedicated version-reporting surface; +//! `cabin --version` continues to work through clap's +//! `#[command(version)]`. The two differ deliberately: +//! +//! - `cabin --version` — concise, clap-framework spelling. Same +//! wording as `cabin version` so scripts that pipe either form +//! stay equivalent. +//! - `cabin version` — concise output by default. Honours the +//! global verbosity model (`-v`) for a stable key/value block. +//! +//! Output is written directly to stdout rather than through the +//! status [`crate::term_verbosity_glue::Reporter`]: a user +//! asking for `cabin version -q` still wants the version line — +//! quiet only suppresses Cabin-owned status / progress messages. + +use std::io::Write as _; + +use anyhow::{Context, Result}; +use cabin_core::Verbosity; +use clap::Args; + +use crate::version_info::{VersionInfo, VersionOutputMode}; + +/// Arguments accepted by `cabin version`. The subcommand has +/// no positional or flag inputs of its own — verbose output is +/// driven entirely by the global `-v` / `--verbose` flag so the +/// surface stays small. +#[derive(Debug, Args)] +pub(crate) struct VersionArgs {} + +/// Decide which output mode to emit, given Cabin's resolved +/// verbosity. Quiet does *not* downgrade the mode — quiet +/// only suppresses status messages, not requested command output. +fn output_mode_for(verbosity: Verbosity) -> VersionOutputMode { + if verbosity.shows_verbose() { + VersionOutputMode::Verbose + } else { + VersionOutputMode::Concise + } +} + +/// Top-level entry point for `cabin version`. The result string +/// already carries a trailing newline so the function writes +/// with `write_all` instead of `println!`. +pub(crate) fn version(_args: VersionArgs, verbosity: Verbosity) -> Result<()> { + let info = VersionInfo::current(); + let output = info.format(output_mode_for(verbosity)); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle + .write_all(output.as_bytes()) + .context("failed to write version output")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_mode_normal_is_concise() { + assert_eq!( + output_mode_for(Verbosity::Normal), + VersionOutputMode::Concise + ); + } + + #[test] + fn output_mode_quiet_is_concise() { + // `-q` does not suppress the version line. The mode + // stays concise so a script that runs `cabin version -q` + // observes the same single-line output. + assert_eq!( + output_mode_for(Verbosity::Quiet), + VersionOutputMode::Concise + ); + } + + #[test] + fn output_mode_verbose_is_verbose() { + assert_eq!( + output_mode_for(Verbosity::Verbose), + VersionOutputMode::Verbose + ); + } + + #[test] + fn output_mode_very_verbose_is_still_verbose() { + // The verbose key/value block is already the most + // detailed view; `-vv` does not unlock a new mode. + assert_eq!( + output_mode_for(Verbosity::VeryVerbose), + VersionOutputMode::Verbose + ); + } +} diff --git a/crates/cabin-cli/src/version_info.rs b/crates/cabin-cli/src/version_info.rs new file mode 100644 index 000000000..3a18cc5c8 --- /dev/null +++ b/crates/cabin-cli/src/version_info.rs @@ -0,0 +1,307 @@ +//! Typed model and deterministic formatter for `cabin version`. +//! +//! `cabin --version` is the clap-style framework spelling and +//! prints the concise `cabin ` line; `cabin version` +//! is the dedicated subcommand: +//! +//! - the concise form (`cabin version`) prints `cabin `; +//! - the verbose form (`cabin version -v`, or the global +//! `cabin -v version`) prints a cargo-style key/value block. +//! +//! Build-time metadata flows in through `option_env!` populated +//! by `build.rs`. Runtime metadata (the OS identity) is probed +//! through the `os_info` crate, which inspects local platform +//! state without any network or filesystem access beyond a +//! `uname`-equivalent syscall. Tests construct `VersionInfo` +//! directly through `VersionInfo::for_tests` so the formatter +//! can be exercised against controlled inputs without touching +//! the host environment. + +use std::fmt::Write as _; + +/// Output mode requested by the CLI caller. The mapping from +/// global verbosity to mode happens in the dispatcher so this +/// module stays decoupled from `cabin_core::Verbosity`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VersionOutputMode { + /// Concise single-line release-name + semver. + Concise, + /// Cargo-style verbose block, headed by the release line + /// and followed by labelled key/value rows. + Verbose, +} + +/// Length of the abbreviated commit hash rendered in the header +/// line. Matches the width cargo uses for the same field so +/// tooling that parses either banner sees the same shape. +const SHORT_COMMIT_LEN: usize = 9; + +/// Typed snapshot of Cabin's version-relevant metadata. The +/// struct is `Clone` so test helpers can compose fixtures +/// without re-deriving every field. +#[derive(Debug, Clone)] +pub(crate) struct VersionInfo { + /// Always present — driven by the workspace's + /// `[workspace.package] version` field. + cabin_version: String, + /// Full git commit hash captured at build time, or `None` + /// when `.git` is unavailable (a published-tarball build). + commit: Option, + /// ISO-8601 commit date (UTC), or `None`. + commit_date: Option, + /// Host target triple (`aarch64-apple-darwin`, â€Ļ), or `None` + /// when the build script could not read `$TARGET`. + host: Option, + /// Human-readable OS identity (`Mac OS 26.4.1 [64-bit]`, + /// `Ubuntu 24.04 [64-bit]`, â€Ļ) captured at runtime, or + /// `None` when probing fails. + os: Option, +} + +impl VersionInfo { + /// Snapshot of the binary that is currently running. + /// Build-time fields are captured by `build.rs`; the + /// runtime OS string is probed once on demand. + pub(crate) fn current() -> Self { + Self { + cabin_version: env!("CARGO_PKG_VERSION").to_owned(), + commit: option_env!("CABIN_BUILD_COMMIT").map(str::to_owned), + commit_date: option_env!("CABIN_BUILD_COMMIT_DATE").map(str::to_owned), + host: option_env!("CABIN_BUILD_HOST").map(str::to_owned), + os: detect_os_string(), + } + } + + /// Build a [`VersionInfo`] from explicit fields. Tests use + /// this constructor to exercise the formatter against a + /// controlled snapshot; production code calls + /// [`VersionInfo::current`]. + #[cfg(test)] + fn for_tests( + cabin_version: &str, + commit: Option<&str>, + commit_date: Option<&str>, + host: Option<&str>, + os: Option<&str>, + ) -> Self { + Self { + cabin_version: cabin_version.to_owned(), + commit: commit.map(str::to_owned), + commit_date: commit_date.map(str::to_owned), + host: host.map(str::to_owned), + os: os.map(str::to_owned), + } + } + + /// Render the requested output mode into a fresh `String`. + /// Trailing newline is included for both modes so a CLI + /// caller can write the result directly with `print!`. + pub(crate) fn format(&self, mode: VersionOutputMode) -> String { + match mode { + VersionOutputMode::Concise => format!("cabin {}\n", self.cabin_version), + VersionOutputMode::Verbose => self.format_verbose(), + } + } + + /// Short hash prefix rendered in the verbose header. + fn short_commit(&self) -> Option<&str> { + self.commit + .as_deref() + .map(|hash| &hash[..hash.len().min(SHORT_COMMIT_LEN)]) + } + + fn format_verbose(&self) -> String { + // Each labelled row contributes roughly `