﻿# 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.

Source: https://laszlonemes.com/university/computer-systems/shell-practice · Author: László Nemes

Session 4 · Computer Systems

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.

20 min read `read, here strings and the pipe trap` `Validating input, stderr and exit codes` `The case statement` `cut | sort | uniq -c pipelines` `Looping over files safely` `Filters vs while-read loops`

In [the previous session](https://laszlonemes.com/university/computer-systems/shell-scripts) 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.

After this session you can

- feed read from the keyboard, a file, a command or a string – and avoid the pipe-into-read trap
- write the standard opening of a script: usage function, argument checks, errors to stderr, exit 1
- dispatch options and menu choices with case
- build the cut | sort | uniq -c | sort -rn | head pipeline step by step
- loop over files safely and modify a file in place without destroying it
- solve the same task with filters and with a while-read loop, and say when to use which

## 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.

> **Fig. 1** · Arguments arrive once, at start. read takes one line at a time, and its line can come from four places.
>
> *Diagram:* A script gets data in two ways. Arguments are fixed when the script starts and land in $1, $2 and so on. The read builtin takes one line at a time while the script runs, from the keyboard, a file, another command's output or a string.

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.

```shell
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"
done
```

`read` 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

```shell
# 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 |

```shell
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)
```

Good to know

**Here strings are a bash feature**

Plain `sh` doesn't know `<<<`. Our scripts start with `#!/bin/bash`, so use it freely – but don't run them as `sh script.sh`.

## 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](https://laszlonemes.com/university/computer-systems/processes-filters) explains why: a child process can't change its parent's variables.

> **Fig. 2** · Top: the read after the pipe runs in a child shell, so its variables vanish. Bottom: a here string keeps read in the current shell.
>
> *Diagram:* Each side of a pipe runs in its own process. With echo | read, the read runs in a subshell, sets its variables there, and they vanish when the subshell exits. A here string (<<<) keeps read in the current shell, so the variables survive.

```shell
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 / headache
```

The 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`:

```shell
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 here
```

Common mistake

**Variables set after a pipe don't survive it**

If a value has to outlive the pipe – a counter, a `found` flag, a `read` result – don't set it on the right side of `|`. Use a here string for single values, let a filter like `wc -l` do the counting, or feed the loop with `done < file` or `done < <(command)` so it runs in the current shell.

## Fail 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 `if`s (Fig. 3).

> **Fig. 3** · Guard clauses: each check either passes the input on or ends the script with a message on stderr and exit 1.
>
> *Diagram:* A robust script checks its input at the top: argument count, files, formats. Each failed check prints a message to stderr and exits with a non-zero code. Only input that passed every check reaches the real work.

| 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

```shell
#!/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 check
```

### Test 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.

```shell
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"
fi
```

### read 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:

```shell
while true; do
    if ! read -r -p "Your choice: " choice; then
        echo                # Ctrl+D: end of input – leave the loop
        break
    fi
    # … handle $choice …
done
```

In your assignment

**Your script will be run with wrong input on purpose**

In the shell-script assignment and on the practical test, the first things checked are usually the unhappy paths: no arguments, too many, a file that doesn't exist, a month like `13`. Write the checks *first*, before the real work, and test each one by hand. Every error message goes to stderr, every error ends with a non-zero exit code, and the results – only the results – go to stdout. Then `./script.sh … > out.txt` produces a clean file, and an error still shows up on the screen.

## The 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).

```shell
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
```

> **Fig. 4** · For the value -w, case skips -d), runs the -w) branch, and ;; jumps straight to esac – the later patterns are never looked at.
>
> *Diagram:* case compares the value with each pattern from top to bottom and runs only the first branch that matches. ;; ends the branch and jumps to esac. The *) pattern matches anything, so it must come last as the catch-all.

| 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 |

Common mistake

***) first means nothing else ever runs**

The first match wins, and `*` matches everything. Put the catch-all at the top and every value ends up in the error branch. Put it last, and make it print the usage to stderr and `exit 1`.

## Filters and the counting pipeline

A **filter** reads standard input and writes standard output, so filters chain with `|` ([Session 2](https://laszlonemes.com/university/computer-systems/processes-filters) 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

```text
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 pain
```

**Which 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:

```shell
cut -d ',' -f 3 patients.txt
```

```text
headache
headache
headache
cold
cold
stomach ache
…
```

Sort it so equal lines sit next to each other, then let `uniq -c` count each group:

```shell
cut -d ',' -f 3 patients.txt | sort | uniq -c
```

```text
      4 cold
      6 headache
      3 leg pain
      1 stomach ache
```

Sort by the count, largest first, and keep the top line:

```shell
cut -d ',' -f 3 patients.txt | sort | uniq -c | sort -rn | head -n 1
```

```text
      6 headache
```

> **Fig. 5** · The data shrinks along the pipe: 14 illness names, sorted, folded into 4 counts, ranked, and cut to the winner.
>
> *Diagram:* The most-common-item pipeline: cut keeps the illness column, sort puts equal lines next to each other, uniq -c collapses them into counts, sort -rn puts the biggest count first and head -n 1 keeps that one line.

The 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`.

Common mistake

**uniq without sort**

`uniq` only merges *neighbouring* identical lines. Without the `sort` in front, the same illness shows up in several groups with partial counts. It is always `sort | uniq -c`.

Good to know

**tr and accented letters**

`tr '[:lower:]' '[:upper:]'` reliably converts the letters a–z. Whether accented letters such as *á* or *é* change too depends on the system and its language settings (the *locale*) – don't count on it.

In your assignment

**Build the pipeline in the terminal, then paste it**

Don't write a five-stage pipeline straight into the script. Type the first stage in the terminal, check the output, press the up arrow, add `| sort`, check again – and only copy the line into the script when the final output is right. When a pipeline gives the wrong answer, cut it back stage by stage until the output is what you expect; the bug is in the next stage.

## 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:

```shell
for file in "$dir"/*.txt; do
    if [ ! -f "$file" ]; then
        continue           # no match: $file holds the literal text "$dir/*.txt"
    fi
    echo "$file"
done
```

The `-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":

```text
$ for f in empty_dir/*.txt; do echo "got: $f"; done
got: empty_dir/*.txt
```

The `*` 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:

```shell
find "$dir" -type f | while read -r file; do
    echo "$file"
done
```

Two 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:

```shell
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-DD
```

## Never 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).

> **Fig. 6** · The shell empties the output file before the command runs. Write to a temporary file and move it over the original instead.
>
> *Diagram:* Redirecting a command's output into the file it reads empties the file: the shell opens the output with > and truncates it before the command starts, so the command reads nothing. Write to a temporary file, then move it over the original.

```shell
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 replace
```

The `&&` 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, `if`s, 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).

> **Fig. 7** · IFS=, makes read split at commas, so one CSV line becomes three variables – and a substring of $date replaces the month grep.
>
> *Diagram:* IFS=, read -r name date illness splits one CSV line at the commas and puts each field into its own variable. With the fields in variables, a substring like ${date:5:2} replaces the grep that filtered by month.

the loop version of grep + cut

```shell
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, `read` still 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](https://laszlonemes.com/university/computer-systems/files-line-endings) 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:

```shell
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` |

Good to know

**Associative arrays**

Bash also has arrays indexed by strings – `declare -A counts; ((counts[$illness]++))` – which make the parallel arrays and the search unnecessary. Older bash versions, such as the one macOS ships, support neither them nor `${line^^}`; the loop solutions stick to plain arrays, `tr` and `shopt -s nocasematch` so they run everywhere.

## 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:

```text
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.txt
```

Rules 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 with `exit 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.

```text
$ ./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 exist
```

**Approach:**

Three guards (`$#`, `-f`, `-r`), then `-e` for the destination and `touch` to create it. Put `cp` itself into the `if`:

```shell
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

```text
Salad Wine
Pasta wine
Pizza
Hamburger
Winegum pudding
Salmon
Carp Wine
```

```text
$ ./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.

```shell
if ! grep -iw "$word" "$file"; then
    echo "No line contains the word '$word'." >&2
    exit 1
fi
```

**Loop version:**

Split every line into words with `read -r -a`, and compare each word with the search word. `shopt -s nocasematch` makes `==` ignore case:

```shell
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.

```text
$ ./find_files_with_word.sh main_folder banana
main_folder/text1.txt
main_folder/text3.txt
main_folder/sub_folder/text2.txt
```

**Approach:**

`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.

```shell
find "$directory" -type f | while read -r file; do
    if grep -q "$word" "$file"; then
        echo "$file"
    fi
done
```

`grep -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:

```shell
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`.

```text
$ ./copy.sh all_lower.txt test.txt     # work on a copy first
$ ./uppercase.sh test.txt
$ cat test.txt
APPLE
PEAR
BANANA
ORANGE
PINE
```

**Approach:**

Check `-f` and also `-w` (you are going to write it). Then the temporary file from Fig. 6:

```shell
[ -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.

```text
$ ./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 user
```

**Approach:**

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.

```shell
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.

```text
$ ./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.

```shell
[[ $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:

```shell
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.

```text
$ ./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.

```shell
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++))
done
```

Task 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 |

```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 ache
```

**Approach:**

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`.

```shell
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
esac
```

For `-i`, anchor the illness to the end of the line, so the whole last field must match, not just part of it:

```shell
grep -i ",$illness$" "$file" | cut -d ',' -f 1
```

Task 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.

```text
===== 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:

```shell
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
done
```

### Further 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 by `case`
- **IFS** – the characters read splits a line at; IFS=, turns read into a CSV splitter

- Arguments are fixed at start; `read` takes 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, or `done < …` instead.
- Check input first; errors go to stderr with `exit 1`; put commands like `cp` and `grep -q` straight into `if`.
- `case` runs 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 and `mv`.
- Every filter has a loop equivalent; the loop needs care with the last line, empty lines and `\r`.
