Session 4 · Computer Systems
Shell scripting in practice: input, errors, case and pipelines
The pieces of a shell script put together: checking input and failing with stderr and exit codes, case for options and menus, and the cut | sort | uniq -c pipeline. Nine tasks build up from a safe file copy to an interactive menu, solved with filters and with while-read loops.
In the previous session you met the pieces of a shell script one by one: variables, arguments, if, loops, functions, regular expressions and redirection. This session puts them together. The tasks build on each other – a file copy with error handling, a word search, a pipeline that finds the most common value in a column, and finally an interactive menu that uses everything at once.
Before the tasks come a few new tools you need for them: read from places other than the keyboard, the case statement, the filters cut, sort and uniq, and the habits that make a script robust – checking the input first, writing errors to stderr, and exiting with a code that says what happened.
The pattern behind every task: check the input at the top and stop with a message on stderr if anything is wrong; then do the work, ideally as a pipeline of small filters you built and tested one stage at a time in the terminal.
Getting input: arguments and read
A script gets data from the user in one of two ways (Fig. 1). Arguments are written after the script name when it starts – ./search.sh menu.txt wine – and are fixed from then on. read takes one line while the script is running. Use arguments for anything another script or a test might pass in (file names, options, values); use read for genuinely interactive programs – a menu, a question, a password.
Copy the arguments into named variables at the top of the script. $2 means nothing to a reader three screens later; $word does. ${3:-01} gives a default when the argument is missing, and "$@" walks all arguments, each as one word even if it contains spaces.
file=$1
word=$2
month=${3:-01} # default value when there is no third argument
for arg in "$@"; do # every argument, each as its own word
echo "Argument: $arg"
doneread doesn't care where its line comes from. Always give it -r (keep backslashes as they are); -p prints a prompt. If it gets several variable names, it splits the line at whitespace: the first word goes into the first variable, and the rest of the line into the last one.
the four sources of read
# from the keyboard
read -r -p "Enter a word: " word
# from a file, line by line
while read -r line; do
echo "Line: $line"
done < menu.txt
# from another command's output, line by line
find main_folder -type f | while read -r file; do
echo "File: $file"
done
# from a variable (here string): first word → count, the rest → illness
result=" 3 headache"
read -r count illness <<< "$result"The <<< operator is the here string: it hands one string to a command's standard input as if it were a one-line file. It belongs to a small family of input redirections:
| Operator | Standard input comes from | Name |
|---|---|---|
< file.txt | a file | input redirection |
<< EOF … EOF | a block of lines written in the script itself | here document |
<<< "$text" | one string or the value of a variable | here string |
read -r first < file.txt # < from a file
cat << EOF # << from a block written in the script
line one
line two
EOF
read -r count illness <<< "$result" # <<< from one string (here string)The pipe-and-read trap
The obvious way to split a variable is to pipe it into read: echo "$result" | read -r count illness. It runs without an error – and afterwards both variables are empty. The reason is how pipes work: bash starts each side of a pipe as a separate process. The read on the right runs in a child shell (a subshell), sets its variables, and exits. The variables die with it (Fig. 2). The process tree from Session 2 explains why: a child process can't change its parent's variables.
result=" 3 headache"
echo "$result" | read -r count illness
echo "$count / $illness" # / (empty – read ran in a subshell)
read -r count illness <<< "$result"
echo "$count / $illness" # 3 / headacheThe same trap catches a while read loop on the right side of a pipe. Printing inside the loop works fine – but a counter or flag you change in the loop is gone after done:
n=0
find main_folder -type f | while read -r file; do
((n++))
done
echo "$n" # 0 – the loop counted in a subshell
n=$(find main_folder -type f | wc -l) # fix 1: let a filter count
while read -r file; do ((n++)); done < <(find main_folder -type f)
# fix 2: process substitution, loop stays hereFail early, fail loudly
The rule for every script in this course: check the input at the top, and stop at the first problem – with a message on standard error (>&2) and a non-zero exit code (exit 1). Everything below the checks can then assume the input is fine, so it stays short and free of nested ifs (Fig. 3).
| What to check | How |
|---|---|
| the number of arguments | [ "$#" -ne 2 ] |
| an empty value | [ -z "$var" ] |
| a file exists / is readable / is writable | [ ! -f "$f" ] [ ! -r "$f" ] [ ! -w "$f" ] |
| a directory exists | [ ! -d "$dir" ] |
| the format of a value, e.g. a whole number | [[ ! $n =~ ^[0-9]+$ ]] |
| a command succeeded | if cp "$a" "$b"; then … |
Here is the opening most of today's scripts share. The usage text lives in a function because it is printed from more than one place; usage >&2 sends the function's entire output to stderr.
the standard opening of a script
#!/bin/bash
usage() {
echo "Usage: $0 <file> <word>"
}
if [ "$#" -ne 2 ]; then
usage >&2 # the function's whole output goes to stderr
exit 1
fi
file=$1
word=$2
if [ ! -f "$file" ]; then
echo "Error: file '$file' does not exist" >&2
exit 1
fi
# from here on: the real work, on input that passed every checkTest the command, not $?
if runs a command and branches on its exit code – 0 means true, anything else false. [ … ] is just one such command; cp, mkdir and grep work the same way, so there is no need to inspect $? separately. grep is especially useful here: it exits with 0 if it found a match, 1 if it didn't, and 2 on an error such as a missing file. With -q it prints nothing and only sets the code.
if cp "$source" "$target"; then
echo "Copied"
else
echo "Error: copy failed" >&2
exit 1
fi
if grep -q "$word" "$file"; then # -q: print nothing, only set the exit code
echo "Found"
firead can fail too
When the user presses Ctrl+D, the terminal signals end of input and read returns a non-zero code. In a while true menu loop that ignores this, the script spins forever printing the menu. Put the read into the condition and leave the loop when it fails:
while true; do
if ! read -r -p "Your choice: " choice; then
echo # Ctrl+D: end of input – leave the loop
break
fi
# … handle $choice …
doneThe case statement
When one value has to be compared with several fixed options – command-line switches, menu choices – an if … elif … elif chain gets long. case says the same thing more directly. It compares the value with each pattern from top to bottom and runs the first branch that matches, and only that one (Fig. 4).
case "$1" in
-d|--date) # | inside a pattern: either one matches
date
;;
-w)
whoami
;;
-l) ls -l "$HOME" ;; # a short branch fits on one line
*) # anything else – always the last branch
echo "Error: unknown option '$1'" >&2
exit 1
;;
esac| Part | Meaning |
|---|---|
case "$x" in | the value to compare – quoted, as always |
pattern) | the start of a branch; it runs if the value matches the pattern |
;; | the end of the branch – required |
*) | matches anything: the "everything else" branch, so it always comes last |
esac | the end of the statement (case spelled backwards) |
The patterns are glob patterns, the same wildcards you use for file names – not regular expressions:
| Pattern | Matches |
|---|---|
-d) | exactly -d |
-d|--date) | -d or --date |
[0-9]) | one digit |
*.txt) | anything ending in .txt |
*) | anything at all |
Filters and the counting pipeline
A filter reads standard input and writes standard output, so filters chain with | (Session 2 introduced them). Besides grep, sort, tr and wc, today's tasks need these:
| Command | What it does |
|---|---|
cut -d ',' -f 3 | splits each line at the delimiter (-d) and keeps field 3 (-f) |
sort | sorts lines as text; -n numerically, -r in reverse, -u keeps one of each |
uniq -c | merges identical neighbouring lines and puts the count in front |
head -n 1 | keeps the first n lines |
tail -n 1 | keeps the last n lines |
wc -l | counts lines |
| grep option | Meaning |
|---|---|
-i | ignore case: wine matches Wine |
-w | whole words only: wine matches "Pasta wine" but not "Winegum" |
-c | print the number of matching lines instead of the lines |
-q | print nothing, only set the exit code – for if |
-r | search every file in a directory, recursively |
-l | print the names of the matching files, not the lines |
The practice file patients.txt has one patient per line: name, date of the visit, illness.
patients.txt
Anna Kovacs,2023-10-13,headache
Peter Szabo,2023-10-13,headache
Eva Toth,2023-10-01,headache
Mark Varga,2023-10-20,cold
Julia Balogh,2023-10-22,cold
Daniel Farkas,2023-09-01,stomach ache
Zoe Papp,2023-09-19,headache
Adam Lukacs,2023-11-07,cold
Nora Fekete,2023-11-15,headache
Bence Kiss,2023-11-21,cold
Lili Molnar,2023-05-30,leg pain
Gergo Simon,2023-05-04,leg pain
Rita Horvath,2023-05-24,headache
Tamas Racz,2023-05-11,leg painWhich illness is the most common? This is the classic pipeline of the course, and the way to write it is one stage at a time, looking at the output after each. First keep only the illness column:
cut -d ',' -f 3 patients.txtheadache
headache
headache
cold
cold
stomach ache
…Sort it so equal lines sit next to each other, then let uniq -c count each group:
cut -d ',' -f 3 patients.txt | sort | uniq -c 4 cold
6 headache
3 leg pain
1 stomach acheSort by the count, largest first, and keep the top line:
cut -d ',' -f 3 patients.txt | sort | uniq -c | sort -rn | head -n 1 6 headacheThe result is a line like 6 headache, with leading spaces. To print it nicely, split it with the here string from earlier: read -r count illness <<< "$result" – the spaces disappear, the number goes into count, and the rest of the line, even if it has several words like stomach ache, into illness.
Walking through files
To visit the files in one directory, combine for with a glob pattern. Quote the variable part and leave the * outside the quotes, so the shell still expands it:
for file in "$dir"/*.txt; do
if [ ! -f "$file" ]; then
continue # no match: $file holds the literal text "$dir/*.txt"
fi
echo "$file"
doneThe -f check is not decoration. When no file matches, bash does not give an empty list – it leaves the pattern as it is, so the loop body runs once with the literal text as the "file name":
$ for f in empty_dir/*.txt; do echo "got: $f"; done
got: empty_dir/*.txtThe * only looks into the directory you name, not into subdirectories. For a whole tree, let find list the files and read its output line by line – keeping the pipe trap above in mind:
find "$dir" -type f | while read -r file; do
echo "$file"
doneTwo small commands help when you build names, for example for a backup folder: basename keeps the last part of a path, and date +%F prints today's date as YYYY-MM-DD. Inside double quotes, $( … ) drops a command's output right into a string:
basename /home/student/tasks/main_folder # main_folder
date +%F # today, as YYYY-MM-DD
name="$(basename "$dir")_backup_$(date +%F)" # main_folder_backup_YYYY-MM-DDNever read and write the same file
"Convert the file to uppercase" sounds like one line: tr … < f.txt > f.txt. It leaves you with an empty file. Redirections are set up by the shell before the command starts, and > opens its file by truncating it to zero bytes. By the time tr reads f.txt, there is nothing left to read (Fig. 6).
tr '[:lower:]' '[:upper:]' < "$file" > "$file" # wrong: the file ends up empty
tr '[:lower:]' '[:upper:]' < "$file" > "$file.tmp" &&
mv "$file.tmp" "$file" # right: temp file, then replaceThe && matters: the original is only replaced if tr succeeded. The same applies to every filter – sort f > f, grep … f > f – always go through a temporary file.
Filters or loops?
Every task in this session can be solved two ways. The Unix way chains ready-made filters – grep, cut, sort, uniq, find, tr – each doing one small job. The programming way reads the file line by line in a while read loop and works with variables, ifs, counters and arrays, the way you would in C, Java or Python. Both are correct; knowing both means you are never stuck.
| Filters and a pipe | A loop and variables | |
|---|---|---|
| who processes the lines | the commands (grep, cut …) | your own while read loop |
| where intermediate data lives | it flows through the pipe | in variables and arrays |
| length | short, often one line | longer, but every step is visible |
| what to watch | the options and their order (sort before uniq) | the end of the file, empty lines, Windows line endings |
The loop's equivalent of cut is read itself: set IFS (the characters read splits at) to a comma for that one command, and each field lands in its own variable (Fig. 7).
the loop version of grep + cut
while IFS=, read -r name date illness || [ -n "$name" ]; do
illness=${illness%$'\r'} # drop a Windows line ending
[ -z "$illness" ] && continue # skip empty lines
[ "${date:5:2}" != "$month" ] && continue # the month filter, without grep
echo "$name: $illness"
done < "$file"Each line of that loop handles something the filters did for free:
|| [ -n "$name" ]– if the last line has no newline at the end,readstill fills the variables but returns false, and without this the last line is silently lost.${illness%$'\r'}– a file saved on Windows ends every line with an extra\r, and"headache\r"is not equal to"headache". Session 5 covers line endings in depth.[ -z "$illness" ] && continue– skip empty lines instead of counting an empty illness.
Counting per illness (what sort | uniq -c did) needs two parallel arrays: illnesses[i] holds a name and counts[i] its count, and the same index ties them together. For each line, search the name in the first array; add it if it's new, or increment its count:
index=-1
for (( i=0; i<${#illnesses[@]}; i++ )); do
if [ "${illnesses[i]}" == "$illness" ]; then
index=$i
break
fi
done
if [ "$index" -eq -1 ]; then
illnesses+=("$illness") # first time: add it with a count of 1
counts+=(1)
else
(( counts[index]++ )) # seen before: increment its count
fi| Filter | Its loop equivalent |
|---|---|
cut -d ',' -f 3 | IFS=, read -r name date illness |
grep "$pattern" | [[ $line == *"$pattern"* ]] |
grep -c | a counter: ((count++)) |
sort | uniq -c | parallel arrays: name[i], count[i] |
sort -rn | head -n 1 | a maximum search over the counts |
find dir -type f | a function that calls itself for each subdirectory |
basename "$path" | ${path##*/} |
case | if … elif … else |
Common mistakes
| Wrong | Right | Why |
|---|---|---|
tr … < f > f | tr … < f > f.tmp && mv f.tmp f | > empties the file before the command starts |
grep $word $file | grep "$word" "$file" | unquoted values with spaces fall apart into several arguments |
grep ',$month,' | grep ",$month," | inside single quotes $month is not substituted |
cut … | uniq -c | cut … | sort | uniq -c | uniq only merges neighbouring lines |
| a case branch without ;; | end every branch with ;; | a syntax error, or the wrong branch runs |
the *) branch first | the *) branch last | the first match wins, and * matches everything |
for f in *.txt | …plus [ -f "$f" ] || continue | with no match, the pattern itself lands in $f |
find … | while …; do ((n++)); done | find … | wc -l | the loop after a pipe counts in a subshell; n stays 0 |
echo "$r" | read -r a b | read -r a b <<< "$r" | the same trap: read runs in a subshell |
while true; do read -r x; … | if ! read -r x; then break; fi | on Ctrl+D the loop never ends |
| usage printed to stdout on error | usage >&2; exit 1 | errors belong on stderr, with a non-zero exit code |
Practice
The tasks build on each other: 1–4 wrap a single command in a script with proper checks, 5 adds case, 6 a pipeline, 7 a loop over files, 8 combines 5 and 6 with functions, and 9 is an interactive menu that uses all of it. Work in the tasks folder:
tasks/
├── source.txt a few lines to copy (task 1)
├── menu.txt one dish per line (tasks 2 and 9)
├── all_lower.txt lowercase words (task 4)
├── patients.txt name,YYYY-MM-DD,illness per line (tasks 6, 8, 9)
└── main_folder/ text1.txt, text3.txt (tasks 3 and 7)
└── sub_folder/ text2.txtRules for every task:
- check the number of arguments; if it's wrong, print a
Usage: …line - error messages go to stderr (
>&2) and the script stops withexit 1 - a missing file or directory is an error too
- quote every variable:
"$file"
Task 1
Copy a file
Write a script that takes a source and a destination file name and copies the content of the source into the destination. If the destination doesn't exist yet, create it and say so. If the source doesn't exist or isn't readable, stop with an error. Decide whether the copy worked from cp's exit code. In effect it is a wrapper around cp, with error handling.
$ ./copy.sh source.txt target.txt
Destination file 'target.txt' does not exist, creating it.
Content copied from 'source.txt' to 'target.txt'.
$ ./copy.sh nope.txt target.txt
Error: source file 'nope.txt' does not existApproach
Three guards ($#, -f, -r), then -e for the destination and touch to create it. Put cp itself into the if:
if [ ! -e "$destination" ]; then
echo "Destination file '$destination' does not exist, creating it."
touch "$destination"
fi
if cp "$source" "$destination"; then
echo "Content copied from '$source' to '$destination'."Task 2
Search lines for a word
The script takes a file name and a word and prints the lines that contain the word. Searching menu.txt for wine must find both Wine and wine – but not Winegum. If no line contains the word, say so and exit with a non-zero code.
menu.txt
Salad Wine
Pasta wine
Pizza
Hamburger
Winegum pudding
Salmon
Carp Wine$ ./search_word.sh menu.txt wine
Salad Wine
Pasta wine
Carp Wine
$ ./search_word.sh menu.txt cheese
No line contains the word 'cheese'.Approach
grep already prints the matching lines; the script only has to react to "no match" – which is grep's exit code 1. -i ignores case, -w keeps Winegum out.
if ! grep -iw "$word" "$file"; then
echo "No line contains the word '$word'." >&2
exit 1
fiLoop version
Split every line into words with read -r -a, and compare each word with the search word. shopt -s nocasematch makes == ignore case:
shopt -s nocasematch # == ignores case from here on
while IFS= read -r line || [ -n "$line" ]; do
read -r -a words <<< "$line" # split the line into an array of words
for w in "${words[@]}"; do
[[ $w == "$word" ]] && { echo "$line"; ((found++)); break; }
done
done < "$file"Task 3
Find the files that contain a word
The script takes a directory and a word, and prints the names of the files in the directory and all its subdirectories that contain the word.
$ ./find_files_with_word.sh main_folder banana
main_folder/text1.txt
main_folder/text3.txt
main_folder/sub_folder/text2.txtApproach
find -type f lists every file in the tree; a while read loop takes them one by one and asks grep -q. Nothing in the loop has to survive it, so the pipe is fine here.
find "$directory" -type f | while read -r file; do
if grep -q "$word" "$file"; then
echo "$file"
fi
donegrep -rl "$word" "$directory" does the same in one command (-r recursive, -l file names only). Write the loop anyway – it adapts to any "do something with each file" task.
Loop version without find
A function that walks one directory and calls itself for every subdirectory:
search_dir() {
local entry
for entry in "$1"/*; do
if [ -d "$entry" ]; then
search_dir "$entry" # the function calls itself for subfolders
elif [ -f "$entry" ] && contains_word "$entry"; then
echo "$entry"
fi
done
}Task 4
Uppercase a file in place
The script takes a file name (or a full path) and rewrites the file's content in uppercase. The file itself must change – no new output file. Try it on a copy of all_lower.txt.
$ ./copy.sh all_lower.txt test.txt # work on a copy first
$ ./uppercase.sh test.txt
$ cat test.txt
APPLE
PEAR
BANANA
ORANGE
PINEApproach
Check -f and also -w (you are going to write it). Then the temporary file from Fig. 6:
[ -w "$file" ] || { echo "Error: '$file' is not writable" >&2; exit 1; }
tr '[:lower:]' '[:upper:]' < "$file" > "$file.tmp"
mv "$file.tmp" "$file"Task 5
Switches
The script runs a different command depending on its option: -d prints the date and time, -w the logged-in user, -l lists the user's home directory. With a wrong option, or none, it explains the valid options and exits with a non-zero code.
$ ./switches.sh -w
student
$ ./switches.sh -x
Error: unknown option '-x'
Usage: ./switches.sh -d | -w | -l
-d print the current date and time
-w print the name of the logged in user
-l list the home directory of the userApproach
A case on "$1", one branch per option. The help text goes into a usage function because two places print it – too few arguments, and the *) branch. Use $USER and $HOME rather than hard-coded names.
usage() {
echo "Usage: $0 -d | -w | -l"
echo " -d print the current date and time"
}
…
*) echo "Error: unknown option '$1'" >&2; usage >&2; exit 1 ;;Task 6
The most common illness of a month
The script takes the patient file and a month as two digits (01–12) and prints the most common illness in that month with its number of patients. Reject anything that isn't a valid month. If nobody was ill that month, say so.
$ ./most_common_illness.sh patients.txt 10
Most common illness in month 10: headache (3 patient(s))
$ ./most_common_illness.sh patients.txt 02
No patients in month 02.Approach
Validate the month with a regular expression: a group with two alternatives, 0[1-9] or 1[0-2]. Then the pipeline from Fig. 5, with a grep in front that keeps the lines of that month. The pattern is in double quotes so $month is substituted. An empty $result means no patients.
[[ $month =~ ^(0[1-9]|1[0-2])$ ]] || { echo "Error: month must be 01-12" >&2; exit 1; }
result=$(grep -E ",[0-9]{4}-$month-[0-9]{2}," "$file" | cut -d ',' -f 3 |
sort | uniq -c | sort -rn | head -n 1)
read -r count illness <<< "$result"Loop version
Read the file with the loop from Fig. 7, fill the parallel arrays, then replace sort -rn | head -n 1 with a maximum search:
best=0
for (( i=1; i<${#counts[@]}; i++ )); do
(( counts[i] > counts[best] )) && best=$i
done
echo "${illnesses[best]} (${counts[best]} patient(s))"Task 7
Back up a folder
The script takes a directory and backs up the .txt files directly inside it (not in subdirectories): it creates <name>_backup_<today> in the current directory, copies the files there, prints each copied name, and finally the number of files. If there was no .txt file at all, it says so and removes the empty backup folder.
$ ./backup.sh main_folder
Copied: main_folder/text1.txt
Copied: main_folder/text3.txt
2 file(s) backed up to 'main_folder_backup_YYYY-MM-DD'.Approach
Build the folder name from basename and date +%F, loop with the glob pattern and its -f guard, count with ((count++)), and rmdir the folder when the count is 0.
backup_dir="$(basename "$directory")_backup_$(date +%F)"
mkdir -p "$backup_dir"
for file in "$directory"/*.txt; do
[ -f "$file" ] || continue
cp "$file" "$backup_dir" && echo "Copied: $file" && ((count++))
doneTask 8
Queries on the patient file
The script takes the patient file, an option and – for some options – a value, and answers one question per option. Each option gets its own function. A missing value, an unknown option or an invalid month ends with an error.
| Option | Answer |
|---|---|
-i <illness> | the names of the patients with that illness |
-m <month> | the number of patients in that month (01–12) |
-l | the illnesses in alphabetical order, each once |
-c | the number of patients per illness, most common first |
-h | the usage text |
$ ./patients_query.sh patients.txt -i headache
Anna Kovacs
Peter Szabo
…
$ ./patients_query.sh patients.txt -c
6 headache
4 cold
3 leg pain
1 stomach acheApproach
It's task 5's case with task 6's pipelines inside functions. -l is cut … | sort -u, -c is the counting pipeline without head, -m is grep -c with the month pattern. The file name is set outside the functions, so it's global and every function sees it; their own variables are local.
file=$1; option=$2; value=$3 # file is global: every function sees it
case "$option" in
-i) [ -z "$value" ] && { echo "Error: -i needs an illness" >&2; exit 1; }
list_patients_by_illness "$value" ;;
-l) list_illnesses ;;
# … -m, -c, -h, and *) for the rest
esacFor -i, anchor the illness to the end of the line, so the whole last field must match, not just part of it:
grep -i ",$illness$" "$file" | cut -d ',' -f 1Task 9
An interactive menu
Run from the tasks folder, the script shows a menu, reads a choice and performs it, until the user chooses 0. On exit it prints how many operations it ran. Requirements: one function per menu item; option 5 asks for a word with read and reuses task 2; an invalid choice prints an error and doesn't count as an operation; at start, check that menu.txt and patients.txt exist; Ctrl+D ends the script instead of looping forever.
===== MENU =====
1) Current date and time
2) Logged in user and home directory
3) Number of .txt files in the current directory
4) List of illnesses (patients.txt)
5) Search a word in menu.txt
0) Exit
Your choice: 7
Error: invalid choice '7'
…
Your choice: 0
Bye! You ran 1 operation(s).Approach
while true around everything, the failing-read guard from the errors section, and a case with one-line branches. break leaves on 0; continue in the *) branch skips the counter at the end of the loop body:
while true; do
print_menu
if ! read -r -p "Your choice: " choice; then echo; break; fi
case "$choice" in
1) show_date ;;
0) break ;;
*) echo "Error: invalid choice '$choice'" >&2; continue ;;
esac
((operations++)) # skipped by continue, so invalid input isn't counted
doneFurther practice
Task 10
Patients of a year
Extend task 8 with -y <year>: the number of patients in that year. Check that the year is four digits.
Hint
[[ $year =~ ^[0-9]{4}$ ]] for the check, and grep -c ",$year-" for the count – the comma in front makes sure you match the date field.
Task 11
Line counts of a folder
The script takes a directory and prints the name and the number of lines of every .txt file in it, then the total.
Hint
wc -l < "$file" prints only the number (with a file argument, wc also prints the name). Add it to a running total with $(( )); the loop reads from a glob, not a pipe, so the total survives.
Task 12
Patients of a month, sorted
The script takes a month and prints the names of that month's patients in alphabetical order. If there is no argument, it asks for the month with read.
Hint
month=$1, then if [ -z "$month" ]; then read -r -p "Month: " month; fi, and validate afterwards – the checks apply to both sources. The work is grep → cut -f 1 → sort.
Task 13
A backup menu item
Add a menu item to task 9 that backs up main_folder the way task 7 does.
Hint
Move the body of task 7 into a function that takes the directory as $1, replace exit with return (an exit would end the whole menu), and add one case branch that calls it.
Summary
- here string
<<< "$s"– one string as a command's standard input- subshell
- a child copy of the shell; each side of a pipe runs in one, and its variables die with it
- guard clause
- a check at the top that ends the script on bad input
- exit status
- 0 for success, 1–255 for failure; what if and && test
- filter
- a command that reads stdin and writes stdout, so it fits into a pipe
- glob pattern
- wildcards like
*and[0-9], used by file names and bycase - IFS
- the characters read splits a line at; IFS=, turns read into a CSV splitter
- Arguments are fixed at start;
readtakes a line at run time, from the keyboard, a file, a pipe or a here string. - Variables set on the right side of
|vanish. Use<<<, a filter, ordone < …instead. - Check input first; errors go to stderr with
exit 1; put commands likecpandgrep -qstraight intoif. caseruns the first matching branch; end branches with;;and put*)last.- Most-common-value:
cut | sort | uniq -c | sort -rn | head -n 1– built and checked one stage at a time. - Guard glob loops with
-f; never redirect into the file you are reading – use a temp file andmv. - Every filter has a loop equivalent; the loop needs care with the last line, empty lines and
\r.