summaryrefslogtreecommitdiff
path: root/src/lib/action.source.sh
blob: b0460f001a98dad504456423cf48350ce7538210 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# ----------------------------------------------------------------------------
# Action registry (single source of truth, task gn0)
# ----------------------------------------------------------------------------
# ACTION_SPECS is the one place a CLI action is declared. Adding an action means
# appending one entry here (plus its handler/validation functions); neither
# run_action nor run_configured_action is touched again (Open/Closed). Each entry
# is a '|'-delimited spec (same encoding as src/shuriken.sh's CLI_OPTION_SPEC and
# stats-aggregate.source.sh's STATS_CATEGORIES):
#
#   flag|handler|requires_config|validation_fn|validation_arg
#
#   flag            the CLI action flag (also the SHURIKEN_CLI_ACTION value).
#   handler         function run to perform the action. For configured actions it
#                   is invoked via run_configured_action_body (in-process, status
#                   propagated); for non-config actions it is called directly.
#   requires_config yes  -> dispatched through run_configured_action: the config
#                          file is resolved/loaded/logged first, then the
#                          validation_fn and handler run.
#                   no   -> dispatched without loading any config (e.g. --version,
#                          --init); these reject any --config/override/--force via
#                          the shared run_unconfigured_action precheck.
#   validation_fn   validation function to run before the handler (empty for none,
#                   e.g. --version prints inline). Its non-zero status aborts the
#                   action before the handler runs, preserving the historical
#                   per-action pre-checks (validate_generation_config, etc.).
#   validation_arg  optional single argument passed to validation_fn (only
#                   --dry-run uses it: "no" tells validate_generation_config to
#                   skip the strict-generation checks).
#
# The array order is the canonical action order. Unknown or empty actions are not
# in the table, so run_action's "no entry" path reproduces the old case "*)" arm
# exactly (usage + exit 1).
# 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 ACTION_SPECS=(
    '--version|action_print_version|no||'
    '--init|init_config|no||'
    '--clean|clean_dist|yes|validate_clean_dist_dir|'
    '--generate|generate_staged|yes|validate_generation_config|'
    '--refresh-splash|refresh_splash|yes|validate_refresh_splash_config|'
    '--sync|sync_dist|yes|validate_sync_config|'
    '--dry-run|dry_run|yes|validate_generation_config|no'
    '--print-config|print_config|yes|validate_print_config|'
)

# Look up a field of an action's registry entry by flag. Prints the requested
# field's value (empty if the action is unknown or the field is empty). Fields
# are addressed by zero-based index into the '|'-delimited spec:
#   0 flag  1 handler  2 requires_config  3 validation_fn  4 validation_arg
action_spec_field() {
    local -r action="$1"; shift
    local -ri field_index="$1"; shift
    local spec
    local -a fields=()

    for spec in "${ACTION_SPECS[@]}"; do
        IFS='|' read -r -a fields <<< "$spec"
        if [ "${fields[0]}" = "$action" ]; then
            printf '%s\n' "${fields[$field_index]:-}"
            return 0
        fi
    done

    return 1
}

# Print the registered handler for an action and the bundled version banner. Kept
# as a named function (not an inline printf) so --version is just another
# registry entry with a handler, like every other action.
action_print_version() {
    printf 'This is Shuriken Version %s\n' "$VERSION"
}

# Run a non-config action (requires_config=no): --version, --init. These never
# load a config, so any --config/override/--force is a usage error (matching the
# historical run_simple_action pre-check that guarded both arms identically).
run_unconfigured_action() {
    local -r action="$1"; shift
    local handler

    if [[ -n "$SHURIKEN_CLI_CONFIG_FILE" \
        || "$SHURIKEN_CLI_HAS_CONFIG_OVERRIDES" = 'yes' \
        || "$SHURIKEN_FORCE_GENERATE" = yes ]]; then
        usage
        exit 1
    fi

    handler=$(action_spec_field "$action" 1)
    "$handler"
}

# Runs an action function in-process and propagates its exit status.
#
# Shuriken is a single-process CLI, so the action body runs as a plain function
# call in the current shell. An earlier version could serialize 30+ globals plus
# every function definition and pipe them into a fresh "bash -euo pipefail"
# subprocess for isolation. That added real complexity (a hand-maintained list
# of variables to forward) for no benefit here: there is no second process to
# isolate from and nothing the action needs protecting from. Per KISS we dropped
# the subprocess runner and call the action directly. Tests that genuinely need
# subprocess isolation provide their own shim in tests/helpers.sh.
run_configured_action_body() {
    local -r action_name="$1"; shift

    "$action_name" "$@"
}

load_configured_action() {
    local -r rc_file="$1"; shift
    local -i status=0

    if [ ! -f "$rc_file" ]; then
        missing_config "$rc_file"
    fi

    # shellcheck source=/dev/null
    source "$rc_file"
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi

    apply_config_defaults
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi

    apply_template_dir_default
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi

    apply_cli_overrides
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi

    SHURIKEN_CONFIG_SOURCE="$rc_file"
    export SHURIKEN_CONFIG_SOURCE
}

# Log the effective configuration for a configured action (verbose mode).
#
# Deliberately NOT derived from CONFIG_SPECS (task mr0, consumer 5). The registry
# is the single source of truth for the config SCHEMA -- defaults, CLI
# overridability, validation, and print_config formatting all derive from it, so
# there is no competing source of truth for those facts. This log, by contrast,
# is bespoke human-facing PROSE: a curated subset of fields (not all 25), each
# with its own label ("Effective incoming directory:"), per-field decoration
# (the "s" suffix on the *_TIMEOUT seconds, the "(bundled default)" placeholder
# for an empty FAVICON), and two values that are NOT config variables at all
# (the resolved rc_file path and SHURIKEN_FORCE_GENERATE). Encoding all that in a
# registry facet would mean a per-field label string plus format directives plus
# an in/out flag -- contorting the schema and harming readability for no DRY win,
# since these strings appear exactly once. So formatting stays hand-written here;
# the config VALUES it reads ($INCOMING_DIR, $SPLASH_PAGE, ...) are the canonical
# globals, which apply_config_defaults already populates from CONFIG_SPECS.
# Output is asserted byte-for-byte by the effective-config log tests.
log_configured_action() {
    local -r rc_file="$1"; shift

    if [ "$SHURIKEN_CLI_ACTION" = --print-config ]; then
        return
    fi

    log_verbose "Selected config file: $rc_file"
    log_verbose "Effective incoming directory: ${INCOMING_DIR:-}"
    log_verbose "Effective output directory: ${DIST_DIR:-}"
    log_verbose "Effective template directory: ${TEMPLATE_DIR:-}"
    log_verbose "Effective favicon: ${FAVICON:-(bundled default)}"
    log_verbose "Effective source URL: $SOURCE_URL"
    log_verbose "Effective image jobs: $IMAGE_JOBS"
    log_verbose "Effective ImageMagick timeout: ${IMAGEMAGICK_TIMEOUT}s"
    log_verbose "Effective tar timeout: ${TAR_TIMEOUT}s"
    log_verbose "Effective splash page setting: $SPLASH_PAGE"
    log_verbose "Effective stats page setting: $STATS_PAGE"
    log_verbose "Effective tarball setting: $TARBALL_INCLUDE"
    log_verbose "Effective sync delete setting: $SYNC_DELETE"
    log_verbose "Effective sync timeout: ${SYNC_TIMEOUT}s"
    log_verbose "Effective force generation setting: $SHURIKEN_FORCE_GENERATE"
}

# Remove leftover generation staging/backup directories for DIST_DIR (ln0).
#
# The generation pipeline (config.staging.source.sh) stages output in sibling
# directories of DIST_DIR named via mktemp templates
# ".shuriken.<basename>.staging.XXXXXX" and ".shuriken.<basename>.backup.XXXXXX"
# in DIST_DIR's parent. A crash or kill can leave these behind, so --clean
# removes them too -- otherwise "clean" would not actually clean all generation
# output (Principle of Least Astonishment).
#
# Safety: callers MUST run validate_clean_dist_dir first so a dangerous DIST_DIR
# aborts before any deletion. We only match shuriken's own, basename-specific
# staging/backup prefixes (never a loose ".shuriken.*" or arbitrary dotfiles),
# derive the parent via working_dir() (the shared plain `dirname "$DIST_DIR"`,
# exactly the parent the staging code stages into), and use nullglob so a missing
# match never expands to a literal pattern to rm.
clean_generation_staging_artifacts() {
    local final_base final_parent artifact
    local -a artifacts=()

    final_base=$(basename "$DIST_DIR")
    final_parent=$(working_dir)

    # nullglob: a non-matching glob expands to nothing rather than to the
    # literal pattern, so we never accidentally rm a path called "*".
    shopt -s nullglob
    artifacts=(
        "$final_parent/.shuriken.$final_base.staging."*
        "$final_parent/.shuriken.$final_base.backup."*
    )
    shopt -u nullglob

    for artifact in "${artifacts[@]}"; do
        if [ -d "$artifact" ]; then
            log_info "Cleaning leftover staging directory $artifact"
            rm -rf "$artifact"
        fi
    done
}

# --clean handler: remove DIST_DIR plus any leftover staging/backup dirs.
#
# Kept as a named handler (referenced from ACTION_SPECS) so --clean dispatches
# exactly like the other configured actions. Its validation (validate_clean_dist_dir,
# declared in the registry) has already run before this is called, so DIST_DIR is
# known safe -- unset, empty, and dangerous paths (/, HOME, cwd, system dirs) were
# rejected before any destructive rm -rf could happen.
clean_dist() {
    if [ -d "$DIST_DIR" ]; then
        log_info "Cleaning $DIST_DIR"
        rm -rf "$DIST_DIR"
    else
        log_verbose "Output directory does not exist: $DIST_DIR"
    fi

    # Also remove any leftover staging/backup directories that the generation
    # pipeline created as siblings of DIST_DIR. This stays behind the
    # validate_clean_dist_dir guard (so a dangerous DIST_DIR aborts before any
    # deletion).
    clean_generation_staging_artifacts
}

# Dispatch a configured action (requires_config=yes) via the registry.
#
# Shared scaffolding for every config-backed action: enforce the force-generate
# guard, resolve/load/log the config, then run the action's registered
# validation_fn and handler looked up in ACTION_SPECS. Adding a configured action
# is a registry entry plus its handler/validation functions -- this dispatcher
# never changes (Open/Closed).
run_configured_action() {
    local -r action="$SHURIKEN_CLI_ACTION"
    local rc_file
    local handler
    local validation_fn
    local validation_arg
    local -i status=0

    # --force only makes sense for --generate; any other configured action with
    # --force set is a usage error (unchanged historical behavior).
    if [[ "$SHURIKEN_FORCE_GENERATE" = yes && "$action" != --generate ]]; then
        usage
        exit 1
    fi

    rc_file="$(resolve_config_file "$SHURIKEN_CLI_CONFIG_FILE")"
    load_configured_action "$rc_file"
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi

    log_configured_action "$rc_file"

    validation_fn=$(action_spec_field "$action" 3)
    validation_arg=$(action_spec_field "$action" 4)
    if [ -n "$validation_fn" ]; then
        # Pass validation_arg only when present (--dry-run uses "no"); otherwise
        # call with no argument so validators see the same argv as before.
        if [ -n "$validation_arg" ]; then
            "$validation_fn" "$validation_arg"
        else
            "$validation_fn"
        fi
        status=$?
        if (( status != 0 )); then
            return "$status"
        fi
    fi

    handler=$(action_spec_field "$action" 1)
    run_configured_action_body "$handler"
    status=$?
    if (( status != 0 )); then
        return "$status"
    fi
}

# Top-level action dispatcher. Looks the parsed action up in ACTION_SPECS and
# routes it: requires_config=no actions run via run_unconfigured_action, the rest
# via run_configured_action. An unknown or empty action has no registry entry, so
# this reproduces the old case "*)" arm exactly: usage + exit 1.
run_action() {
    local -r action="$SHURIKEN_CLI_ACTION"
    local requires_config

    if ! requires_config=$(action_spec_field "$action" 2); then
        usage
        exit 1
    fi

    # The chosen runner is a bare call under errexit (set -euo pipefail): a
    # non-zero status aborts with that exact code, so no explicit status check is
    # needed. We deliberately do NOT use "|| return $?" here: run_configured_action
    # runs generate_staged, which relies on errexit staying active so its internal
    # "set -e" parallel-job failure detection fires. A "||" list would suppress
    # inner errexit and let a failing job sail past (see album.source.sh's
    # splash-render note).
    if [ "$requires_config" = no ]; then
        run_unconfigured_action "$action"
    else
        run_configured_action
    fi
}