Bash getopts with Positional Validation

Parse short options with getopts, distinguish missing arguments from unknown flags, then validate exactly two positional paths.

BashCli Tooling

Code

snippet
#!/usr/bin/env bash
set -euo pipefail

usage() {
    cat <<'EOF'
Usage: publish.sh [-n] [-v] [-o DIR] <source-dir> <destination-dir>

  -n  dry run: print actions without copying
  -v  verbose: list every copied file
  -o  write a summary report into DIR (default: .)
  -h  show this help
EOF
}

dry_run=0
verbose=0
report_dir="."

while getopts ":nvo:h" opt; do
    case "$opt" in
        n) dry_run=1 ;;
        v) verbose=1 ;;
        o) report_dir="$OPTARG" ;;
        h) usage; exit 0 ;;
        :)
            printf 'option -%s requires an argument\n' "$OPTARG" >&2
            usage >&2
            exit 2
            ;;
        \?)
            printf 'unknown option -%s\n' "$OPTARG" >&2
            usage >&2
            exit 2
            ;;
    esac
done
shift "$((OPTIND - 1))"

if (( $# != 2 )); then
    printf 'expected 2 positional arguments, got %d\n' "$#" >&2
    usage >&2
    exit 2
fi

source_dir=$1
destination_dir=$2

if [[ ! -d "$source_dir" ]]; then
    printf 'source directory does not exist: %s\n' "$source_dir" >&2
    exit 1
fi

if [[ -z "$report_dir" ]]; then
    printf 'report directory must not be empty\n' >&2
    exit 2
fi

if (( dry_run )); then
    printf 'would copy %s -> %s\n' "$source_dir" "$destination_dir"
    printf 'would write report to %s\n' "$report_dir"
    exit 0
fi

mkdir -p -- "$destination_dir" "$report_dir"

copied=0
while IFS= read -r -d '' file; do
    relative=${file#"$source_dir"/}
    mkdir -p -- "$destination_dir/$(dirname -- "$relative")"
    cp -- "$file" "$destination_dir/$relative"
    copied=$((copied + 1))
    if (( verbose )); then
        printf 'copied %s\n' "$relative"
    fi
done < <(find "$source_dir" -type f -print0)

printf 'copied %d files to %s\n' "$copied" "$destination_dir" |
    tee -- "$report_dir/publish-report.txt"