#!/hint/bash

# Copyright © Tavian Barnes <tavianator@tavianator.com>
# SPDX-License-Identifier: 0BSD

## Running test cases

# Beginning/end of line escape sequences
BOL=$'\n'
EOL=$'\n'

# Update $EOL for the terminal size
update_eol() {
    # Bash gets $COLUMNS from stderr, so if it's redirected use tput instead
    local cols="${COLUMNS-}"
    if [ -z "$cols" ]; then
        cols=$(tput cols 2>/dev/tty)
    fi

    # Put the cursor at the last column, then write a space so the next
    # character will wrap
    EOL=$'\e['"${cols}G "
}

# ERR trap for tests
debug_err() {
    local ret=$? line func file
    callers | while read -r line func file; do
        if [ "$func" = source ]; then
            local cmd="$(awk "NR == $line" "$file" 2>/dev/null)" || :
            debug "$file" $line "${RED}error $ret${RST}" "$cmd" >&4
            break
        fi
    done
}

# Run a single test
run_test() (
    set -eE
    trap debug_err ERR
    cd "$TMP"
    source "$@"
)

# Run a test in the background
bg_test() {
    if ((VERBOSE_ERRORS)); then
        run_test "$1"
    else
        run_test "$1" 2>"$TMP/$TEST.err"
    fi
    ret=$?

    if ((ret != 0 && ret != EX_SKIP)); then
        ((VERBOSE_ERRORS)) || cat "$TMP/$TEST.err" >&2
        color printf "${BOL}${RED}%s failed!${RST}\n" "$TEST"
    fi

    return $ret
}

# Wait for any background job to complete
if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3))); then
    wait_any() {
        wait -n
    }
else
    wait_any() {
        read -ra jobs < <(jobs -p)
        wait ${jobs[0]}
    }
fi

# Wait for a background test to finish
wait_test() {
    wait_any
    ret=$?
    ((BG--))

    case $ret in
        0)
            ((++passed))
            ;;
        $EX_SKIP)
            ((++skipped))
            ;;
        *)
            ((++failed))
            ;;
    esac
}

# Run all the tests
run_tests() {
    if ((VERBOSE_TESTS)); then
        BOL=''
    elif ((COLOR_STDOUT)); then
        # Carriage return + clear line
        BOL=$'\r\e[K'

        # Workaround for bash 4: checkwinsize is off by default.  We can turn it
        # on, but we also have to explicitly trigger a foreground job to finish
        # so that it will update the window size before we use $COLUMNS
        shopt -s checkwinsize
        (:)

        update_eol
        trap update_eol WINCH
    fi

    passed=0
    failed=0
    skipped=0

    if ((COLOR_STDOUT || VERBOSE_TESTS)); then
        TEST_FMT="${BOL}${YLW}%s${RST}${EOL}"
    else
        TEST_FMT="."
    fi

    BG=0

    # Turn off set -e (but turn it back on in run_test)
    set +e

    for TEST in "${TEST_CASES[@]}"; do
        if ((BG >= JOBS)); then
            wait_test
        fi

        printf "$TEST_FMT" "$TEST"

        mkdir -p "$TMP/$TEST"
        OUT="$TMP/$TEST.out"

        bg_test "$TESTS/$TEST.sh" &
        ((++BG))
    done

    while ((BG > 0)); do
        wait_test
    done

    printf "${BOL}"

    if ((passed > 0)); then
        color printf "${GRN}tests passed: %d${RST}\n" "$passed"
    fi
    if ((skipped > 0)); then
        color printf "${CYN}tests skipped: %s${RST}\n" "$skipped"
    fi
    if ((failed > 0)); then
        color printf "${RED}tests failed: %s${RST}\n" "$failed"
        exit 1
    fi
}

## Utilities for the tests themselves

# Default return value for failed tests
EX_FAIL=1

# Fail the current test
fail() {
    exit $EX_FAIL
}

# Return value when a test is skipped
EX_SKIP=77

# Skip the current test
skip() {
    if ((VERBOSE_SKIPPED)); then
        caller | {
            read -r line file
            printf "${BOL}"
            debug "$file" $line "${CYN}$TEST skipped!${RST}" "$(awk "NR == $line" "$file")" >&3
        }
    elif ((VERBOSE_TESTS)); then
        color printf "${BOL}${CYN}%s skipped!${RST}\n" "$TEST"
    fi

    exit $EX_SKIP
}

# Run a command and check its exit status
check_exit() {
    local expected="$1"
    local actual=0
    shift
    "$@" || actual=$?
    ((actual == expected))
}

# Run a command with sudo
bfs_sudo() {
    if ((${#SUDO[@]})); then
        "${SUDO[@]}" "$@"
    else
        return 1
    fi
}

# Get the inode number of a file
inum() {
    ls -id "$@" | awk '{ print $1 }'
}

# Set an ACL on a file
set_acl() {
    case "$UNAME" in
        Darwin)
            chmod +a "$(id -un) allow read,write" "$1"
            ;;
        FreeBSD)
            if (($(getconf ACL_NFS4 "$1") > 0)); then
                setfacl -m "u:$(id -un):rw::allow" "$1"
            else
                setfacl -m "u:$(id -un):rw" "$1"
            fi
            ;;
        *)
            setfacl -m "u:$(id -un):rw" "$1"
            ;;
    esac
}

# Print a bfs invocation for --verbose=commands
bfs_verbose() {
    if ((VERBOSE_COMMANDS)); then
        (
            # Close some fds to make room for the pipe,
            # even with extremely low ulimit -n
            exec >&- 4>&-
            exec >&3 3>&-
            color bfs_verbose_impl "$@"
        )
    fi
}

bfs_verbose_impl() {
    printf "${GRN}%q${RST}" "${BFS[0]}"
    if ((${#BFS[@]} > 1)); then
        printf " ${GRN}%q${RST}" "${BFS[1:]}"
    fi

    local expr_started=0 color
    for arg; do
        case "$arg" in
            -[A-Z]*|-[dsxf]|-j*)
                color="${CYN}"
                ;;
            \(|!|-[ao]|-and|-or|-not|-exclude)
                expr_started=1
                color="${RED}"
                ;;
            \)|,)
                if ((expr_started)); then
                    color="${RED}"
                else
                    color="${MAG}"
                fi
                ;;
            -?*)
                expr_started=1
                color="${BLU}"
                ;;
            *)
                if ((expr_started)); then
                    color="${BLD}"
                else
                    color="${MAG}"
                fi
                ;;
        esac
        printf " ${color}%q${RST}" "$arg"
    done

    printf '\n'
}

# Run the bfs we're testing
invoke_bfs() {
    bfs_verbose "$@"

    local ret=0
    # Close the logging fds
    "${BFS[@]}" "$@" 3>&- 4>&- || ret=$?

    # Allow bfs to fail, but not crash
    if ((ret > 125)); then
        exit $ret
    else
        return $ret
    fi
}

if command -v unbuffer &>/dev/null; then
    UNBUFFER=unbuffer
elif command -v expect_unbuffer &>/dev/null; then
    UNBUFFER=expect_unbuffer
fi

# Run bfs with a pseudo-terminal attached
bfs_pty() {
    test -n "${UNBUFFER:-}" || skip

    bfs_verbose "$@"

    local ret=0
    "$UNBUFFER" bash -c 'stty cols 80 rows 24 && "$@"' bash "${BFS[@]}" "$@" || ret=$?

    if ((ret > 125)); then
        exit $ret
    else
        return $ret
    fi
}

# Create a directory tree with xattrs in scratch
make_xattrs() {
    cd "$TEST"

    "$XTOUCH" normal xattr xattr_2
    ln -s xattr link
    ln -s normal xattr_link

    case "$UNAME" in
        Darwin)
            xattr -w bfs_test true xattr \
                && xattr -w bfs_test_2 true xattr_2 \
                && xattr -s -w bfs_test true xattr_link
            ;;
        FreeBSD)
            setextattr user bfs_test true xattr \
                && setextattr user bfs_test_2 true xattr_2 \
                && setextattr -h user bfs_test true xattr_link
            ;;
        *)
            # Linux tmpfs doesn't support the user.* namespace, so we use the security.*
            # namespace, which is writable by root and readable by others
            bfs_sudo setfattr -n security.bfs_test xattr \
                && bfs_sudo setfattr -n security.bfs_test_2 xattr_2 \
                && bfs_sudo setfattr -h -n security.bfs_test xattr_link
            ;;
    esac
}

## Snapshot testing

# Return value when a difference is detected
EX_DIFF=20

# Detect colored diff support
if diff --color /dev/null /dev/null 2>/dev/null; then
    DIFF="diff --color"
else
    DIFF="diff"
fi

# Sort the output file
sort_output() {
    sort -o "$OUT" "$OUT"
}

# Diff against the expected output
diff_output() {
    local GOLD="$TESTS/$TEST.out"

    if ((UPDATE)); then
        cp "$OUT" "$GOLD"
    else
        $DIFF -u "$GOLD" "$OUT" >&4
    fi
}

# Run bfs, and diff it against the expected output
bfs_diff() {
    local ret=0
    invoke_bfs "$@" >"$OUT" || ret=$?

    sort_output
    diff_output || exit $EX_DIFF

    return $ret
}