# -*- mode: sh; sh-basic-offset: 2; indent-tabs-mode: nil; -*-
# vim:set ft=sh et sw=2 ts=2:
#
# shtest v1.5.1 - Shell script testing functions
# Author: Scott Shambarger <devel@shambarger.net>
#
# Copyright (C) 2021 Scott Shambarger
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# === Public Function Reference ===
#
#   shtest::verbose [ <level> ]
#
#     Displays test descriptions even when test passes.
#
#   shtest::quiet [ 0 ]
#
#     Display only summary report.  Passing 0 resets to normal logging.
#
#   shtest::log <msg>
#
#     Writes <msg> to shtest message stream (may be muted).
#
#   shtest::add_focus <id/pattern>
#
#     Show only tests matching <id> or <pattern> (*/? wildcards).
#
#   shtest::add_onexit [ <cmd> ]
#
#     Adds function <cmd> that is called if script exists before
#     shtest::cleanup/reset is called.  Without <cmd>, echos
#     list of onexit functions.  <cmd> arg is exit code.
#
#   shtest::remove_onexit <cmd>
#
#     Removes function <cmd> previously added with add_onexit
#
#   shtest::trace [ "on"(default) | "off" ]
#
#     Enables backtrace on errors.
#
#   shtest::strict [ "on"(default) | "trace" | "notrace" | "off" ]
#
#     Enables 'set -eEu' bash options, any failures a captured and fail
#     the next shtest::check_*.  "trace" enables backtrace on failure,
#     "notrace" enables without backtrace, "on"/"off" don't modify trace.
#
#   shtest::global_whitelist <pattern>...
#
#     Strict mode ignores failure of any function matching <pattern>
#     Useful to allow functions in <pattern> to call functions that
#     fail without triggering strict-mode failures.
#
#   shtest::whitelist <pattern>...
#
#     As global_whitelist, but only until next shtest::check_* called.
#
#   shtest::parse <file>
#
#     Runs a bash parse test of <file>, displays errors to stderr,
#     returns !0 if failure.
#
#   shtest::cleanup
#
#     Removes all global environment variables and traps set by shtest
#     Call before exit to avoid warnings on unexpected exit.
#
#   shtest::fatal [ <msg> [ <backtrace-skip> ] ]
#
#     Exits 1 with text <msg>, optionally printing backtrace (skipping
#     <backtrace-skip> calls if > 0).
#
#   shtest::alert [ <msg> ]
#
#     Logs <msg> ignoring mute (to stderr after cleanup).
#
#   shtest::title <title>
#
#     Prints a new section header with <title> (resets <prefix>)
#
#   shtest::prefix [ <prefix> ]
#
#     Adds <prefix> before all test report <descrition>s (resets if empty).
#
#   shtest::check_result <id> <ref> [ <description> ]
#
#     Checks $? (most recent function return) against <ref>, which can be:
#       "t" - $? == 0
#       "f" - $? != 0
#       # - $? == #
#     Displayes "<id> OK", or "<id> FAIL | <desciption>" if failure,
#     including expected vs actual results.
#
#   shtest::check_value <id> <varname> [ <ref> [ <desc> ] ]
#
#     Checks value of variable named <varname> against <ref>, and
#     displays "<id> OK" or "<id> FAIL | <description>" including
#     expected vs actual value.
#
#   shtest::check_var <id> [ <ref> [ <desc> ] ]
#
#     Shortcut for shtest::check_value <id> "var"...
#
#   shtest::check_array <id> <varname> <refname> [ <desc> ]
#
#     Checks value of array elements in array named <aname> with those in
#     array named <refname>, displays "<id> OK" or
#     "<id> FAIL | <descr>" including expected vs actual value.
#
#   shtest::check_file <id> <filename> <contents> [ <desc> ]
#
#     Checks contents of <filename> against <contents>, displays
#     "<id> OK" or "<id> FAIL | <desc>" including expected vs actual
#     contents.
#
#   shtest::reg_file [ <filename> ]
#
#     Register <filename> for shtest::check_reg_files tests.
#     Without <filename>, echos list of registered filenames.
#
#   shtest::dereg_file <filename>
#
#     Remove <filename> from shtest::check_reg_files tests.
#
#   shtest::check_reg_files <id> <desc> [ <file#-contents>... ]
#
#     Checks contents of files registered with shtest::reg_file
#     in order against <file#-contents>, displays "<id> OK" or
#     <id> FAIL | <desc>" including expected vs actual for first
#     file with mis-matched content.
#
#   shtest::last_check_ok
#
#     Returns boolean result of last shtest::check_*
#
#   shtest::strict_failed
#
#     Returns boolean if strict mode failed since last shtest::check_*
#
#   shtest::reset_state
#
#     Clear any strict/check state, non-global whitelist.
#
#   shtest::summary_report [ <title> ]
#
#     Prints count of tests, with any fail counts.  <title> replaces
#     default report title.
#
#   shtest::reset
#
#     Re-initializes shtest for a new run, retains log/trace settings.
#
#   shtest::save_env <varname>
#
#     Store current shell variable names in <varname>.
#
#   shtest::check_env <id> <varname> [ <desc> ]
#
#     Checks shell variables against those stored in <varname>, and
#     displays "<id> OK" or "<id> FAIL | <description>" including
#     a list of new variables added to the environment.
#
# shellcheck shell=bash

shtest::_rm() {
  local f
  for f in "$@"; do [[ -f $f ]] && command -p rm -f "$f"; done
  return 0
}

shtest::_disable_onexit() {
  # check if we own the exit trap
  if [[ $(trap -p EXIT) =~ trap\ --\ \'shtest::_handle_exit ]]; then
    # restore the previous trap (non-subshell), or clear it
    if (( BASH_SUBSHELL == 0 )) &&
         [[ ${_SHTEST_STATE[4]-} =~ trap\ --\ \' ]]; then
      eval "${_SHTEST_STATE[4]}"
    else
      trap - EXIT
    fi
  fi
  _SHTEST_STATE[4]=''
  return 0
}

shtest::_enable_onexit() {
  # only save prev trap on main shell; in subshells: empty on bash3, and not
  # used on bash4+ if not set in that subshell.
  if (( BASH_SUBSHELL == 0 )); then
    local ptrap
    ptrap=$(trap -p EXIT)
    [[ ${ptrap} && ! ${ptrap} =~ trap\ --\ \'shtest::_handle_exit ]] &&
      _SHTEST_STATE[4]=${ptrap}
  else
    _SHTEST_STATE[4]=''
  fi
  # save onexit level
  [[ ${_SHTEST_STATE[12]} != "${BASH_SUBSHELL}" ]] && {
    # clear onexit list (new subshell)
    _SHTEST_ONEXIT=()
    _SHTEST_STATE[12]=${BASH_SUBSHELL}
    # only error on exit if reset called
    _SHTEST_STATE[9]=''
  }
  trap 'shtest::_handle_exit $?' EXIT
}

shtest::_disable_strict() {
  # remove strict tracking file
  if [[ ${_SHTEST_STATE[2]-} ]]; then
     shtest::_rm "${_SHTEST_STATE[2]}"
     # unset strict mode
     set +eu
  fi
  # clear strict
  _SHTEST_STATE[2]=''
  trap - ERR
  return 0
}

shtest::_enable_strict() {
  # only enable once
  [[ ${_SHTEST_STATE[2]} ]] ||
    _SHTEST_STATE[2]=$(command -p mktemp -u -t "shtest-strict-XXXXXX" || :)
  [[ ${_SHTEST_STATE[2]} ]] ||
    shtest::fatal "shtest::strict: Unable to setup tmpfile"
  shtest::_enable_onexit
  trap shtest::_handle_err ERR
  set -eEu
}

shtest::_cleanup() { # [ <1=full> ]
  # any cleanup?
  [[ ${_SHTEST_STATE[0]-} ]] || return 0
  shtest::_disable_strict
  # close our log if "full" cleanup
  [[ ${1-} == 1 ]] && {
    shtest::_disable_onexit
    exec 88>&-
  }
  unset _SHTEST_STATE _SHTEST_TESTLIST _SHTEST_REG_FILES _SHTEST_FOCUS
  unset _SHTEST_FAILED _SHTEST_ONEXIT
  unset _SHTEST_WHITELIST _SHTEST_GLOBAL_WHITELIST
}

shtest::cleanup() {
  shtest::_cleanup 1
}

# initialize all globals
shtest::_init_globals() {

  local i
  # internal state
  # 0 - COUNT, 1 - FAILED, 2 - STRICT, 3 - VERBOSE, 4 - NEXT_EXITTRAP,
  # 5 - PREFIX, 6 - TRACE, 7 - LAST_CHECK, 8 - MUTE, 9 - TOP_SUBSHELL
  # 10 - LOGFD, 11 - MULTI_TEST, 12 - ONEXIT_SUBSHELL, 13-DIFF
  _SHTEST_STATE=(0 0)
  for (( i=2; i<=13; i++ )); do _SHTEST_STATE[${i}]=''; done
  _SHTEST_REG_FILES=() _SHTEST_FOCUS=() _SHTEST_FAILED=()
  _SHTEST_ONEXIT=() _SHTEST_TESTLIST=()

  # strict mode lists
  _SHTEST_WHITELIST=() _SHTEST_GLOBAL_WHITELIST=()

  # save top-subshell for exit/strict checks
  _SHTEST_STATE[9]=${BASH_SUBSHELL}
  # save onexit subshell
  _SHTEST_STATE[12]=${BASH_SUBSHELL}
  local PATH=/bin:/usr/bin:/usr/local/bin
  _SHTEST_STATE[13]=$(command -p -v diff)
}

# returns 1 if strict mode failed
shtest::_reset_state() {
  _SHTEST_STATE[7]='' _SHTEST_WHITELIST=()
  # if strict handled err, re-enable
  if [[ ${_SHTEST_STATE[2]} ]]; then
    [[ $- =~ e ]] || set -eEu
    [[ -f ${_SHTEST_STATE[2]} ]] && {
      shtest::_rm "${_SHTEST_STATE[2]}"; return 1; }
  fi
  return 0
}

shtest::reset_state() {
  shtest::_reset_state || :
}

shtest::_log() { # <msg>
  # focus mutes _log too
  [[ ${_SHTEST_STATE[8]} == 1 ]] && return 0
  printf >&"${_SHTEST_STATE[10]:-88}" "%s\n" "${1-}"
}

shtest::_checkinit() { # [ <backtrace-level>(1) ]
  local lvl=${1:-1}
  [[ ${_SHTEST_STATE[0]+set} == set ]] && {
    [[ ${_SHTEST_STATE[0]} ]] && return 0
    # lvl == 0 allows _SHTEST_STATE[0]=='' (shtest::fatal etc)
    [[ ${lvl} == 0 ]] && return 0
  }
  (( lvl > 0 )) || lvl=1
  shtest::fatal "${FUNCNAME[${lvl}]}: You must call shtest::reset after shtest::cleanup" $((lvl+1))
  exit 1
}

shtest::log() { # <msg>
  shtest::_checkinit 0
  [[ ${_SHTEST_STATE[8]} ]] && return 0
  shtest::_log "${1-}"
}

shtest::_fmtlog() { # <format> <args>
  local msg
  [[ ${1-} ]] || return 0
  # shellcheck disable=SC2059
  printf -v msg "$@" ||
    shtest::fatal "shtest::_fmtlog: bad format: $1" 1
  shtest::log "${msg}"
}

# assumes lowest two stack frames can be skipping in backtrace
shtest::_backtrace() { # <skip>
  local i depth=${#FUNCNAME[@]} func lno src skip
  printf 2>/dev/null -v skip "%d" "${1-0}" || skip=0
  [[ ${skip} -lt 0 ]] && skip=0
  shtest::log "Backtrace:"
  for (( i=skip+2; i < depth; i++ )); do
    func=${FUNCNAME[${i}]}
    lno=${BASH_LINENO[(( i - 1 ))]}
    src=${BASH_SOURCE[${i}]}
    shtest::_fmtlog "      %s %s %s" "${func}()" "${src-(no file)}" "${lno}"
  done
}

shtest::alert() { # [ <msg> ]
  if [[ ${_SHTEST_STATE[0]+set} != set ]]; then
    # enable temp stderr log
    local _SHTEST_STATE=(''); _SHTEST_STATE[10]=2
    shtest::log "${1-}"
  else
    # temporarily unmute
    local mute=${_SHTEST_STATE[8]}; _SHTEST_STATE[8]=''
    shtest::log "${1-}"
    _SHTEST_STATE[8]=${mute}
  fi
  return 0
}

shtest::fatal() { # <msg> [ <backtrace-skip> ]
  # just in case...
  [[ ${_SHTEST_FATAL-} ]] &&
    { printf >&2 '%s\n' "shtest::fatal: called itself!"; exit 1; }
  _SHTEST_FATAL=1
  [[ ${_SHTEST_STATE[0]+set} != set ]] && {
    # enable log
    _SHTEST_STATE[0]='' _SHTEST_STATE[10]=2
  }
  _SHTEST_STATE[8]='' # unmute
  if [[ ${1-} ]]; then
    shtest::log "FATAL: $1"
  else
    shtest::log "Fatal error, quitting..."
  fi
  [[ ${2-} ]] && shtest::_backtrace "$2"
  shtest::cleanup
  exit 1
}

shtest::log_setfd() { # <fd>/0
  shtest::_checkinit
  [[ ${1-} ]] || { printf '%s' "${_SHTEST_STATE[10]:-88}"; return 0; }
  [[ $1 == 0 ]] && { _SHTEST_STATE[10]=''; return 0; }
  local -i fd
  printf 2>/dev/null -v fd '%d' "${1-}" || fd=0
  [[ ${fd} -lt 1 ]] && shtest::fatal "shtest::log_setfd: bad fd '$1'" 1
  _SHTEST_STATE[10]=${fd}
}

shtest::_log_ok() { # <id> <description>
  local desc=''
  _SHTEST_STATE[7]=1
  [[ ${_SHTEST_STATE[3]} ]] && desc="${2:+   | ${_SHTEST_STATE[5]}}${2:-}"
  shtest::_fmtlog " %-4s OK%s" "$1" "${desc}"
  [[ ${#_SHTEST_FOCUS[*]} != 0 ]] && _SHTEST_STATE[8]=1
  return 0
}

shtest::last_check_ok() {
  shtest::_checkinit
  [[ ${_SHTEST_STATE[7]} ]]
}

shtest::strict_failed() {
  shtest::_checkinit
  [[ ${_SHTEST_STATE[2]} && -f ${_SHTEST_STATE[2]} ]] && return 0
  return 1
}

shtest::_log_fail() {
  # <id> <description> <reason>
  # <id> <description> <expect> <found> [ <no-quote> ]
  local id=${1-} desc="${2:+ | ${_SHTEST_STATE[5]}}${2:-}"
  shtest::_fmtlog " %-4s FAIL%s" "${id}" "${desc}"
  if [[ ${3+set} && ${4+set} ]]; then
    if [[ ( $3 =~ $'\n' || $4 =~ $'\n' ) && ${_SHTEST_STATE[13]} ]]; then
      shtest::log "diff: <expected> <found>"
      shtest::log "$("${_SHTEST_STATE[13]}" <(echo "$3") <(echo "$4") || :)"
    elif [[ ${5-} ]]; then
      shtest::log "    expected: ${3-}"
      shtest::log "       found: ${4-}"
    else
      shtest::log "    expected: '${3-}'"
      shtest::log "       found: '${4-}'"
    fi
  elif [[ ${3+set} ]]; then
    shtest::log "      reason: ${3-}"
  fi
  ((_SHTEST_STATE[1]++)) || :
  [[ ${_SHTEST_STATE[11]} ]] || {
    _SHTEST_FAILED+=("${id}")
    [[ ${#_SHTEST_FOCUS[*]} != 0 ]] && _SHTEST_STATE[8]=1
  }
  return 0
}

shtest::title() { # <title>
  shtest::_checkinit
  _SHTEST_STATE[5]=''
  shtest::_reset_state || :
  [[ ${1-} ]] && shtest::log $'\n'"===== $1 ====="$'\n'
}

shtest::prefix() { # <description-prefix>
  shtest::_checkinit
  _SHTEST_STATE[5]="${1-}"
}

shtest::_check_entry() { # <id> [ <desc> ]
  local id=$1 desc=${2-} IFS; unset IFS
  shtest::_checkinit 2
  ((_SHTEST_STATE[0]++)) || :
  [[ ${_SHTEST_TESTLIST[*]-} =~ (^| )"${id}"($| ) ]] &&
    shtest::fatal "${FUNCNAME[1]}: Duplicate test <id> '${id}'" 2
  _SHTEST_TESTLIST+=("${id}")
  [[ ${#_SHTEST_FOCUS[*]} != 0 ]] && {
    IFS='|'
    [[ ${id} =~ ^${_SHTEST_FOCUS[*]}$ ]] && _SHTEST_STATE[8]=''
  }
  shtest::_reset_state && return 0
  shtest::_log_fail "${id}" "${desc}" "strict mode failure"
  return 1
}

shtest::check_result() { # <id> <ref> [ <desc> ] - OK if <ref> == $?, else FAIL
  # <ref> can be "t": $? == 0, "f": $? != 0, #: $? == #
  local rc=$? id=${1-} ref=${2-} desc=${3-}
  local orc=${rc}
  [[ ${id} && ${ref} ]] ||
    shtest::fatal "Usage: shtest::check_result <id> <t | f | #> [ <desc> ]" 1

  shtest::_check_entry "${id}" "${desc}" || return 0

  if [[ "${ref}" == "t" || "${ref}" == "f" ]]; then
    [[ ${rc} == 0 ]] && rc="t" || rc="f"
  fi
  if [[ ${ref} == "${rc}" ]]; then
    shtest::_log_ok "${id}" "${desc}"
  elif [[ ${ref} == t ]]; then
    shtest::_log_fail "${id}" "${desc}" "<true>" "<false>(${orc})" 1
  elif [[ ${ref} == f ]]; then
    shtest::_log_fail "${id}" "${desc}" "<false>" "<true>" 1
  else
    shtest::_log_fail "${id}" "${desc}" "${ref}" "${rc}" 1
  fi
  return 0
}

shtest::check_value() { # <id> <varname> [ <ref> [ <desc> ] ]
  local id=${1-} varname=${2-} ref=${3-} desc=${4-}
  [[ ${varname} && ${id} ]] || {
    if [[ ${varname} == var ]]; then
      shtest::fatal "Usage: shtest::check_var <id> [ <value> [ <desc> ] ]" 2
    else
      shtest::fatal "Usage: shtest::check_value <id> <varname> [ <value> [ <desc> ] ]" 1
    fi
  }

  shtest::_check_entry "${id}" "${desc}" || return 0

  if ! declare &>/dev/null -p "${varname}" || [[ -z ${!varname+x} ]]; then
    shtest::_log_fail "${id}" "${desc}" "<varname> '${varname}' is unset"
  elif [[ ${ref} == "${!varname}" ]]; then
    shtest::_log_ok "${id}" "${desc}"
  else
    shtest::_log_fail "${id}" "${desc}" "${ref}" "${!varname}"
  fi
  return 0
}

shtest::check_var() { # <id> [ <ref> [ <desc> ] ]
  local id=${1-}
  shift || set --
  shtest::check_value "${id}" var "$@"
}

shtest::check_array() { # <id> <varname> <refname> [ <desc> ]
  local _i _j _vn=() _rn=() _va=() _ra=() IFS=$'\n'
  if ! [[ ${1-} && ${2-} && ${3-} ]]; then
    shtest::fatal "Usage: shtest::check_array <id> <varname> <refname> [ <desc> ]" 1
  fi
  shtest::_check_entry "$1" "${4-}" || return 0

  # fetch array indexes
  declare &>/dev/null -p "$2" && eval "(( \${#$2[*]} > 0 ))" &&
    eval "for _i in \${$2[*]+\"\${!$2[@]}\"}; do _vn+=(\"\${_i}\"); done"
  declare &>/dev/null -p "$3" && eval "(( \${#$3[*]} > 0 ))" &&
    eval "for _i in \${$3[*]+\"\${!$3[@]}\"}; do _rn+=(\"\${_i}\"); done"

  for _i in ${_vn[*]+"${_vn[@]}"}; do
    _j=${_i//$'\n'/\$\'\\n\'}
    eval "_va+=(\"[\${_j}]='\${$2[\"\${_i}\"]}'\")"
  done
  for _i in ${_rn[*]+"${_rn[@]}"}; do
    # shellcheck disable=SC2034
    _j=${_i//$'\n'/\$\'\\n\'}
    eval "_ra+=(\"[\${_j}]='\${$3[\"\${_i}\"]}'\")"
  done
  # quote lhs as _va values may have newlines (bash3 breaks)
  [[ "${_va[*]-}" != "${_ra[*]-}" ]] && {
    shtest::_log_fail "$1" "${4-}" "${_ra[*]-}" "${_va[*]-}"
    return 0
  }
  shtest::_log_ok "$1" "${4-}"
  return 0
}

shtest::_check_file() { # <id> <filename> <contents> <desc>, sets $rc
  local id=$1 file=$2 ref=$3 desc=$4 out IFS=''
  rc=1 # failure unless we match content
  if ! [[ -f ${file} ]]; then
    shtest::_log_fail "${id}" "${desc}" "File \"${file}\" not found"
  elif ! [[ -r "${file}" ]]; then
    shtest::_log_fail "${id}" "${desc}" "File \"${file}\" unreadable"
  else
    unset out
    { read -r -d '' out || :; } 2>/dev/null < "${file}"
    if [[ ${out+set} == set ]]; then
      out=${out%$'\n'} # strip trailing newline
      if [[ ${out} == "${ref}" ]]; then
        rc=0
      else
        shtest::_log_fail "${id}" "${desc}" "${ref}" "${out}"
      fi
    else
      out=$(: 2>&1 >/dev/null < "${file}" || :)
      [[ ${out} ]] || out="Failed to read \"${file}\""
      shtest::_log_fail "${id}" "${desc}" "${out}"
    fi
  fi
  return 0
}

shtest::check_file() { # <id> <filename> <contents> [ <desc> ]
  local id=${1-} file=${2-} ref=${3-} desc=${4-} rc
  [[ ${id} && ${file} && ${ref+set} ]] || \
    shtest::fatal "Usage: shtest::check_file <id> <filename> <contents> [ <desc> ]" 1
  shtest::_check_entry "${id}" "${desc}" || return 0
  shtest::_check_file  "${id}" "${file}" "${ref}" "${desc}"
  [[ ${rc} == 0 ]] && shtest::_log_ok "${id}" "${desc}"
  return 0
}

shtest::reg_file() { # [ <filename> ]
  shtest::_checkinit
  [[ ${1-} ]] || {
    local file
    for file in "${_SHTEST_REG_FILES[@]-}"; do
      [[ ${file} ]] && printf '%s\n' "${file}"
    done
    return 0
  }
  _SHTEST_REG_FILES+=("$1")
}

shtest::dereg_file() { # <filename>
  local i
  shtest::_checkinit
  [[ ${1-} ]] || return 0
  for i in ${!_SHTEST_REG_FILES[*]}; do
    [[ ${_SHTEST_REG_FILES[$i]} == "$1" ]] && unset "_SHTEST_REG_FILES[$i]"
  done
}

shtest::check_reg_files() { # <id> <desc> [ <file#-contents>... ]
  local id=${1-} desc=${2-} file rc fails num
  [[ ${id} ]] ||
    shtest::fatal "Usage: shtest::check_reg_files <id> <desc> [ <file#-contents>... ]" 1
  shtest::_check_entry "${id}" "${desc}" || return 0
  shift 2 || set --
  fails=${_SHTEST_STATE[1]} num=0
  _SHTEST_STATE[11]=1 # multi-test state
  for file in "${_SHTEST_REG_FILES[@]-}"; do
    ((num++)) || :
    [[ ${file} ]] || continue
    shtest::_check_file "${id}" "${file}" "${1-}" "${desc} (reg ${num})"
    shift || set --
  done
  _SHTEST_STATE[11]='' # reset multi-test
  [[ ${fails} != "${_SHTEST_STATE[1]}" ]] && {
    # multiple file failures is only a single test failure
    _SHTEST_STATE[1]=$((++fails))
    _SHTEST_FAILED+=("${id}")
    [[ ${#_SHTEST_FOCUS[*]} != 0 ]] && _SHTEST_STATE[8]=1
    return 0
  }
  shtest::_log_ok "${id}" "${desc}"
}

shtest::_toreg() { # <retvar> <name/pattern>
  local pat=${2-}
  [[ ${1-} && ${pat} ]] || return 1
  # convert to regex
  pat=${pat//[*]/.*}; pat=${pat//[?]/.}
  printf -v "$1" "%s" "${pat}"
}

shtest::add_focus() { # <id/pattern>
  local p IFS; unset IFS
  shtest::_checkinit
  shtest::_toreg p "${1-}" || return
  [[ ${_SHTEST_FOCUS[*]-} =~ (^| )"$p"($| ) ]] && return 0
  _SHTEST_FOCUS+=("$p")
  # mutes log/_log except during test
  _SHTEST_STATE[8]=1
  return 0
}

shtest::add_onexit() { # [ <cmd> ]
  local IFS cmd; unset IFS
  shtest::_checkinit
  [[ ${1-} ]] || {
    for cmd in "${_SHTEST_ONEXIT[@]-}"; do
      [[ ${cmd} ]] && printf '%s\n' "${cmd}"
    done
    return 0
  }
  shtest::_enable_onexit
  [[ ${_SHTEST_ONEXIT[*]-} =~ (^| )"$1"($| ) ]] && return 0
  _SHTEST_ONEXIT+=("$1")
  return 0
}

shtest::remove_onexit() { # <cmd>
  local i
  shtest::_checkinit
  [[ ${1-} ]] || return 0
  for i in ${!_SHTEST_ONEXIT[*]}; do
    [[ ${_SHTEST_ONEXIT[$i]} == "$1" ]] && unset "_SHTEST_ONEXIT[$i]"
  done
  return 0
}

shtest::save_env() { # <varname>
  local IFS; unset IFS
  [[ ${1-} ]] || shtest::fatal "Usage: shteset::save_env <varname>" 1
  printf -v "$1" %s "" || shtest::fatal "shteset::save_env: invalid <varname> '$1'" 1
  { read -r -a "$1" -d '' || :; } <<< "$(compgen -v)"
  return 0
}

shtest::check_env() { # <id> <varname> [ <desc> ]
  local IFS; unset IFS
  [[ ${1-} && ${2-} ]] ||
    shtest::fatal "Usage: shtest::check_env <id> <varname> [ <desc> ]" 1

  shtest::_check_entry "$1" "${3-}" || return 0

  local _SHTEST_NAME _SHTEST_ENV="${2}[*]" _SHTEST_NEWVARS=()
  for _SHTEST_NAME in $(compgen -v); do
    [[ ${!_SHTEST_ENV} =~ (^| )"${_SHTEST_NAME}"($| ) ]] && continue
    [[ ${_SHTEST_NAME} =~ ^_SHTEST_(NEWVARS|NAME|ENV)$ ]] && continue
    [[ ${_SHTEST_NAME} =~ ^BASH_.*|LINES|COLUMNS$ ]] && continue
    _SHTEST_NEWVARS+=("${_SHTEST_NAME}")
  done
  if [[ ${#_SHTEST_NEWVARS[*]} == 0 ]]; then
    shtest::_log_ok "$1" "${3-}"
  else
    shtest::_log_fail "$1" "${3-}" "New variables: ${_SHTEST_NEWVARS[*]}"
  fi
  return 0
}

shtest::parse() { # <file>
  local file=${1-}
  shtest::_checkinit
  [[ ${file} ]] || {
    shtest::log "Usage: shtest::parse <file>"; return 1; }
  [[ -f ${file} ]] || {
    shtest::log "shtest::parse(${file}) not found"; return 1; }
  "${BASH}" -n "${file}" || {
    shtest::log "shtest::parse(${file}) failed"; return 1; }
}

shtest::summary_report() { # [ <title> ]
  local rc=0
  shtest::_checkinit
  shtest::_reset_state || :
  shtest::_log $'\n'"%%%%% ${1:-SUMMARY REPORT} %%%%%"$'\n'
  shtest::_log "     TESTS RUN: ${_SHTEST_STATE[0]}"
  shtest::_log "  TESTS PASSED: $(( _SHTEST_STATE[0] - _SHTEST_STATE[1] ))"
  if [[ ${_SHTEST_STATE[1]} != 0 ]]; then
    local id msg
    shtest::_log "  TESTS FAILED: ${_SHTEST_STATE[1]}"
    msg="    FAILED IDS:"
    for id in "${_SHTEST_FAILED[@]}"; do
      msg+=" ${id}"
    done
    shtest::_log "${msg}"
    rc=1
  fi
  shtest::_log ""
  return ${rc}
}

shtest::quiet() { # [ 0 ]
  shtest::_checkinit
  if [[ ${#_SHTEST_FOCUS[*]} != 0 ]]; then
    # focus mutes _log too (no summary)
   _SHTEST_STATE[8]=1
  elif [[ ${1-} == 0 ]]; then
    _SHTEST_STATE[8]=''
  else
    # mutes all except _log (summary only)
    _SHTEST_STATE[8]=2
  fi
  return 0
}

shtest::_handle_err() {
  # allow code to continue (leave unbound set)
  set +e
  local IFS='|'
  [[ ${#_SHTEST_WHITELIST[*]} != 0 &&
       ${FUNCNAME[1]} =~ ^${_SHTEST_WHITELIST[*]}$ ]] && return 0
  [[ ${#_SHTEST_GLOBAL_WHITELIST[*]} != 0 &&
       ${FUNCNAME[1]} =~ ^${_SHTEST_GLOBAL_WHITELIST[*]}$ ]] && return 0
  # may be in subshell, so touch file to communicate strict failure
  printf '%s' "" >> "${_SHTEST_STATE[2]}"
  # show backtrace if requested
  if [[ ${_SHTEST_STATE[6]} ]]; then
    shtest::log "Strict mode failure"
    shtest::_backtrace 0
  else
    shtest::log "${BASH_SOURCE[1]}: line ${BASH_LINENO[0]}: Strict mode failure (try trace for details)"
  fi
  return 0
}

shtest::_handle_exit() { # <$?>
  # still have state?
  [[ -z ${_SHTEST_STATE[0]-} ]] && return 0

  # don't do anything in a untrapped subshell (in case we're called by another)
  (( BASH_SUBSHELL > _SHTEST_STATE[12] )) && return 0
  _SHTEST_STATE[12]=0

  # check if we're strict, or in reset subshell
  if [[ ${_SHTEST_STATE[2]} ]] ||
       { [[ ${_SHTEST_STATE[9]} ]] &&
           (( BASH_SUBSHELL <= _SHTEST_STATE[9] )); } then
    shtest::_disable_strict
    _SHTEST_STATE[8]='' # unmute
    if [[ ${_SHTEST_STATE[6]} ]]; then
      shtest::log "Unexpected exit ${1:-0}"
      shtest::_backtrace 0
    else
      shtest::log "Unexpected exit ${1:-0} (try trace for details)"
    fi
  fi

  # call onexits (may be subshel of reset)
  local _shtest_cmd
  for _shtest_cmd in ${_SHTEST_ONEXIT[*]+"${_SHTEST_ONEXIT[@]}"}; do
    "${_shtest_cmd}" "${1:-0}"
  done

  # if not in reset subshell, stop
  (( BASH_SUBSHELL > _SHTEST_STATE[9] )) && return 0

  shtest::cleanup

  # call previous traps (if not subshell)
  (( BASH_SUBSHELL == 0 )) &&
    [[ ${_SHTEST_STATE[4]} =~ trap\ --\ \'([^\047]*)\'.* ]] && {
    eval "${BASH_REMATCH[1]//\$?/\$1}" || exit; }
  return 0
}

shtest::trace() { # [ "on" | "off" ]
  shtest::_checkinit
  case ${1-} in
    ''|on) _SHTEST_STATE[6]=1 ;;
    off) _SHTEST_STATE[6]='' ;;
    *) shtest::fatal "Usage: shtest::trace [ on | off ]" 1 ;;
  esac
  shtest::_enable_onexit
  return 0
}

shtest::strict() { # [ "on" | "trace" | "notrace" | "off" ]
  shtest::_checkinit
  case ${1-} in
    ''|on) ;;
    trace) _SHTEST_STATE[6]=1 ;;
    notrace) _SHTEST_STATE[6]='' ;;
    off) shtest::_disable_strict; return 0 ;;
    *) shtest::fatal "Usage: shtest::strict [ trace | notrace | off ]" 1 ;;
  esac
  shtest::_enable_strict
}

shtest::whitelist() { # <pattern>...
  local p IFS; unset IFS
  shtest::_checkinit
  for p in "$@"; do
    shtest::_toreg p "$p" || continue
    [[ ${_SHTEST_WHITELIST[*]-} =~ (^| )"$p"($| ) ]] && continue
    _SHTEST_WHITELIST+=("$p")
  done
}

shtest::global_whitelist() { # <pattern>...
  local p IFS; unset IFS
  shtest::_checkinit
  for p in "$@"; do
    shtest::_toreg p "$p" || continue
    [[ ${_SHTEST_GLOBAL_WHITELIST[*]-} =~ (^| )"$p"($| ) ]] && continue
    _SHTEST_GLOBAL_WHITELIST+=("$p")
  done
}

shtest::verbose() { # [ <level> ]
  local v=${1-1}
  shtest::_checkinit
  [[ ${v} == 0 ]] && v=''
  _SHTEST_STATE[3]=${v}
}

shtest::reset() {
  local i state=("${_SHTEST_STATE[@]-}")
  # remove focus mute
  [[ ${state[8]-} == 1 ]] && state[8]=''
  shtest::_cleanup
  shtest::_init_globals
  shtest::_enable_onexit
  if (( ${#state[*]} == 1 )); then
    # empty prev-state, init default log fd
    exec 88>&2
  else
    # restore logging
    for i in 3 6 8 10; do _SHTEST_STATE[$i]=${state[$i]-}; done
    # if toplevel, keep saved trap
    (( BASH_SUBSHELL == 0 )) && _SHTEST_STATE[4]=${state[4]}
  fi
  return 0
}

# initialize everything
shtest::reset
