We reach the module's milestone, and a promise we have been carrying since 04-02. daily-report.sh is today a robust program: it validates its environment, protects itself with flock, aborts with a diagnosis, validates text with regex and directs its output precisely. But it is also a single file of several hundred lines where log_info, die, validate_env and percentage coexist —functions that have nothing to do with reports and that any other script in the toolkit would need—. The temptation, when you write archive-history.sh next week, will be to copy and paste. And that is where the decay begins: two copies that diverge, a bug fixed in one and not in the other, three different logging conventions. In this lesson you turn that file into a project with an architecture.

Contents

  1. source versus executing
  2. What a library is in Bash
  3. Locating the library without paths betraying you
  4. Include guards
  5. Names: what is public and what is private
  6. Configuration files
  7. Precedence order
  8. The toolkit's final structure
  9. lib/common.sh
  10. daily-report.sh as an orchestrator
  11. Testing the library by hand

  1. source versus executing

In 03-01 you saw the ways to run a script. The one that matters now is the one that does not create a process: as opposed to ./script.sh or bash script.sh, which launch a new process from which nothing comes back, source script.sh (or its POSIX form . script.sh) runs the file in this shell.

Executing source
Process A new one (fork + exec) The current one
Variables and functions it defines Die with the child Stay available
Any cd it does Does not affect you Changes your directory
Needs +x permission and a shebang Yes No
exit inside it Ends the script Ends your shell

The last two rows explain why a library is not a normal script. Since it is loaded with source, it needs neither an executable shebang nor execute permission. And above all: an exit in a library kills whoever loads it, including your interactive session if you were testing it; inside a library you use return. This is nothing new: it is the same mechanism by which ~/.bashrc defines functions your shell knows about (01-02). A library is a .bashrc for your scripts.

  1. What a library is in Bash

A library is a file containing definitions only: functions, constants and, at most, default values. Nothing that acts on its own.

# lib/common.sh — CORRECT: it only defines
log_info() { printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2; }

# lib/bad.sh — INCORRECT: all of this ACTS when loaded
log_info "loading library"            # pollutes the output of whoever uses it
set -euo pipefail                     # IMPOSES its policy on whoever loads it
cd /srv/veloz                         # changes the directory of whoever loads it

The rule is that loading the library must be silent and without side effects. The set -euo pipefail inside a library is especially treacherous: when you source it, it applies to the shell that loads it, so it can change the error behavior of a script that was not expecting it. The strict policy is decided by the executable, not by the library. Conventions: .sh extension (or .bash), no executable shebang —optionally a #!/usr/bin/env bash as a hint for editors and for shellcheck, but without +x permission— and a comment header saying what it offers.

  1. Locating the library without paths betraying you

Here is the real technical problem. How does bin/daily-report.sh find lib/common.sh?

source lib/common.sh              # WRONG: relative to the CURRENT directory, not the script
source ~/veloz-ops/lib/common.sh  # Works, but only for you and in your $HOME

Relative paths are resolved from the current working directory, which is wherever the person launching the script happens to be, not where the script lives. Since ~/veloz-ops/bin is on the PATH (we put it there in 01-02), the normal thing is to invoke it from anywhere, and the source fails. The canonical idiom is this:

BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/common.sh"

Taken apart from the inside out:

  • ${BASH_SOURCE[0]} is the path of the file being executed. It is used instead of $0 for two reasons: $0 is bash when the script is invoked as bash script.sh, and inside a file loaded with source, $0 is still the main script's while BASH_SOURCE[0] is the library. For a library that needs to know where it is, $0 always gives the wrong answer.
  • dirname gives the directory containing it (04-04) and /.. climbs to the project root; cd ... && pwd turns that path, which may be relative (./bin/..), into an absolute, normalized one; and it is all quoted, in case the path contains a space.

With that, the script works when invoked as ./bin/daily-report.sh, as ~/veloz-ops/bin/daily-report.sh, from the PATH or from cron with any working directory. And if you also want to follow symbolic links down to the real file, readlink -f (05-01) over ${BASH_SOURCE[0]} before the dirname resolves it.

  1. Include guards

If daily-report.sh loads common.sh and also report.sh, which in turn loads common.sh, the file is processed twice. Redefining functions is harmless, but a duplicated readonly causes a fatal error under strict mode (VELOZ_VERSION: readonly variable). The solution is the same idea as C's include guards:

[[ -n "${_COMMON_SH:-}" ]] && return 0       # already loaded: leave without doing anything
readonly _COMMON_SH=1

A top-level return 0 in a file loaded with source is legal and means "stop reading this file". The :- is mandatory because the script loading it has set -u (05-03) and the variable does not yet exist the first time around. The leading underscore marks the variable as internal.

  1. Names: what is public and what is private

Bash has no namespaces: everything lives in a single global scope. If your library defines log() and your colleague's does too, the last one loaded silently wins. The remedy is a prefix:

veloz::log_info()  { ... }      # double-colon style
veloz_log_info()   { ... }      # underscore style, more portable
_veloz_normalize() { ... }      # leading underscore: INTERNAL use, do not call it

Bash allows :: in function names and it reads very nicely, but it is not POSIX and some old tools choke on it; veloz_ is the safe option. Pick one and be consistent. The public/private distinction is by convention, the interpreter does not enforce it: the leading underscore is a promise that "this may change without warning, do not depend on it". What you really can control is variable scope: every variable inside a function goes with local (04-02), and that way it does not pollute the script using it.

  1. Configuration files

~/veloz-ops/etc/veloz-ops.conf has existed since the start of the course with permissions 600. Its simplest form is a file of assignments (VELOZ_CSV=/srv/veloz/data/shipments.csv, VELOZ_LOG_LEVEL=info, VELOZ_CITIES="Valencia Sevilla Bilbao Madrid") loaded with [[ -r "$BASE_DIR/etc/veloz-ops.conf" ]] && source "$BASE_DIR/etc/veloz-ops.conf". It is convenient —it accepts comments, quotes, even $(...)— and it carries a risk you must be aware of: source executes arbitrary code. A line rm -rf ~ in that file runs with your permissions. That is why the 600 is not decorative: if someone can write to your configuration file, they can run whatever they want as you. The full implications are covered in 08-03.

The safe alternative is to parse key=value without executing anything, using what you learned in 05-04:

# _veloz_load_conf — Loads key=value without executing code. Usage: ... <file>
_veloz_load_conf() {
    local file="${1:?}" key value line
    [[ -r "$file" ]] || return 0
    while IFS= read -r line; do
        [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
        key="${BASH_REMATCH[1]}"; [[ "$key" == VELOZ_* ]] || continue   # only ours
        value="${BASH_REMATCH[2]}"; value="${value%\"}"; value="${value#\"}"
        printf -v "$key" '%s' "$value"                            # indirect assignment
    done < "$file"
}

Three modules meet here: the regex with BASH_REMATCH from 05-04 (which additionally discards comments and empty lines by not matching), the ${v%\"} expansions from 04-04 and the while read loop from 04-01. The VELOZ_* filter stops the file from redefining PATH or HOME, and printf -v assigns to the variable whose name is in $key: the safe way of doing indirect assignment without eval.

  1. Precedence order

Here we close the option design started in 03-05. When the same value can come from four places, you need an explicit and documented order, from lowest to highest priority:

Level Source Example Beats
1 Default value in the code VELOZ_CSV=/srv/veloz/data/shipments.csv Nothing
2 Configuration file etc/veloz-ops.conf The default
3 Environment variable VELOZ_CSV=/tmp/x.csv daily-report.sh The previous ones
4 Command-line option --csv /tmp/x.csv All of them

The principle: the closer to the user and to the moment of execution, the higher the priority. Implementing it is simple if you respect the loading order, and : "${VAR:=value}" is the idiom for "assign only if it is empty or undefined" —the := assigns, unlike the :- which only substitutes, and the leading : is the null command— so that an already-defined environment variable survives:

: "${VELOZ_CSV:=/srv/veloz/data/shipments.csv}"   # 1. default, only if it does not exist
_veloz_load_conf "$BASE_DIR/etc/veloz-ops.conf"   # 2. config (respects what is already set)
# 3. the environment was already set before the script started
while [[ $# -gt 0 ]]; do case "$1" in             # 4. options (03-05)
    --csv) VELOZ_CSV="$2"; shift 2 ;; *) break ;;
esac; done

  1. The toolkit's final structure

~/veloz-ops/
├── bin/                    # executables (+x, with shebang). They are on the PATH
│   ├── daily-report.sh
│   └── archive-history.sh
├── lib/                    # libraries (no +x, no shebang, definitions only)
│   ├── common.sh           # logging, errors, validations, utilities
│   └── report.sh           # report-specific logic
├── etc/veloz-ops.conf      # configuration (permissions 600)
└── logs/                   # output: reports, traces, lock files

The allocation rule in one sentence: bin/ holds what is invoked, lib/ what is reused, etc/ what changes between machines and logs/ what is generated. It is the same FHS logic you saw in 01-03, applied at project scale, and that is why it feels familiar to anyone opening the repository. A practical criterion for deciding where a function goes: if a script that had nothing to do with reports would need it, it goes in common.sh; if it only makes sense when talking about shipments and cities, it goes in report.sh.

  1. lib/common.sh

#!/usr/bin/env bash
# common.sh — Shared utilities for the Veloz Envíos toolkit.
# Load with: source "$BASE_DIR/lib/common.sh"
[[ -n "${_COMMON_SH:-}" ]] && return 0
readonly _COMMON_SH=1 VELOZ_VERSION='1.0'
readonly _VELOZ_RE_DATE='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'

# veloz_log_info — Informational message to stderr. Usage: veloz_log_info <text...>
veloz_log_info() {
    [[ "${VELOZ_LOG_LEVEL:-info}" == quiet ]] && return 0
    printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2
}
# veloz_log_error — Error message to stderr. Usage: veloz_log_error <text...>
veloz_log_error() { printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2; }
# veloz_die — Fatal error and exit. Usage: veloz_die <code> <text...>
veloz_die() { local c="${1:?}"; shift; veloz_log_error "$*"; exit "$c"; }

# veloz_validate_date — Is it YYYY-MM-DD and does it exist? Usage: veloz_validate_date <date>
veloz_validate_date() { [[ "${1:-}" =~ $_VELOZ_RE_DATE ]] && date -d "$1" &>/dev/null; }

# veloz_percentage — Computes a/b*100 with 2 decimals. Usage: veloz_percentage <a> <b>
veloz_percentage() {
    (( ${2:?} == 0 )) && { printf '0.00\n'; return 0; }
    bc -l <<< "scale=2; ${1:?} * 100 / $2"
}
# veloz_validate_env — Checks dependencies and paths. Usage: veloz_validate_env
veloz_validate_env() {
    local cmd
    for cmd in bc date find flock; do
        command -v "$cmd" > /dev/null || veloz_die 69 "Missing utility: $cmd"
    done
    [[ -r "${VELOZ_CSV:?not defined}" ]] || veloz_die 66 "Cannot read $VELOZ_CSV"
    mkdir -p "${VELOZ_LOG_DIR:?}" || veloz_die 73 "Cannot create $VELOZ_LOG_DIR"
}

Notice what is not there: no set -euo pipefail, no executable shebang, not a single line that acts when loaded beyond the guard and the constants. veloz_die is the only one that calls exit, and that is correct because it is designed to be used from an executable, not from an interactive session.

  1. daily-report.sh as an orchestrator

#!/usr/bin/env bash
# daily-report.sh — Veloz Envíos daily delivery report.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/common.sh"
source "$BASE_DIR/lib/report.sh"
: "${VELOZ_CSV:=/srv/veloz/data/shipments.csv}"
: "${VELOZ_LOG_DIR:=$BASE_DIR/logs}"
_veloz_load_conf "$BASE_DIR/etc/veloz-ops.conf"

main() {
    local report_date subcommand; report_date=$(date +%F)
    while [[ $# -gt 0 ]]; do case "$1" in       # the option loop from 03-05
        --date) veloz_validate_date "${2:-}" || veloz_die 64 "Invalid date: ${2:-}"
                report_date="$2"; shift 2 ;;
        --debug) enable_debug; shift ;;
        -h|--help) usage; exit 0 ;;
        *) break ;;
    esac; done
    subcommand="${1:-summary}"

    exec 9> "$VELOZ_LOG_DIR/.report.lock"
    flock -n 9 || veloz_die 75 "A report is already running"
    WORKDIR=$(mktemp -d); readonly WORKDIR
    trap 'rm -rf "$WORKDIR"' EXIT INT TERM
    veloz_validate_env
    accumulate_day "$report_date"
    case "$subcommand" in
        summary) generate_report "$report_date" "$VELOZ_LOG_DIR/report-$report_date.txt" ;;
        detail)  detail_table "$report_date" ;;
        cities)  cities_table ;;
        *) veloz_die 64 "Unknown subcommand: $subcommand" ;;
    esac
}
main "$@"

This is the result of five modules. The executable barely implements anything anymore: it locates its base, loads its libraries, resolves the configuration, processes options, protects itself with flock and a trap, and dispatches. All the reusable logic lives in lib/. When you write archive-history.sh, the first three lines will be identical and you will get logging, errors and validations for free.

  1. Testing the library by hand

A well-made library can be loaded in an interactive session, and that is what makes it verifiable:

$ source ~/veloz-ops/lib/common.sh
$ veloz_percentage 11 128                                   # 8.59
$ veloz_validate_date 2026-02-31 && echo ok || echo bad     # bad
$ declare -F | grep veloz          # every function defined
$ type veloz_percentage            # see a function's code

declare -F lists the names of the defined functions and type shows the body (01-04). If any output appears when you source it, there is top-level code that should not be there. For real automated tests —assertions, edge cases, a results report— the tool is bats, in 08-06. And shellcheck -x follows the source calls so it analyzes the libraries too, in 08-05.

Common Mistakes and Tips

  • source lib/common.sh with a relative path. It works in your directory and fails from cron or from the PATH.
  • Using $0 to locate the script. It gives bash with bash script.sh and the wrong file inside a library. Use ${BASH_SOURCE[0]}.
  • exit inside a library loaded into your shell. It closes your terminal. Use return.
  • Top-level code in the library, and especially a set -euo pipefail: it pollutes the output and imposes its policy on the script loading it, perhaps without it expecting so.
  • readonly without an include guard, which gives a fatal error when loaded twice, and functions without local, which clobber variables of the script calling them weeks later.
  • Tip: document every function with a line # name — what it does. Usage: name <args> right above it. It is what you will read six months from now, and it is what lets you generate the toolkit's help with a simple grep '^# [a-z]' lib/*.sh.

Exercises

Exercise 1. Write the complete header for archive-history.sh (in bin/) that locates BASE_DIR robustly, loads lib/common.sh, applies strict mode and fails with a clear message if the library is not where it should be.

Exercise 2. Add to common.sh a function veloz_config that returns the value of a configuration key respecting the precedence order (default < file < environment), with an include guard and without using eval.

Exercise 3. Split the toolkit: decide for each of these functions whether it goes in common.sh, in report.sh or in the executable itself, and justify it: veloz_log_info, cities_table, veloz_percentage, accumulate_day, usage, veloz_validate_date.

Solutions

Solution 1.

#!/usr/bin/env bash
# archive-history.sh — Archives the Veloz Envíos monthly history.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 78
readonly BASE_DIR LIB="$BASE_DIR/lib/common.sh"
[[ -r "$LIB" ]] || { printf 'Missing library %s\n' "$LIB" >&2; exit 78; }
source "$LIB"

The check before the source matters: without it, the message would be source: lib/common.sh: No such file or directory, which does not say where it looked. Code 78 (EX_CONFIG, from the table in 05-03) is the right one: the installation is wrong, not the data. And the message uses a direct printf because veloz_log_error does not exist yet.

Solution 2.

# veloz_config — Returns the value of a key. Usage: veloz_config <KEY> [default]
veloz_config() {
    local key="${1:?}" default="${2:-}" value l
    value="${!key:-}"                         # 1) is it in the environment or already loaded?
    if [[ -z "$value" && -r "${VELOZ_CONF:-}" ]]; then
        while IFS= read -r l; do              # 2) look in the file
            [[ "$l" =~ ^[[:space:]]*"$key"=\"?([^\"]*)\"?$ ]] || continue
            value="${BASH_REMATCH[1]}"; break
        done < "$VELOZ_CONF"
    fi
    printf '%s\n' "${value:-$default}"        # 3) default as the last resort
}

${!key} is indirect expansion: it obtains the value of the variable whose name is in $key. It is the read-side equivalent of the printf -v from section 6, and both avoid eval, which would execute the contents of the configuration file —exactly the risk we are running from—. The order of the three branches is the precedence table.

Solution 3.

Function Destination Reason
veloz_log_info, veloz_percentage, veloz_validate_date lib/common.sh Generic logging, arithmetic and validation: they know nothing about shipments
accumulate_day, cities_table lib/report.sh They know the CSV format, the shipment statuses and how to present them
usage The executable Every script has its own options; it is not reusable

The criterion is a single question: would a script that does not talk about shipments need it? If the answer is yes, it goes in common.sh. usage is the interesting case: even though every script has one, its content is different in each, so what is shareable is not the function but the convention that it exists.

Conclusion

source runs a file in the current shell, and everything else follows from that: the functions and variables it defines stay available, no shebang or execute permission is needed, and an exit would kill whoever loads it —inside a library you use return—. A library contains definitions only, with no code that acts, no set -euo pipefail imposing someone else's policies and no messages when loaded. It is located with BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)", never with $0 nor with relative paths, because a script gets invoked from any directory and from the PATH. A guard [[ -n ${_COMMON_SH:-} ]] && return 0 prevents double loading and the duplicated-readonly error, and a veloz_ prefix stands in for the namespaces Bash does not have, with the leading underscore marking what is private. The configuration lives in etc/veloz-ops.conf with permissions 600 —because loading it with source executes whatever it contains— or is parsed as key=value with BASH_REMATCH and printf -v, and it resolves its conflicts with an explicit precedence: default < file < environment < command line. The result is ~/veloz-ops/ with bin/, lib/, etc/ and logs/, and a daily-report.sh that no longer implements: it orchestrates.

With this Module 5 closes, and with it the part of the course devoted to what Bash can do on its own. The Veloz Envíos toolkit is today robust, safe with its files and processes, able to diagnose its own failures, precise when validating text, master of its input and output, and modular. But it is still clumsy in one particular area: every time it has to do column arithmetic over the CSV, it chains cut, sort, uniq and loops that read the file several times; every time text has to be rewritten, the tools are missing; and when veloz-api stops being a process you check with pgrep and becomes an API you have to query, Bash alone does not reach.

In Module 6 in come the external tools that multiply what Bash can do: awk, which processes columns and aggregates in a single pass what today costs you twenty lines (06-01); sed, for transforming text in a stream with the regexes you already master (06-02); the commands for interrogating the system —disk, memory, users, hardware— (06-03); the networking tools and the /dev/tcp we left noted (06-04); and curl with jq to talk to veloz-api and handle real JSON, which is where your toolkit will stop reading files and start integrating with the rest of the system (06-05).

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved