How to rewrite a wall of if-elif statements

For cigs.sh - the complete script can be found here - I wrote the following ugly (but perfectly working) logic to print and format the script's output, partly just to figure out all the edge cases but also because I didn't see any other alternative.

...

if [[ $W -le 0 && $D -le 0 && $H -eq 1 ]]; then string="$H hour"
elif [[ $W -le 0 && $D -le 0 && $H -gt 1 ]]; then string="$H hours"
elif [[ $W -le 0 && $D -eq 1 && $H -le 0 ]]; then string="$D day" 
elif [[ $W -le 0 && $D -eq 1 && $H -eq 1 ]]; then string="$D day and $H hour"
elif [[ $W -le 0 && $D -eq 1 && $H -gt 1 ]]; then string="$D day and $H hours"   
elif [[ $W -le 0 && $D -gt 1 && $H -le 0 ]]; then string="$D days"
elif [[ $W -le 0 && $D -gt 1 && $H -eq 1 ]]; then string="$D days and $H hour"
elif [[ $W -le 0 && $D -gt 1 && $H -gt 1 ]]; then string="$D days and $H hours"
elif [[ $W -eq 1 && $D -le 0 && $H -le 0 ]]; then string="$W week"
elif [[ $W -eq 1 && $D -le 0 && $H -eq 1 ]]; then string="$W week and $H hour"
elif [[ $W -eq 1 && $D -le 0 && $H -gt 1 ]]; then string="$W week and $H hours"
elif [[ $W -eq 1 && $D -eq 1 && $H -le 0 ]]; then string="$W week and $D day"
elif [[ $W -eq 1 && $D -gt 1 && $H -le 0 ]]; then string="$W week and $D days"
elif [[ $W -eq 1 && $D -eq 1 && $H -eq 1 ]]; then string="$W week, $D day and $H hour"
elif [[ $W -eq 1 && $D -eq 1 && $H -gt 1 ]]; then string="$W week, $D day and $H hours"
elif [[ $W -eq 1 && $D -gt 1 && $H -eq 1 ]]; then string="$W week, $D days and $H hour"
elif [[ $W -eq 1 && $D -gt 1 && $H -gt 1 ]]; then string="$W week, $D days and $H hours"
elif [[ $W -gt 1 && $D -le 0 && $H -le 0 ]]; then string="$W weeks"
elif [[ $W -gt 1 && $D -le 0 && $H -eq 1 ]]; then string="$W weeks and $H hour"
elif [[ $W -gt 1 && $D -le 0 && $H -gt 1 ]]; then string="$W weeks and $H hours"
elif [[ $W -gt 1 && $D -eq 1 && $H -le 0 ]]; then string="$W weeks and $D day"
elif [[ $W -gt 1 && $D -gt 1 && $H -le 0 ]]; then string="$W weeks and $D days"
elif [[ $W -gt 1 && $D -eq 1 && $H -eq 1 ]]; then string="$W weeks, $D day and $H hour"
elif [[ $W -gt 1 && $D -eq 1 && $H -gt 1 ]]; then string="$W weeks, $D day and $H hours"
elif [[ $W -gt 1 && $D -gt 1 && $H -eq 1 ]]; then string="$W weeks, $D days and $H hour"
elif [[ $W -gt 1 && $D -gt 1 && $H -gt 1 ]]; then string="$W weeks, $D days and $H hours"
fi

colour1='\033[0;31m'
colour2='\033[0;32m'
if (($elapsed < threshold))
then echo -e "${colour1}It's been $string since you last bought a $item."
else
echo -e "${colour2}It's been $string since you last bought a $item."
fi

Maybe I'm just being dumb, but embarrassing as the above code is, I can't see a better way it could be rewritten. Does one exist, and if so, what is it?


Solution 1:

In the days before bash had [[ ]] expressions, case statements were used a lot for pattern matching. The list of if's can be converted into a single case statement (the + sign is just an arbitrary separator):

case $W+$D+$H in
0+0+0 ) string="" ;;
0+0+1 ) string="$H hour" ;;
0+1+0 ) string="$D day"  ;;
0+1+1 ) string="$D day and $H hour" ;;
1+0+0 ) string="$W week" ;;
1+0+1 ) string="$W week and $H hour" ;;
1+1+0 ) string="$W week and $D day" ;;
1+1+1 ) string="$W week, $D day and $H hour" ;;

0+0+* ) string="$H hours" ;;
0+*+0 ) string="$D days"  ;;
*+0+0 ) string="$W weeks" ;;
0+1+* ) string="$D day and $H hours" ;;
0+*+1 ) string="$D days and $H hour" ;;
*+0+1 ) string="$W weeks and $H hour" ;;
*+1+0 ) string="$W weeks and $D day" ;;
1+0+* ) string="$W week and $H hours" ;;
1+*+0 ) string="$W week and $D days" ;;
1+1+* ) string="$W week, $D day and $H hours" ;;
1+*+1 ) string="$W week, $D days and $H hour" ;;
*+1+1 ) string="$W weeks, $D day and $H hour" ;;

0+*+* ) string="$D days and $H hours" ;;
1+*+* ) string="$W week, $D days and $H hours" ;;
*+0+* ) string="$W weeks and $H hours" ;;
*+*+0 ) string="$W weeks and $D days"  ;;
*+1+* ) string="$W weeks, $D day and $H hours" ;;
*+*+1 ) string="$W weeks, $D days and $H hour" ;;
*+*+* ) string="$W weeks, $D days and $H hours" ;;
esac

This is still hard to digest, but if we separate out the plural words with variables holding an s or not, the case statement is a lot simpler:

ws=s; [ $W = 1 ] && ws=
ds=s; [ $D = 1 ] && ds=
hs=s; [ $H = 1 ] && hs=
case $W+$D+$H in
0+0+0 ) string="" ;;
0+0+* ) string="$H hour$hs" ;;
0+*+0 ) string="$D day$ds"  ;;
0+*+* ) string="$D day$ds and $H hour$hs" ;;
*+0+0 ) string="$W week$ws" ;;
*+0+* ) string="$W week$ws and $H hour$hs" ;;
*+*+0 ) string="$W week$ws and $D day$ds" ;;
*+*+* ) string="$W week$ws, $D day$ds and $H hour$hs" ;;
esac

Solution 2:

If you're running the script under bash, how about this (based on Jeff Zeitlin's comment):

# Start with an empty list of units
units=()

# Add the non-zero units to the list
if (( W == 1 )); then units+=("1 week")
elif (( W > 1 )); then units+=("$W weeks")
fi

if (( D == 1 )); then units+=("1 day")
elif (( D > 1 )); then units+=("$D days")
fi

if (( H == 1 )); then units+=("1 hour")
elif (( H > 1 )); then units+=("$H hours")
fi

# Based on the number of non-zero units, add separators appropriately
case ${#units[@]} in
        3) string="${units[0]}, ${units[1]} and ${units[2]}" ;;
        2) string="${units[0]} and ${units[1]}" ;;
        1) string="${units[0]}" ;;
        0) string="less than an hour" ;;
esac

Warning: this pretty much requires bash. zsh also has arrays, but it numbers the entries differently (starting at 1 rather than 0), so the final section would fail weirdly under zsh.

Solution 3:

An alternative approach is to start with the long verbose version and trim it down using matching patterns to remove plurals etc. I don't know that it is more radable though, so might be harder to maintain.

string=" $W weeks, $D days and $H hours" # leading space to simplify matching
string=${string/ 1 weeks/1 week}
string=${string/ 1 days/ 1 day}
string=${string/ 1 hours/ 1 hour}
string=${string# 0 weeks,}
string=${string/ 0 days/}
string=${string%and 0 hours}
string=${string/, and/ and}
string=${string# }
string=${string% }
string=${string%,}
string=${string#and }
[[ $string =~ and ]] || string=${string/,/ and}

This uses bash's parameter expansion syntax, where ${parameter/pattern/replacement} tries to match the glob pattern, and if found replaces it. This is used to "fix" the "errors" like "1 weeks" to make it "1 week". To avoid also matching 21 weeks, the initial string has an extra space at the start, so the space can be in the pattern to ensure only " 1 " is matched.

The syntax ${parameter#pattern} deletes the match if found at the beginning of the parameter. This is used to delete " 0 weeks,".

The syntax ${parameter%pattern} deletes the match if found at the end of the parameter. This is used to delete "and 0 hours". The other fixes delete a space if it gets left at the start or the end, or a comma if it gets left at the end, or an "and" and space if it gets left at the beginning.

The final line replaces comma by " and" if there is no "and" in the string. This corresponds to, say, changing "2 weeks, 3 days" to "2 weeks and 3 days", where we have already removed the "and 0 hours".