diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-28 09:47:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-28 09:47:22 +0300 |
| commit | 493f8aa11ce49572fa23ac06ebde323b470e1623 (patch) | |
| tree | c99276320dffe09f707f96b760c30752f3ddf9bb /src | |
| parent | ea9de473e3d6788ea08ca3697722cf89b9d1d107 (diff) | |
mr0: add CONFIG_SPECS registry; derive defaults, CLI targets, print, validation
Introduce src/lib/config.spec.source.sh: CONFIG_SPECS, a single '|'-delimited
config-field registry (name|default|has_default|cli_overridable|validation|
print_kind), using the same spec idiom as ACTION_SPECS / TEMPLATE_RENDER_FIELD_SPECS.
This replaces the parallel, hand-maintained config-knowledge lists that caused the
TARBALL_INCLUDE default-drift bug (fixed in 7r0).
Derived consumers (behaviour byte-identical):
- apply_config_defaults: loops the registry applying VAR="${VAR:-default}" for
has_default=yes scalars; arrays keep their declare -p guards.
- CLI_CONFIG_OVERRIDE_TARGETS: built from cli_overridable=yes (verified to match
CLI_OPTION_SPEC's config= targets exactly).
- print_config: emits in registry order dispatching on print_kind.
- validate_common_config: required set + per-field rule come from the registry via
config_spec_validation + validate_config_field_required/_kind; two-phase order and
historical reporting order preserved.
log_configured_action and the dry-run plan are not yet converted (bespoke prose /
intermixed computed values). shellcheck: TARBALL_SUFFIX lost its visible literal
assignment, annotated at the archive use site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/archive.source.sh | 4 | ||||
| -rw-r--r-- | src/lib/config.print.source.sh | 63 | ||||
| -rw-r--r-- | src/lib/config.source.sh | 67 | ||||
| -rw-r--r-- | src/lib/config.spec.source.sh | 126 | ||||
| -rw-r--r-- | src/lib/config.validate.source.sh | 113 | ||||
| -rwxr-xr-x | src/shuriken.sh | 30 |
6 files changed, 310 insertions, 93 deletions
diff --git a/src/lib/archive.source.sh b/src/lib/archive.source.sh index c27c65c..73d5a2e 100644 --- a/src/lib/archive.source.sh +++ b/src/lib/archive.source.sh @@ -12,6 +12,10 @@ tarball_name_plan() { local base base=$(basename "$INCOMING_DIR") + # TARBALL_SUFFIX is assigned by apply_config_defaults via the CONFIG_SPECS + # registry loop (printf -v "$name"), so shellcheck no longer sees a literal + # assignment and mistakes the uppercase name for a misspelling of a local. + # shellcheck disable=SC2153 printf '%s-<timestamp>%s\n' "$base" "$TARBALL_SUFFIX" } diff --git a/src/lib/config.print.source.sh b/src/lib/config.print.source.sh index fce3ea3..ead6fb3 100644 --- a/src/lib/config.print.source.sh +++ b/src/lib/config.print.source.sh @@ -16,37 +16,50 @@ print_shell_array_assignment() { printf ' )\n' } +# Emit the effective config as a re-sourceable block, driven by CONFIG_SPECS +# (task mr0): the field set and their print order are the registry order, and +# each field's print_kind facet selects scalar (%s=%q) vs array (%s=( ... )) +# output. This replaces the hand-kept print list that had to stay in lockstep +# with apply_config_defaults. CONFIG_SOURCE is printed first and is NOT a +# registry entry: it is the resolved config path, not a config variable. +# +# The two array fields are not plain shell variables at print time -- they are +# normalised through resolve_tar_opts / resolve_sync_destinations (which fill an +# unset/empty array with its default) -- so the loop dispatches array fields to +# those resolved local copies rather than reading the raw global. print_config() { local -a tar_opts=() local -a sync_destinations=() + local spec + local -a fields=() + local name print_kind resolve_tar_opts tar_opts resolve_sync_destinations sync_destinations print_shell_assignment CONFIG_SOURCE "$SHURIKEN_CONFIG_SOURCE" - print_shell_assignment INCOMING_DIR "$INCOMING_DIR" - print_shell_assignment DIST_DIR "$DIST_DIR" - print_shell_assignment TEMPLATE_DIR "$TEMPLATE_DIR" - print_shell_assignment FAVICON "$FAVICON" - print_shell_assignment SOURCE_URL "$SOURCE_URL" - print_shell_assignment TITLE "$TITLE" - print_shell_assignment HEIGHT "$HEIGHT" - print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT" - print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS" - print_shell_assignment THUMB_SUBDIVIDE_PERCENT "$THUMB_SUBDIVIDE_PERCENT" - print_shell_assignment THUMB_FEATURE_PERCENT "$THUMB_FEATURE_PERCENT" - print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS" - print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT" - print_shell_assignment RANDOM_SEED "$RANDOM_SEED" - print_shell_assignment SHUFFLE "$SHUFFLE" - print_shell_assignment SPLASH_PAGE "$SPLASH_PAGE" - print_shell_assignment STATS_PAGE "$STATS_PAGE" - print_shell_assignment TARBALL_INCLUDE "$TARBALL_INCLUDE" - print_shell_assignment TARBALL_SUFFIX "$TARBALL_SUFFIX" - print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" - print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" - print_shell_assignment SYNC_DELETE "$SYNC_DELETE" - print_shell_assignment SYNC_TIMEOUT "$SYNC_TIMEOUT" - print_shell_array_assignment SYNC_DESTINATIONS "${sync_destinations[@]}" - print_shell_assignment ORIGINAL_BASEPATH "$ORIGINAL_BASEPATH" + + for spec in "${CONFIG_SPECS[@]}"; do + config_spec_split "$spec" fields + name="${fields[0]}" + print_kind="${fields[5]}" + + case "$print_kind" in + scalar) + print_shell_assignment "$name" "${!name}" + ;; + array) + case "$name" in + TAR_OPTS) + print_shell_array_assignment TAR_OPTS \ + "${tar_opts[@]}" + ;; + SYNC_DESTINATIONS) + print_shell_array_assignment SYNC_DESTINATIONS \ + "${sync_destinations[@]}" + ;; + esac + ;; + esac + done } diff --git a/src/lib/config.source.sh b/src/lib/config.source.sh index c676899..b08aec9 100644 --- a/src/lib/config.source.sh +++ b/src/lib/config.source.sh @@ -43,40 +43,41 @@ missing_config() { exit 1 } +# Apply the documented defaults for every config field that has one. Driven +# entirely by CONFIG_SPECS (task mr0): each scalar entry with has_default=yes +# gets VAR="${VAR:-$default}" applied, so a field's default value lives in +# exactly one place (the registry) instead of being restated here. This is what +# eliminates the default-drift class of bug (TARBALL_INCLUDE once read 'no' here +# while the registry/default-conf said 'yes', fixed in 7r0): the default and the +# documented value can no longer disagree because they are the same datum. +# +# Notes: +# - Empty defaults are intentional and applied verbatim (e.g. FAVICON='' means +# "use the bundled default favicon"; HEIGHT/RANDOM_SEED/ORIGINAL_BASEPATH +# default to the empty string). +# - has_default=no scalars (TITLE, THUMBHEIGHT, MAXPREVIEWS, ...) are required +# and deliberately get no default; validate_common_config rejects them when +# unset. +# - The two array fields (TAR_OPTS, SYNC_DESTINATIONS) cannot use the scalar +# "${VAR:-...}" form, so they keep their `declare -p` guards below. They are +# marked print_kind=array / has_default=no in the registry so this loop +# skips them. apply_config_defaults() { - # Empty FAVICON means use the bundled default favicon; otherwise it is a path - # to a custom favicon file copied into the album as favicon.ico. - FAVICON="${FAVICON:-}" - HEIGHT="${HEIGHT:-}" - IMAGE_JOBS="${IMAGE_JOBS:-3}" - IMAGEMAGICK_TIMEOUT="${IMAGEMAGICK_TIMEOUT:-60}" - ORIGINAL_BASEPATH="${ORIGINAL_BASEPATH:-}" - RANDOM_SEED="${RANDOM_SEED:-}" - SHUFFLE="${SHUFFLE:-no}" - # SOURCE_URL is the project/source link shown in the page header bar ("Site - # generated ... with <SOURCE_URL>"). Defaults to the shuriken.sh repo; - # override it per site (e.g. to the album's own repo) via config or - # --source-url. The header bar derives the displayed text from the URL itself. - SOURCE_URL="${SOURCE_URL:-https://codeberg.org/snonux/shuriken.sh}" - SPLASH_PAGE="${SPLASH_PAGE:-yes}" - STATS_PAGE="${STATS_PAGE:-no}" - # Optional with a default (unlike the required THUMBHEIGHT): the percent - # chance a preview tile is subdivided into smaller thumbnails, and the - # percent chance it becomes a large 2x2 "feature" tile. 0 disables either. - THUMB_SUBDIVIDE_PERCENT="${THUMB_SUBDIVIDE_PERCENT:-30}" - THUMB_FEATURE_PERCENT="${THUMB_FEATURE_PERCENT:-10}" - SYNC_DELETE="${SYNC_DELETE:-yes}" - # Per-destination rsync timeout (seconds), mirroring TAR_TIMEOUT/ - # IMAGEMAGICK_TIMEOUT. Each destination in sync_dist is wrapped in - # run_with_timeout so a hung/unreachable mirror cannot block the whole sync. - SYNC_TIMEOUT="${SYNC_TIMEOUT:-300}" - # Default 'yes': a tarball of the incoming dir is included in the dist unless - # disabled. This must match the documented default in shuriken.default.conf - # (TARBALL_INCLUDE=yes) -- it previously drifted to 'no' here. 'yes' is the - # original, authoritative default (tarball inclusion was on from the start). - TARBALL_INCLUDE="${TARBALL_INCLUDE:-yes}" - TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" - TAR_TIMEOUT="${TAR_TIMEOUT:-120}" + local spec + local -a fields=() + local name default has_default + + for spec in "${CONFIG_SPECS[@]}"; do + config_spec_split "$spec" fields + name="${fields[0]}" + default="${fields[1]}" + has_default="${fields[2]}" + + if [ "$has_default" = yes ]; then + printf -v "$name" '%s' "${!name:-$default}" + fi + done + if ! declare -p TAR_OPTS >/dev/null 2>&1; then TAR_OPTS=(-c) fi diff --git a/src/lib/config.spec.source.sh b/src/lib/config.spec.source.sh new file mode 100644 index 0000000..d4778a9 --- /dev/null +++ b/src/lib/config.spec.source.sh @@ -0,0 +1,126 @@ +# ---------------------------------------------------------------------------- +# Config field registry (single source of truth, task mr0) +# ---------------------------------------------------------------------------- +# CONFIG_SPECS is the one place a config field's cross-cutting facts are +# declared. Before mr0 the same knowledge (default value, CLI-overridability, +# validation rule, how it prints) was restated in ~6 hand-maintained lists -- +# apply_config_defaults, CLI_CONFIG_OVERRIDE_TARGETS, validate_common_config, +# print_config, and parts of log_configured_action / the dry-run plan -- so +# adding or renaming an option was shotgun surgery and the lists drifted (the +# TARBALL_INCLUDE default once flipped to 'no' here while shuriken.default.conf +# still said 'yes', fixed in 7r0). Those consumers now DERIVE from this registry, +# so a field's facts live in exactly one entry. +# +# Each entry is a '|'-delimited spec, the same encoding used by ACTION_SPECS +# (action.source.sh) and TEMPLATE_RENDER_FIELD_SPECS (template.source.sh): +# +# name|default|has_default|cli_overridable|validation|print_kind +# +# name the config variable name (also the env/override key). +# default the value apply_config_defaults applies via +# VAR="${VAR:-$default}" when has_default=yes. May be empty +# (e.g. FAVICON, HEIGHT default to the empty string). +# has_default yes -> apply the scalar default above. no -> never apply a +# scalar default: either a required var (TITLE, THUMBHEIGHT, +# ...) that must be set in the config, or an array +# (TAR_OPTS / SYNC_DESTINATIONS) defaulted separately via a +# `declare -p` guard in apply_config_defaults. +# cli_overridable yes -> the field appears in CLI_CONFIG_OVERRIDE_TARGETS, i.e. +# a CLI flag (declared in CLI_OPTION_SPEC) can override it. The +# rich per-flag table (argument names, flag/value pairs) stays +# in CLI_OPTION_SPEC; this facet only drives the override-target +# list that used to be a separate hand-kept copy of it. +# validation the rule validate_common_config applies. Empty means +# validate_common_config does not check this field (SYNC_DELETE, +# the timeouts only checked on their own paths, etc. are +# validated elsewhere). One of: +# required require_config_var (non-empty) +# required-posint require_config_var + positive integer +# posint positive integer (no non-empty requirement) +# opt-posint positive integer only if set (HEIGHT) +# percentage integer 0..100 +# yesno literal yes or no +# favicon validate_favicon_config (readable file/empty) +# print_kind how print_config emits the field. scalar -> %s=%q. array -> +# %s=( ... ) via the resolve_*-fed array printer. Empty means +# print_config does not emit it (none today). CONFIG_SOURCE is +# printed separately (it is the resolved config path, not a +# config variable) and so is not a registry entry. +# +# Entry order is the canonical print order (print_config emits in this order). +# validate_common_config does NOT reuse this order directly: it runs all +# `required*` checks before any kind check (so a missing required var is reported +# before a malformed one -- see test_config_validators_fail_fast_without_errexit), +# which it achieves with two filtered passes over the registry. +# +# Declared -g so it survives being sourced from inside a function (the test +# harness sources the lib via test::source_shuriken_lib); a plain `declare -r` +# would be function-local and vanish on return. +declare -gra CONFIG_SPECS=( + 'INCOMING_DIR||no|yes|required|scalar' + 'DIST_DIR||no|yes|required|scalar' + 'TEMPLATE_DIR||no|yes|required|scalar' + 'FAVICON||yes|yes|favicon|scalar' + 'SOURCE_URL|https://codeberg.org/snonux/shuriken.sh|yes|yes||scalar' + 'TITLE||no|yes|required|scalar' + 'HEIGHT||yes|yes|opt-posint|scalar' + 'THUMBHEIGHT||no|yes|required-posint|scalar' + 'MAXPREVIEWS||no|yes|required-posint|scalar' + 'THUMB_SUBDIVIDE_PERCENT|30|yes|yes|percentage|scalar' + 'THUMB_FEATURE_PERCENT|10|yes|yes|percentage|scalar' + 'IMAGE_JOBS|3|yes|yes|required-posint|scalar' + 'IMAGEMAGICK_TIMEOUT|60|yes|no|posint|scalar' + 'RANDOM_SEED||yes|yes||scalar' + 'SHUFFLE|no|yes|yes|yesno|scalar' + 'SPLASH_PAGE|yes|yes|yes|yesno|scalar' + 'STATS_PAGE|no|yes|yes|yesno|scalar' + 'TARBALL_INCLUDE|yes|yes|yes|yesno|scalar' + 'TARBALL_SUFFIX|.tar|yes|no||scalar' + 'TAR_TIMEOUT|120|yes|no|posint|scalar' + 'TAR_OPTS||no|no||array' + 'SYNC_DELETE|yes|yes|yes||scalar' + 'SYNC_TIMEOUT|300|yes|no|posint|scalar' + 'SYNC_DESTINATIONS||no|no||array' + 'ORIGINAL_BASEPATH||yes|no||scalar' +) + +# Split one CONFIG_SPECS entry into the caller's named array (IFS='|' read), the +# same accessor pattern action_spec_field uses for ACTION_SPECS. Field indices: +# 0 name 1 default 2 has_default 3 cli_overridable 4 validation 5 print_kind +config_spec_split() { + local -r spec="$1"; shift + # shellcheck disable=SC2178 + local -n fields_ref="$1"; shift + + # fields_ref is a nameref output array filled for the caller; shellcheck + # cannot see the indirect use through the nameref. + # shellcheck disable=SC2034 + IFS='|' read -r -a fields_ref <<< "$spec" +} + +# Populate CLI_CONFIG_OVERRIDE_TARGETS from the registry: every field marked +# cli_overridable=yes. This is the list apply_cli_overrides iterates to copy +# parsed --flag values onto their config var. Declared (empty) in src/shuriken.sh +# before the libs are sourced; filled here, once CONFIG_SPECS exists. Replaces the +# hand-kept copy of CLI_OPTION_SPEC's config= targets that used to drift (mr0). +# Iteration order is registry order; it is unobservable because each override +# targets a distinct variable, so no field can shadow another. +build_cli_config_override_targets() { + local spec + local -a fields=() + + CLI_CONFIG_OVERRIDE_TARGETS=() + for spec in "${CONFIG_SPECS[@]}"; do + config_spec_split "$spec" fields + if [ "${fields[3]}" = yes ]; then + CLI_CONFIG_OVERRIDE_TARGETS+=("${fields[0]}") + fi + done +} + +# Build the override-target list at source time so it is ready before any CLI +# parsing. Guarded so sourcing this module without the shuriken.sh-level +# declaration (e.g. a narrowly scoped unit test) is a no-op rather than an error. +if declare -p CLI_CONFIG_OVERRIDE_TARGETS >/dev/null 2>&1; then + build_cli_config_override_targets +fi diff --git a/src/lib/config.validate.source.sh b/src/lib/config.validate.source.sh index 4d4466e..3ca720a 100644 --- a/src/lib/config.validate.source.sh +++ b/src/lib/config.validate.source.sh @@ -189,8 +189,86 @@ validate_imagemagick() { return 1 } +# Look up a config field's validation facet (index 4) from CONFIG_SPECS. Prints +# the validation token (e.g. required-posint, percentage, yesno) or empty if the +# field is unknown / has no validation rule. The single place validate_* +# dispatchers learn which rule a field uses (task mr0). +config_spec_validation() { + local -r name="$1"; shift + local spec + local -a fields=() + + for spec in "${CONFIG_SPECS[@]}"; do + config_spec_split "$spec" fields + if [ "${fields[0]}" = "$name" ]; then + printf '%s\n' "${fields[4]}" + return 0 + fi + done + + return 1 +} + +# Run a config field's "required" check based on its registry validation facet. +# Only required / required-posint fields are required-to-be-set; everything else +# is a no-op here. Used by validate_common_config's required pass so the set of +# required vars derives from the registry instead of a separate hand-kept list. +validate_config_field_required() { + local -r name="$1"; shift + local validation + + validation=$(config_spec_validation "$name") + case "$validation" in + required|required-posint) + require_config_var "$name" || return 1 + ;; + esac +} + +# Run a config field's "kind" check based on its registry validation facet, +# dispatching to the matching validator. This is the single source for "which +# rule validates which field" (task mr0): validate_common_config calls this per +# field in its historical order, so the rule lives in CONFIG_SPECS while the +# error-reporting order stays byte-identical. The required-only facet has no kind +# check (the required pass covers it); required-posint additionally enforces a +# positive integer here. +validate_config_field_kind() { + local -r name="$1"; shift + local validation + + validation=$(config_spec_validation "$name") + case "$validation" in + required) + ;; + required-posint|posint) + validate_positive_integer_config_var "$name" || return 1 + ;; + opt-posint) + validate_optional_positive_integer_config_var "$name" || return 1 + ;; + percentage) + validate_percentage_config_var "$name" || return 1 + ;; + yesno) + validate_yes_no_config_var "$name" || return 1 + ;; + favicon) + validate_favicon_config || return 1 + ;; + esac +} + +# Validate the config fields shared by every action that needs a loaded config. +# The set of required vars and each field's validation rule come from CONFIG_SPECS +# via the dispatchers above (task mr0). The two phases (all required checks, then +# all kind checks) are preserved deliberately: a missing required var must be +# reported before a malformed one (test_config_validators_fail_fast_without_errexit +# proves an unset TITLE is reported while a bad THUMBHEIGHT is not). The per-field +# call order below is the historical reporting order, so error messages for a +# config with several problems appear in the same sequence as before. validate_common_config() { local required_var + local kind_var local -a required_vars=( TITLE THUMBHEIGHT @@ -200,25 +278,30 @@ validate_common_config() { DIST_DIR TEMPLATE_DIR ) + local -a kind_vars=( + HEIGHT + THUMBHEIGHT + MAXPREVIEWS + THUMB_SUBDIVIDE_PERCENT + THUMB_FEATURE_PERCENT + IMAGE_JOBS + IMAGEMAGICK_TIMEOUT + TAR_TIMEOUT + SYNC_TIMEOUT + SHUFFLE + SPLASH_PAGE + STATS_PAGE + TARBALL_INCLUDE + FAVICON + ) for required_var in "${required_vars[@]}"; do - require_config_var "$required_var" || return + validate_config_field_required "$required_var" || return done - validate_optional_positive_integer_config_var HEIGHT || return - validate_positive_integer_config_var THUMBHEIGHT || return - validate_positive_integer_config_var MAXPREVIEWS || return - validate_percentage_config_var THUMB_SUBDIVIDE_PERCENT || return - validate_percentage_config_var THUMB_FEATURE_PERCENT || return - validate_positive_integer_config_var IMAGE_JOBS || return - validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT || return - validate_positive_integer_config_var TAR_TIMEOUT || return - validate_positive_integer_config_var SYNC_TIMEOUT || return - validate_yes_no_config_var SHUFFLE || return - validate_yes_no_config_var SPLASH_PAGE || return - validate_yes_no_config_var STATS_PAGE || return - validate_yes_no_config_var TARBALL_INCLUDE || return - validate_favicon_config || return + for kind_var in "${kind_vars[@]}"; do + validate_config_field_kind "$kind_var" || return + done } # A custom FAVICON (when set) must be a readable file; empty means the bundled diff --git a/src/shuriken.sh b/src/shuriken.sh index 290a074..e26bbf8 100755 --- a/src/shuriken.sh +++ b/src/shuriken.sh @@ -28,26 +28,14 @@ SHURIKEN_CLI_HAS_CONFIG_OVERRIDES='no' declare -A SHURIKEN_CLI_OVERRIDES=() declare -a SHURIKEN_CLI_SYNC_DESTINATIONS=() -declare -ra CLI_CONFIG_OVERRIDE_TARGETS=( - INCOMING_DIR - DIST_DIR - TEMPLATE_DIR - FAVICON - TITLE - HEIGHT - THUMBHEIGHT - MAXPREVIEWS - THUMB_SUBDIVIDE_PERCENT - THUMB_FEATURE_PERCENT - IMAGE_JOBS - RANDOM_SEED - SHUFFLE - SOURCE_URL - SPLASH_PAGE - STATS_PAGE - SYNC_DELETE - TARBALL_INCLUDE -) +# CLI_CONFIG_OVERRIDE_TARGETS (the set of config vars a CLI flag may override) is +# no longer hand-listed here: it is derived from CONFIG_SPECS (cli_overridable=yes +# facet) by build_cli_config_override_targets, called from config.spec.source.sh +# once the registry is sourced. This removes the redundant copy of CLI_OPTION_SPEC's +# config= targets that used to drift (task mr0). CLI_OPTION_SPEC below stays the +# authoritative per-flag table (argument names, flag/value pairs, action flags); +# only the override-target list is registry-derived. +declare -a CLI_CONFIG_OVERRIDE_TARGETS=() declare -Ar CLI_OPTION_SPEC=( [--config]='kind=value target=SHURIKEN_CLI_CONFIG_FILE argument=path' [--favicon]='kind=value config=FAVICON argument=path' @@ -143,6 +131,8 @@ source "$SHURIKEN_SOURCE_DIR/lib/stats-aggregate.source.sh" source "$SHURIKEN_SOURCE_DIR/lib/stats-render.source.sh" # shellcheck source=src/lib/stats-filter-album.source.sh source "$SHURIKEN_SOURCE_DIR/lib/stats-filter-album.source.sh" +# shellcheck source=src/lib/config.spec.source.sh +source "$SHURIKEN_SOURCE_DIR/lib/config.spec.source.sh" # shellcheck source=src/lib/config.source.sh source "$SHURIKEN_SOURCE_DIR/lib/config.source.sh" # shellcheck source=src/lib/config.print.source.sh |
