diff --git a/.github/workflows/GnuComment.yml b/.github/workflows/GnuComment.yml new file mode 100644 index 00000000..c654ddb5 --- /dev/null +++ b/.github/workflows/GnuComment.yml @@ -0,0 +1,100 @@ +name: GnuComment + +on: + workflow_run: + workflows: ["GnuTests"] + types: + - completed + +permissions: {} + +jobs: + post-comment: + permissions: + actions: read # to list workflow runs artifacts + pull-requests: write # to comment on pr + + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' + steps: + - name: 'Download artifact' + uses: actions/github-script@v9 + with: + script: | + // List all artifacts from GnuTests + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + + // Download the "comment" artifact, which contains a PR number (NR) and result.txt + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "comment" + })[0]; + + if (!matchArtifact) { + console.log('No comment artifact found'); + return; + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{ github.workspace }}/comment.zip', Buffer.from(download.data)); + + - run: unzip comment.zip || echo "Failed to unzip comment artifact" + + - name: 'Comment on PR' + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + var fs = require('fs'); + + // Check if files exist + if (!fs.existsSync('./NR')) { + console.log('No NR file found, skipping comment'); + return; + } + if (!fs.existsSync('./result.txt')) { + console.log('No result.txt file found, skipping comment'); + return; + } + + var issue_number = Number(fs.readFileSync('./NR')); + var content = fs.readFileSync('./result.txt'); + + if (content.toString().trim().length > 7) { // 7 because we have backquote + \n + // Update existing comment if present, otherwise create a new one + var marker = ''; + var body = marker + '\nGNU diffutils testsuite comparison:\n```\n' + content + '```'; + var comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + }); + var existing = comments.data.filter(c => c.body.includes(marker))[0]; + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue_number, + body: body, + }); + } + } else { + console.log('Comment content too short, skipping'); + } diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml new file mode 100644 index 00000000..a853a8e5 --- /dev/null +++ b/.github/workflows/GnuTests.yml @@ -0,0 +1,261 @@ +name: GnuTests + +# Run GNU diffutils testsuite against the Rust diffutils implementation +# and compare results against the main branch to catch regressions + +on: + pull_request: + push: + branches: + - '*' + +permissions: + contents: write # Publish diffutils instead of discarding + +# End the current execution if there is a new changeset in the PR +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TEST_FULL_SUMMARY_FILE: 'diffutils-gnu-full-result.json' + +jobs: + native: + name: Run GNU diffutils testsuite + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - uses: Swatinem/rust-cache@v2 + + ### Build + - name: Build Rust diffutils binary + shell: bash + run: | + ## Build Rust diffutils binary + cargo build --config=profile.release.strip=true --profile=release + zstd -19 target/release/diffutils -o diffutils-x86_64-unknown-linux-gnu.zst + + - name: Publish latest commit + uses: softprops/action-gh-release@v3 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + tag_name: latest-commit + body: | + commit: ${{ github.sha }} + draft: false + prerelease: true + files: | + diffutils-x86_64-unknown-linux-gnu.zst + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + ### Run tests + - name: Run GNU diffutils testsuite + shell: bash + run: | + ## Run GNU diffutils testsuite + # Exit code 1 means some tests failed, which is expected and handled + # by the comparison below; 2 means the suite couldn't be run at all + # 'shell: bash' runs with -e, so don't let the expected exit code 1 + # abort the step before the exit code is inspected + result=0 + ./tests/run-upstream-testsuite.sh release || result=$? + if [[ $result -ge 2 ]]; then + echo "::error ::The GNU testsuite could not be run (exit code $result); see the log above" + exit 1 + fi + env: + TERM: xterm + + - name: Upload full json results + uses: actions/upload-artifact@v4 + with: + name: diffutils-gnu-full-result + path: tests/test-results.json + if-no-files-found: warn + + aggregate: + needs: [native] + permissions: + actions: read + contents: read + pull-requests: read + name: Aggregate GNU test results + runs-on: ubuntu-24.04 + steps: + - name: Initialize workflow variables + id: vars + shell: bash + run: | + ## VARs setup + outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } + + TEST_SUMMARY_FILE='diffutils-gnu-result.json' + outputs TEST_SUMMARY_FILE + + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Retrieve reference artifacts + uses: dawidd6/action-download-artifact@v24 + continue-on-error: true + with: + workflow: GnuTests.yml + branch: "${{ env.DEFAULT_BRANCH }}" + workflow_conclusion: completed + path: "reference" + if_no_artifact_found: warn + + - name: Download full json results + uses: actions/download-artifact@v4 + with: + name: diffutils-gnu-full-result + path: results + + - name: Extract/summarize testing info + id: summary + shell: bash + run: | + ## Extract/summarize testing info + outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } + + RESULT_FILE="results/test-results.json" + if [[ ! -f "$RESULT_FILE" ]]; then + echo "::error ::Missing test results at $RESULT_FILE" + exit 1 + fi + + if ! jq -e . "$RESULT_FILE" > /dev/null; then + echo "::error ::Test results at $RESULT_FILE are not valid JSON" + exit 1 + fi + + TOTAL=$(jq '[.tests[]] | length' "$RESULT_FILE") + PASS=$(jq '[.tests[] | select(.result=="PASS")] | length' "$RESULT_FILE") + FAIL=$(jq '[.tests[] | select(.result=="FAIL")] | length' "$RESULT_FILE") + SKIP=$(jq '[.tests[] | select(.result=="SKIP")] | length' "$RESULT_FILE") + ERROR=0 + + if [[ "$TOTAL" -eq 0 ]]; then + echo "::error ::No test was run; refusing to report or compare an empty test run" + exit 1 + fi + + output="GNU diffutils tests summary = TOTAL: $TOTAL / PASS: $PASS / FAIL: $FAIL / SKIP: $SKIP" + echo "${output}" + + if [[ "$FAIL" -gt 0 ]]; then + echo "::warning ::${output}" + fi + + jq -n \ + --arg date "$(date --rfc-email)" \ + --arg sha "$GITHUB_SHA" \ + --arg total "$TOTAL" \ + --arg pass "$PASS" \ + --arg skip "$SKIP" \ + --arg fail "$FAIL" \ + --arg error "$ERROR" \ + '{($date): { sha: $sha, total: $total, pass: $pass, skip: $skip, fail: $fail, error: $error }}' > '${{ steps.vars.outputs.TEST_SUMMARY_FILE }}' + + HASH=$(sha1sum '${{ steps.vars.outputs.TEST_SUMMARY_FILE }}' | cut --delim=" " -f 1) + outputs HASH TOTAL PASS FAIL SKIP + + - name: Upload SHA1/ID of 'test-summary' + uses: actions/upload-artifact@v4 + with: + name: "${{ steps.summary.outputs.HASH }}" + path: "${{ steps.vars.outputs.TEST_SUMMARY_FILE }}" + + - name: Upload test results summary + uses: actions/upload-artifact@v4 + with: + name: test-summary + path: "${{ steps.vars.outputs.TEST_SUMMARY_FILE }}" + + - name: Compare test failures VS reference + shell: bash + run: | + ## Compare test failures VS reference + REF_SUMMARY_FILE='reference/diffutils-gnu-full-result/test-results.json' + CURRENT_SUMMARY_FILE="results/test-results.json" + + IGNORE_INTERMITTENT=".github/workflows/ignore-intermittent.txt" + + # Build the comment in a directory of its own: 'reference/' holds the + # artifacts downloaded from the reference run, including its own + # 'comment' artifact, and re-uploading those would post a comparison + # belonging to another run (see https://github.com/uutils/diffutils/pull/262) + COMMENT_DIR="comment" + rm -rf ${COMMENT_DIR} + mkdir -p ${COMMENT_DIR} + echo ${{ github.event.number }} > ${COMMENT_DIR}/NR + COMMENT_LOG="${COMMENT_DIR}/result.txt" + : > "${COMMENT_LOG}" + + COMPARISON_RESULT=0 + if test -f "${CURRENT_SUMMARY_FILE}"; then + if test -s "${REF_SUMMARY_FILE}"; then + echo "Reference summary SHA1/ID: $(sha1sum -- "${REF_SUMMARY_FILE}")" + echo "Current summary SHA1/ID: $(sha1sum -- "${CURRENT_SUMMARY_FILE}")" + + python3 util/compare_test_results.py \ + --ignore-file "${IGNORE_INTERMITTENT}" \ + --output "${COMMENT_LOG}" \ + "${CURRENT_SUMMARY_FILE}" "${REF_SUMMARY_FILE}" + + COMPARISON_RESULT=$? + else + echo "::warning ::Skipping test comparison; no usable reference summary is available at '${REF_SUMMARY_FILE}'." + fi + else + echo "::error ::Failed to find summary of test results (missing '${CURRENT_SUMMARY_FILE}'); failing early" + exit 1 + fi + + if [ ${COMPARISON_RESULT} -eq 1 ]; then + echo "::error ::Found new non-intermittent test failures" + exit 1 + elif [ ${COMPARISON_RESULT} -ge 2 ]; then + # The comparison itself failed (e.g. an unusable reference summary). + # Don't post a comment rather than post a misleading one. + : > "${COMMENT_LOG}" + echo "::warning ::Could not compare the test results against the reference" + else + echo "::notice ::No new test failures detected" + fi + + - name: Upload comparison log (for GnuComment workflow) + if: success() || failure() + uses: actions/upload-artifact@v4 + with: + name: comment + path: comment/ + + - name: Report test results + if: success() || failure() + shell: bash + run: | + ## Report final results + echo "::notice ::GNU diffutils testsuite results:" + echo "::notice :: Total tests: ${{ steps.summary.outputs.TOTAL }}" + echo "::notice :: Passed: ${{ steps.summary.outputs.PASS }}" + echo "::notice :: Failed: ${{ steps.summary.outputs.FAIL }}" + echo "::notice :: Skipped: ${{ steps.summary.outputs.SKIP }}" + + if [[ "${{ steps.summary.outputs.FAIL }}" -gt 0 ]]; then + PASS_RATE=$(( ${{ steps.summary.outputs.PASS }} * 100 / (${{ steps.summary.outputs.PASS }} + ${{ steps.summary.outputs.FAIL }}) )) + echo "::notice :: Pass rate: ${PASS_RATE}%" + fi diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..e1d63c40 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,15 @@ +name: Security audit + +on: + schedule: + - cron: "0 0 * * *" +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8343add7..415812e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ name: Basic CI env: CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 jobs: check: @@ -15,7 +16,6 @@ jobs: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - run: cargo check test: @@ -27,12 +27,10 @@ jobs: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - name: install GNU patch on MacOS if: runner.os == 'macOS' run: | brew install gpatch - echo "/opt/homebrew/opt/gpatch/libexec/gnubin" >> "$GITHUB_PATH" - name: set up PATH on Windows # Needed to use GNU's patch.exe instead of Strawberry Perl patch if: runner.os == 'Windows' @@ -44,8 +42,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: rustup component add rustfmt - run: cargo fmt --all -- --check clippy: @@ -57,29 +53,12 @@ jobs: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: rustup component add clippy - run: cargo clippy -- -D warnings - gnu-testsuite: - name: GNU test suite - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo build --release - # do not fail, the report is merely informative (at least until all tests pass reliably) - - run: ./tests/run-upstream-testsuite.sh release || true - env: - TERM: xterm - - uses: actions/upload-artifact@v4 - with: - name: test-results.json - path: tests/test-results.json - - run: ./tests/print-test-results.sh tests/test-results.json - coverage: name: Code Coverage + env: + RUSTC_BOOTSTRAP: 1 runs-on: ${{ matrix.job.os }} strategy: fail-fast: false @@ -96,26 +75,15 @@ jobs: run: | ## VARs setup outputs() { step_id="vars"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } - # toolchain - TOOLCHAIN="nightly" ## default to "nightly" toolchain (required for certain required unstable compiler flags) ## !maint: refactor when stable channel has needed support - # * specify gnu-type TOOLCHAIN for windows; `grcov` requires gnu-style code coverage data files - case ${{ matrix.job.os }} in windows-*) TOOLCHAIN="$TOOLCHAIN-x86_64-pc-windows-gnu" ;; esac; - # * use requested TOOLCHAIN if specified - if [ -n "${{ matrix.job.toolchain }}" ]; then TOOLCHAIN="${{ matrix.job.toolchain }}" ; fi - outputs TOOLCHAIN # target-specific options # * CODECOV_FLAGS CODECOV_FLAGS=$( echo "${{ matrix.job.os }}" | sed 's/[^[:alnum:]]/_/g' ) outputs CODECOV_FLAGS - - name: rust toolchain ~ install - uses: dtolnay/rust-toolchain@nightly - - run: rustup component add llvm-tools-preview - name: install GNU patch on MacOS if: runner.os == 'macOS' run: | brew install gpatch - echo "/opt/homebrew/opt/gpatch/libexec/gnubin" >> "$GITHUB_PATH" - name: set up PATH on Windows # Needed to use GNU's patch.exe instead of Strawberry Perl patch if: runner.os == 'Windows' @@ -123,7 +91,6 @@ jobs: - name: Test run: cargo test --all-features --no-fail-fast env: - CARGO_INCREMENTAL: "0" RUSTC_WRAPPER: "" RUSTFLAGS: "-Cinstrument-coverage -Zcoverage-options=branch -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" RUSTDOCFLAGS: "-Cpanic=abort" @@ -160,7 +127,7 @@ jobs: grcov . --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --binary-path "${COVERAGE_REPORT_DIR}" --branch echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT - name: Upload coverage results (to Codecov.io) - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.coverage.outputs.report }} @@ -168,4 +135,3 @@ jobs: flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} name: codecov-umbrella fail_ci_if_error: false - diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 00000000..00042f22 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,37 @@ +name: CodSpeed + +on: + push: + branches: + - "main" + pull_request: + # `workflow_dispatch` allows CodSpeed to trigger backtest + # performance analysis in order to generate initial data. + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + codspeed: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup rust toolchain, cache and cargo-codspeed binary + uses: moonrepo/setup-rust@v1 + with: + channel: stable + cache-target: release + bins: cargo-codspeed + + - name: Build the benchmark target(s) + run: cargo codspeed build -m simulation + + - name: Run the benchmarks + uses: CodSpeedHQ/action@v5 + with: + mode: simulation + run: cargo codspeed run diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 9ad1c173..898a96de 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -2,6 +2,10 @@ name: Fuzzing # spell-checker:ignore fuzzer +env: + CARGO_INCREMENTAL: 0 + RUSTC_BOOTSTRAP: 1 + on: pull_request: push: @@ -21,15 +25,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` - run: cargo install cargo-fuzz + run: | + cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2 with: shared-key: "cargo-fuzz-cache-key" cache-directories: "fuzz/target" - name: Run `cargo-fuzz build` - run: cargo +nightly fuzz build + run: cargo fuzz build fuzz-run: needs: fuzz-build @@ -46,28 +50,29 @@ jobs: - { name: fuzz_ed, should_pass: true } - { name: fuzz_normal, should_pass: true } - { name: fuzz_patch, should_pass: true } + - { name: fuzz_side, should_pass: true } steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` - run: cargo install cargo-fuzz + run: | + cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2 with: shared-key: "cargo-fuzz-cache-key" cache-directories: "fuzz/target" - name: Restore Cached Corpus - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v6 with: key: corpus-cache-${{ matrix.test-target.name }} path: | fuzz/corpus/${{ matrix.test-target.name }} - name: Run ${{ matrix.test-target.name }} for XX seconds shell: bash - continue-on-error: ${{ !matrix.test-target.name.should_pass }} + continue-on-error: ${{ !matrix.test-target.should_pass }} run: | - cargo +nightly fuzz run ${{ matrix.test-target.name }} -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 + cargo fuzz run ${{ matrix.test-target.name }} -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 - name: Save Corpus Cache - uses: actions/cache/save@v4 + uses: actions/cache/save@v6 with: key: corpus-cache-${{ matrix.test-target.name }} path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c1f4f88..3c59af53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,12 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: # # * checks for a Git Tag that looks like a release -# * builds artifacts with cargo-dist (archives, installers, hashes) +# * builds artifacts with dist (archives, installers, hashes) # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # @@ -12,9 +14,8 @@ # title/body based on your changelogs. name: Release - permissions: - contents: write + "contents": "write" # This task will run whenever you push a git tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. @@ -23,10 +24,10 @@ permissions: # must be a Cargo-style SemVer Version (must have at least major.minor.patch). # # If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't cargo-dist-able). +# package (erroring out if it doesn't have the given version or isn't dist-able). # # If PACKAGE_NAME isn't specified, then the announcement will be for all -# (cargo-dist-able) packages in the workspace with that version (this mode is +# (dist-able) packages in the workspace with that version (this mode is # intended for workspaces with only one dist-able package, or with all dist-able # packages versioned/released in lockstep). # @@ -38,15 +39,15 @@ permissions: # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: + pull_request: push: tags: - '**[0-9]+.[0-9]+.[0-9]+*' - pull_request: jobs: - # Run 'cargo dist plan' (or host) to determine what tasks we need to do + # Run 'dist plan' (or host) to determine what tasks we need to do plan: - runs-on: ubuntu-latest + runs-on: "ubuntu-22.04" outputs: val: ${{ steps.plan.outputs.manifest }} tag: ${{ !github.event.pull_request && github.ref_name || '' }} @@ -57,12 +58,18 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - - name: Install cargo-dist + - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.13.3/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist # sure would be cool if github gave us proper conditionals... # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible # functionality based on whether this is a pull_request, and whether it's from a fork. @@ -70,8 +77,8 @@ jobs: # but also really annoying to build CI around when it needs secrets to work right.) - id: plan run: | - cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json - echo "cargo dist ran successfully" + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" @@ -89,18 +96,19 @@ jobs: if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} strategy: fail-fast: false - # Target platforms/runners are computed by cargo-dist in create-release. + # Target platforms/runners are computed by dist in create-release. # Each member of the matrix has the following arguments: # # - runner: the github runner - # - dist-args: cli flags to pass to cargo dist - # - install-dist: expression to run to install cargo-dist on the runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner # # Typically there will be: # - 1 "global" task that builds universal installers # - N "local" tasks that build each platform's binaries and platform-specific installers matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json @@ -110,12 +118,17 @@ jobs: git config --global core.longpaths true - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - - uses: swatinem/rust-cache@v2 - with: - key: ${{ join(matrix.targets, '-') }} - - name: Install cargo-dist - run: ${{ matrix.install_dist }} + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts uses: actions/download-artifact@v4 @@ -129,8 +142,8 @@ jobs: - name: Build artifacts run: | # Actually do builds and make zips and whatnot - cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json - echo "cargo dist ran successfully" + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up @@ -140,7 +153,7 @@ jobs: run: | # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" - jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" cp dist-manifest.json "$BUILD_MANIFEST_NAME" @@ -157,17 +170,21 @@ jobs: needs: - plan - build-local-artifacts - runs-on: "ubuntu-20.04" + runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - - name: Install cargo-dist - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.13.3/cargo-dist-installer.sh | sh" + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts uses: actions/download-artifact@v4 @@ -178,8 +195,8 @@ jobs: - id: cargo-dist shell: bash run: | - cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json - echo "cargo dist ran successfully" + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" @@ -200,19 +217,24 @@ jobs: - plan - build-local-artifacts - build-global-artifacts - # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-20.04" + runs-on: "ubuntu-22.04" outputs: val: ${{ steps.host.outputs.manifest }} steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - - name: Install cargo-dist - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.13.3/cargo-dist-installer.sh | sh" + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts uses: actions/download-artifact@v4 @@ -220,11 +242,10 @@ jobs: pattern: artifacts-* path: target/distrib/ merge-multiple: true - # This is a harmless no-op for GitHub Releases, hosting for that happens in "announce" - id: host shell: bash run: | - cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" @@ -234,8 +255,29 @@ jobs: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* - # Create a GitHub Release while uploading all files to it announce: needs: - plan @@ -244,28 +286,11 @@ jobs: # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! if: ${{ always() && needs.host.result == 'success' }} - runs-on: "ubuntu-20.04" + runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: artifacts - merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json - - name: Create GitHub Release - uses: ncipollo/release-action@v1 - with: - tag: ${{ needs.plan.outputs.tag }} - name: ${{ fromJson(needs.host.outputs.val).announcement_title }} - body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} - prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} - artifacts: "artifacts/*" diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml new file mode 100644 index 00000000..f3a0f3e0 --- /dev/null +++ b/.github/workflows/wasi.yml @@ -0,0 +1,28 @@ +# spell-checker:ignore wasip +name: WASI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +# End the current execution if there is a new changeset in the PR. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test_wasi: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + - name: check + run: cargo check --target wasm32-wasip1 diff --git a/.gitignore b/.gitignore index e9868bd2..515a64d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target *.swp +/tests/test-results.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..25bf13de --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,48 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +exclude: ^tests/fixtures/ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-executables-have-shebangs + - id: check-json + exclude: '\.vscode/(cSpell|extensions)\.json' # cSpell.json and extensions.json use comments + - id: check-shebang-scripts-are-executable + exclude: '.+\.rs' # would be triggered by #![some_attribute] + - id: check-symlinks + - id: check-toml + - id: check-yaml + args: [ --allow-multiple-documents ] + - id: destroyed-symlinks + - id: end-of-file-fixer + - id: mixed-line-ending + args: [ --fix=lf ] + - id: trailing-whitespace + + - repo: local + hooks: + - id: rust-linting + name: Rust linting + description: Run cargo fmt on files included in the commit. + entry: cargo +stable fmt -- + pass_filenames: true + types: [file, rust] + language: system + - id: rust-clippy + name: Rust clippy + description: Run cargo clippy on files included in the commit. + entry: cargo +stable clippy --workspace --all-targets --all-features -- -D warnings + pass_filenames: false + types: [file, rust] + language: system + - id: cspell + name: Code spell checker (cspell) + description: Run cspell to check for spelling errors (if available). + entry: bash -c 'if command -v cspell >/dev/null 2>&1; then cspell --no-must-find-files -- "$@"; else echo "cspell not found, skipping spell check"; exit 0; fi' -- + pass_filenames: true + language: system + +ci: + skip: [rust-linting, rust-clippy, cspell] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b2f59c22 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to diffutils + +Hi! Welcome to uutils/diffutils, and thanks for wanting to contribute! + +This project follows the shared conventions of the [uutils](https://github.com/uutils) +organization. Before opening a pull request, please read: + +- Our **[Review Guidelines](https://uutils.github.io/reviews/)** — what we expect + from a pull request and how reviews are carried out. +- Our community's [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md), if present. + +Finally, feel free to join our [Discord](https://discord.gg/wQVJbvJ)! + +> [!WARNING] +> uutils is original code and cannot contain any code from GNU or other +> strongly-licensed (GPL/LGPL) implementations. We **cannot** accept changes +> based on the GNU source code, and you **must not link** to it either. You may +> look at permissively-licensed implementations (MIT/BSD) and read the GNU +> *manuals* — never the GNU *source*. + +## In short + +- Discuss non-trivial changes in an issue **before** writing the code. +- Keep pull requests **small, self-contained, and descriptively titled** + (e.g. `diffutils: fix ...`). +- Make sure CI passes: tests are green, `rustfmt` is satisfied, and there are + no `clippy` warnings. +- Add tests for new behavior; don't let coverage regress. +- Write small, atomic commits annotated with the component you touched. + +See the [Review Guidelines](https://uutils.github.io/reviews/) for the full +details. diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 00000000..22d70ffa --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,8 @@ +Copyright (c) Michael Howell +Copyright (c) uutils developers + +Licensed under the Apache License, Version 2.0 or the MIT license +, at your +option. All files in the project carrying such notice may not be +copied, modified, or distributed except according to those terms. diff --git a/Cargo.lock b/Cargo.lock index 29a8623f..8ee35c2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,19 +4,13 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -28,19 +22,33 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.6" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "assert_cmd" -version = "2.0.17" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", - "doc-comment", "libc", "predicates", "predicates-core", @@ -50,21 +58,21 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bstr" -version = "1.9.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", @@ -73,29 +81,49 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.15.4" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cc" -version = "1.0.90" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +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 = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -103,11 +131,121 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "terminal_size", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codspeed" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7083f253260bcb4aaa3b4aa4c52973703dabc1a85c2f193997e2689aafa8a919" +dependencies = [ + "anyhow", + "cc", + "colored", + "getrandom", + "glob", + "libc", + "nix", + "serde", + "serde_json", + "statrs", +] + +[[package]] +name = "codspeed-divan-compat" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1065d507e1cbab731a7976db4cef7e47e49b87b4dbc0a925df07d343558420" +dependencies = [ + "clap", + "codspeed", + "codspeed-divan-compat-macros", + "codspeed-divan-compat-walltime", + "regex", +] + +[[package]] +name = "codspeed-divan-compat-macros" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd05482a95823ffe421e8a9ba24fa22a6a30d594e2c60455cbb43a41bf2d8fa" +dependencies = [ + "divan-macros", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "codspeed-divan-compat-walltime" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f8eae75b8fa85357020a404899c4280d590c9fd1c47640b3ce53106c62d2ee" +dependencies = [ + "cfg-if", + "clap", + "codspeed", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] [[package]] name = "diff" @@ -123,14 +261,16 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "diffutils" -version = "0.4.2" +version = "0.5.0" dependencies = [ "assert_cmd", "chrono", + "codspeed-divan-compat", "diff", "itoa", "predicates", "pretty_assertions", + "rand", "regex", "same-file", "tempfile", @@ -138,16 +278,33 @@ dependencies = [ ] [[package]] -name = "doc-comment" -version = "0.3.3" +name = "divan-macros" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys", @@ -155,9 +312,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "float-cmp" @@ -168,28 +331,64 @@ dependencies = [ "num-traits", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "wasi", - "windows-targets", + "r-efi", + "rand_core", + "wasip2", + "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[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 = "iana-time-zone" -version = "0.1.60" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -203,44 +402,90 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "once_cell", "wasm-bindgen", ] +[[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.170" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "linux-raw-sys" -version = "0.9.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.21" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] [[package]] name = "normalize-line-endings" @@ -250,24 +495,24 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", @@ -279,15 +524,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.6" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.9" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -303,29 +548,71 @@ dependencies = [ "yansi", ] +[[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-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +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 = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -335,26 +622,32 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" -version = "1.0.0" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f8dcd64f141950290e45c99f7710ede1b600297c91818bb30b3667c0f45dc0" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -363,6 +656,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -372,31 +671,76 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.197" +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 = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +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 = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "syn" -version = "2.0.50" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -405,9 +749,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom", @@ -416,72 +760,115 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + [[package]] name = "termtree" -version = "0.4.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "wait-timeout" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ "libc", ] [[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen" -version = "0.2.92" +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 = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" +name = "wasm-bindgen" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ - "bumpalo", - "log", + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -489,149 +876,232 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "leb128fmt", + "wasmparser", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] [[package]] -name = "winapi-util" -version = "0.1.6" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "winapi", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-link" -version = "0.1.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows-sys" -version = "0.59.0" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "windows-targets", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 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", + "windows-link", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "winnow" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] @@ -639,3 +1109,9 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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 index 6fa1a3cc..ded812c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffutils" -version = "0.4.2" +version = "0.5.0" edition = "2021" description = "A CLI app for generating diff files" license = "MIT OR Apache-2.0" @@ -23,25 +23,30 @@ same-file = "1.0.6" unicode-width = "0.2.0" [dev-dependencies] -pretty_assertions = "1.4.0" assert_cmd = "2.0.14" +divan = { version = "5.0.0", package = "codspeed-divan-compat" } +pretty_assertions = "1.4.0" predicates = "3.1.0" -tempfile = "3.10.1" +rand = "0.10.0" +tempfile = "3.26.0" + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" -# The profile that 'cargo dist' will build with +# alias profile for 'dist' [profile.dist] inherits = "release" -lto = "thin" -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.13.3" -# CI backends to support -ci = ["github"] -# The installers to generate for each app -installers = [] -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] -# Publish jobs to run in CI -pr-run-mode = "plan" +[[bench]] +name = "bench_diffutils" +path = "benches/bench-diffutils.rs" +harness = false + +[features] +# default = ["feat_bench_not_diff"] +# Turn bench for diffutils cmp off +feat_bench_not_cmp = [] +# Turn bench for diffutils diff off +feat_bench_not_diff = [] diff --git a/LICENSE-APACHE b/LICENSE-APACHE index 3d8493ef..1b5ec8b7 100644 --- a/LICENSE-APACHE +++ b/LICENSE-APACHE @@ -1,6 +1,3 @@ -Copyright (c) Michael Howell -Copyright (c) uutils developers - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/LICENSE-MIT b/LICENSE-MIT index ba409322..31aa7938 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,3 @@ -Copyright (c) Michael Howell -Copyright (c) uutils developers - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the diff --git a/README.md b/README.md index fae06d6c..489e5de5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Discord](https://img.shields.io/badge/discord-join-7289DA.svg?logo=discord&longCache=true&style=flat)](https://discord.gg/wQVJbvJ) [![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/diffutils/blob/main/LICENSE) [![dependency status](https://deps.rs/repo/github/uutils/diffutils/status.svg)](https://deps.rs/repo/github/uutils/diffutils) +[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/uutils/diffutils?utm_source=badge) [![CodeCov](https://codecov.io/gh/uutils/diffutils/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/diffutils) @@ -38,7 +39,7 @@ Fig Cherry EOF -$ cargo run -- -u fruits_old.txt fruits_new.txt +$ cargo run -- diff -u fruits_old.txt fruits_new.txt Finished dev [unoptimized + debuginfo] target(s) in 0.00s Running `target/debug/diffutils -u fruits_old.txt fruits_new.txt` --- fruits_old.txt @@ -53,4 +54,8 @@ $ cargo run -- -u fruits_old.txt fruits_new.txt ## License -diffutils is licensed under the MIT and Apache Licenses - see the `LICENSE-MIT` or `LICENSE-APACHE` files for details +This project is distributed under the terms of both the MIT license and the +Apache License (Version 2.0). + +See [LICENSE-APACHE](LICENSE-APACHE), [LICENSE-MIT](LICENSE-MIT), and +[COPYRIGHT](COPYRIGHT) for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..7a79a3b7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +## Supported Versions + +We provide security updates only for the latest released version of `uutils/diffutils`. +Older versions may not receive patches. +If you are using a version packaged by your Linux distribution, please check with your distribution maintainers for their update policy. + +--- + +## Reporting a Vulnerability + +**Do not open public GitHub issues for security vulnerabilities.** +This prevents accidental disclosure before a fix is available. + +Instead, please use the following method: + +- **Email:** [sylvestre@debian.org](mailto:Sylvestre@debian.org) +- **Encryption (optional):** You may encrypt your report using our PGP key: +Fingerprint: B60D B599 4D39 BEC4 D1A9 5CCF 7E65 28DA 752F 1BE1 +--- + +### What to Include in Your Report + +To help us investigate and resolve the issue quickly, please include as much detail as possible: + +- **Type of issue:** e.g. privilege escalation, information disclosure. +- **Location in the source:** file path, commit hash, branch, or tag. +- **Steps to reproduce:** exact commands, test cases, or scripts. +- **Special configuration:** any flags, environment variables, or system setup required. +- **Affected systems:** OS/distribution and version(s) where the issue occurs. +- **Impact:** your assessment of the potential severity (DoS, RCE, data leak, etc.). + +--- + +## Disclosure Policy + +We follow a **Coordinated Vulnerability Disclosure (CVD)** process: + +1. We will acknowledge receipt of your report within **10 days**. +2. We will investigate, reproduce, and assess the issue. +3. We will provide a timeline for developing and releasing a fix. +4. Once a fix is available, we will publish a GitHub Security Advisory. +5. You will be credited in the advisory unless you request anonymity. diff --git a/benches/bench-diffutils.rs b/benches/bench-diffutils.rs new file mode 100644 index 00000000..e506b3f1 --- /dev/null +++ b/benches/bench-diffutils.rs @@ -0,0 +1,377 @@ +// This file is part of the uutils diffutils package. +// +// For the full copyright and license information, please view the LICENSE-* +// files that was distributed with this source code. + +//! Benches for all utils in diffutils. +//! +//! There is a file generator included to create files of different sizes for comparison. \ +//! Set the TEMP_DIR const to keep the files. df_to_ files have small changes in them, search for '#'. \ +//! File generation up to 1 GB is really fast, Benchmarking above 100 MB takes very long. + +/// Generate test files with these sizes in KB. +const FILE_SIZE_KILO_BYTES: [u64; 4] = [100, 1 * MB, 10 * MB, 25 * MB]; +// const FILE_SIZE_KILO_BYTES: [u64; 3] = [100, 1 * MB, 5 * MB]; +// Empty String to use TempDir (files will be removed after test) or specify dir to keep generated files +const TEMP_DIR: &str = ""; +const NUM_DIFF: u64 = 4; +// just for FILE_SIZE_KILO_BYTES +const MB: u64 = 1_000; +const CHANGE_CHAR: u8 = b'#'; + +#[cfg(not(feature = "feat_bench_not_cmp"))] +mod diffutils_cmp { + use std::hint::black_box; + + use diffutilslib::cmp; + use divan::Bencher; + + use crate::{binary, prepare::*, FILE_SIZE_KILO_BYTES}; + + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmp_compare_files_equal(bencher: Bencher, kb: u64) { + let (from, to) = get_context().get_test_files_equal(kb); + let cmd = format!("cmp {from} {to}"); + let opts = str_to_options(&cmd).into_iter().peekable(); + let params = cmp::parse_params(opts).unwrap(); + + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| params.clone()) + .bench_refs(|params| black_box(cmp::cmp(¶ms).unwrap())); + } + + // bench the actual compare; cmp exits on first difference + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmp_compare_files_different(bencher: Bencher, bytes: u64) { + let (from, to) = get_context().get_test_files_different(bytes); + let cmd = format!("cmp {from} {to} -s"); + let opts = str_to_options(&cmd).into_iter().peekable(); + let params = cmp::parse_params(opts).unwrap(); + + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| params.clone()) + .bench_refs(|params| black_box(cmp::cmp(¶ms).unwrap())); + } + + // bench original GNU cmp + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmd_cmp_gnu_equal(bencher: Bencher, bytes: u64) { + let (from, to) = get_context().get_test_files_equal(bytes); + let args_str = format!("{from} {to}"); + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| args_str.clone()) + .bench_refs(|cmd_args| binary::bench_binary("cmp", cmd_args)); + } + + // bench the compiled release version + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmd_cmp_release_equal(bencher: Bencher, bytes: u64) { + let (from, to) = get_context().get_test_files_equal(bytes); + let args_str = format!("cmp {from} {to}"); + + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| args_str.clone()) + .bench_refs(|cmd_args| binary::bench_binary("target/release/diffutils", cmd_args)); + } +} + +#[cfg(not(feature = "feat_bench_not_diff"))] +mod diffutils_diff { + // use std::hint::black_box; + + use crate::{binary, prepare::*, FILE_SIZE_KILO_BYTES}; + // use diffutilslib::params; + use divan::Bencher; + + // bench the actual compare + // TODO diff does not have a diff function + // #[divan::bench(args = [100_000,10_000])] + // fn diff_compare_files(bencher: Bencher, bytes: u64) { + // let (from, to) = gen_testfiles(lines, 0, "id"); + // let cmd = format!("cmp {from} {to}"); + // let opts = str_to_options(&cmd).into_iter().peekable(); + // let params = params::parse_params(opts).unwrap(); + // + // bencher + // // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + // .with_inputs(|| params.clone()) + // .bench_refs(|params| diff::diff(¶ms).unwrap()); + // } + + // bench original GNU diff + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmd_diff_gnu_equal(bencher: Bencher, bytes: u64) { + let (from, to) = get_context().get_test_files_equal(bytes); + let args_str = format!("{from} {to}"); + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| args_str.clone()) + .bench_refs(|cmd_args| binary::bench_binary("diff", cmd_args)); + } + + // bench the compiled release version + #[divan::bench(args = FILE_SIZE_KILO_BYTES)] + fn cmd_diff_release_equal(bencher: Bencher, bytes: u64) { + let (from, to) = get_context().get_test_files_equal(bytes); + let args_str = format!("diff {from} {to}"); + + bencher + // .with_inputs(|| prepare::cmp_params_identical_testfiles(lines)) + .with_inputs(|| args_str.clone()) + .bench_refs(|cmd_args| binary::bench_binary("target/release/diffutils", cmd_args)); + } +} + +mod parser { + use std::hint::black_box; + + use diffutilslib::{cmp, params}; + use divan::Bencher; + + use crate::prepare::str_to_options; + + // bench the time it takes to parse the command line arguments + #[divan::bench] + fn cmp_parser(bencher: Bencher) { + let cmd = "cmd file_1.txt file_2.txt -bl n10M --ignore-initial=100KiB:1MiB"; + let args = str_to_options(&cmd).into_iter().peekable(); + bencher + .with_inputs(|| args.clone()) + .bench_values(|data| black_box(cmp::parse_params(data))); + } + + // // test the impact on the benchmark if not converting the cmd to Vec (doubles for parse) + // #[divan::bench] + // fn cmp_parser_no_prepare() { + // let cmd = "cmd file_1.txt file_2.txt -bl n10M --ignore-initial=100KiB:1MiB"; + // let args = str_to_options(&cmd).into_iter().peekable(); + // let _ = cmp::parse_params(args); + // } + + // bench the time it takes to parse the command line arguments + #[divan::bench] + fn diff_parser(bencher: Bencher) { + let cmd = "diff file_1.txt file_2.txt -s --brief --expand-tabs --width=100"; + let args = str_to_options(&cmd).into_iter().peekable(); + bencher + .with_inputs(|| args.clone()) + .bench_values(|data| black_box(params::parse_params(data))); + } +} + +mod prepare { + use std::{ + ffi::OsString, + fs::{self, File}, + io::{BufWriter, Write}, + path::Path, + sync::OnceLock, + }; + + use rand::RngExt; + use tempfile::TempDir; + + use crate::{CHANGE_CHAR, FILE_SIZE_KILO_BYTES, NUM_DIFF, TEMP_DIR}; + + // file lines and .txt will be added + const FROM_FILE: &str = "from_file"; + const TO_FILE: &str = "to_file"; + const LINE_LENGTH: usize = 60; + + /// Contains test data (file names) which only needs to be created once. + #[derive(Debug, Default)] + pub struct BenchContext { + pub tmp_dir: Option, + pub dir: String, + pub files_equal: Vec<(String, String)>, + pub files_different: Vec<(String, String)>, + } + + impl BenchContext { + pub fn get_path(&self) -> &Path { + match &self.tmp_dir { + Some(tmp) => tmp.path(), + None => Path::new(&self.dir), + } + } + + pub fn get_test_files_equal(&self, kb: u64) -> &(String, String) { + let p = FILE_SIZE_KILO_BYTES.iter().position(|f| *f == kb).unwrap(); + &self.files_equal[p] + } + + #[allow(unused)] + pub fn get_test_files_different(&self, kb: u64) -> &(String, String) { + let p = FILE_SIZE_KILO_BYTES.iter().position(|f| *f == kb).unwrap(); + &self.files_different[p] + } + } + + // Since each bench function is separate in Divan it is more difficult to dynamically create test data. + // This keeps the TempDir alive until the program exits and generates the files only once. + static SHARED_CONTEXT: OnceLock = OnceLock::new(); + /// Creates the test files once and provides them to all tests. + pub fn get_context() -> &'static BenchContext { + SHARED_CONTEXT.get_or_init(|| { + let mut ctx = BenchContext::default(); + if TEMP_DIR.is_empty() { + let tmp_dir = TempDir::new().expect("Failed to create temp dir"); + ctx.tmp_dir = Some(tmp_dir); + } else { + // uses current directory, the generated files are kept + let path = Path::new(TEMP_DIR); + if !path.exists() { + fs::create_dir_all(path).expect("Path {path} could not be created"); + } + ctx.dir = TEMP_DIR.to_string(); + }; + + // generate test bytes + for kb in FILE_SIZE_KILO_BYTES { + let f = generate_test_files_bytes(ctx.get_path(), kb * 1000, 0, "eq") + .expect("generate_test_files failed"); + ctx.files_equal.push(f); + let f = generate_test_files_bytes(ctx.get_path(), kb * 1000, NUM_DIFF, "df") + .expect("generate_test_files failed"); + ctx.files_different.push(f); + } + + ctx + }) + } + + pub fn str_to_options(opt: &str) -> Vec { + let s: Vec = opt + .split(" ") + .into_iter() + .filter(|s| !s.is_empty()) + .map(|s| OsString::from(s)) + .collect(); + + s + } + + /// Generates two test files for comparison with size. + /// + /// Each line consists of 10 words with 5 letters, giving a line length of 60 bytes. + /// If num_differences is set, '#' will be inserted between the first two words of a line, + /// evenly spaced in the file. 1 will add the change in the last line, so the comparison takes longest. + fn generate_test_files_bytes( + dir: &Path, + bytes: u64, + num_differences: u64, + id: &str, + ) -> std::io::Result<(String, String)> { + let id = if id.is_empty() { + "".to_string() + } else { + format!("{id}_") + }; + let f1 = format!("{id}{FROM_FILE}_{bytes}.txt"); + let f2 = format!("{id}{TO_FILE}_{bytes}.txt"); + let from_path = dir.join(f1); + let to_path = dir.join(f2); + + generate_file_bytes(&from_path, &to_path, bytes, num_differences)?; + + Ok(( + from_path.to_string_lossy().to_string(), + to_path.to_string_lossy().to_string(), + )) + } + + fn generate_file_bytes( + from_name: &Path, + to_name: &Path, + bytes: u64, + num_differences: u64, + ) -> std::io::Result<()> { + let file_from = File::create(from_name)?; + let file_to = File::create(to_name)?; + // for int division, lines will be smaller than requested bytes + let n_lines = bytes / LINE_LENGTH as u64; + let change_every_n_lines = if num_differences == 0 { + 0 + } else { + let c = n_lines / num_differences; + if c == 0 { + 1 + } else { + c + } + }; + // Use a larger 128KB buffer for massive files + let mut writer_from = BufWriter::with_capacity(128 * 1024, file_from); + let mut writer_to = BufWriter::with_capacity(128 * 1024, file_to); + let mut rng = rand::rng(); + + // Each line: (5 chars * 10 words) + 9 spaces + 1 newline = 60 bytes + let mut line_buffer = [b' '; 60]; + line_buffer[59] = b'\n'; // Set the newline once at the end + + for i in (0..n_lines).rev() { + // Fill only the letter positions, skipping spaces and the newline + for word_idx in 0..10 { + let start = word_idx * 6; // Each word + space block is 6 bytes + for i in 0..5 { + line_buffer[start + i] = rng.random_range(b'a'..b'z' + 1); + } + } + + // Write the raw bytes directly to both files + writer_from.write_all(&line_buffer)?; + // make changes in the file + if num_differences == 0 { + writer_to.write_all(&line_buffer)?; + } else { + if i % change_every_n_lines == 0 && n_lines - i > 2 { + line_buffer[5] = CHANGE_CHAR; + } + writer_to.write_all(&line_buffer)?; + line_buffer[5] = b' '; + } + } + + // create last line + let missing = (bytes - n_lines as u64 * LINE_LENGTH as u64) as usize; + if missing > 0 { + for word_idx in 0..10 { + let start = word_idx * 6; // Each word + space block is 6 bytes + for i in 0..5 { + line_buffer[start + i] = rng.random_range(b'a'..b'z' + 1); + } + } + line_buffer[missing - 1] = b'\n'; + writer_from.write_all(&line_buffer[0..missing])?; + writer_to.write_all(&line_buffer[0..missing])?; + } + + writer_from.flush()?; + writer_to.flush()?; + + Ok(()) + } +} + +mod binary { + use std::process::Command; + + use crate::prepare::str_to_options; + + pub fn bench_binary(program: &str, cmd_args: &str) -> std::process::ExitStatus { + let args = str_to_options(cmd_args); + Command::new(program) + .args(args) + .status() + .expect("Failed to execute binary") + } +} + +fn main() { + // Run registered benchmarks. + divan::main(); +} diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 00000000..92c4095a --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,13 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 00000000..47a03af4 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,447 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "diffutils" +version = "0.5.0" +dependencies = [ + "chrono", + "diff", + "itoa", + "regex", + "same-file", + "unicode-width", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unified-diff-fuzz" +version = "0.0.0" +dependencies = [ + "diffutils", + "libfuzzer-sys", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8b0b5218..45c4016b 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -47,4 +47,8 @@ path = "fuzz_targets/fuzz_ed.rs" test = false doc = false - +[[bin]] +name = "fuzz_side" +path = "fuzz_targets/fuzz_side.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/fuzz_cmp.rs b/fuzz/fuzz_targets/fuzz_cmp.rs index e9d0e4ce..8b500fa8 100644 --- a/fuzz/fuzz_targets/fuzz_cmp.rs +++ b/fuzz/fuzz_targets/fuzz_cmp.rs @@ -4,7 +4,7 @@ extern crate libfuzzer_sys; use diffutilslib::cmp::{self, Cmp}; use std::ffi::OsString; -use std::fs::File; +use std::fs::{self, File}; use std::io::Write; fn os(s: &str) -> OsString { @@ -18,7 +18,7 @@ fuzz_target!(|x: (Vec, Vec)| { .peekable(); let (from, to) = x; - + fs::create_dir_all("target").unwrap(); File::create("target/fuzz.cmp.a") .unwrap() .write_all(&from) diff --git a/fuzz/fuzz_targets/fuzz_cmp_args.rs b/fuzz/fuzz_targets/fuzz_cmp_args.rs index 579cf34c..667319f5 100644 --- a/fuzz/fuzz_targets/fuzz_cmp_args.rs +++ b/fuzz/fuzz_targets/fuzz_cmp_args.rs @@ -11,6 +11,9 @@ fn os(s: &str) -> OsString { } fuzz_target!(|x: Vec| -> Corpus { + if x.iter().any(|a| a == "--help") { + return Corpus::Reject; + } if x.len() > 6 { // Make sure we try to parse an option when we get longer args. x[0] will be // the executable name. diff --git a/fuzz/fuzz_targets/fuzz_ed.rs b/fuzz/fuzz_targets/fuzz_ed.rs index 7c38fda5..fefb9c13 100644 --- a/fuzz/fuzz_targets/fuzz_ed.rs +++ b/fuzz/fuzz_targets/fuzz_ed.rs @@ -38,6 +38,7 @@ fuzz_target!(|x: (Vec, Vec)| { } else { return; } + fs::create_dir_all("target").unwrap(); let diff = diff_w(&from, &to, "target/fuzz.file").unwrap(); File::create("target/fuzz.file.original") .unwrap() diff --git a/fuzz/fuzz_targets/fuzz_normal.rs b/fuzz/fuzz_targets/fuzz_normal.rs index 6b1e6b90..132cd25b 100644 --- a/fuzz/fuzz_targets/fuzz_normal.rs +++ b/fuzz/fuzz_targets/fuzz_normal.rs @@ -23,6 +23,7 @@ fuzz_target!(|x: (Vec, Vec)| { return }*/ let diff = normal_diff::diff(&from, &to, &Params::default()); + fs::create_dir_all("target").unwrap(); File::create("target/fuzz.file.original") .unwrap() .write_all(&from) diff --git a/fuzz/fuzz_targets/fuzz_patch.rs b/fuzz/fuzz_targets/fuzz_patch.rs index 4dea4b50..d9f06309 100644 --- a/fuzz/fuzz_targets/fuzz_patch.rs +++ b/fuzz/fuzz_targets/fuzz_patch.rs @@ -21,15 +21,17 @@ fuzz_target!(|x: (Vec, Vec, u8)| { } else { return }*/ + fs::create_dir_all("target").unwrap(); + let patched = "target/fuzz.file"; let diff = unified_diff::diff( &from, &to, &Params { - from: "a/fuzz.file".into(), - to: "target/fuzz.file".into(), + from: patched.into(), + to: patched.into(), context_count: context as usize, ..Default::default() - } + }, ); File::create("target/fuzz.file.original") .unwrap() diff --git a/fuzz/fuzz_targets/fuzz_side.rs b/fuzz/fuzz_targets/fuzz_side.rs new file mode 100644 index 00000000..45580efb --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_side.rs @@ -0,0 +1,43 @@ +#![no_main] +#[macro_use] +extern crate libfuzzer_sys; + +use diffutilslib::side_diff; + +use diffutilslib::params::Params; +use std::fs::{self, File}; +use std::io::Write; + +fuzz_target!(|x: (Vec, Vec, /* usize, usize */ bool)| { + let (original, new, /* width, tabsize, */ expand) = x; + + // if width == 0 || tabsize == 0 { + // return; + // } + + let params = Params { + // width, + // tabsize, + expand_tabs: expand, + ..Default::default() + }; + fs::create_dir_all("target").unwrap(); + let mut output_buf = vec![]; + side_diff::diff(&original, &new, &mut output_buf, ¶ms); + File::create("target/fuzz.file.original") + .unwrap() + .write_all(&original) + .unwrap(); + File::create("target/fuzz.file.new") + .unwrap() + .write_all(&new) + .unwrap(); + File::create("target/fuzz.file") + .unwrap() + .write_all(&original) + .unwrap(); + File::create("target/fuzz.diff") + .unwrap() + .write_all(&output_buf) + .unwrap(); +}); diff --git a/src/cmp.rs b/src/cmp.rs index c0fc397e..0f7250c0 100644 --- a/src/cmp.rs +++ b/src/cmp.rs @@ -11,34 +11,39 @@ use std::iter::Peekable; use std::process::ExitCode; use std::{cmp, fs, io}; -#[cfg(not(target_os = "windows"))] +#[cfg(unix)] use std::os::fd::{AsRawFd, FromRawFd}; -#[cfg(not(target_os = "windows"))] +#[cfg(unix)] use std::os::unix::fs::MetadataExt; #[cfg(target_os = "windows")] use std::os::windows::fs::MetadataExt; +/// for --bytes, so really large number limits can be expressed, like 1Y. +pub type BytesLimitU64 = u64; +// ignore initial is currently limited to u64, as take(skip) is used. +pub type SkipU64 = u64; + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Params { executable: OsString, from: OsString, to: OsString, print_bytes: bool, - skip_a: Option, - skip_b: Option, - max_bytes: Option, + skip_a: Option, + skip_b: Option, + max_bytes: Option, verbose: bool, quiet: bool, } #[inline] fn usage_string(executable: &str) -> String { - format!("Usage: {} ", executable) + format!("Usage: {executable} ") } -#[cfg(not(target_os = "windows"))] +#[cfg(unix)] fn is_stdout_dev_null() -> bool { let Ok(dev_null) = fs::metadata("/dev/null") else { return false; @@ -60,23 +65,25 @@ fn is_stdout_dev_null() -> bool { is_dev_null } +#[cfg(not(any(unix, target_os = "windows")))] +fn is_stdout_dev_null() -> bool { + false +} + pub fn parse_params>(mut opts: Peekable) -> Result { - let Some(executable) = opts.next() else { - return Err("Usage: ".to_string()); - }; + let executable = opts.next().ok_or("Usage: ".to_string())?; let executable_str = executable.to_string_lossy().to_string(); - let parse_skip = |param: &str, skip_desc: &str| -> Result { + let parse_skip = |param: &str, skip_desc: &str| -> Result { let suffix_start = param .find(|b: char| !b.is_ascii_digit()) .unwrap_or(param.len()); - let mut num = match param[..suffix_start].parse::() { + let mut num = match param[..suffix_start].parse::() { Ok(num) => num, - Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => usize::MAX, + Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => SkipU64::MAX, Err(_) => { return Err(format!( - "{}: invalid --ignore-initial value '{}'", - executable_str, skip_desc + "{executable_str}: invalid --ignore-initial value '{skip_desc}'" )) } }; @@ -84,7 +91,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu if suffix_start != param.len() { // Note that GNU cmp advertises supporting up to Y, but fails if you try // to actually use anything beyond E. - let multiplier: usize = match ¶m[suffix_start..] { + let multiplier: SkipU64 = match ¶m[suffix_start..] { "kB" => 1_000, "K" => 1_024, "MB" => 1_000_000, @@ -97,21 +104,21 @@ pub fn parse_params>(mut opts: Peekable) -> Resu "P" => 1_125_899_906_842_624, "EB" => 1_000_000_000_000_000_000, "E" => 1_152_921_504_606_846_976, - "ZB" => usize::MAX, // 1_000_000_000_000_000_000_000, - "Z" => usize::MAX, // 1_180_591_620_717_411_303_424, - "YB" => usize::MAX, // 1_000_000_000_000_000_000_000_000, - "Y" => usize::MAX, // 1_208_925_819_614_629_174_706_176, + // TODO setting usize:MAX does not mimic GNU cmp behavior, it should be an error. + "ZB" => SkipU64::MAX, // 1_000_000_000_000_000_000_000, + "Z" => SkipU64::MAX, // 1_180_591_620_717_411_303_424, + "YB" => SkipU64::MAX, // 1_000_000_000_000_000_000_000_000, + "Y" => SkipU64::MAX, // 1_208_925_819_614_629_174_706_176, _ => { return Err(format!( - "{}: invalid --ignore-initial value '{}'", - executable_str, skip_desc + "{executable_str}: invalid --ignore-initial value '{skip_desc}'" )); } }; num = match num.overflowing_mul(multiplier) { (n, false) => n, - _ => usize::MAX, + _ => SkipU64::MAX, } } @@ -165,13 +172,13 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let (_, arg) = param_str.split_once('=').unwrap(); arg.to_string() }; - let max_bytes = match max_bytes.parse::() { + let max_bytes = match max_bytes.parse::() { Ok(num) => num, - Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => usize::MAX, + // TODO limit to MAX is dangerous, this should become an error like in GNU cmp. + Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => BytesLimitU64::MAX, Err(_) => { return Err(format!( - "{}: invalid --bytes value '{}'", - executable_str, max_bytes + "{executable_str}: invalid --bytes value '{max_bytes}'" )) } }; @@ -210,7 +217,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu std::process::exit(0); } if param_str.starts_with('-') { - return Err(format!("Unknown option: {:?}", param)); + return Err(format!("unrecognized option '{}'", param.to_string_lossy())); } if from.is_none() { from = Some(param); @@ -226,7 +233,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu } // Do as GNU cmp, and completely disable printing if we are - // outputing to /dev/null. + // outputting to /dev/null. #[cfg(not(target_os = "windows"))] if is_stdout_dev_null() { params.quiet = true; @@ -236,8 +243,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu if params.quiet && params.verbose { return Err(format!( - "{}: options -l and -s are incompatible", - executable_str + "{executable_str}: options -l and -s are incompatible" )); } @@ -279,32 +285,21 @@ pub fn parse_params>(mut opts: Peekable) -> Resu fn prepare_reader( path: &OsString, - skip: &Option, + skip: &Option, params: &Params, ) -> Result, String> { let mut reader: Box = if path == "-" { Box::new(BufReader::new(io::stdin())) } else { - match fs::File::open(path) { - Ok(file) => Box::new(BufReader::new(file)), - Err(e) => { - return Err(format_failure_to_read_input_file( - ¶ms.executable, - path, - &e, - )); - } - } + let file = fs::File::open(path) + .map_err(|e| format_failure_to_read_input_file(¶ms.executable, path, &e))?; + Box::new(BufReader::new(file)) }; if let Some(skip) = skip { - if let Err(e) = io::copy(&mut reader.by_ref().take(*skip as u64), &mut io::sink()) { - return Err(format_failure_to_read_input_file( - ¶ms.executable, - path, - &e, - )); - } + // cast as u64 must remain, because value of IgnInit data type could be changed. + io::copy(&mut reader.by_ref().take(*skip), &mut io::sink()) + .map_err(|e| format_failure_to_read_input_file(¶ms.executable, path, &e))?; } Ok(reader) @@ -320,11 +315,11 @@ pub fn cmp(params: &Params) -> Result { let mut from = prepare_reader(¶ms.from, ¶ms.skip_a, params)?; let mut to = prepare_reader(¶ms.to, ¶ms.skip_b, params)?; - let mut offset_width = params.max_bytes.unwrap_or(usize::MAX); + let mut offset_width = params.max_bytes.unwrap_or(BytesLimitU64::MAX); if let (Ok(a_meta), Ok(b_meta)) = (fs::metadata(¶ms.from), fs::metadata(¶ms.to)) { #[cfg(not(target_os = "windows"))] - let (a_size, b_size) = (a_meta.size(), b_meta.size()); + let (a_size, b_size) = (a_meta.len(), b_meta.len()); #[cfg(target_os = "windows")] let (a_size, b_size) = (a_meta.file_size(), b_meta.file_size()); @@ -335,7 +330,7 @@ pub fn cmp(params: &Params) -> Result { return Ok(Cmp::Different); } - let smaller = cmp::min(a_size, b_size) as usize; + let smaller = cmp::min(a_size, b_size) as BytesLimitU64; offset_width = cmp::min(smaller, offset_width); } @@ -344,34 +339,20 @@ pub fn cmp(params: &Params) -> Result { // Capacity calc: at_byte width + 2 x 3-byte octal numbers + 2 x 4-byte value + 4 spaces let mut output = Vec::::with_capacity(offset_width + 3 * 2 + 4 * 2 + 4); - let mut at_byte = 1; - let mut at_line = 1; + let mut at_byte: BytesLimitU64 = 1; + let mut at_line: u64 = 1; let mut start_of_line = true; let mut stdout = BufWriter::new(io::stdout().lock()); let mut compare = Cmp::Equal; loop { // Fill up our buffers. - let from_buf = match from.fill_buf() { - Ok(buf) => buf, - Err(e) => { - return Err(format_failure_to_read_input_file( - ¶ms.executable, - ¶ms.from, - &e, - )); - } - }; + let from_buf = from + .fill_buf() + .map_err(|e| format_failure_to_read_input_file(¶ms.executable, ¶ms.from, &e))?; - let to_buf = match to.fill_buf() { - Ok(buf) => buf, - Err(e) => { - return Err(format_failure_to_read_input_file( - ¶ms.executable, - ¶ms.to, - &e, - )); - } - }; + let to_buf = to + .fill_buf() + .map_err(|e| format_failure_to_read_input_file(¶ms.executable, ¶ms.to, &e))?; // Check for EOF conditions. if from_buf.is_empty() && to_buf.is_empty() { @@ -395,8 +376,8 @@ pub fn cmp(params: &Params) -> Result { if from_buf[..consumed] == to_buf[..consumed] { let last = from_buf[..consumed].last().unwrap(); - at_byte += consumed; - at_line += from_buf[..consumed].iter().filter(|&c| *c == b'\n').count(); + at_byte += consumed as BytesLimitU64; + at_line += (from_buf[..consumed].iter().filter(|&c| *c == b'\n').count()) as u64; start_of_line = *last == b'\n'; @@ -494,15 +475,9 @@ pub fn main(opts: Peekable) -> ExitCode { } } -#[inline] -fn is_ascii_printable(byte: u8) -> bool { - let c = byte as char; - c.is_ascii() && !c.is_ascii_control() -} - #[inline] fn format_octal(byte: u8, buf: &mut [u8; 3]) -> &str { - *buf = [b' ', b' ', b'0']; + *buf = *b" 0"; let mut num = byte; let mut idx = 2; // Start at the last position in the buffer @@ -519,32 +494,68 @@ fn format_octal(byte: u8, buf: &mut [u8; 3]) -> &str { } #[inline] -fn format_byte(byte: u8) -> String { - let mut byte = byte; - let mut quoted = vec![]; - - if !is_ascii_printable(byte) { - if byte >= 128 { - quoted.push(b'M'); - quoted.push(b'-'); - byte -= 128; +fn write_visible_byte(output: &mut Vec, byte: u8) -> usize { + match byte { + // Control characters: ^@, ^A, ..., ^_ + 0..=31 => { + output.push(b'^'); + output.push(byte + 64); + 2 } - - if byte < 32 { - quoted.push(b'^'); - byte += 64; - } else if byte == 127 { - quoted.push(b'^'); - byte = b'?'; + // Printable ASCII (space through ~) + 32..=126 => { + output.push(byte); + 1 + } + // DEL: ^? + 127 => { + output.extend_from_slice(b"^?"); + 2 + } + // High bytes with control equivalents: M-^@, M-^A, ..., M-^_ + 128..=159 => { + output.push(b'M'); + output.push(b'-'); + output.push(b'^'); + output.push(byte - 64); + 4 + } + // High bytes: M-, M-!, ..., M-~ + 160..=254 => { + output.push(b'M'); + output.push(b'-'); + output.push(byte - 128); + 3 + } + // Byte 255: M-^? + 255 => { + output.extend_from_slice(b"M-^?"); + 4 } - assert!((byte as char).is_ascii()); } +} + +/// Writes a byte in visible form with right-padding to 4 spaces. +#[inline] +fn write_visible_byte_padded(output: &mut Vec, byte: u8) { + const SPACES: &[u8] = b" "; + const WIDTH: usize = SPACES.len(); - quoted.push(byte); + let display_width = write_visible_byte(output, byte); - // SAFETY: the checks and shifts we do above match what cat and GNU + // Add right-padding spaces + let padding = WIDTH.saturating_sub(display_width); + output.extend_from_slice(&SPACES[..padding]); +} + +/// Formats a byte as a visible string (for non-performance-critical path) +#[inline] +fn format_visible_byte(byte: u8) -> String { + let mut result = Vec::with_capacity(4); + write_visible_byte(&mut result, byte); + // SAFETY: the checks and shifts in write_visible_byte match what cat and GNU // cmp do to ensure characters fall inside the ascii range. - unsafe { String::from_utf8_unchecked(quoted) } + unsafe { String::from_utf8_unchecked(result) } } // This function has been optimized to not use the Rust fmt system, which @@ -554,7 +565,7 @@ fn format_byte(byte: u8) -> String { fn format_verbose_difference( from_byte: u8, to_byte: u8, - at_byte: usize, + at_byte: BytesLimitU64, offset_width: usize, output: &mut Vec, params: &Params, @@ -582,14 +593,7 @@ fn format_verbose_difference( output.push(b' '); - let from_byte_str = format_byte(from_byte); - let from_byte_padding = 4 - from_byte_str.len(); - - output.extend_from_slice(from_byte_str.as_bytes()); - - for _ in 0..from_byte_padding { - output.push(b' ') - } + write_visible_byte_padded(output, from_byte); output.push(b' '); @@ -597,13 +601,13 @@ fn format_verbose_difference( output.push(b' '); - output.extend_from_slice(format_byte(to_byte).as_bytes()); + write_visible_byte(output, to_byte); output.push(b'\n'); } else { // "{:>width$} {:>3o} {:>3o}" let at_byte_str = at_byte_buf.format(at_byte); - let at_byte_padding = offset_width - at_byte_str.len(); + let at_byte_padding = offset_width.saturating_sub(at_byte_str.len()); for _ in 0..at_byte_padding { output.push(b' ') @@ -626,7 +630,13 @@ fn format_verbose_difference( } #[inline] -fn report_eof(at_byte: usize, at_line: usize, start_of_line: bool, eof_on: &str, params: &Params) { +fn report_eof( + at_byte: BytesLimitU64, + at_line: u64, + start_of_line: bool, + eof_on: &str, + params: &Params, +) { if params.quiet { return; } @@ -678,7 +688,13 @@ fn is_posix_locale() -> bool { } #[inline] -fn report_difference(from_byte: u8, to_byte: u8, at_byte: usize, at_line: usize, params: &Params) { +fn report_difference( + from_byte: u8, + to_byte: u8, + at_byte: BytesLimitU64, + at_line: u64, + params: &Params, +) { if params.quiet { return; } @@ -690,8 +706,8 @@ fn report_difference(from_byte: u8, to_byte: u8, at_byte: usize, at_line: usize, }; print!( "{} {} differ: {term} {}, line {}", - ¶ms.from.to_string_lossy(), - ¶ms.to.to_string_lossy(), + params.from.to_string_lossy(), + params.to.to_string_lossy(), at_byte, at_line ); @@ -700,9 +716,9 @@ fn report_difference(from_byte: u8, to_byte: u8, at_byte: usize, at_line: usize, print!( " is {:>3o} {:char_width$} {:>3o} {:char_width$}", from_byte, - format_byte(from_byte), + format_visible_byte(from_byte), to_byte, - format_byte(to_byte) + format_visible_byte(to_byte) ); } println!(); @@ -775,7 +791,7 @@ mod tests { from: os("foo"), to: os("bar"), skip_a: Some(1), - skip_b: Some(usize::MAX), + skip_b: Some(SkipU64::MAX), ..Default::default() }), parse_params( @@ -953,7 +969,7 @@ mod tests { executable: os("cmp"), from: os("foo"), to: os("bar"), - max_bytes: Some(usize::MAX), + max_bytes: Some(BytesLimitU64::MAX), ..Default::default() }), parse_params( @@ -970,6 +986,7 @@ mod tests { ); // Failure case + // TODO This is actually fine in GNU cmp. --bytes does not have a unit parser yet. assert_eq!( Err("cmp: invalid --bytes value '1K'".to_string()), parse_params( @@ -1015,8 +1032,8 @@ mod tests { executable: os("cmp"), from: os("foo"), to: os("bar"), - skip_a: Some(usize::MAX), - skip_b: Some(usize::MAX), + skip_a: Some(SkipU64::MAX), + skip_b: Some(SkipU64::MAX), ..Default::default() }), parse_params( @@ -1056,6 +1073,9 @@ mod tests { from: os("foo"), to: os("bar"), skip_a: Some(1_000_000_000), + #[cfg(target_pointer_width = "32")] + skip_b: Some((2_147_483_647.5 * 2.0) as usize), + #[cfg(target_pointer_width = "64")] skip_b: Some(1_152_921_504_606_846_976 * 2), ..Default::default() }), @@ -1087,8 +1107,12 @@ mod tests { .enumerate() { let values = [ - 1_000usize.checked_pow((i + 1) as u32).unwrap_or(usize::MAX), - 1024usize.checked_pow((i + 1) as u32).unwrap_or(usize::MAX), + (1_000 as SkipU64) + .checked_pow((i + 1) as u32) + .unwrap_or(SkipU64::MAX), + (1024 as SkipU64) + .checked_pow((i + 1) as u32) + .unwrap_or(SkipU64::MAX), ]; for (j, v) in values.iter().enumerate() { assert_eq!( diff --git a/src/context_diff.rs b/src/context_diff.rs index 873fc3d8..7ddffea6 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -381,6 +381,9 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { mod tests { use super::*; use pretty_assertions::assert_eq; + + use crate::utils::testcmds::PATCH_CMD; + #[test] fn test_permutations() { // test all possible six-line files. @@ -394,7 +397,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"b\n" }) @@ -429,12 +431,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alef"); let diff = diff( &alef, &bet, &Params { - from: "a/alef".into(), - to: (&format!("{target}/alef")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -449,7 +452,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg("--context") .stdin(File::open(format!("{target}/ab.diff")).unwrap()) @@ -481,7 +485,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"\n" } else { b"b\n" }).unwrap(); @@ -510,12 +513,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alef_"); let diff = diff( &alef, &bet, &Params { - from: "a/alef_".into(), - to: (&format!("{target}/alef_")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -530,7 +534,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg("--context") .stdin(File::open(format!("{target}/ab_.diff")).unwrap()) @@ -562,7 +567,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"" }).unwrap(); @@ -594,12 +598,13 @@ mod tests { }; // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alefx"); let diff = diff( &alef, &bet, &Params { - from: "a/alefx".into(), - to: (&format!("{target}/alefx")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -614,7 +619,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg("--context") .stdin(File::open(format!("{target}/abx.diff")).unwrap()) @@ -646,7 +652,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"f\n" }) @@ -681,12 +686,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let alefr_path = &format!("{target}/alefr"); let diff = diff( &alef, &bet, &Params { - from: "a/alefr".into(), - to: (&format!("{target}/alefr")).into(), + from: alefr_path.into(), + to: alefr_path.into(), context_count: 2, ..Default::default() }, @@ -701,7 +707,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg("--context") .stdin(File::open(format!("{target}/abr.diff")).unwrap()) diff --git a/src/diff.rs b/src/diff.rs index f769a29f..f4c0614c 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -5,11 +5,11 @@ use crate::params::{parse_params, Format}; use crate::utils::report_failure_to_read_input_file; -use crate::{context_diff, ed_diff, normal_diff, unified_diff}; +use crate::{context_diff, ed_diff, normal_diff, side_diff, unified_diff}; use std::env::ArgsOs; use std::ffi::OsString; use std::fs; -use std::io::{self, Read, Write}; +use std::io::{self, stdout, Read, Write}; use std::iter::Peekable; use std::process::{exit, ExitCode}; @@ -79,6 +79,10 @@ pub fn main(opts: Peekable) -> ExitCode { eprintln!("{error}"); exit(2); }), + Format::SideBySide => { + let mut output = stdout().lock(); + side_diff::diff(&from_content, &to_content, &mut output, ¶ms) + } }; if params.brief && !result.is_empty() { println!( diff --git a/src/ed_diff.rs b/src/ed_diff.rs index b8cdbc5e..81ddfb46 100644 --- a/src/ed_diff.rs +++ b/src/ed_diff.rs @@ -162,6 +162,9 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Result, mod tests { use super::*; use pretty_assertions::assert_eq; + + use crate::utils::testcmds::ED_CMD; + pub fn diff_w(expected: &[u8], actual: &[u8], filename: &str) -> Result, DiffError> { let mut output = diff(expected, actual, &Params::default())?; writeln!(&mut output, "w {filename}").unwrap(); @@ -237,8 +240,8 @@ mod tests { let _ = fb; #[cfg(not(windows))] // there's no ed on windows { - use std::process::Command; - let output = Command::new("ed") + let output = ED_CMD + .new() .arg(format!("{target}/alef")) .stdin(File::open(format!("{target}/ab.ed")).unwrap()) .output() @@ -311,8 +314,8 @@ mod tests { let _ = fb; #[cfg(not(windows))] // there's no ed on windows { - use std::process::Command; - let output = Command::new("ed") + let output = ED_CMD + .new() .arg(format!("{target}/alef_")) .stdin(File::open(format!("{target}/ab_.ed")).unwrap()) .output() @@ -391,8 +394,8 @@ mod tests { let _ = fb; #[cfg(not(windows))] // there's no ed on windows { - use std::process::Command; - let output = Command::new("ed") + let output = ED_CMD + .new() .arg(format!("{target}/alefr")) .stdin(File::open(format!("{target}/abr.ed")).unwrap()) .output() diff --git a/src/lib.rs b/src/lib.rs index a20ac566..342b01ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod ed_diff; pub mod macros; pub mod normal_diff; pub mod params; +pub mod side_diff; pub mod unified_diff; pub mod utils; @@ -11,4 +12,5 @@ pub mod utils; pub use context_diff::diff as context_diff; pub use ed_diff::diff as ed_diff; pub use normal_diff::diff as normal_diff; +pub use side_diff::diff as side_by_side_diff; pub use unified_diff::diff as unified_diff; diff --git a/src/main.rs b/src/main.rs index 8194d000..d0adb692 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod ed_diff; mod macros; mod normal_diff; mod params; +mod side_diff; mod unified_diff; mod utils; @@ -57,7 +58,7 @@ fn main() -> ExitCode { let exe_path = binary_path(&mut args); let exe_name = name(&exe_path); - let util_name = if exe_name == "diffutils" { + let util_name = if exe_name.as_encoded_bytes().ends_with(b"diffutils") { // Discard the item we peeked. let _ = args.next(); @@ -68,13 +69,17 @@ fn main() -> ExitCode { OsString::from(exe_name) }; - match util_name.to_str() { - Some("diff") => diff::main(args), - Some("cmp") => cmp::main(args), - Some(name) => { - eprintln!("{}: utility not supported", name); + match util_name.as_encoded_bytes() { + name if name.ends_with(b"diff") => diff::main(args), + name if name.ends_with(b"cmp") => cmp::main(args), + name => { + use std::io::{stderr, Write as _}; + let _ = writeln!( + stderr(), + "{}: utility not supported", + String::from_utf8_lossy(name) + ); ExitCode::from(2) } - None => second_arg_error(exe_name), } } diff --git a/src/normal_diff.rs b/src/normal_diff.rs index 002cd016..652dc84e 100644 --- a/src/normal_diff.rs +++ b/src/normal_diff.rs @@ -215,6 +215,8 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + use crate::utils::testcmds::PATCH_CMD; + #[test] fn test_basic() { let mut a = Vec::new(); @@ -239,7 +241,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"b\n" }) @@ -285,7 +286,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg(format!("{target}/alef")) .stdin(File::open(format!("{target}/ab.diff")).unwrap()) @@ -318,7 +320,6 @@ mod tests { for &g in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"b\n" }) @@ -377,7 +378,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg("--normal") .arg(format!("{target}/alefn")) @@ -411,7 +413,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"\n" } else { b"b\n" }).unwrap(); @@ -451,7 +452,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg(format!("{target}/alef_")) .stdin(File::open(format!("{target}/ab_.diff")).unwrap()) @@ -483,7 +485,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"f\n" }) @@ -529,7 +530,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .arg(format!("{target}/alefr")) .stdin(File::open(format!("{target}/abr.diff")).unwrap()) diff --git a/src/params.rs b/src/params.rs index 9b3abc4d..74ef3e37 100644 --- a/src/params.rs +++ b/src/params.rs @@ -11,6 +11,7 @@ pub enum Format { Unified, Context, Ed, + SideBySide, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -24,6 +25,7 @@ pub struct Params { pub brief: bool, pub expand_tabs: bool, pub tabsize: usize, + pub width: usize, } impl Default for Params { @@ -38,6 +40,7 @@ impl Default for Params { brief: false, expand_tabs: false, tabsize: 8, + width: 130, } } } @@ -57,6 +60,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut format = None; let mut context = None; let tabsize_re = Regex::new(r"^--tabsize=(?\d+)$").unwrap(); + let width_re = Regex::new(r"--width=(?P\d+)$").unwrap(); while let Some(param) = opts.next() { let next_param = opts.peek(); if param == "--" { @@ -101,6 +105,34 @@ pub fn parse_params>(mut opts: Peekable) -> Resu format = Some(Format::Ed); continue; } + if param == "-y" || param == "--side-by-side" { + if format.is_some() && format != Some(Format::SideBySide) { + return Err("Conflicting output style option".to_string()); + } + format = Some(Format::SideBySide); + continue; + } + if width_re.is_match(param.to_string_lossy().as_ref()) { + let param = param.into_string().unwrap(); + let width_str: &str = width_re + .captures(param.as_str()) + .unwrap() + .name("long") + .unwrap() + .as_str(); + + params.width = match width_str.parse::() { + Ok(num) => { + if num == 0 { + return Err("invalid width «0»".to_string()); + } + + num + } + Err(_) => return Err(format!("invalid width «{width_str}»")), + }; + continue; + } if tabsize_re.is_match(param.to_string_lossy().as_ref()) { // Because param matches the regular expression, // it is safe to assume it is valid UTF-8. @@ -112,9 +144,16 @@ pub fn parse_params>(mut opts: Peekable) -> Resu .unwrap() .as_str(); params.tabsize = match tabsize_str.parse::() { - Ok(num) => num, + Ok(num) => { + if num == 0 { + return Err("invalid tabsize «0»".to_string()); + } + + num + } Err(_) => return Err(format!("invalid tabsize «{tabsize_str}»")), }; + continue; } match match_context_diff_params(¶m, next_param, format) { @@ -156,7 +195,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu Err(error) => return Err(error), } if param.to_string_lossy().starts_with('-') { - return Err(format!("Unknown option: {:?}", param)); + return Err(format!("unrecognized option '{}'", param.to_string_lossy())); } if from.is_none() { from = Some(param); @@ -240,17 +279,15 @@ fn match_context_diff_params( context_count = Some(numvalue.as_str().parse::().unwrap()); } } - if param == "-C" && next_param.is_some() { - match next_param.unwrap().to_string_lossy().parse::() { - Ok(context_size) => { - context_count = Some(context_size); - next_param_consumed = true; - } - Err(_) => { - return Err(format!( - "invalid context length '{}'", - next_param.unwrap().to_string_lossy() - )) + if param == "-C" { + if let Some(p) = next_param { + let size_str = p.to_string_lossy(); + match size_str.parse::() { + Ok(context_size) => { + context_count = Some(context_size); + next_param_consumed = true; + } + Err(_) => return Err(format!("invalid context length '{size_str}'")), } } } @@ -286,17 +323,15 @@ fn match_unified_diff_params( context_count = Some(numvalue.as_str().parse::().unwrap()); } } - if param == "-U" && next_param.is_some() { - match next_param.unwrap().to_string_lossy().parse::() { - Ok(context_size) => { - context_count = Some(context_size); - next_param_consumed = true; - } - Err(_) => { - return Err(format!( - "invalid context length '{}'", - next_param.unwrap().to_string_lossy() - )) + if param == "-U" { + if let Some(p) = next_param { + let size_str = p.to_string_lossy(); + match size_str.parse::() { + Ok(context_size) => { + context_count = Some(context_size); + next_param_consumed = true; + } + Err(_) => return Err(format!("invalid context length '{size_str}'")), } } } @@ -704,11 +739,11 @@ mod tests { executable: os("diff"), from: os("foo"), to: os("bar"), - tabsize: 0, + tabsize: 1, ..Default::default() }), parse_params( - [os("diff"), os("--tabsize=0"), os("foo"), os("bar")] + [os("diff"), os("--tabsize=1"), os("foo"), os("bar")] .iter() .cloned() .peekable() diff --git a/src/side_diff.rs b/src/side_diff.rs new file mode 100644 index 00000000..56953d25 --- /dev/null +++ b/src/side_diff.rs @@ -0,0 +1,1263 @@ +// This file is part of the uutils diffutils package. +// +// For the full copyright and license information, please view the LICENSE-* +// files that was distributed with this source code. + +use core::cmp::{max, min}; +use diff::Result; +use std::{io::Write, vec}; +use unicode_width::UnicodeWidthStr; + +use crate::params::Params; + +const GUTTER_WIDTH_MIN: usize = 3; + +struct CharIter<'a> { + current: &'a [u8], +} + +struct Config { + sdiff_half_width: usize, + sdiff_column_two_offset: usize, + tab_size: usize, + expanded: bool, + separator_pos: usize, +} + +impl<'a> From<&'a [u8]> for CharIter<'a> { + fn from(value: &'a [u8]) -> Self { + CharIter { current: value } + } +} + +impl<'a> Iterator for CharIter<'a> { + // (bytes for the next char, visible width) + type Item = (&'a [u8], usize); + + fn next(&mut self) -> Option { + let max = self.current.len().min(4); + + // We reached the end. + if max == 0 { + return None; + } + + // Try to find the next utf-8 character, if present in the next 4 bytes. + let mut index = 1; + let mut view = &self.current[..index]; + let mut char = str::from_utf8(view); + while char.is_err() { + index += 1; + if index > max { + break; + } + view = &self.current[..index]; + char = str::from_utf8(view) + } + + match char { + Ok(c) => { + self.current = self + .current + .get(view.len()..) + .unwrap_or(&self.current[0..0]); + Some((view, UnicodeWidthStr::width(c))) + } + Err(_) => { + // We did not find an utf-8 char within the next 4 bytes, return the single byte. + self.current = &self.current[1..]; + Some((&view[..1], 1)) + } + } + } +} + +impl Config { + pub fn new(full_width: usize, tab_size: usize, expanded: bool) -> Self { + // diff uses this calculation to calculate the size of a half line + // based on the options passed (like -w, -t, etc.). It's actually + // pretty useless, because we (actually) don't have any size modifiers + // that can change this, however I just want to leave the calculate + // here, since it's not very clear and may cause some confusion + + let w = full_width as isize; + let t = tab_size as isize; + let t_plus_g = t + GUTTER_WIDTH_MIN as isize; + let unaligned_off = (w >> 1) + (t_plus_g >> 1) + (w & t_plus_g & 1); + let off = unaligned_off - unaligned_off % t; + let hw = max(0, min(off - GUTTER_WIDTH_MIN as isize, w - off)) as usize; + let c2o = if hw != 0 { off as usize } else { w as usize }; + + Self { + expanded, + sdiff_column_two_offset: c2o, + tab_size, + sdiff_half_width: hw, + separator_pos: ((hw + c2o - 1) >> 1), + } + } +} + +fn format_tabs_and_spaces( + from: usize, + to: usize, + config: &Config, + buf: &mut T, +) -> std::io::Result<()> { + let expanded = config.expanded; + let tab_size = config.tab_size; + let mut current = from; + + if current > to { + return Ok(()); + } + + if expanded { + while current < to { + buf.write_all(b" ")?; + current += 1; + } + return Ok(()); + } + + while current + (tab_size - current % tab_size) <= to { + let next_tab = current + (tab_size - current % tab_size); + buf.write_all(b"\t")?; + current = next_tab; + } + + while current < to { + buf.write_all(b" ")?; + current += 1; + } + + Ok(()) +} + +fn process_half_line( + s: &[u8], + max_width: usize, + is_right: bool, + white_space_gutter: bool, + config: &Config, + buf: &mut T, +) -> std::io::Result<()> { + if s.is_empty() { + if !is_right { + format_tabs_and_spaces( + 0, + max_width + + if white_space_gutter { + GUTTER_WIDTH_MIN + } else { + 1 + }, + config, + buf, + )?; + } + + return Ok(()); + } + + if max_width > config.sdiff_half_width { + return Ok(()); + } + + if max_width > config.sdiff_column_two_offset && !is_right { + return Ok(()); + } + + let expanded = config.expanded; + let tab_size = config.tab_size; + let sdiff_column_two_offset = config.sdiff_column_two_offset; + let mut current_width = 0; + let iter = CharIter::from(s); + + // the encoding will probably be compatible with utf8, so we can take advantage + // of that to get the size of the columns and iterate without breaking the encoding of anything. + // It seems like a good trade, since there is still a fallback in case it is not utf8. + // But I think it would be better if we used some lib that would allow us to handle this + // in the best way possible, in order to avoid overhead (currently 2 for loops are needed). + // There is a library called mcel (mcel.h) that is used in GNU diff, but the documentation + // about it is very scarce, nor is its use documented on the internet. In fact, from my + // research I didn't even find any information about it in the GNU lib's own documentation. + + for c in iter { + let (char, c_width) = c; + + if current_width + c_width > max_width { + break; + } + + match char { + b"\t" => { + if expanded && (current_width + tab_size - (current_width % tab_size)) <= max_width + { + let mut spaces = tab_size - (current_width % tab_size); + while spaces > 0 { + buf.write_all(b" ")?; + current_width += 1; + spaces -= 1; + } + } else if current_width + tab_size - (current_width % tab_size) <= max_width { + buf.write_all(b"\t")?; + current_width += tab_size - (current_width % tab_size); + } + } + b"\n" => { + break; + } + b"\r" => { + buf.write_all(b"\r")?; + format_tabs_and_spaces(0, sdiff_column_two_offset, config, buf)?; + current_width = 0; + } + b"\0" | b"\x07" | b"\x0C" | b"\x0B" => { + buf.write_all(char)?; + } + _ => { + buf.write_all(char)?; + current_width += c_width; + } + } + } + + // gnu sdiff do not tabulate the hole empty right line, instead, just keep the line empty + if !is_right { + // we always sum + 1 or + GUTTER_WIDTH_MIN cause we want to expand + // up to the third column of the gutter column if the gutter is gutter white space, + // otherwise we can expand to only the first column of the gutter middle column, cause + // the next is the sep char + format_tabs_and_spaces( + current_width, + max_width + + if white_space_gutter { + GUTTER_WIDTH_MIN + } else { + 1 + }, + config, + buf, + )?; + } + + Ok(()) +} + +fn push_output( + left_ln: &[u8], + right_ln: &[u8], + symbol: u8, + output: &mut T, + config: &Config, +) -> std::io::Result<()> { + if left_ln.is_empty() && right_ln.is_empty() { + writeln!(output)?; + return Ok(()); + } + + let white_space_gutter = symbol == b' '; + let half_width = config.sdiff_half_width; + let column_two_offset = config.sdiff_column_two_offset; + let separator_pos = config.separator_pos; + let put_new_line = true; // should be false when | is allowed + + // this involves a lot of the '|' mark, however, as it is not active, + // it is better to deactivate it as it introduces visual bug if + // the line is empty. + // if !left_ln.is_empty() { + // put_new_line = put_new_line || (left_ln.last() == Some(&b'\n')); + // } + // if !right_ln.is_empty() { + // put_new_line = put_new_line || (right_ln.last() == Some(&b'\n')); + // } + + process_half_line( + left_ln, + half_width, + false, + white_space_gutter, + config, + output, + )?; + if symbol != b' ' { + // the diff always want to put all tabs possible in the usable are, + // even in the middle space between the gutters if possible. + + output.write_all(&[symbol])?; + if !right_ln.is_empty() { + format_tabs_and_spaces(separator_pos + 1, column_two_offset, config, output)?; + } + } + process_half_line( + right_ln, + half_width, + true, + white_space_gutter, + config, + output, + )?; + + if put_new_line { + writeln!(output)?; + } + + Ok(()) +} + +pub fn diff( + from_file: &[u8], + to_file: &[u8], + output: &mut T, + params: &Params, +) -> Vec { + // ^ The left file ^ The right file + + let mut left_lines: Vec<&[u8]> = from_file.split_inclusive(|&c| c == b'\n').collect(); + let mut right_lines: Vec<&[u8]> = to_file.split_inclusive(|&c| c == b'\n').collect(); + let config = Config::new(params.width, params.tabsize, params.expand_tabs); + + if left_lines.last() == Some(&&b""[..]) { + left_lines.pop(); + } + + if right_lines.last() == Some(&&b""[..]) { + right_lines.pop(); + } + + /* + DISCLAIMER: + Currently the diff engine does not produce results like the diff engine used in GNU diff, + so some results may be inaccurate. For example, the line difference marker "|", according + to the GNU documentation, appears when the same lines (only the actual line, although the + relative line may change the result, so occasionally '|' markers appear with the same lines) + are different but exist in both files. In the current solution the same result cannot be + obtained because the diff engine does not return Both if both exist but are different, + but instead returns a Left and a Right for each one, implying that two lines were added + and deleted. Furthermore, the GNU diff program apparently stores some internal state + (this internal state is just a note about how the diff engine works) about the lines. + For example, an added or removed line directly counts in the line query of the original + lines to be printed in the output. Because of this imbalance caused by additions and + deletions, the characters ( and ) are introduced. They basically represent lines without + context, which have lost their pair in the other file due to additions or deletions. Anyway, + my goal with this disclaimer is to warn that for some reason, whether it's the diff engine's + inability to determine and predict/precalculate the result of GNU's sdiff, with this software it's + not possible to reproduce results that are 100% faithful to GNU's, however, the basic premise + e of side diff of showing added and removed lines and creating edit scripts is totally possible. + More studies are needed to cover GNU diff side by side with 100% accuracy, which is one of + the goals of this project : ) + */ + for result in diff::slice(&left_lines, &right_lines) { + match result { + Result::Left(left_ln) => push_output(left_ln, b"", b'<', output, &config).unwrap(), + Result::Right(right_ln) => push_output(b"", right_ln, b'>', output, &config).unwrap(), + Result::Both(left_ln, right_ln) => { + push_output(left_ln, right_ln, b' ', output, &config).unwrap() + } + } + } + + vec![] +} + +#[cfg(test)] +mod tests { + const DEF_TAB_SIZE: usize = 4; + + use super::*; + + mod format_tabs_and_spaces { + use super::*; + + const CONFIG_E_T: Config = Config { + sdiff_half_width: 60, + tab_size: DEF_TAB_SIZE, + expanded: true, + sdiff_column_two_offset: 0, + separator_pos: 0, + }; + + const CONFIG_E_F: Config = Config { + sdiff_half_width: 60, + tab_size: DEF_TAB_SIZE, + expanded: false, + sdiff_column_two_offset: 0, + separator_pos: 0, + }; + + #[test] + fn test_format_tabs_and_spaces_expanded_false() { + let mut buf = vec![]; + format_tabs_and_spaces(0, 5, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b' ']); + } + + #[test] + fn test_format_tabs_and_spaces_expanded_true() { + let mut buf = vec![]; + format_tabs_and_spaces(0, 5, &CONFIG_E_T, &mut buf).unwrap(); + assert_eq!(buf, vec![b' '; 5]); + } + + #[test] + fn test_format_tabs_and_spaces_from_greater_than_to() { + let mut buf = vec![]; + format_tabs_and_spaces(6, 5, &CONFIG_E_F, &mut buf).unwrap(); + assert!(buf.is_empty()); + } + + #[test] + fn test_format_from_non_zero_position() { + let mut buf = vec![]; + format_tabs_and_spaces(2, 7, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b' ', b' ', b' ']); + } + + #[test] + fn test_multiple_full_tabs_needed() { + let mut buf = vec![]; + format_tabs_and_spaces(0, 12, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b'\t', b'\t']); + } + + #[test] + fn test_uneven_tab_boundary_with_spaces() { + let mut buf = vec![]; + format_tabs_and_spaces(3, 10, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b'\t', b' ', b' ']); + } + + #[test] + fn test_expanded_true_with_offset() { + let mut buf = vec![]; + format_tabs_and_spaces(3, 9, &CONFIG_E_T, &mut buf).unwrap(); + assert_eq!(buf, vec![b' '; 6]); + } + + #[test] + fn test_exact_tab_boundary_from_midpoint() { + let mut buf = vec![]; + format_tabs_and_spaces(4, 8, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t']); + } + + #[test] + fn test_mixed_tabs_and_spaces_edge_case() { + let mut buf = vec![]; + format_tabs_and_spaces(5, 9, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b' ']); + } + + #[test] + fn test_minimal_gap_with_tab() { + let mut buf = vec![]; + format_tabs_and_spaces(7, 8, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t']); + } + + #[test] + fn test_expanded_false_with_tab_at_end() { + let mut buf = vec![]; + format_tabs_and_spaces(6, 8, &CONFIG_E_F, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t']); + } + } + + mod process_half_line { + use super::*; + + fn create_test_config(expanded: bool, tab_size: usize) -> Config { + Config { + sdiff_half_width: 30, + sdiff_column_two_offset: 60, + tab_size, + expanded, + separator_pos: 15, + } + } + + #[test] + fn test_empty_line_left_expanded_false() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"", 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf.len(), 5); + assert_eq!(buf, vec![b'\t', b'\t', b' ', b' ', b' ']); + } + + #[test] + fn test_tabs_unexpanded() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"\tabc", 8, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b'a', b'b', b'c', b'\t', b' ']); + } + + #[test] + fn test_utf8_multibyte() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = "😉😉😉".as_bytes(); + process_half_line(s, 3, false, false, &config, &mut buf).unwrap(); + let mut r = vec![]; + r.write_all("😉\t".as_bytes()).unwrap(); + assert_eq!(buf, r) + } + + #[test] + fn test_newline_handling() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"abc\ndef", 5, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, vec![b'a', b'b', b'c', b'\t', b' ', b' ']); + } + + #[test] + fn test_carriage_return() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"\rxyz", 5, true, false, &config, &mut buf).unwrap(); + let mut r = vec![b'\r']; + r.extend(vec![b'\t'; 15]); + r.extend(vec![b'x', b'y', b'z']); + assert_eq!(buf, r); + } + + #[test] + fn test_exact_width_fit() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"abcd", 4, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf.len(), 5); + assert_eq!(buf, b"abcd ".to_vec()); + } + + #[test] + fn test_non_utf8_bytes() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + // ISO-8859-1 + process_half_line( + &[0x63, 0x61, 0x66, 0xE9], + 5, + false, + false, + &config, + &mut buf, + ) + .unwrap(); + assert_eq!(&buf, &[0x63, 0x61, 0x66, 0xE9, b' ', b' ']); + assert!(String::from_utf8(buf).is_err()); + } + + #[test] + fn test_non_utf8_bytes_ignore_padding_bytes() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + + let utf32le_bytes = [ + 0x63, 0x00, 0x00, 0x00, // 'c' + 0x61, 0x00, 0x00, 0x00, // 'a' + 0x66, 0x00, 0x00, 0x00, // 'f' + 0xE9, 0x00, 0x00, 0x00, // 'é' + ]; + // utf8 little endiand 32 bits (or 4 bytes per char) + process_half_line(&utf32le_bytes, 6, false, false, &config, &mut buf).unwrap(); + let mut r = utf32le_bytes.to_vec(); + r.extend(vec![b' '; 3]); + assert_eq!(buf, r); + } + + #[test] + fn test_non_utf8_non_preserve_ascii_bytes_cut() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + + let gb18030 = b"\x63\x61\x66\xA8\x80"; // some random chinese encoding + // ^ é char, start multi byte + process_half_line(gb18030, 4, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"\x63\x61\x66\xA8 "); // break the encoding of 'é' letter + } + + #[test] + fn test_right_line_padding() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"xyz", 5, true, true, &config, &mut buf).unwrap(); + assert_eq!(buf.len(), 3); + } + + #[test] + fn test_mixed_tabs_spaces() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + process_half_line(b"\t \t", 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b' ', b' ', b'\t', b' ', b' ', b' ']); + } + + #[test] + fn test_overflow_multibyte() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = "日本語".as_bytes(); + process_half_line(s, 5, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, "日本 ".as_bytes()); + } + + #[test] + fn test_white_space_gutter() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc"; + process_half_line(s, 3, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abc\t "); + } + + #[test] + fn test_expanded_true() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abc ") + } + + #[test] + fn test_expanded_true_with_gutter() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc"; + process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abc ") + } + + #[test] + fn test_width0_chars() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc\0\x0B\x07\x0C"; + process_half_line(s, 4, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abc\0\x0B\x07\x0C\t ") + } + + #[test] + fn test_left_empty_white_space_gutter() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b""; + process_half_line(s, 9, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b"\t\t\t"); + } + + #[test] + fn test_s_size_eq_max_width_p1() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abcdefghij"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abcdefghij "); + } + + #[test] + fn test_mixed_tabs_and_spaces_inversion() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b" \t \t "; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b" \t \t "); + } + + #[test] + fn test_expanded_with_tabs() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b" \t \t "; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b" "); + } + + #[test] + fn test_expanded_with_tabs_and_space_gutter() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b" \t \t "; + process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b" "); + } + + #[test] + fn test_zero_width_unicode_chars() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = "\u{200B}".as_bytes(); + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, "\u{200B}\t\t ".as_bytes()); + } + + #[test] + fn test_multiple_carriage_returns() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"\r\r"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + let mut r = vec![b'\r']; + r.extend(vec![b'\t'; 15]); + r.push(b'\r'); + r.extend(vec![b'\t'; 15]); + r.extend(vec![b'\t'; 2]); + r.extend(vec![b' '; 3]); + assert_eq!(buf, r); + } + + #[test] + fn test_multiple_carriage_returns_is_right_true() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"\r\r"; + process_half_line(s, 10, true, false, &config, &mut buf).unwrap(); + let mut r = vec![b'\r']; + r.extend(vec![b'\t'; 15]); + r.push(b'\r'); + r.extend(vec![b'\t'; 15]); + assert_eq!(buf, r); + } + + #[test] + fn test_mixed_invalid_utf8_with_valid() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc\xFF\xFEdef"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert!(String::from_utf8(s.to_vec()).is_err()); + assert_eq!(buf, b"abc\xFF\xFEdef "); + } + + #[test] + fn test_max_width_zero() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"foo bar"; + process_half_line(s, 0, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, vec![b' ']); + } + + #[test] + fn test_line_only_with_tabs() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"\t\t\t"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, vec![b'\t', b'\t', b' ', b' ', b' ']) + } + + #[test] + fn test_tabs_expanded() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"\t\t\t"; + process_half_line(s, 12, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b" ".repeat(13)); + } + + #[test] + fn test_mixed_tabs() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"a\tb\tc\t"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"a\tb\tc "); + } + + #[test] + fn test_mixed_tabs_with_gutter() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"a\tb\tc\t"; + process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b"a\tb\tc\t "); + } + + #[test] + fn test_mixed_tabs_expanded() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"a\tb\tc\t"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"a b c "); + } + + #[test] + fn test_mixed_tabs_expanded_with_gutter() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"a\tb\tc\t"; + process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b"a b c "); + } + + #[test] + fn test_break_if_invalid_max_width() { + let config = create_test_config(true, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"a\tb\tc\t"; + process_half_line(s, 61, false, true, &config, &mut buf).unwrap(); + assert_eq!(buf, b""); + assert_eq!(buf.len(), 0); + } + + #[test] + fn test_new_line() { + let config = create_test_config(false, DEF_TAB_SIZE); + let mut buf = vec![]; + let s = b"abc"; + process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); + assert_eq!(buf, b"abc\t\t "); + } + } + + mod push_output { + // almost all behavior of the push_output was tested with tests on process_half_line + + use super::*; + + impl Default for Config { + fn default() -> Self { + Config::new(130, 8, false) + } + } + + fn create_test_config_def() -> Config { + Config::default() + } + + #[test] + fn test_left_empty_right_not_added() { + let config = create_test_config_def(); + let left_ln = b""; + let right_ln = b"bar"; + let symbol = b'>'; + let mut buf = vec![]; + push_output(&left_ln[..], &right_ln[..], symbol, &mut buf, &config).unwrap(); + assert_eq!(buf, b"\t\t\t\t\t\t\t >\tbar\n"); + } + + #[test] + fn test_right_empty_left_not_del() { + let config = create_test_config_def(); + let left_ln = b"bar"; + let right_ln = b""; + let symbol = b'>'; + let mut buf = vec![]; + push_output(&left_ln[..], &right_ln[..], symbol, &mut buf, &config).unwrap(); + assert_eq!(buf, b"bar\t\t\t\t\t\t\t >\n"); + } + + #[test] + fn test_both_empty() { + let config = create_test_config_def(); + let left_ln = b""; + let right_ln = b""; + let symbol = b' '; + let mut buf = vec![]; + push_output(&left_ln[..], &right_ln[..], symbol, &mut buf, &config).unwrap(); + assert_eq!(buf, b"\n"); + } + + #[test] + fn test_output_cut_with_maximization() { + let config = create_test_config_def(); + let left_ln = b"a".repeat(62); + let right_ln = b"a".repeat(62); + let symbol = b' '; + let mut buf = vec![]; + push_output(&left_ln[..], &right_ln[..], symbol, &mut buf, &config).unwrap(); + assert_eq!(buf.len(), 61 * 2 + 2); + assert_eq!(&buf[0..61], vec![b'a'; 61]); + assert_eq!(&buf[61..62], b"\t"); + let mut end = b"a".repeat(61); + end.push(b'\n'); + assert_eq!(&buf[62..], end); + } + + #[test] + fn test_both_lines_non_empty_with_space_symbol_max_tabs() { + let config = create_test_config_def(); + let left_ln = b"left"; + let right_ln = b"right"; + let symbol = b' '; + let mut buf = vec![]; + push_output(left_ln, right_ln, symbol, &mut buf, &config).unwrap(); + let expected_left = "left\t\t\t\t\t\t\t\t"; + let expected_right = "right"; + assert_eq!(buf, format!("{expected_left}{expected_right}\n").as_bytes()); + } + + #[test] + fn test_non_space_symbol_with_padding() { + let config = create_test_config_def(); + let left_ln = b"data"; + let right_ln = b""; + let symbol = b'<'; // impossible case, just to use different symbol + let mut buf = vec![]; + push_output(left_ln, right_ln, symbol, &mut buf, &config).unwrap(); + assert_eq!(buf, "data\t\t\t\t\t\t\t <\n".as_bytes()); + } + + #[test] + fn test_lines_exceeding_half_width() { + let config = create_test_config_def(); + let left_ln = vec![b'a'; 100]; + let left_ln = left_ln.as_slice(); + let right_ln = vec![b'b'; 100]; + let right_ln = right_ln.as_slice(); + let symbol = b' '; + let mut buf = vec![]; + push_output(left_ln, right_ln, symbol, &mut buf, &config).unwrap(); + let expected_left = "a".repeat(61); + let expected_right = "b".repeat(61); + assert_eq!(buf.len(), 61 + 1 + 61 + 1); + assert_eq!(&buf[0..61], expected_left.as_bytes()); + assert_eq!(buf[61], b'\t'); + assert_eq!(&buf[62..123], expected_right.as_bytes()); + assert_eq!(&buf[123..], b"\n"); + } + + #[test] + fn test_tabs_in_lines_expanded() { + let mut config = create_test_config_def(); + config.expanded = true; + let left_ln = b"\tleft"; + let right_ln = b"\tright"; + let symbol = b' '; + let mut buf = vec![]; + push_output(left_ln, right_ln, symbol, &mut buf, &config).unwrap(); + let expected_left = " left".to_string() + &" ".repeat(61 - 12); + let expected_right = " right"; + assert_eq!( + buf, + format!("{}{}{}\n", expected_left, " ", expected_right).as_bytes() + ); + } + + #[test] + fn test_unicode_characters() { + let config = create_test_config_def(); + let left_ln = "áéíóú".as_bytes(); + let right_ln = "😀😃😄".as_bytes(); + let symbol = b' '; + let mut buf = vec![]; + push_output(left_ln, right_ln, symbol, &mut buf, &config).unwrap(); + let expected_left = "áéíóú\t\t\t\t\t\t\t\t"; + let expected_right = "😀😃😄"; + assert_eq!(buf, format!("{expected_left}{expected_right}\n").as_bytes()); + } + } + + mod diff { + /* + Probably this hole section should be refactored when complete sdiff + arrives. I would say that these tests are more to document the + behavior of the engine than to actually test whether it is right, + because it is right, but right up to its limitations. + */ + + use super::*; + + fn generate_params() -> Params { + Params { + tabsize: 8, + expand_tabs: false, + width: 130, + ..Default::default() + } + } + + fn contains_string(vec: &[u8], s: &str) -> usize { + let pattern = s.as_bytes(); + vec.windows(pattern.len()).filter(|s| s == &pattern).count() + } + + fn calc_lines(input: &Vec) -> usize { + let mut lines_counter = 0; + + for c in input { + if c == &b'\n' { + lines_counter += 1; + } + } + + lines_counter + } + + #[test] + fn test_equal_lines() { + let params = generate_params(); + let from_file = b"equal"; + let to_file = b"equal"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + assert_eq!(calc_lines(&output), 1); + assert!(!output.contains(&b'<')); + assert!(!output.contains(&b'>')); + assert_eq!(contains_string(&output, "equal"), 2) + } + + #[test] + fn test_different_lines() { + let params = generate_params(); + let from_file = b"eq"; + let to_file = b"ne"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + assert_eq!(calc_lines(&output), 2); + assert!(output.contains(&b'>')); + assert!(output.contains(&b'<')); + assert_eq!(contains_string(&output, "eq"), 1); + assert_eq!(contains_string(&output, "ne"), 1); + } + + #[test] + fn test_added_line() { + let params = generate_params(); + let from_file = b""; + let to_file = b"new line"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 1); + assert_eq!(contains_string(&output, ">"), 1); + assert_eq!(contains_string(&output, "new line"), 1); + } + + #[test] + fn test_removed_line() { + let params = generate_params(); + let from_file = b"old line"; + let to_file = b""; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 1); + assert_eq!(contains_string(&output, "<"), 1); + assert_eq!(contains_string(&output, "old line"), 1); + } + + #[test] + fn test_multiple_changes() { + let params = generate_params(); + let from_file = b"line1\nline2\nline3"; + let to_file = b"line1\nmodified\nline4"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 5); + assert_eq!(contains_string(&output, "<"), 2); + assert_eq!(contains_string(&output, ">"), 2); + } + + #[test] + fn test_unicode_and_special_chars() { + let params = generate_params(); + let from_file = "á\t€".as_bytes(); + let to_file = "€\t😊".as_bytes(); + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert!(String::from_utf8_lossy(&output).contains("á")); + assert!(String::from_utf8_lossy(&output).contains("€")); + assert!(String::from_utf8_lossy(&output).contains("😊")); + assert_eq!(contains_string(&output, "<"), 1); + assert_eq!(contains_string(&output, ">"), 1); + } + + #[test] + fn test_mixed_whitespace() { + let params = generate_params(); + let from_file = b" \tspaces"; + let to_file = b"\t\t tabs"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert!(output.contains(&b'<')); + assert!(output.contains(&b'>')); + assert!(String::from_utf8_lossy(&output).contains("spaces")); + assert!(String::from_utf8_lossy(&output).contains("tabs")); + } + + #[test] + fn test_empty_files() { + let params = generate_params(); + let from_file = b""; + let to_file = b""; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(output, vec![]); + } + + #[test] + fn test_partially_matching_lines() { + let params = generate_params(); + let from_file = b"match\nchange"; + let to_file = b"match\nupdated"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 3); + assert_eq!(contains_string(&output, "match"), 2); + assert_eq!(contains_string(&output, "<"), 1); + assert_eq!(contains_string(&output, ">"), 1); + } + + #[test] + fn test_interleaved_add_remove() { + let params = generate_params(); + let from_file = b"A\nB\nC\nD"; + let to_file = b"B\nX\nD\nY"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 7); + assert_eq!(contains_string(&output, "A"), 1); + assert_eq!(contains_string(&output, "X"), 1); + assert_eq!(contains_string(&output, "Y"), 1); + assert_eq!(contains_string(&output, "<"), 3); + assert_eq!(contains_string(&output, ">"), 3); + } + + #[test] + fn test_swapped_lines() { + let params = generate_params(); + let from_file = b"1\n2\n3\n4"; + let to_file = b"4\n3\n2\n1"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 7); + assert_eq!(contains_string(&output, "<"), 3); + assert_eq!(contains_string(&output, ">"), 3); + } + + #[test] + fn test_gap_between_changes() { + let params = generate_params(); + let from_file = b"Start\nKeep1\nRemove\nKeep2\nEnd"; + let to_file = b"Start\nNew1\nKeep1\nKeep2\nNew2\nEnd"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 7); + assert_eq!(contains_string(&output, "Remove"), 1); + assert_eq!(contains_string(&output, "New1"), 1); + assert_eq!(contains_string(&output, "New2"), 1); + assert_eq!(contains_string(&output, "<"), 1); + assert_eq!(contains_string(&output, ">"), 2); + } + + #[test] + fn test_mixed_operations_complex() { + let params = generate_params(); + let from_file = b"Same\nOld1\nSameMid\nOld2\nSameEnd"; + let to_file = b"Same\nNew1\nSameMid\nNew2\nNew3\nSameEnd"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 8); + assert_eq!(contains_string(&output, "<"), 2); + assert_eq!(contains_string(&output, ">"), 3); + } + + #[test] + fn test_insert_remove_middle() { + let params = generate_params(); + let from_file = b"Header\nContent1\nFooter"; + let to_file = b"Header\nContent2\nFooter"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 4); + assert_eq!(contains_string(&output, "Content1"), 1); + assert_eq!(contains_string(&output, "Content2"), 1); + assert_eq!(contains_string(&output, "<"), 1); + assert_eq!(contains_string(&output, ">"), 1); + } + + #[test] + fn test_multiple_adjacent_changes() { + let params = generate_params(); + let from_file = b"A\nB\nC\nD\nE"; + let to_file = b"A\nX\nY\nD\nZ"; + let mut output = vec![]; + diff(from_file, to_file, &mut output, ¶ms); + + assert_eq!(calc_lines(&output), 8); + assert_eq!(contains_string(&output, "<"), 3); + assert_eq!(contains_string(&output, ">"), 3); + } + } + + mod config { + use super::*; + + fn create_config(full_width: usize, tab_size: usize, expanded: bool) -> Config { + Config::new(full_width, tab_size, expanded) + } + + #[test] + fn test_full_width_80_tab_4() { + let config = create_config(80, 4, false); + assert_eq!(config.sdiff_half_width, 37); + assert_eq!(config.sdiff_column_two_offset, 40); + assert_eq!(config.separator_pos, 38); + } + + #[test] + fn test_full_width_40_tab_8() { + let config = create_config(40, 8, true); + assert_eq!(config.sdiff_half_width, 16); + assert_eq!(config.sdiff_column_two_offset, 24); + assert_eq!(config.separator_pos, 19); // (16 +24 -1) /2 = 19.5 + } + + #[test] + fn test_full_width_30_tab_2() { + let config = create_config(30, 2, false); + assert_eq!(config.sdiff_half_width, 13); + assert_eq!(config.sdiff_column_two_offset, 16); + assert_eq!(config.separator_pos, 14); + } + + #[test] + fn test_small_width_10_tab_4() { + let config = create_config(10, 4, false); + assert_eq!(config.sdiff_half_width, 2); + assert_eq!(config.sdiff_column_two_offset, 8); + assert_eq!(config.separator_pos, 4); + } + + #[test] + fn test_minimal_width_3_tab_4() { + let config = create_config(3, 4, false); + assert_eq!(config.sdiff_half_width, 0); + assert_eq!(config.sdiff_column_two_offset, 3); + assert_eq!(config.separator_pos, 1); + } + + #[test] + fn test_odd_width_7_tab_3() { + let config = create_config(7, 3, false); + assert_eq!(config.sdiff_half_width, 1); + assert_eq!(config.sdiff_column_two_offset, 6); + assert_eq!(config.separator_pos, 3); + } + + #[test] + fn test_tab_size_larger_than_width() { + let config = create_config(5, 10, false); + assert_eq!(config.sdiff_half_width, 0); + assert_eq!(config.sdiff_column_two_offset, 5); + assert_eq!(config.separator_pos, 2); + } + } +} diff --git a/src/unified_diff.rs b/src/unified_diff.rs index 0f504a84..27773b10 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -408,6 +408,8 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + use crate::utils::testcmds::PATCH_CMD; + #[test] fn test_permutations() { let target = "target/unified-diff/"; @@ -421,7 +423,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"b\n" }) @@ -456,12 +457,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alef"); let diff = diff( &alef, &bet, &Params { - from: "a/alef".into(), - to: (&format!("{target}/alef")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -492,7 +494,10 @@ mod tests { .unwrap_or_else(|_| String::from("[Invalid UTF-8]")) ); - let output = Command::new("patch") + use crate::utils::testcmds::PATCH_CMD; + + let output = PATCH_CMD + .new() .arg("-p0") .stdin(File::open(format!("{target}/ab.diff")).unwrap()) .output() @@ -524,7 +529,6 @@ mod tests { for &g in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"b\n" }) @@ -572,12 +576,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alefn"); let diff = diff( &alef, &bet, &Params { - from: "a/alefn".into(), - to: (&format!("{target}/alefn")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -592,7 +597,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .stdin(File::open(format!("{target}/abn.diff")).unwrap()) .output() @@ -625,7 +631,6 @@ mod tests { for &g in &[0, 1, 2, 3] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"\n" } else { b"b\n" }).unwrap(); @@ -668,12 +673,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alef_"); let diff = diff( &alef, &bet, &Params { - from: "a/alef_".into(), - to: (&format!("{target}/alef_")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -688,7 +694,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .stdin(File::open(format!("{target}/ab_.diff")).unwrap()) .output() @@ -720,7 +727,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"" }).unwrap(); @@ -749,12 +755,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alefx"); let diff = diff( &alef, &bet, &Params { - from: "a/alefx".into(), - to: (&format!("{target}/alefx")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -769,7 +776,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .stdin(File::open(format!("{target}/abx.diff")).unwrap()) .output() @@ -800,7 +808,6 @@ mod tests { for &f in &[0, 1, 2] { use std::fs::{self, File}; use std::io::Write; - use std::process::Command; let mut alef = Vec::new(); let mut bet = Vec::new(); alef.write_all(if a == 0 { b"a\n" } else { b"f\n" }) @@ -835,12 +842,13 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. + let patched = &format!("{target}/alefr"); let diff = diff( &alef, &bet, &Params { - from: "a/alefr".into(), - to: (&format!("{target}/alefr")).into(), + from: patched.into(), + to: patched.into(), context_count: 2, ..Default::default() }, @@ -855,7 +863,8 @@ mod tests { fb.write_all(&bet[..]).unwrap(); let _ = fa; let _ = fb; - let output = Command::new("patch") + let output = PATCH_CMD + .new() .arg("-p0") .stdin(File::open(format!("{target}/abr.diff")).unwrap()) .output() diff --git a/src/utils.rs b/src/utils.rs index 88b39ff5..d9415afd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,9 +3,8 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. -use std::{ffi::OsString, io::Write}; - use regex::Regex; +use std::{ffi::OsString, io::Write}; use unicode_width::UnicodeWidthStr; /// Replace tabs by spaces in the input line. @@ -99,6 +98,99 @@ pub fn report_failure_to_read_input_file( ); } +#[cfg(test)] +pub mod testcmds { + // Command construction wrapper that provides some validation and non-obscure, "fail fast" + // feedback and error messages. + use std::any::Any; + use std::io::Write; + use std::panic::catch_unwind; + use std::process::{Command, Stdio}; + use std::sync::LazyLock; + + pub struct CmdFactory { + cmd: &'static str, + validated_once: LazyLock>, + validate: fn(&CmdFactory) -> (), + } + + impl CmdFactory { + pub fn new(&self) -> Command { + match &*self.validated_once { + Ok(()) => Command::new(self.cmd), + Err(errmsg) => panic!( + "'{}' validation failed in earlier thread/test: {}", + self.cmd, errmsg + ), + } + } + // "self" is not compatible with static initialization + fn try_catch_validate(cmd: &CmdFactory) -> Result<(), String> { + // Note catch_unwind() does _not_ hide error messages, stack traces, etc. + catch_unwind(|| { + let _ = (cmd.validate)(cmd); + }) + .map_err(find_panic_message) + } + } + + fn find_panic_message(payload: Box) -> String { + // https://github.com/rust-lang/rust/blob/1.95.0/library/std/src/panicking.rs#L771 + if let Some(&s) = payload.downcast_ref::<&'static str>() { + String::from(s) + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + format!( + "Unusual panic payload type {:?}, look for the first thread/test that failed", + payload.type_id(), + ) + } + } + + pub static PATCH_CMD: CmdFactory = CmdFactory { + cmd: if cfg!(target_os = "macos") { + "gpatch" // brew install gpatch + } else { + "patch" + }, + validated_once: LazyLock::new(|| CmdFactory::try_catch_validate(&PATCH_CMD)), + + validate: (|myself| { + let output = Command::new(myself.cmd) + .arg("--version") + .output() + .expect(format!("`{} --version` failed", myself.cmd).as_str()); + // Non-GNU versions have subtle differences. When some newlines are missing in some test + // patches, the macOS version can even stall the whole test run. + assert!(output.stdout.starts_with(b"GNU patch")); + assert!(output.status.success()); + }), + }; + + pub static ED_CMD: CmdFactory = CmdFactory { + cmd: "ed", + validated_once: LazyLock::new(|| CmdFactory::try_catch_validate(&ED_CMD)), + + validate: (|myself| { + let mut child = Command::new(myself.cmd) + .arg("!echo hello_ed") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("Failed to start 'ed' command"); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "1p\nq").expect("Failed to send command to 'ed'"); + + let output = child + .wait_with_output() + .expect("Failed to read 'ed' stdout"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "9\nhello_ed\n"); + }), + }; +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration.rs b/tests/integration.rs index cfbf529d..12aabb84 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. -use assert_cmd::cmd::Command; +use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use std::fs::File; #[cfg(not(windows))] @@ -17,14 +17,14 @@ mod common { #[test] fn unknown_param() -> Result<(), Box> { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("patch"); cmd.assert() .code(predicate::eq(2)) .failure() .stderr(predicate::eq("patch: utility not supported\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.assert() .code(predicate::eq(0)) .success() @@ -32,14 +32,14 @@ mod common { "Expected utility name as second argument, got nothing.\n", )); - for subcmd in ["diff", "cmp"] { - let mut cmd = Command::cargo_bin("diffutils")?; + for subcmd in ["diff", "cmp", "uu-diff", "uucmp"] { + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg(subcmd); cmd.arg("--foobar"); cmd.assert() .code(predicate::eq(2)) .failure() - .stderr(predicate::str::starts_with("Unknown option: \"--foobar\"")); + .stderr(predicate::str::contains("unrecognized option '--foobar'")); } Ok(()) } @@ -58,7 +58,7 @@ mod common { let error_message = "The system cannot find the file specified."; for subcmd in ["diff", "cmp"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg(subcmd); cmd.arg(&nopath).arg(file.path()); cmd.assert() @@ -69,7 +69,7 @@ mod common { &nopath.as_os_str().to_string_lossy() ))); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg(subcmd); cmd.arg(file.path()).arg(&nopath); cmd.assert() @@ -81,7 +81,7 @@ mod common { ))); } - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg(&nopath).arg(&nopath); cmd.assert().code(predicate::eq(2)).failure().stderr( @@ -105,7 +105,7 @@ mod diff { fn no_differences() -> Result<(), Box> { let file = NamedTempFile::new()?; for option in ["", "-u", "-c", "-e"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); if !option.is_empty() { cmd.arg(option); @@ -125,7 +125,7 @@ mod diff { let mut file1 = NamedTempFile::new()?; file1.write_all("foo\n".as_bytes())?; for option in ["", "-u", "-c", "-e"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); if !option.is_empty() { cmd.arg(option); @@ -144,7 +144,7 @@ mod diff { let mut file2 = NamedTempFile::new()?; file2.write_all("foo\n".as_bytes())?; for option in ["", "-u", "-c", "-e"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); if !option.is_empty() { cmd.arg(option); @@ -169,7 +169,7 @@ mod diff { let mut file2 = NamedTempFile::new()?; file2.write_all("bar\n".as_bytes())?; for option in ["", "-u", "-c", "-e"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); if !option.is_empty() { cmd.arg(option); @@ -190,7 +190,7 @@ mod diff { let mut file2 = NamedTempFile::new()?; file2.write_all("bar\n".as_bytes())?; for option in ["", "-u", "-c", "-e"] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); if !option.is_empty() { cmd.arg(option); @@ -214,7 +214,7 @@ mod diff { file1.write_all("foo".as_bytes())?; let mut file2 = NamedTempFile::new()?; file2.write_all("bar".as_bytes())?; - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-e").arg(file1.path()).arg(file2.path()); cmd.assert() @@ -231,7 +231,7 @@ mod diff { let mut file2 = NamedTempFile::new()?; file2.write_all("bar\n".as_bytes())?; - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u") .arg(file1.path()) @@ -248,7 +248,7 @@ mod diff { ) ); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u") .arg("-") @@ -265,7 +265,7 @@ mod diff { ) ); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u").arg("-").arg("-"); cmd.assert() @@ -275,7 +275,7 @@ mod diff { #[cfg(unix)] { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u") .arg(file1.path()) @@ -311,7 +311,7 @@ mod diff { let mut da = File::create(&da_path).unwrap(); da.write_all(b"da\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u").arg(&directory).arg(&a_path); cmd.assert().code(predicate::eq(1)).failure(); @@ -326,7 +326,7 @@ mod diff { ) ); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("diff"); cmd.arg("-u").arg(&a_path).arg(&directory); cmd.assert().code(predicate::eq(1)).failure(); @@ -348,9 +348,29 @@ mod diff { mod cmp { use super::*; + // A file whose metadata length is 0 but which still yields bytes (/dev/zero) + // collapses the offset column to two characters, so the padding subtraction + // underflowed once the running offset reached three digits. + #[test] + #[cfg(unix)] + fn cmp_verbose_zero_length_metadata() -> Result<(), Box> { + let mut file = NamedTempFile::new()?; + file.write_all(&[0xffu8; 150])?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("cmp") + .arg("--verbose") + .arg("/dev/zero") + .arg(file.path()); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::contains("150 0 377")); + Ok(()) + } + #[test] fn cmp_incompatible_params() -> Result<(), Box> { - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-s"); @@ -373,7 +393,7 @@ mod cmp { let mut a = File::create(&a_path).unwrap(); a.write_all(b"a\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg(&a_path); cmd.write_stdin("a\n"); @@ -383,7 +403,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::str::is_empty()); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg(&a_path); @@ -409,7 +429,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"a\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); cmd.assert() @@ -432,7 +452,7 @@ mod cmp { let b_path = tmp_dir.path().join("b"); let _ = File::create(&b_path).unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); cmd.assert() @@ -456,7 +476,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"bcd\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); @@ -465,7 +485,7 @@ mod cmp { .failure() .stdout(predicate::str::ends_with(" differ: char 1, line 1\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-b"); @@ -478,7 +498,7 @@ mod cmp { " differ: byte 1, line 1 is 141 a 142 b\n", )); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-l"); @@ -489,7 +509,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::eq("1 141 142\n2 142 143\n3 143 144\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-l"); @@ -518,7 +538,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"abc\ndef\ng").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); @@ -528,7 +548,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::str::ends_with(" differ: char 8, line 2\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-b"); @@ -541,7 +561,7 @@ mod cmp { " differ: byte 8, line 2 is 147 g 12 ^J\n", )); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-l"); @@ -553,7 +573,7 @@ mod cmp { .stderr(predicate::str::contains(" EOF on")) .stderr(predicate::str::ends_with(" after byte 8\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-b"); @@ -581,7 +601,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"abcdefghijkl\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-b"); @@ -594,7 +614,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::str::is_empty()); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-b"); @@ -607,7 +627,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::eq("4 40 144 d\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-b"); @@ -634,7 +654,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"###abc\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-i"); @@ -647,7 +667,7 @@ mod cmp { .stdout(predicate::str::is_empty()); // Positional skips should be ignored - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg("-i"); @@ -661,7 +681,7 @@ mod cmp { .stdout(predicate::str::is_empty()); // Single positional argument should only affect first file. - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); @@ -672,7 +692,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::str::ends_with(" differ: char 1, line 1\n")); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.env("LC_ALL", "C"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); @@ -701,7 +721,7 @@ mod cmp { writeln!(b, "{}c", "b".repeat(1024)).unwrap(); b.flush().unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("--ignore-initial=1K"); cmd.arg(&a_path).arg(&b_path); @@ -726,7 +746,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(b"abcdefghijkl\n").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-b"); @@ -739,7 +759,7 @@ mod cmp { .stderr(predicate::str::is_empty()) .stdout(predicate::str::is_empty()); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-b"); cmd.arg("-i"); @@ -772,7 +792,7 @@ mod cmp { let mut b = File::create(&b_path).unwrap(); b.write_all(&bytes).unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-l"); cmd.arg("-b"); @@ -817,7 +837,7 @@ mod cmp { let dev_null = OpenOptions::new().write(true).open("/dev/null").unwrap(); - let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin("diffutils")) + let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin!("diffutils")) .arg("cmp") .arg(&a_path) .arg(&b_path) @@ -825,12 +845,27 @@ mod cmp { .spawn() .unwrap(); - std::thread::sleep(std::time::Duration::from_millis(100)); - - assert_eq!(child.try_wait().unwrap().unwrap().code(), Some(1)); + // Bound the runtime to a very short time that still allows for some resource + // constraint to slow it down while also allowing very fast systems to exit as + // early as possible. + const MAX_TRIES: u8 = 50; + for tries in 0..=MAX_TRIES { + if tries == MAX_TRIES { + panic!("cmp took too long to run, /dev/null optimization probably not working") + } + match child.try_wait() { + Ok(Some(status)) => { + assert_eq!(status.code(), Some(1)); + break; + } + Ok(None) => (), + Err(e) => panic!("{e:#?}"), + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } // Two stdins should be equal - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg("-"); cmd.arg("-"); @@ -861,9 +896,10 @@ mod cmp { b.write_all(bytes).unwrap(); b.write_all(b"B").unwrap(); - let mut cmd = Command::cargo_bin("diffutils")?; + let mut cmd = cargo_bin_cmd!("diffutils"); cmd.arg("cmp"); cmd.arg(&a_path).arg(&b_path); + cmd.env("LC_ALL", "en_US"); cmd.assert() .code(predicate::eq(1)) .failure() diff --git a/tests/run-upstream-testsuite.sh b/tests/run-upstream-testsuite.sh index 2593eb27..78f1c405 100755 --- a/tests/run-upstream-testsuite.sh +++ b/tests/run-upstream-testsuite.sh @@ -16,6 +16,11 @@ # tests are run might not match exactly that used when the upstream tests are # run through the autotools. +# Exit codes: 0 if all tests passed, 1 if at least one test failed, and 2 if +# the test suite could not be run at all (e.g. the upstream repository couldn't +# be fetched). Callers must treat 2 as an infrastructure error: no meaningful +# result was produced. + # By default it expects a release build of the diffutils binary, but a # different build profile can be specified as an argument # (e.g. 'dev' or 'test'). @@ -26,6 +31,12 @@ scriptpath=$(dirname "$(readlink -f "$0")") rev=$(git rev-parse HEAD) +# Report an infrastructure error: the test suite could not be run at all +die() { + echo "ERROR: $*" >&2 + exit 2 +} + # Allow passing a specific profile as parameter (default to "release") profile="release" [[ -n $1 ]] && profile="$1" @@ -34,35 +45,57 @@ profile="release" binary="$scriptpath/../target/$profile/diffutils" if [[ ! -x "$binary" ]] then - echo "Missing build for profile $profile" - exit 1 + die "Missing build for profile $profile" fi # Work in a temporary directory tempdir=$(mktemp -d) -cd "$tempdir" +trap 'rm -rf "$tempdir"' EXIT +cd "$tempdir" || die "Cannot enter temporary directory $tempdir" -# Check out the upstream test suite +# Check out the upstream test suite. git.savannah.gnu.org is regularly +# unavailable or slow, so retry a few times before giving up. gitserver="https://git.savannah.gnu.org" testsuite="$gitserver/git/diffutils.git" -echo "Fetching upstream test suite from $testsuite" -git clone -n --depth=1 --filter=tree:0 "$testsuite" &> /dev/null -cd diffutils -git sparse-checkout set --no-cone tests &> /dev/null -git checkout &> /dev/null +attempts=3 +for (( attempt = 1; attempt <= attempts; attempt++ )) +do + echo "Fetching upstream test suite from $testsuite (attempt $attempt/$attempts)" + rm -rf diffutils + git clone -n --depth=1 --filter=tree:0 "$testsuite" && break + (( attempt < attempts )) && sleep $(( attempt * 10 )) +done +[[ -d diffutils ]] || die "Failed to fetch the upstream test suite from $testsuite" +cd diffutils || die "Failed to fetch the upstream test suite from $testsuite" +git sparse-checkout set --no-cone tests &> /dev/null || die "Cannot sparse-checkout the upstream tests" +git checkout &> /dev/null || die "Cannot check out the upstream tests" upstreamrev=$(git rev-parse HEAD) +[[ -d tests ]] || die "The upstream checkout contains no tests directory" # Ensure that calling `diff` invokes the built `diffutils` binary instead of # the upstream `diff` binary that is most likely installed on the system mkdir src -cd src +cd src || die "Cannot create the directory holding the diff and cmp symlinks" ln -s "$binary" diff ln -s "$binary" cmp -cd ../tests +cd ../tests || die "Cannot enter the upstream tests directory" # Fetch tests/init.sh from the gnulib repository (needed since # https://git.savannah.gnu.org/cgit/diffutils.git/commit/tests?id=1d2456f539) -curl -s "$gitserver/gitweb/?p=gnulib.git;a=blob_plain;f=tests/init.sh;hb=HEAD" -o init.sh +# The savannah gitweb interface is often rate-limited or unavailable, so fall +# back to the official gnulib mirror on GitHub +initsh_urls=( + "$gitserver/gitweb/?p=gnulib.git;a=blob_plain;f=tests/init.sh;hb=HEAD" + "https://raw.githubusercontent.com/coreutils/gnulib/master/tests/init.sh" +) +for url in "${initsh_urls[@]}" +do + echo "Fetching tests/init.sh from $url" + curl -sSL --fail --retry 3 --retry-delay 5 --retry-all-errors \ + --connect-timeout 30 --max-time 300 "$url" -o init.sh && [[ -s init.sh ]] && break + rm -f init.sh +done +[[ -s init.sh ]] || die "Failed to fetch tests/init.sh from the gnulib repository" if [[ -n "$TESTS" ]] then @@ -73,6 +106,7 @@ else tests=$(make -f Makefile.am printtests) fi total=$(echo "$tests" | wc -w) +(( total > 0 )) || die "No test to run: the upstream test list is empty" echo "Running $total tests" export LC_ALL=C export KEEP=yes @@ -105,7 +139,9 @@ do # but there isn't much value added in doing so for file in * do - [[ -f "$file" ]] && json+="\"$file\":\"$(base64 -w0 < "$file")\"," + # Encode the name with jq: some tests create files whose name contains + # quotes or control characters, which would produce invalid JSON + [[ -f "$file" ]] && json+="$(jq -Rn --arg name "$file" '$name'):\"$(base64 -w0 < "$file")\"," done json="${json%,}}}," cd - > /dev/null @@ -144,10 +180,16 @@ json="{$metadata $json}" # Clean up cd "$scriptpath" -rm -rf "$tempdir" +# Write the results out only once they are known to be valid JSON, so that a +# malformed (or truncated) file is never left behind for the caller to consume resultsfile="test-results.json" -echo "$json" | jq > "$resultsfile" +if ! echo "$json" | jq > "$resultsfile.tmp" +then + rm -f "$resultsfile.tmp" + die "Generated invalid JSON results" +fi +mv "$resultsfile.tmp" "$resultsfile" echo "Results written to $scriptpath/$resultsfile" (( failed > 0 )) && exit 1 diff --git a/util/compare_test_results.py b/util/compare_test_results.py new file mode 100755 index 00000000..00469d6e --- /dev/null +++ b/util/compare_test_results.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +""" +Compare the current GNU test results to the last results gathered from the main branch to +highlight if a PR is making the results better/worse. +Don't exit with error code if all failing tests are in the ignore-intermittent.txt list. + +Exit status: 0 if the comparison shows no new failure, 1 if new non-intermittent +failures appeared, and 2 if the comparison couldn't be performed at all (e.g. one +of the result files is missing or malformed). +""" + +import json +import sys +import argparse +from pathlib import Path + + +def load_ignore_list(ignore_file): + """Load list of intermittent test names to ignore from file.""" + ignore_set = set() + if ignore_file and Path(ignore_file).exists(): + with open(ignore_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + ignore_set.add(line) + return ignore_set + + +def extract_test_results(json_data): + """Extract test results from a diffutils test-results.json. + + Note: unlike sed, diffutils JSON has no 'summary' object — results are + computed from the 'tests' array using the 'result' and 'test' fields. + """ + tests = json_data.get("tests", []) + passed = sum(1 for t in tests if t.get("result") == "PASS") + failed = sum(1 for t in tests if t.get("result") == "FAIL") + skipped = sum(1 for t in tests if t.get("result") == "SKIP") + summary = {"total": len(tests), "passed": passed, "failed": failed, "skipped": skipped} + failed_tests = [t["test"] for t in tests if t.get("result") == "FAIL"] + return summary, failed_tests + + +def compare_results(current_file, reference_file, ignore_file=None, output_file=None): + """Compare current results with reference results.""" + ignore_set = load_ignore_list(ignore_file) + + try: + with open(current_file, "r") as f: + current_data = json.load(f) + current_summary, current_failed = extract_test_results(current_data) + except Exception as e: + print(f"Error loading current results: {e}") + return 2 + + try: + with open(reference_file, "r") as f: + reference_data = json.load(f) + reference_summary, reference_failed = extract_test_results(reference_data) + except Exception as e: + print(f"Error loading reference results: {e}") + return 2 + + # Calculate differences + pass_diff = int(current_summary.get("passed", 0)) - int(reference_summary.get("passed", 0)) + fail_diff = int(current_summary.get("failed", 0)) - int(reference_summary.get("failed", 0)) + total_diff = int(current_summary.get("total", 0)) - int(reference_summary.get("total", 0)) + + # Find new failures and improvements + current_failed_set = set(current_failed) + reference_failed_set = set(reference_failed) + + new_failures = current_failed_set - reference_failed_set + improvements = reference_failed_set - current_failed_set + + # Filter out intermittent failures + non_intermittent_new_failures = new_failures - ignore_set + + # Check if results are identical (no changes) + no_changes = ( + pass_diff == 0 + and fail_diff == 0 + and total_diff == 0 + and not new_failures + and not improvements + ) + + # If no changes, write empty output to prevent comment posting + if no_changes: + if output_file: + with open(output_file, "w") as f: + f.write("") + return 0 + + # Prepare output message + output_lines = [] + + output_lines.append("Test results comparison:") + output_lines.append( + f" Current: TOTAL: {current_summary.get('total', 0)} / PASSED: {current_summary.get('passed', 0)} / FAILED: {current_summary.get('failed', 0)} / SKIPPED: {current_summary.get('skipped', 0)}" + ) + output_lines.append( + f" Reference: TOTAL: {reference_summary.get('total', 0)} / PASSED: {reference_summary.get('passed', 0)} / FAILED: {reference_summary.get('failed', 0)} / SKIPPED: {reference_summary.get('skipped', 0)}" + ) + output_lines.append("") + + if pass_diff != 0 or fail_diff != 0 or total_diff != 0: + output_lines.append("Changes from main branch:") + output_lines.append(f" TOTAL: {total_diff:+d}") + output_lines.append(f" PASSED: {pass_diff:+d}") + output_lines.append(f" FAILED: {fail_diff:+d}") + output_lines.append("") + + if new_failures: + output_lines.append(f"New test failures ({len(new_failures)}):") + for test in sorted(new_failures): + if test in ignore_set: + output_lines.append(f" - {test} (intermittent)") + else: + output_lines.append(f" - {test}") + output_lines.append("") + + if improvements: + output_lines.append(f"Test improvements ({len(improvements)}):") + for test in sorted(improvements): + output_lines.append(f" + {test}") + output_lines.append("") + + output_text = "\n".join(output_lines) + if output_file: + with open(output_file, "w") as f: + f.write(output_text) + else: + print(output_text) + + if non_intermittent_new_failures: + print( + f"ERROR: Found {len(non_intermittent_new_failures)} new non-intermittent test failures" + ) + return 1 + + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="Compare GNU diffutils test results") + parser.add_argument("current", help="Current test results JSON file") + parser.add_argument("reference", help="Reference test results JSON file") + parser.add_argument( + "--ignore-file", help="File containing intermittent test names to ignore" + ) + parser.add_argument("--output", help="Output file for comparison results") + + args = parser.parse_args() + + return compare_results(args.current, args.reference, args.ignore_file, args.output) + + +if __name__ == "__main__": + sys.exit(main())