#!/usr/bin/env -S just --working-directory . --justfile
# Load project-specific properties from the `.env` file

set dotenv-load := true

# Whether coverage should be measured when running tests. Use `create-coverage-report` to create a report from the collected data.

coverage := env("COVERAGE_REPORT", "false")

# Lists all available recipes.
default:
    just --list

# Adds pre-commit and pre-push git hooks
[private]
add-hooks:
    #!/usr/bin/env bash
    set -euo pipefail

    echo just run-pre-commit-hook > .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit

    echo just run-pre-push-hook > .git/hooks/pre-push
    chmod +x .git/hooks/pre-push

    cat > .git/hooks/prepare-commit-msg <<'EOL'
    #!/bin/sh

    COMMIT_MSG_FILE=$1
    COMMIT_SOURCE=$2

    SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
    git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
    if test -z "$COMMIT_SOURCE"; then
        /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
    fi
    EOL
    chmod +x .git/hooks/prepare-commit-msg

# Updates the local cargo index and displays which crates would be updated
[private]
dry-update:
    cargo update --dry-run --verbose

# Ensures that one or more required commands are installed
[private]
ensure-command +command:
    #!/usr/bin/env bash
    set -euo pipefail

    read -r -a commands <<< "{{ command }}"

    for cmd in "${commands[@]}"; do
        if ! command -v "$cmd" > /dev/null 2>&1 ; then
            printf "Couldn't find required executable '%s'\n" "$cmd" >&2
            exit 1
        fi
    done

# Retrieves the configured target directory for cargo.
[private]
get-cargo-target-directory:
    just ensure-command cargo jq
    cargo metadata --format-version 1 | jq -r  .target_directory

# Gets names of all workspace members
[private]
get-workspace-members:
    cargo metadata --format-version=1 |jq -r '.workspace_members[] | capture("/(?<name>[a-z-]+)#.*").name'

# Gets metadata version of a workspace member
[private]
get-workspace-member-version package:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly version="$(cargo metadata --format-version=1 |jq -r --arg pkg {{ package }} '.workspace_members[] | capture("/(?<name>[a-z-]+)#(?<version>[0-9.]+)") | select(.name == $pkg).version')"

    if [[ -z "$version" ]]; then
        printf "No version found for package %s\n" {{ package }} >&2
        exit 1
    fi

    printf "$version\n"

# Installs a `set` of ALPM packages.
[private]
install-alpm-package-set set:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly set="{{ set }}"

    case "$set" in
        all)
            packages="${PACMAN_PACKAGES}"
            ;;
        commits)
            packages="${CHECK_COMMITS_PACKAGES}"
            ;;
        containerized)
            packages="${CONTAINERIZED_TEST_PACKAGES}"
            ;;
        coverage)
            packages="${TEST_COVERAGE_PACKAGES}"
            ;;
        docs)
            packages="${DOCS_PACKAGES}"
            ;;
        dependencies)
            packages="${CHECK_DEPENDENCIES_PACKAGES}"
            ;;
        formatting)
            packages="${FORMAT_PACKAGES}"
            ;;
        licenses)
            packages="${CHECK_LICENSES_PACKAGES}"
            ;;
        links)
            packages="${CHECK_LINKS_PACKAGES}"
            ;;
        publish)
            packages="${PUBLISH_PACKAGES}"
            ;;
        rust)
            packages="${CHECK_RUST_PACKAGES}"
            ;;
        rust-dev)
            packages="${RUST_DEV_TOOLS_PACKAGES}"
            ;;
        shell)
            packages="${CHECK_SHELL_PACKAGES}"
            ;;
        spelling)
            packages="${SPELLING_PACKAGES}"
            ;;
        test)
            packages="${TEST_PACKAGES}"
            ;;
        unused)
            packages="${CHECK_UNUSED_PACKAGES}"
            ;;
        *)
            printf 'Invalid package set %s\n' "$set" >&2
            exit 1
    esac

    just ensure-command pacman run0

    # Read all packages into an array.
    read -r -d '' -a packages < <(printf '%s\0' "$packages")

    # Deduplicate using an associative array
    declare -A unique_packages
    for package in "${packages[@]}"; do
        if [[ ! "${unique_packages[$package]+_}" ]]; then
            unique_packages["$package"]=1
        fi
    done

    # Use run0 when not root
    command=()
    if (( "$(id -u)" > 0 )); then
        command+=(run0)
    fi
    command+=(
        pacman -Su --needed --noconfirm
    )

    "${command[@]}" "${!unique_packages[@]}"

# Checks if a string matches a workspace member exactly
[private]
is-workspace-member package:
    #!/usr/bin/env bash
    set -euo pipefail

    mapfile -t workspace_members < <(just get-workspace-members 2>/dev/null)

    for name in "${workspace_members[@]}"; do
        if [[ "$name" == {{ package }} ]]; then
            exit 0
        fi
    done
    exit 1

# Runs checks and tests before creating a commit.
[private]
run-pre-commit-hook: check docs test test-docs

# Runs checks before pushing commits to remote repository.
[private]
run-pre-push-hook: check-commits

# Build local documentation
[group('build')]
docs:
    RUSTDOCFLAGS='-D warnings' cargo doc --document-private-items --no-deps

# Checks source code formatting
[group('check')]
check-formatting:
    just ensure-command cargo-sort-derives mado taplo

    just --unstable --fmt --check
    # We're using nightly to properly group imports, see rustfmt.toml
    cargo +nightly fmt -- --check

    taplo format --check

    # Checks for consistent sorting of rust derives
    cargo sort-derives --check

    mado check

# Runs all check targets
[group('check')]
check: check-spelling check-formatting check-shell-code check-rust-code check-unused-deps check-dependencies check-licenses check-links

# Checks commit messages for correctness
[group('check')]
check-commits:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly default_branch="${CI_DEFAULT_BRANCH:-main}"

    just ensure-command codespell cog committed rg

    if ! git rev-parse --verify "origin/$default_branch" > /dev/null 2>&1; then
        printf "The default branch '%s' does not exist!\n" "$default_branch" >&2
        exit 1
    fi

    tmpdir="$(mktemp --dry-run --directory)"
    readonly check_tmpdir="$tmpdir"
    mkdir -p "$check_tmpdir"

    # remove temporary dir on exit
    cleanup() (
      if [[ -n "${check_tmpdir:-}" ]]; then
        rm -rf "${check_tmpdir}"
      fi
    )

    trap cleanup EXIT

    for commit in $(git rev-list "origin/${default_branch}.."); do
        printf "Checking commit %s\n" "$commit"

        commit_message="$(git show -s --format=%B "$commit")"
        codespell_config="$(mktemp --tmpdir="$check_tmpdir")"

        # either use the commit's .codespellrc or create one
        if git show "$commit:.codespellrc" > /dev/null 2>&1; then
            git show "$commit:.codespellrc" > "$codespell_config"
        else
            printf "[codespell]\nskip = .cargo,.git,target,.env,Cargo.lock\nignore-words-list = crate\n" > "$codespell_config"
        fi

        if ! rg -q "Signed-off-by: " <<< "$commit_message"; then
            printf "Commit %s ❌️\n" "$commit" >&2
            printf "The commit message lacks a \"Signed-off-by\" line.\n" >&2
            printf "%s\n" \
                "  Please use:" \
                "    git rebase --signoff main && git push --force-with-lease" \
                "  See https://developercertificate.org/ for more details." >&2
            exit 1
        elif ! codespell --config "$codespell_config" - <<< "$commit_message"; then
            printf "Commit %s ❌️\n" "$commit" >&2
            printf "The spelling of the commit message needs improvement.\n" >&2
            exit 1
        elif ! cog verify "$commit_message"; then
            printf "Commit %s ❌️\n" "$commit" >&2
            printf "%s\n" \
                "The commit message is not a conventional commit message:" \
                "$commit_message" \
                "See https://www.conventionalcommits.org/en/v1.0.0/ for more details." >&2
            exit 1
        elif ! committed "$commit"; then
            printf "Commit %s ❌️\n" "$commit" >&2
            printf "%s\n" \
                "The commit message does not meet the required standards:" \
                "$commit_message"
            exit 1
        else
            printf "Commit %s ✅️\n\n" "$commit"
        fi
    done

# Checks for issues with dependencies
[group('check')]
check-dependencies: dry-update
    just ensure-command cargo-deny
    cargo deny --all-features check

# Checks licensing status
[group('check')]
check-licenses:
    just ensure-command reuse
    reuse lint

# Check for stale links in documentation
[group('check')]
check-links:
    just ensure-command lychee
    lychee .

# Checks the Rust source code using cargo-clippy.
[group('check')]
check-rust-code:
    just ensure-command cargo cargo-clippy mold
    cargo clippy --all-features --all-targets --workspace -- -D warnings

# Checks shell code using shellcheck.
[group('check')]
check-shell-code:
    just check-shell-readme rootless-run

    just check-shell-recipe check-commits
    just check-shell-recipe ci-publish
    just check-shell-recipe containerized-integration-tests
    just check-shell-recipe 'install-alpm-package-set all'
    just check-shell-recipe 'is-workspace-member test'
    just check-shell-recipe 'prepare-release test'
    just check-shell-recipe 'release test'
    just check-shell-recipe flaky
    just check-shell-recipe test
    just check-shell-recipe test-docs
    just check-shell-recipe 'ensure-command test'
    just check-shell-recipe virtualized-integration-tests

    just check-shell-script .cargo/runner.sh

# Checks the script examples of a project's README using shellcheck.
[group('check')]
check-shell-readme project:
    just ensure-command shellcheck tangler
    tangler bash < {{ project }}/README.md | shellcheck --shell bash -

# Checks justfile recipe relying on shell semantics using shellcheck.
[group('check')]
check-shell-recipe recipe:
    just ensure-command rg shellcheck
    just -vv -n {{ recipe }} 2>&1 | rg -v '===> Running recipe' | shellcheck -

# Checks a shell script using shellcheck.
[group('check')]
check-shell-script file:
    just ensure-command shellcheck
    shellcheck --shell bash {{ file }}

# Checks common spelling mistakes
[group('check')]
check-spelling:
    just ensure-command codespell
    codespell

# Checks for unused dependencies
[group('check')]
check-unused-deps:
    just ensure-command cargo-machete
    cargo machete

# Adds needed git configuration for the local repository
[group('dev')]
configure-git:
    # Enforce gpg signed keys for this repository
    git config commit.gpgsign true

    just add-hooks

# Installs all tools required for development
[group('dev')]
dev-install: install-pacman-dev-packages install-rust-dev-tools

# Fixes common issues. Files need to be git add'ed
[group('dev')]
fix:
    #!/usr/bin/env bash
    set -euo pipefail

    if ! git diff-files --quiet ; then
        echo "Working tree has changes. Please stage them: git add ."
        exit 1
    fi

    codespell --write-changes
    just --unstable --fmt
    cargo clippy --fix --allow-staged

    # fmt must be last as clippy's changes may break formatting
    cargo +nightly fmt

# Installs development packages using pacman
[group('dev')]
install-pacman-dev-packages:
    just install-alpm-package-set all

# Installs all Rust tools required for development
[group('dev')]
install-rust-dev-tools:
    rustup default stable
    rustup component add clippy
    # Install nightly as we use it for formatting rules.
    rustup toolchain install nightly
    rustup component add --toolchain nightly rustfmt
    # llvm-tools-preview for code coverage
    rustup component add llvm-tools-preview

# Continuously run integration tests for a given number of rounds
[group('test')]
flaky test='just test' rounds='999999999999':
    #!/usr/bin/bash
    set -euo pipefail

    seq 1 {{ rounds }} | while read -r counter; do
      printf "Running flaky tests (%d/{{ rounds }})...\n" "$counter"
      sleep 1
      {{ test }}
      echo
    done

# Runs integration tests guarded by the `_containerized-integration-test` feature, located in modules named `containerized` (accepts `cargo nextest run` options via `options`).
[group('test')]
containerized-integration-tests *options:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly coverage="{{ coverage }}"
    commands=(
        cargo
        cargo-nextest
        podman
    )
    read -r -a options <<< "{{ options }}"

    if [[ "$coverage" == "true" ]]; then
        commands+=(cargo-llvm-cov)
        just ensure-command "${commands[@]}"
        # Use the environment prepared by `cargo llvm-cov show-env`
        # shellcheck source=/dev/null
        source <(cargo llvm-cov show-env --export-prefix)
    else
        just ensure-command "${commands[@]}"
    fi

    cargo build --examples --bins
    cargo nextest run --features _containerized-integration-test --filterset 'kind(test) and binary_id(/::containerized$/)' "${options[@]}"

[doc('Creates code coverage report for all projects from all available sources.
When providing `with-docs` to the `mode` parameter, this also includes doc test coverage in the report (requires nightly).
The `metrics_name` parameter can be used to override the metrics name in the `coverage-metrics.txt` file used by GitLab.')]
[group('test')]
create-coverage-report mode="without-docs" metrics_name="Test-coverage":
    #!/usr/bin/env bash
    set -euo pipefail

    readonly metrics_name="{{ metrics_name }}"
    readonly mode="{{ mode }}"

    target_dir="$(just get-cargo-target-directory)"

    just ensure-command cargo-llvm-cov cargo-nextest jq

    mkdir --parents "$target_dir/llvm-cov/"

    # Options for cargo
    cargo_options=(+stable)

    # The chosen reporting style (defaults to without doctest coverage)
    reporting_style="without doctest coverage"

    # Options for creating cobertura coverage report with cargo-llvm-cov
    cargo_llvm_cov_cobertura_options=(
        --cobertura
        --output-path "$target_dir/llvm-cov/cobertura-coverage.xml"
    )

    # Options for creating coverage report summary with cargo-llvm-cov
    cargo_llvm_cov_summary_options=(
        --json
        --summary-only
    )

    if [[ "$mode" == "with-docs" ]]; then
        reporting_style="with doctest coverage"
        # The support for doctest coverage is a nightly feature
        cargo_options=(+nightly)
        cargo_llvm_cov_cobertura_options+=(--doctests)
        cargo_llvm_cov_summary_options+=(--doctests)
    fi

    printf "Creating report %s\n" "$reporting_style"

    # Use the environment prepared by `cargo llvm-cov show-env`
    # shellcheck source=/dev/null
    source <(cargo llvm-cov show-env --export-prefix)

    # Create cobertura coverage report
    cargo "${cargo_options[@]}" llvm-cov report "${cargo_llvm_cov_cobertura_options[@]}"

    printf "Calculating percentage...\n"

    # Get total coverage percentage from summary
    percentage="$(cargo "${cargo_options[@]}" llvm-cov report "${cargo_llvm_cov_summary_options[@]}" | jq '.data[0].totals.lines.percent')"

    # Trim percentage to 4 decimal places.
    percentage="$(LC_NUMERIC=C printf "%.4f\n" "$percentage")"

    # Writes to target/coverage-metrics.txt for Gitlab CI metric consumption.
    # https://docs.gitlab.com/ci/testing/metrics_reports/
    printf "%s %s\n" "$metrics_name" "$percentage" > "$target_dir/llvm-cov/coverage-metrics.txt"
    printf "Test-coverage: %s%%\n" "$percentage"

# Runs all unit tests. Options to `cargo nextest run` can be passed in using `options`.
[group('test')]
test *options:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly coverage="{{ coverage }}"
    commands=(
        cargo
        cargo-nextest
        mold
    )
    read -r -a options <<< "{{ options }}"

    if [[ "$coverage" == "true" ]]; then
        commands+=(cargo-llvm-cov)
        just ensure-command "${commands[@]}"
        # Use the environment prepared by `cargo llvm-cov show-env`
        # shellcheck source=/dev/null
        source <(cargo llvm-cov show-env --export-prefix)
    else
        just ensure-command "${commands[@]}"
    fi

    cargo nextest run --locked --all "${options[@]}"

# Runs all doc tests. Options to `cargo test` can be passed in using `options`.
[group('test')]
test-docs *options:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly coverage="{{ coverage }}"
    toolchain="+stable"
    commands=(cargo)
    read -r -a options <<< "{{ options }}"

    if [[ "$coverage" == "true" ]]; then
        commands+=(cargo-llvm-cov)
        toolchain="+nightly"
        just ensure-command "${commands[@]}"
        # Use the environment prepared by `cargo llvm-cov show-env`
        # shellcheck source=/dev/null
        source <(cargo llvm-cov show-env --export-prefix)
    else
        just ensure-command "${commands[@]}"
    fi

    cargo "$toolchain" test --locked --doc "${options[@]}"

# Runs integration tests guarded by the `_virtualized-integration-test` feature (accepts `cargo nextest run` options via `options`).
[group('test')]
virtualized-integration-tests *options:
    #!/usr/bin/env bash
    set -euo pipefail

    readonly coverage="{{ coverage }}"
    commands=(cargo)
    read -r -a options <<< "{{ options }}"

    just ensure-command bash cargo cargo-nextest
    if [[ "$coverage" == "true" ]]; then
        commands+=(cargo-llvm-cov)
        just ensure-command "${commands[@]}"
        # Use the environment prepared by `cargo llvm-cov show-env`
        # shellcheck source=/dev/null
        source <(cargo llvm-cov show-env --export-prefix)
    else
        just ensure-command "${commands[@]}"
    fi

    cargo build --examples --bins
    cargo nextest run --features _virtualized-integration-test --filterset 'kind(test) and binary_id(/::virtualized$/)' "${options[@]}"

# Publishes a crate in the workspace from GitLab CI in a pipeline for tags
[group('release')]
ci-publish:
    #!/usr/bin/env bash
    set -euo pipefail

    # an auth token with publishing capabilities is expected to be set in GitLab project settings
    readonly token="${CARGO_REGISTRY_TOKEN:-}"
    # rely on predefined variable to retrieve git tag: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
    readonly tag="${CI_COMMIT_TAG:-}"
    readonly crate="${tag//\/*/}"
    readonly version="${tag#*/}"

    just ensure-command cargo mold

    if [[ -z "$tag" ]]; then
        printf "There is no tag!\n" >&2
        exit 1
    fi
    if [[ -z "$token" ]]; then
        printf "There is no token for crates.io!\n" >&2
        exit 1
    fi
    if ! just is-workspace-member "$crate" &>/dev/null; then
        printf "The crate %s is not a workspace member of the project!\n" "$crate" >&2
        exit 1
    fi

    current_member_version="$(just get-workspace-member-version "$crate" 2>/dev/null)"
    readonly current_member_version="$current_member_version"
    if [[ "$version" != "$current_member_version" ]]; then
        printf "Current version in metadata of crate %s (%s) does not match the version from the tag (%s)!\n" "$crate" "$current_member_version" "$version"
        exit 1
    fi

    printf "Found tag %s (crate %s in version %s).\n" "$tag" "$crate" "$version"
    cargo publish -p "$crate"

# Prepares the release of a crate by updating dependencies, incrementing the crate version and creating a changelog entry (optionally, the version can be set explicitly)
[group('release')]
prepare-release package version="":
    #!/usr/bin/env bash
    set -euo pipefail

    readonly package_name="{{ package }}"
    if [[ -z "$package_name" ]]; then
        printf "No package name provided!\n"
        exit 1
    fi
    readonly package_version="{{ version }}"
    branch_name=""

    just ensure-command git release-plz

    release-plz update -u -p "$package_name"

    # NOTE: When setting the version specifically, we are likely in a situation where `release-plz` did not detect a version change (e.g. when only changes to top-level files took place since last release).
    # In this case we are fine to potentially have no changes in the CHANGELOG.md or having to adjust it manually afterwards.
    if [[ -n "$package_version" ]]; then
        release-plz set-version "${package_name}@${package_version}"
    fi

    # make sure that the current version would be publishable, but ignore files not added to git
    cargo publish -p "$package_name" --dry-run --allow-dirty

    updated_package_version="$(just get-workspace-member-version "$package_name")"
    readonly updated_package_version="$updated_package_version"

    if [[ -n "$package_version" ]]; then
        branch_name="release/$package_name/$package_version"
    else
        branch_name="release/$package_name/$updated_package_version"
    fi
    git checkout -b "$branch_name"

    shopt -s globstar
    git add ./**/Cargo.* ./**/CHANGELOG.md
    git commit --gpg-sign --signoff --message "chore: Upgrade $package_name crate to $updated_package_version"
    git push --set-upstream origin "$branch_name"

# Creates a release of a crate in the workspace by creating a tag and pushing it
[group('release')]
release package:
    #!/usr/bin/env bash
    set -euo pipefail

    package_version="$(just get-workspace-member-version {{ package }})"
    readonly package_version="$package_version"
    if [[ -z "$package_version" ]]; then
        exit 1
    fi
    readonly current_version="{{ package }}/$package_version"

    just ensure-command git

    if [[ -n "$(git tag -l "$current_version")" ]]; then
        printf "The tag %s exists already!\n" "$current_version" >&2
        exit 1
    fi

    printf "Creating tag %s...\n" "$current_version"
    git tag -s "$current_version" -m "$current_version"
    printf "Pushing tag %s...\n" "$current_version"
    git push origin refs/tags/"$current_version"
