Blog Logo

30-Sep-2024 ~ 3 min read

6 techniques to create a great user experience for shell scripts


This post is inspired by Nochlin’s blog post on creating a great user experience for shell scripts.

Here are 6 techniques that can be used to make shell scripts robust, safe and fun:

1. Comprehensive Error Handling and Input Validation

For clear error messages and good user experience:

if [ -z "$1" ]
    then
        echo "Usage: evaluate.sh <fork name> (<fork name 2> ...)"
        echo " for each fork, there must be a 'calculate*average*<fork name>.sh' script and an optional 'prepare\_<fork name>.sh'."
        exit 1
fi

This approach helps users quickly identify and resolve issues, saving them time and frustration.

2. Clear and Colorful Output

BOLD*RED='\033[1;31m'
RESET='\033[0m'
echo -e "${BOLD_RED}ERROR${RESET}: ./calculate_average*$fork.sh does not exist." >&2

This visual distinction helps users quickly grasp the nature of each message.

3. Detailed Progress Reporting

function print_and_execute() {
    echo "+ $@" >&2
    "$@"
}

This matches the output format of Bash’s builtin set -x tracing, but gives the script author more granular control of what is printed.

This level of transparency not only keeps users informed but also aids in debugging if something goes wrong.

4. Strategic Error Handling with “set -e” and “set +e”

# At the beginning of the script

set -eo pipefail

# Before running tests and benchmarks for each fork

for fork in "$@"; do
    set +e # we don't want prepare.sh, test.sh or hyperfine failing on 1 fork to exit the script early

    # Run prepare script (simplified)

    print*and_execute source "./prepare*$fork.sh"

    # Run the test suite (simplified)

    print_and_execute $TIMEOUT ./test.sh $fork

    # ... (other fork-specific operations)

done
set -e # Re-enable exit on error after the fork-specific operations

This approach gives the script author fine-grained control over which errors cause the script to exit and which can be handled in other ways.

5. Platform-Specific Adaptations

if [ "$(uname -s)" == "Linux" ]; then
    TIMEOUT="timeout -v $RUN_TIME_LIMIT"
else # Assume MacOS
    if [ -x "$(command -v gtimeout)" ]; then
        TIMEOUT="gtimeout -v $RUN_TIME_LIMIT"
    else
        echo -e "${BOLD_YELLOW}WARNING${RESET} gtimeout not available, install with `brew install coreutils` or benchmark runs may take indefinitely long."
    fi
fi

This ensures a consistent experience across different environments.

6. Timestamped File Outputs for Multiple Runs

This allows users to run the script multiple times and keep a historical record of all results. Here’s how I did it:

filetimestamp=$(date +"%Y%m%d%H%M%S")

# ... (in the loop for each fork)

HYPERFINE_OPTS="--warmup 0 --runs $RUNS --export-json $fork-$filetimestamp-timing.json --output ./$fork-$filetimestamp.out"

# ... (after the benchmarks)

echo "Raw results saved to file(s):"
for fork in "$@"; do
    if [ -f "$fork-$filetimestamp-timing.json" ]; then
        cat $fork-$filetimestamp-timing.json >> $fork-$filetimestamp.out
        rm $fork-$filetimestamp-timing.json
    fi

    if [ -f "$fork-$filetimestamp.out" ]; then
        echo " $fork-$filetimestamp.out"
    fi
done