#!/bin/sh
# clitest - Tester for Unix command lines
#
# Author:  Aurelio Jargas (http://aurelio.net)
# Created: 2013-07-24
# License: MIT
# GitHub:  https://github.com/aureliojargas/clitest
#
# POSIX shell script:
#   This script was coded to be compatible with POSIX shells.
#   Tested in Bash 3.2, dash 0.5.5.1, ksh 93u 2011-02-08.
#   Note: Can't set -o posix nor POSIXLY_CORRECT: test env must be intact.
#
# Exit codes:
#   0  All tests passed, or normal operation (--help, --list, ...)
#   1  One or more tests have failed
#   2  An error occurred (file not found, invalid range, ...)
#
# Test environment:
#   By default, the tests will run in the current working directory ($PWD).
#   You can change to another dir normally using 'cd' inside the test file.
#   All the tests are executed in the same shell, using eval. Test data
#   such as variables and working directory will persist between tests.
#
# Namespace:
#   All variables and functions in this script are prefixed by 'tt_' to
#   avoid clashing with test's variables, functions, aliases and commands.

tt_my_name="$(basename "$0")"
tt_my_version='HEAD'
tt_my_version_url="https://github.com/aureliojargas/clitest/tree/$tt_my_version"

# Customization (if needed, edit here or use the command line options)
tt_prefix=''
tt_prompt='$ '
tt_inline_prefix='#→ '    # Problem with Unicode? Use '#=> ' or '### '
tt_diff_options='-u'
tt_color_mode='auto'      # auto, always, never
tt_progress='test'        # test, number, dot, none
# End of customization

# --help message, keep it simple, short and informative
tt_my_help="\
Usage: $tt_my_name [options] <file ...>

Options:
  -1, --first                 Stop execution upon first failed test
  -l, --list                  List all the tests (no execution)
  -L, --list-run              List all the tests with OK/FAIL status
  -t, --test RANGE            Run specific tests, by number (1,2,4-7)
  -s, --skip RANGE            Skip specific tests, by number (1,2,4-7)
      --pre-flight COMMAND    Execute command before running the first test
      --post-flight COMMAND   Execute command after running the last test
  -q, --quiet                 Quiet operation, no output shown
  -V, --version               Show program version and exit

Customization options:
  -P, --progress TYPE         Set progress indicator: test, number, dot, none
      --color WHEN            Set when to use colors: auto, always, never
      --diff-options OPTIONS  Set diff command options (default: '$tt_diff_options')
      --inline-prefix PREFIX  Set inline output prefix (default: '$tt_inline_prefix')
      --prefix PREFIX         Set command line prefix (default: '$tt_prefix')
      --prompt STRING         Set prompt string (default: '$tt_prompt')"

# Temporary files (using files because <(...) is not portable)
tt_temp_dir="${TMPDIR:-/tmp}/clitest.$$"
tt_temp_file="$tt_temp_dir/temp.txt"
tt_test_ok_file="$tt_temp_dir/ok.txt"
tt_test_output_file="$tt_temp_dir/output.txt"

# Flags (0=off, 1=on), most can be altered by command line options
tt_debug=0
tt_use_colors=0
tt_stop_on_first_fail=0
tt_separator_line_shown=0

# The output mode values are mutually exclusive
tt_output_mode='normal'    # normal, quiet, list, list-run

# Globals (all variables are globals, for better portability)
tt_nr_files=0
tt_nr_total_tests=0
tt_nr_total_fails=0
tt_nr_total_skips=0
tt_nr_file_tests=0
tt_nr_file_fails=0
tt_nr_file_skips=0
tt_nr_file_ok=0
tt_files_stats=
tt_original_dir=$(pwd)
tt_pre_command=
tt_post_command=
tt_run_range=
tt_run_range_data=
tt_skip_range=
tt_skip_range_data=
tt_failed_range=
tt_test_file=
tt_input_line=
tt_line_number=0
tt_test_number=0
tt_test_line_number=0
tt_test_command=
tt_test_inline=
tt_test_mode=
tt_test_status=2
tt_test_output=
tt_test_exit_code=
tt_test_diff=
tt_test_ok_text=
tt_missing_nl=0

# Special handy chars
tt_tab='	'
tt_nl='
'

# Handle command line options
while test "${1#-}" != "$1"
do
	case "$1" in
		-1|--first      ) shift; tt_stop_on_first_fail=1 ;;
		-l|--list       ) shift; tt_output_mode='list' ;;
		-L|--list-run   ) shift; tt_output_mode='list-run' ;;
		-q|--quiet      ) shift; tt_output_mode='quiet' ;;
		-t|--test       ) shift; tt_run_range="$1"; shift ;;
		-s|--skip       ) shift; tt_skip_range="$1"; shift ;;
		--pre-flight    ) shift; tt_pre_command="$1"; shift ;;
		--post-flight   ) shift; tt_post_command="$1"; shift ;;
		--debug         ) shift; tt_debug=1 ;;
		-P|--progress   ) shift; tt_progress="$1"; tt_output_mode='normal'; shift ;;
		--color|--colour) shift; tt_color_mode="$1"; shift ;;
		--diff-options  ) shift; tt_diff_options="$1"; shift ;;
		--inline-prefix ) shift; tt_inline_prefix="$1"; shift ;;
		--prefix        ) shift; tt_prefix="$1"; shift ;;
		--prompt        ) shift; tt_prompt="$1"; shift ;;
		-h|--help)
			printf '%s\n' "$tt_my_help"
			exit 0
		;;
		-V|--version)
			printf '%s %s\n%s\n' $tt_my_name $tt_my_version $tt_my_version_url
			exit 0
		;;
		--) shift; break ;;
		*) break ;;
	esac
done

# Command line options consumed, now it's just the files
tt_nr_files=$#


### Utilities

tt_clean_up ()
{
	rm -rf "$tt_temp_dir"
}
tt_message ()
{
	test "$tt_output_mode" = 'quiet' && return 0
	test $tt_missing_nl -eq 1 && echo
	printf '%s\n' "$*"
	tt_separator_line_shown=0
	tt_missing_nl=0
}
tt_message_part ()  # no line break
{
	test "$tt_output_mode" = 'quiet' && return 0
	printf '%s' "$*"
	tt_separator_line_shown=0
	tt_missing_nl=1
}
tt_error ()
{
	test $tt_missing_nl -eq 1 && echo
	printf '%s\n' "$tt_my_name: Error: $1" >&2
	tt_clean_up
	exit 2
}
tt_debug ()  # $1=id, $2=contents
{
	test $tt_debug -ne 1 && return 0
	if test INPUT_LINE = "$1"
	then
		# Original input line is all blue
		printf "${tt_color_blue}[%10s: %s]${tt_color_off}\n" "$1" "$2"
	else
		# Highlight tabs and inline prefix
		printf "${tt_color_blue}[%10s:${tt_color_off} %s${tt_color_blue}]${tt_color_off}\n" "$1" "$2" |
			sed "/LINE_CMD:/ s/$tt_inline_prefix/${tt_color_red}&${tt_color_off}/g" |
			sed "s/$tt_tab/${tt_color_green}<tab>${tt_color_off}/g"
	fi
}
tt_separator_line ()
{
	printf "%${COLUMNS}s" ' ' | tr ' ' -
}
tt_list_test ()  # $1=normal|list|ok|fail
{
	# Show the test command in normal mode, --list and --list-run
	case "$1" in
		normal | list)
			# Normal line, no color, no stamp (--list)
			tt_message "#${tt_test_number}${tt_tab}${tt_test_command}"
		;;
		ok)
			# Green line or OK stamp (--list-run)
			if test $tt_use_colors -eq 1
			then
				tt_message "${tt_color_green}#${tt_test_number}${tt_tab}${tt_test_command}${tt_color_off}"
			else
				tt_message "#${tt_test_number}${tt_tab}OK${tt_tab}${tt_test_command}"
			fi
		;;
		fail)
			# Red line or FAIL stamp (--list-run)
			if test $tt_use_colors -eq 1
			then
				tt_message "${tt_color_red}#${tt_test_number}${tt_tab}${tt_test_command}${tt_color_off}"
			else
				tt_message "#${tt_test_number}${tt_tab}FAIL${tt_tab}${tt_test_command}"
			fi
		;;
	esac
}
tt_parse_range ()  # $1=range
{
	# Parse numeric ranges and output them in an expanded format
	#
	#     Supported formats             Expanded
	#     ------------------------------------------------------
	#     Single:  1                    :1:
	#     List:    1,3,4,7              :1:3:4:7:
	#     Range:   1-4                  :1:2:3:4:
	#     Mixed:   1,3,4-7,11,13-15     :1:3:4:5:6:7:11:13:14:15:
	#
	#     Reverse ranges and repeated/unordered numbers are ok.
	#     Later we will just grep for :number: in each test.

	case "$1" in
		# No range, nothing to do
		0 | '')
			return 0
		;;
		# Error: strange chars, not 0123456789,-
		*[!0-9,-]*)
			return 1
		;;
	esac

	# OK, all valid chars in range, let's parse them

	tt_part=
	tt_n1=
	tt_n2=
	tt_operation=
	tt_range_data=':'  # :1:2:4:7:

	# Loop each component: a number or a range
	for tt_part in $(echo "$1" | tr , ' ')
	do
		# If there's an hyphen, it's a range
		case "$tt_part" in
			*-*)
				# Error: Invalid range format, must be: number-number
				echo "$tt_part" | grep '^[0-9][0-9]*-[0-9][0-9]*$' > /dev/null || return 1

				tt_n1=${tt_part%-*}
				tt_n2=${tt_part#*-}

				tt_operation='+'
				test $tt_n1 -gt $tt_n2 && tt_operation='-'

				# Expand the range (1-4 => 1:2:3:4)
				tt_part=$tt_n1:
				while test $tt_n1 -ne $tt_n2
				do
					tt_n1=$(($tt_n1 $tt_operation 1))
					tt_part=$tt_part$tt_n1:
				done
				tt_part=${tt_part%:}
			;;
		esac

		# Append the number or expanded range to the holder
		test $tt_part != 0 && tt_range_data=$tt_range_data$tt_part:
	done

	test $tt_range_data != ':' && echo $tt_range_data
	return 0
}
tt_reset_test_data ()
{
	tt_test_command=
	tt_test_inline=
	tt_test_mode=
	tt_test_status=2
	tt_test_output=
	tt_test_diff=
	tt_test_ok_text=
}
tt_run_test ()
{
	tt_test_number=$(($tt_test_number + 1))
	tt_nr_total_tests=$(($tt_nr_total_tests + 1))
	tt_nr_file_tests=$(($tt_nr_file_tests + 1))

	# Run range on: skip this test if it's not listed in $tt_run_range_data
	if test -n "$tt_run_range_data" && test "$tt_run_range_data" = "${tt_run_range_data#*:$tt_test_number:}"
	then
		tt_nr_total_skips=$(($tt_nr_total_skips + 1))
		tt_nr_file_skips=$(($tt_nr_file_skips + 1))
		tt_reset_test_data
		return 0
	fi

	# Skip range on: skip this test if it's listed in $tt_skip_range_data
	# Note: --skip always wins over --test, regardless of order
	if test -n "$tt_skip_range_data" && test "$tt_skip_range_data" != "${tt_skip_range_data#*:$tt_test_number:}"
	then
		tt_nr_total_skips=$(($tt_nr_total_skips + 1))
		tt_nr_file_skips=$(($tt_nr_file_skips + 1))
		tt_reset_test_data
		return 0
	fi

	case "$tt_output_mode" in
		normal)
			# Normal mode: show progress indicator
			case "$tt_progress" in
				test)
					tt_list_test normal
				;;
				number)
					tt_message_part "$tt_test_number "
				;;
				none)
					:
				;;
				*)
					tt_message_part "$tt_progress"
				;;
			esac
		;;
		list)
			# List mode: just show the command and return (no execution)
			tt_list_test list
			tt_reset_test_data
			return 0
		;;
	esac

	#tt_debug EVAL "$tt_test_command"

	# Execute the test command, saving output (STDOUT and STDERR)
	eval "$tt_test_command" > "$tt_test_output_file" 2>&1
	tt_test_exit_code=$?

	#tt_debug OUTPUT "$(cat "$tt_test_output_file")"

	# The command output matches the expected output?
	case $tt_test_mode in
		output)
			printf %s "$tt_test_ok_text" > "$tt_test_ok_file"
			tt_test_diff=$(diff $tt_diff_options "$tt_test_ok_file" "$tt_test_output_file")
			tt_test_status=$?
		;;
		text)
			# Inline OK text represents a full line, with \n
			printf '%s\n' "$tt_test_inline" > "$tt_test_ok_file"
			tt_test_diff=$(diff $tt_diff_options "$tt_test_ok_file" "$tt_test_output_file")
			tt_test_status=$?
		;;
		eval)
			eval "$tt_test_inline" > "$tt_test_ok_file"
			tt_test_diff=$(diff $tt_diff_options "$tt_test_ok_file" "$tt_test_output_file")
			tt_test_status=$?
		;;
		lines)
			tt_test_output=$(sed -n '$=' "$tt_test_output_file")
			test -z "$tt_test_output" && tt_test_output=0
			test "$tt_test_output" -eq "$tt_test_inline"
			tt_test_status=$?
			tt_test_diff="Expected $tt_test_inline lines, got $tt_test_output."
		;;
		file)
			# If path is relative, make it relative to the test file path, not $PWD
			if test $tt_test_inline = ${tt_test_inline#/}
			then
				tt_test_inline="$(dirname "$tt_test_file")/$tt_test_inline"
			fi
			# Abort when ok file not found/readable
			if test ! -f "$tt_test_inline" || test ! -r "$tt_test_inline"
			then
				tt_error "cannot read inline output file '$tt_test_inline', from line $tt_line_number of $tt_test_file"
			fi

			tt_test_diff=$(diff $tt_diff_options "$tt_test_inline" "$tt_test_output_file")
			tt_test_status=$?
		;;
		egrep)
			egrep "$tt_test_inline" "$tt_test_output_file" > /dev/null
			tt_test_status=$?

			# Test failed: the regex not matched
			if test $tt_test_status -eq 1
			then
				tt_test_diff="egrep '$tt_test_inline' failed in:$tt_nl$(cat "$tt_test_output_file")"

			# Regex errors are common and user must take action to fix them
			elif test $tt_test_status -eq 2
			then
				tt_error "check your inline egrep regex at line $tt_line_number of $tt_test_file"
			fi
		;;
		perl | regex)
			# Escape regex delimiter (if any) inside the regex: ' => \'
			if test "$tt_test_inline" != "${tt_test_inline#*\'}"
			then
				tt_test_inline=$(printf %s "$tt_test_inline" | sed "s/'/\\\\'/g")
			fi

			# Note: -0777 to get the full file contents as a single string
			perl -0777 -ne "exit(!m'$tt_test_inline')" "$tt_test_output_file"
			tt_test_status=$?

			case $tt_test_status in
				0)   # Test matched, nothing to do
					:
				;;
				1)   # Test failed: the regex not matched
					tt_test_diff="Perl regex '$tt_test_inline' not matched in:$tt_nl$(cat "$tt_test_output_file")"
				;;
				127) # Perl not found :(
					tt_error "Perl not found. It's needed by --$tt_test_mode at line $tt_line_number of $tt_test_file"
				;;
				255) # Regex syntax errors are common and user must take action to fix them
					tt_error "check your inline Perl regex at line $tt_line_number of $tt_test_file"
				;;
				*)
					tt_error "unknown error when running Perl for --$tt_test_mode at line $tt_line_number of $tt_test_file"
				;;
			esac
		;;
		exit)
			test "$tt_test_exit_code" -eq "$tt_test_inline"
			tt_test_status=$?
			tt_test_diff="Expected exit code $tt_test_inline, got $tt_test_exit_code"
		;;
		*)
			tt_error "unknown test mode '$tt_test_mode'"
		;;
	esac

	# Test failed :(
	if test $tt_test_status -ne 0
	then
		tt_nr_file_fails=$(($tt_nr_file_fails + 1))
		tt_nr_total_fails=$(($tt_nr_total_fails + 1))
		tt_failed_range="$tt_failed_range$tt_test_number,"

		# Decide the message format
		if test "$tt_output_mode" = 'list-run'
		then
			# List mode
			tt_list_test fail
		else
			# Normal mode: show FAILED message and the diff
			if test $tt_separator_line_shown -eq 0  # avoid dups
			then
				tt_message "${tt_color_red}$(tt_separator_line)${tt_color_off}"
			fi
			tt_message "${tt_color_red}[FAILED #$tt_test_number, line $tt_test_line_number] $tt_test_command${tt_color_off}"
			tt_message "$tt_test_diff" | sed '1 { /^--- / { N; /\n+++ /d; }; }'  # no ---/+++ headers
			tt_message "${tt_color_red}$(tt_separator_line)${tt_color_off}"
			tt_separator_line_shown=1
		fi

		# Should I abort now?
		if test $tt_stop_on_first_fail -eq 1
		then
			tt_clean_up
			exit 1
		fi

	# Test OK
	else
		test "$tt_output_mode" = 'list-run' && tt_list_test ok
	fi

	tt_reset_test_data
}
tt_process_test_file ()
{
	# Reset counters
	tt_nr_file_tests=0
	tt_nr_file_fails=0
	tt_nr_file_skips=0
	tt_line_number=0
	tt_test_line_number=0

	# Loop for each line of input file
	# Note: changing IFS to avoid right-trimming of spaces/tabs
	# Note: read -r to preserve the backslashes
	while IFS='' read -r tt_input_line || test -n "$tt_input_line"
	do
		tt_line_number=$(($tt_line_number + 1))
		#tt_debug INPUT_LINE "$tt_input_line"

		case "$tt_input_line" in

			# Prompt alone: closes previous command line (if any)
			"$tt_prefix$tt_prompt" | "$tt_prefix${tt_prompt% }" | "$tt_prefix$tt_prompt ")
				#tt_debug 'LINE_$' "$tt_input_line"

				# Run pending tests
				test -n "$tt_test_command" && tt_run_test
			;;

			# This line is a command line to be tested
			"$tt_prefix$tt_prompt"*)
				#tt_debug LINE_CMD "$tt_input_line"

				# Run pending tests
				test -n "$tt_test_command" && tt_run_test

				# Remove the prompt
				tt_test_command="${tt_input_line#"$tt_prefix$tt_prompt"}"

				# Save the test's line number for future messages
				tt_test_line_number=$tt_line_number

				# This is a special test with inline output?
				if printf '%s\n' "$tt_test_command" | grep "$tt_inline_prefix" > /dev/null
				then
					# Separate command from inline output
					tt_test_command="${tt_test_command%"$tt_inline_prefix"*}"
					tt_test_inline="${tt_input_line##*"$tt_inline_prefix"}"

					#tt_debug NEW_CMD "$tt_test_command"
					#tt_debug OK_INLINE "$tt_test_inline"

					# Maybe the OK text has options?
					case "$tt_test_inline" in
						'--egrep '*)
							tt_test_inline=${tt_test_inline#--egrep }
							tt_test_mode='egrep'
						;;
						'--regex '*)  # alias to --perl
							tt_test_inline=${tt_test_inline#--regex }
							tt_test_mode='regex'
						;;
						'--perl '*)
							tt_test_inline=${tt_test_inline#--perl }
							tt_test_mode='perl'
						;;
						'--file '*)
							tt_test_inline=${tt_test_inline#--file }
							tt_test_mode='file'
						;;
						'--lines '*)
							tt_test_inline=${tt_test_inline#--lines }
							tt_test_mode='lines'
						;;
						'--exit '*)
							tt_test_inline=${tt_test_inline#--exit }
							tt_test_mode='exit'
						;;
						'--eval '*)
							tt_test_inline=${tt_test_inline#--eval }
							tt_test_mode='eval'
						;;
						'--text '*)
							tt_test_inline=${tt_test_inline#--text }
							tt_test_mode='text'
						;;
						*)
							tt_test_mode='text'
						;;
					esac

					#tt_debug OK_TEXT "$tt_test_inline"

					# There must be a number in --lines and --exit
					if test "$tt_test_mode" = 'lines' || test "$tt_test_mode" = 'exit'
					then
						case "$tt_test_inline" in
							'' | *[!0-9]*)
								tt_error "--$tt_test_mode requires a number. See line $tt_line_number of $tt_test_file"
							;;
						esac
					fi

					# An empty inline parameter is an error user must see
					if test -z "$tt_test_inline" && test "$tt_test_mode" != 'text'
					then
						tt_error "empty --$tt_test_mode at line $tt_line_number of $tt_test_file"
					fi

					# Since we already have the command and the output, run test
					tt_run_test
				else
					# It's a normal command line, output begins in next line
					tt_test_mode='output'

					#tt_debug NEW_CMD "$tt_test_command"
				fi
			;;

			# Test output, blank line or comment
			*)
				#tt_debug 'LINE_*' "$tt_input_line"

				# Ignore this line if there's no pending test
				test -n "$tt_test_command" || continue

				# Required prefix is missing: we just left a command block
				if test -n "$tt_prefix" && test "${tt_input_line#"$tt_prefix"}" = "$tt_input_line"
				then
					#tt_debug BLOCK_OUT "$tt_input_line"

					# Run the pending test and we're done in this line
					tt_run_test
					continue
				fi

				# This line is a test output, save it (without prefix)
				tt_test_ok_text="$tt_test_ok_text${tt_input_line#"$tt_prefix"}$tt_nl"

				#tt_debug OK_TEXT "${tt_input_line#"$tt_prefix"}"
			;;
		esac
	done < "$tt_temp_file"

	#tt_debug LOOP_OUT "\$tt_test_command=$tt_test_command"

	# Run pending tests
	test -n "$tt_test_command" && tt_run_test
}


### Init process


# No files?
if test $tt_nr_files -eq 0
then
	tt_error 'no test file informed (try --help)'
fi

# Handy shortcuts for prefixes
case "$tt_prefix" in
	tab)
		tt_prefix="$tt_tab"
	;;
	0)
		tt_prefix=''
	;;
	[1-9] | [1-9][0-9])  # 1-99
		# convert number to spaces: 2 => '  '
		tt_prefix=$(printf "%${tt_prefix}s" ' ')
	;;
	*\\*)
		tt_prefix="$(printf %b "$tt_prefix")"  # expand \t and others
	;;
esac

# Validate and normalize progress value
if test "$tt_output_mode" = 'normal'
then
	case "$tt_progress" in
		test)
			:
		;;
		number | n | [0-9])
			tt_progress='number'
		;;
		dot | .)
			tt_progress='.'
		;;
		none | no)
			tt_progress='none'
		;;
		?)  # Single char, use it as the progress
			:
		;;
		*)
			tt_error "invalid value '$tt_progress' for --progress. Use: test, number, dot or none."
		;;
	esac
fi

# Will we use colors in the output?
case "$tt_color_mode" in
	always | yes | y)
		tt_use_colors=1
	;;
	never | no | n)
		tt_use_colors=0
	;;
	auto | a)
		# The auto mode will use colors if the output is a terminal
		# Note: test -t is in POSIX
		if test -t 1
		then
			tt_use_colors=1
		else
			tt_use_colors=0
		fi
	;;
	*)
		tt_error "invalid value '$tt_color_mode' for --color. Use: auto, always or never."
	;;
esac

# Set colors
# Remember: colors must be readable in dark and light backgrounds
# Customization: tweak the numbers after [ to adjust the colors
if test $tt_use_colors -eq 1
then
	tt_color_red=$(  printf '\033[31m')  # fail
	tt_color_green=$(printf '\033[32m')  # ok
	tt_color_blue=$( printf '\033[34m')  # debug
	#tt_color_cyan=$( printf '\033[36m') # not used
	tt_color_off=$(  printf '\033[m')
fi

# Find the terminal width
# The COLUMNS env var is set by Bash (must be exported in ~/.bashrc).
# In other shells, try to use 'tput cols' (not POSIX).
# If not, defaults to 50 columns, a conservative amount.
: ${COLUMNS:=$(tput cols 2> /dev/null)}
: ${COLUMNS:=50}

# Parse and validate --test option value, if informed
tt_run_range_data=$(tt_parse_range "$tt_run_range")
if test $? -ne 0
then
	tt_error "invalid argument for -t or --test: $tt_run_range"
fi

# Parse and validate --skip option value, if informed
tt_skip_range_data=$(tt_parse_range "$tt_skip_range")
if test $? -ne 0
then
	tt_error "invalid argument for -s or --skip: $tt_skip_range"
fi

# Create temp dir, protected from others
umask 077 && mkdir "$tt_temp_dir" || tt_error "cannot create temporary dir: $tt_temp_dir"


### Real execution begins here

# Some preparing command to run before all the tests?
if test -n "$tt_pre_command"
then
	eval "$tt_pre_command" ||
		tt_error "pre-flight command failed with status=$?: $tt_pre_command"
fi

# For each input file in $@
for tt_test_file
do
	# Some tests may 'cd' to another dir, we need to get back
	# to preserve the relative paths of the input files
	cd "$tt_original_dir"

	# Abort when test file not found/readable
	if test ! -f "$tt_test_file" || test ! -r "$tt_test_file"
	then
		tt_error "cannot read input file: $tt_test_file"
	fi

	# In multifile mode, identify the current file
	if test $tt_nr_files -gt 1
	then
		case "$tt_output_mode" in
			normal)
				# Normal mode, show message with filename
				case "$tt_progress" in
					test | none)
						tt_message "Testing file $tt_test_file"
					;;
					*)
						test $tt_missing_nl -eq 1 && echo
						tt_message_part "Testing file $tt_test_file "
					;;
				esac
			;;
			list | list-run)
				# List mode, show ------ and the filename
				tt_message $(tt_separator_line | cut -c 1-40) $tt_test_file
			;;
		esac
	fi

	# Convert Windows files (CRLF) to the Unix format (LF)
	# Note: the temporary file is required, because doing "sed | while" opens
	#       a subshell and global vars won't be updated outside the loop.
	sed "s/$(printf '\r')$//" "$tt_test_file" > "$tt_temp_file"

	# The magic happens here
	tt_process_test_file

	# Abort when no test found (and no active range with --test or --skip)
	if test $tt_nr_file_tests -eq 0 && test -z "$tt_run_range_data" && test -z "$tt_skip_range_data"
	then
		tt_error "no test found in input file: $tt_test_file"
	fi

	# Save file stats
	tt_nr_file_ok=$(($tt_nr_file_tests - $tt_nr_file_fails - $tt_nr_file_skips))
	tt_files_stats="$tt_files_stats$tt_nr_file_ok $tt_nr_file_fails $tt_nr_file_skips$tt_nl"

	# Dots mode: any missing new line?
	# Note: had to force tt_missing_nl=0, even when it's done in tt_message :/
	test $tt_missing_nl -eq 1 && tt_missing_nl=0 && tt_message
done

tt_clean_up

# Some clean up command to run after all the tests?
if test -n "$tt_post_command"
then
	eval "$tt_post_command"
fi

#-----------------------------------------------------------------------
# From this point on, it's safe to use non-prefixed global vars
#-----------------------------------------------------------------------

# Range active, but no test matched :(
if test $tt_nr_total_tests -eq $tt_nr_total_skips
then
	if test -n "$tt_run_range_data" && test -n "$tt_skip_range_data"
	then
		tt_error "no test found. The combination of -t and -s resulted in no tests."
	elif test -n "$tt_run_range_data"
	then
		tt_error "no test found for the specified number or range '$tt_run_range'"
	elif test -n "$tt_skip_range_data"
	then
		tt_error "no test found. Maybe '--skip $tt_skip_range' was too much?"
	fi
fi

# List mode has no stats
if test "$tt_output_mode" = 'list' || test "$tt_output_mode" = 'list-run'
then
	if test $tt_nr_total_fails -eq 0
	then
		exit 0
	else
		exit 1
	fi
fi

# Show stats
#   Data:
#     $tt_files_stats -> "100 0 23 \n 12 34 0"
#     $@ -> foo.sh bar.sh
#   Output:
#          ok  fail  skip
#         100     0    23  foo.sh
#          12    34     0  bar.sh
if test $tt_nr_files -gt 1 && test "$tt_output_mode" != 'quiet'
then
	echo
	printf '  %5s %5s %5s\n' ok fail skip
	printf %s "$tt_files_stats" | while read ok fail skip
	do
		printf '  %5s %5s %5s    %s\n' $ok $fail $skip "$1"
		shift
	done | sed 's/     0/     -/g'  # hide zeros
	echo
fi

# The final message: OK or FAIL?
#   OK: 123 of 123 tests passed
#   OK: 100 of 123 tests passed (23 skipped)
#   FAIL: 123 of 123 tests failed
#   FAIL: 100 of 123 tests failed (23 skipped)
skips=
if test $tt_nr_total_skips -gt 0
then
	skips=" ($tt_nr_total_skips skipped)"
fi
if test $tt_nr_total_fails -eq 0
then
	stamp="${tt_color_green}OK:${tt_color_off}"
	stats="$(($tt_nr_total_tests - $tt_nr_total_skips)) of $tt_nr_total_tests tests passed"
	test $tt_nr_total_tests -eq 1 && stats=$(echo "$stats" | sed 's/tests /test /')
	tt_message "$stamp $stats$skips"
	exit 0
else
	test $tt_nr_files -eq 1 && tt_message  # separate from previous FAILED message

	stamp="${tt_color_red}FAIL:${tt_color_off}"
	stats="$tt_nr_total_fails of $tt_nr_total_tests tests failed"
	test $tt_nr_total_tests -eq 1 && stats=$(echo "$stats" | sed 's/tests /test /')
	tt_message "$stamp $stats$skips"
	test $tt_test_file = 'dev/test.md' && tt_message "-t ${tt_failed_range%,}"  # XXX dev helper, remove before release
	exit 1
fi
