diff --git a/CHANGELOG.md b/CHANGELOG.md index f541af7..747c227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ and versions are tracked in the repo-root `VERSION` file. - Added `lib/bash/str/lib_str.sh` with string case, trim, predicate, split, join, and membership helpers. +- Added a documented stdlib-loaded marker for companion-library dependency + guards. +- Added stdlib cleanup hook and cleanup path registration backed by a shared + `EXIT` trap. +- Added portable stdlib temporary file and directory helpers with default exit + cleanup. +- Added stdlib command path and function introspection helpers. +- Added `std_run_with_timeout` for bounded command execution with macOS/Linux + fallback behavior. ## [0.2.1] - 2026-06-18 diff --git a/lib/bash/file/lib_file.sh b/lib/bash/file/lib_file.sh index aedfa90..fe11515 100644 --- a/lib/bash/file/lib_file.sh +++ b/lib/bash/file/lib_file.sh @@ -4,7 +4,7 @@ # [[ -n "${__lib_file_sourced__:-}" ]] && return 0 -if ! declare -F log_error >/dev/null || ! declare -F log_debug >/dev/null; then +if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then printf '%s\n' "Error: lib_file.sh requires lib_std.sh to be sourced first." >&2 return 1 2>/dev/null || exit 1 fi diff --git a/lib/bash/file/tests/lib_file.bats b/lib/bash/file/tests/lib_file.bats index 88843b4..b62a113 100644 --- a/lib/bash/file/tests/lib_file.bats +++ b/lib/bash/file/tests/lib_file.bats @@ -68,6 +68,14 @@ file_mode() { [[ "$output" != *"command not found"* ]] } +@test "lib_file requires the stdlib loaded marker" { + bats_run bash -c 'log_error() { :; }; log_debug() { :; }; source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/file/lib_file.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_file.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] +} + @test "update_file_section writes option-like markers literally" { local target="$TEST_TMPDIR/config.txt" printf 'line-one' > "$target" diff --git a/lib/bash/git/lib_git.sh b/lib/bash/git/lib_git.sh index c98a356..ac7d9fb 100644 --- a/lib/bash/git/lib_git.sh +++ b/lib/bash/git/lib_git.sh @@ -4,7 +4,7 @@ # [[ -n "${__lib_git_sourced__:-}" ]] && return 0 -if ! declare -F log_error >/dev/null || ! declare -F log_debug >/dev/null; then +if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then printf '%s\n' "Error: lib_git.sh requires lib_std.sh to be sourced first." >&2 return 1 2>/dev/null || exit 1 fi diff --git a/lib/bash/git/tests/lib_git.bats b/lib/bash/git/tests/lib_git.bats index 1db624b..86ba03d 100644 --- a/lib/bash/git/tests/lib_git.bats +++ b/lib/bash/git/tests/lib_git.bats @@ -23,6 +23,14 @@ setup() { [[ "$output" != *"command not found"* ]] } +@test "lib_git requires the stdlib loaded marker" { + bats_run bash -c 'log_error() { :; }; log_debug() { :; }; source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/git/lib_git.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_git.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] +} + @test "git_get_current_branch returns the current branch name" { local repo="$TEST_TMPDIR/repo" local branch="" diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 18803df..527c0d1 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -19,8 +19,16 @@ The library improves Bash-based scripting in a few practical ways: instead of a mysterious non-zero exit. - **Safe command execution**: `std_run` preserves argument boundaries, supports dry-run mode, and can either exit or return a status. +- **Bounded command execution**: `std_run_with_timeout` applies the same command + runner conventions with a timeout. - **Shared dry-run behavior**: scripts do not need to reimplement "print what would happen" logic. +- **Composable cleanup**: scripts can register exit cleanup without replacing + an already-installed `EXIT` trap. +- **Portable temp state**: scripts can create temp files or directories under + `TMPDIR` and register them for cleanup in one call. +- **Non-fatal introspection**: scripts can resolve command paths and check + function availability without turning every probe into a hard exit. - **Simple library imports**: scripts can import helpers relative to their own source directory. - **Predictable PATH edits**: PATH additions avoid duplicates and can prepend or @@ -56,6 +64,7 @@ Sourcing `lib_std.sh` runs a small one-time initializer: - records the original script arguments in `__SCRIPT_ARGS__` - derives the caller's source directory in `__SCRIPT_DIR__` - exposes the package version in `BASE_BASH_LIBS_VERSION` +- exposes the successful stdlib load marker in `BASE_BASH_LIBS_STDLIB_LOADED` - consumes Base wrapper flags such as `--debug-wrapper`, `--verbose-wrapper`, `--utc-wrapper`, and `--color` - resets the caller's positional parameters to the filtered argument list @@ -64,6 +73,8 @@ Caller-visible globals: - `BASE_BASH_LIBS_VERSION`: readonly package version read from the root `VERSION` file +- `BASE_BASH_LIBS_STDLIB_LOADED`: readonly marker set to `1` after + `lib_std.sh` has initialized successfully - `__SCRIPT_ARGS__`: original arguments before wrapper flags were stripped - `__SCRIPT_DIR__`: absolute source directory for the script being bootstrapped @@ -196,6 +207,26 @@ in the calling script so the code remains clear. code should use `std_run` to avoid collisions with test frameworks and other Bash libraries that define their own `run` helper. +Use `std_run_with_timeout` when a command must finish within a bounded number of +seconds: + +```bash +std_run_with_timeout 30 curl -fsSL "$health_url" +``` + +It accepts the same initial `--no-exit` and `--quiet` options as `std_run`: + +```bash +if ! std_run_with_timeout --no-exit --quiet 5 nc -z localhost 5432; then + log_warn "database port did not open within 5 seconds" +fi +``` + +Timeouts return status `124`. The helper prefers `timeout` or `gtimeout` when +available and otherwise uses a Bash fallback so scripts work on macOS and Linux. +As with `std_run`, command arguments are executed as an argument array and +dry-run mode logs without running the command. + ## Importing Other Bash Libraries Use `import` to source helper libraries: @@ -257,6 +288,94 @@ entered. `safe_mkdir` accepts only `-p` as an option. Calling it without directory arguments logs a warning and returns success without creating anything. +## Cleanup Helpers + +Use cleanup registration when a script creates transient state that should be +removed on exit: + +```bash +workspace="$(mktemp -d)" +std_register_cleanup_path "$workspace" +``` + +Cleanup paths are removed with `rm -rf --` from a shared `EXIT` trap. Empty +paths, root paths, and current/parent directory traversal components are +rejected before registration. + +For custom cleanup, register a function name: + +```bash +cleanup_workspace() { + rm -rf -- "$workspace" +} + +std_register_cleanup_hook cleanup_workspace +std_unregister_cleanup_hook cleanup_workspace +``` + +Hooks run in registration order and duplicate registrations are ignored. If an +`EXIT` trap already exists when the first cleanup hook or path is registered, +that existing trap is preserved and runs before the stdlib cleanup hooks. + +## Temporary Path Helpers + +Use temp helpers when a script needs a scratch file or directory and wants the +path stored in a variable: + +```bash +std_make_temp_file temp_file base +std_make_temp_dir temp_dir workspace +``` + +Both helpers create paths under `${TMPDIR:-/tmp}` using `mktemp` templates that +work on macOS/BSD and GNU systems. The created path is registered for exit +cleanup by default: + +```bash +std_make_temp_dir workspace_dir +printf 'payload\n' > "$workspace_dir/input.txt" +``` + +Pass `--keep` when the caller intentionally owns cleanup: + +```bash +std_make_temp_file --keep report_path report +``` + +The optional prefix is a filename prefix, not a directory path. It must be +non-empty and must not contain `/`. Set `TMPDIR` before calling the helper when +the temp root should be somewhere other than `/tmp`. + +## Introspection Helpers + +Use `std_command_path` when a script needs the path to an external command but +wants to decide what to do if it is absent: + +```bash +if std_command_path git_path git; then + std_run "$git_path" status --short +else + log_warn "git is not available; skipping repository status." +fi +``` + +The helper stores an executable path in the named result variable and returns +nonzero with an empty result when the command is not found. + +Use `std_function_exists` for predicate-style checks: + +```bash +if std_function_exists cleanup_workspace; then + std_register_cleanup_hook cleanup_workspace +fi +``` + +Use `assert_function_exists` when missing functions should be fatal: + +```bash +assert_function_exists main cleanup_workspace +``` + ## Validation Helpers Use assertions near the top of functions to make assumptions explicit: @@ -267,6 +386,7 @@ assert_not_null BASE_HOME project_name assert_integer retry_count assert_integer_range retry_count 0 5 assert_command_exists git brew +assert_function_exists main cleanup_workspace assert_file_exists "$manifest_path" assert_executable "$project_root/bin/build" assert_dir_exists "$project_root" @@ -355,6 +475,9 @@ main "$@" - imports - validation - simple filesystem safety wrappers +- exit cleanup registration +- temporary file and directory creation +- command and function introspection Domain-specific behavior should live in other libraries or command modules. For example, Git helpers belong in a Git library, file editing helpers belong in a diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index cd4efab..008bcf0 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -22,14 +22,24 @@ # Caller-visible globals: # BASE_BASH_LIBS_VERSION # Package version read from the repository/package VERSION file. +# BASE_BASH_LIBS_STDLIB_LOADED +# Set to 1 after lib_std.sh has initialized successfully. # __SCRIPT_ARGS__ Original "$@" before lib_std consumed global flags. # __SCRIPT_DIR__ Absolute path to the script that sourced the library. # # Core helpers: # std_run [--no-exit] [--quiet] cmd ... # # Safe command runner with dry-run & failure handling. +# std_run_with_timeout [opts] seconds cmd ... +# # Safe command runner with a timeout. # exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status). # fatal_error msg... # Convenience wrapper: exit with last status or 1. +# std_register_cleanup_hook fn # Run a cleanup function from the shared EXIT trap. +# std_register_cleanup_path p # Remove files/directories from the shared EXIT trap. +# std_make_temp_file var [pfx] # Create a temp file and store its path in var. +# std_make_temp_dir var [pfx] # Create a temp directory and store its path in var. +# std_command_path var cmd # Resolve an external command path without exiting. +# std_function_exists fn # Predicate for defined Bash functions. # add_to_path [-n] [-p] dir # Append/prepend unique PATH entries. # set_log_level [LEVEL] # Adjust default logger (FATAL..VERBOSE). # log_info/debug/... msgs # Structured logging (color in interactive shells). @@ -123,6 +133,10 @@ __SCRIPT_DIR__=$( cd -- "$(dirname -- "${BASE_BASH_BOOTSTRAP_SOURCE:-${BASH_SOURCE[1]}}" )" &>/dev/null && pwd -P ) readonly __SCRIPT_DIR__ +declare -ga __std_cleanup_hooks=() +declare -ga __std_cleanup_paths=() +declare -g __std_cleanup_dispatcher_installed=0 +declare -g __std_original_exit_trap="" ############################################ BASH VERSION CHECKER ####################################################### @@ -814,6 +828,127 @@ run() { __std_run_impl__ run "$@" } +__std_sleep_interval__() { + if [[ -x /bin/sleep ]]; then + /bin/sleep "$1" + else + sleep "$1" + fi +} + +__std_run_with_timeout_fallback__() { + local timeout_seconds="$1" + shift + local timeout_marker command_pid timer_pid command_status + + timeout_marker="$(mktemp "${TMPDIR:-/tmp}/base-bash-libs-timeout.XXXXXXXXXX" 2>/dev/null)" || return 127 + + "$@" & + command_pid=$! + + ( + __std_sleep_interval__ "$timeout_seconds" + printf '1' > "$timeout_marker" + kill -TERM "$command_pid" 2>/dev/null || true + ) & + timer_pid=$! + + wait "$command_pid" + command_status=$? + + if kill -0 "$timer_pid" 2>/dev/null; then + kill "$timer_pid" 2>/dev/null || true + fi + wait "$timer_pid" 2>/dev/null || true + + if [[ -s "$timeout_marker" ]]; then + command_status=124 + fi + rm -f -- "$timeout_marker" + + return "$command_status" +} + +# +# std_run_with_timeout - Safely executes a command with a timeout. +# +# This helper mirrors `std_run` option handling while bounding the command +# runtime. It prefers `timeout` or `gtimeout` when available and otherwise uses +# a Bash fallback so callers have portable behavior on macOS and Linux. +# +# Usage: +# std_run_with_timeout [--no-exit] [--quiet] command [arg1] ... +# +std_run_with_timeout() { + local exit_on_failure=1 quiet=0 timeout_seconds timeout_path="" exit_code printable_command message + + while (($#)); do + case "${1-}" in + --no-exit) + exit_on_failure=0 + shift + ;; + --quiet) + quiet=1 + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + + if (($# < 2)); then + log_error "std_run_with_timeout: usage: std_run_with_timeout [--no-exit] [--quiet] command [arg1] ..." + return 1 + fi + + timeout_seconds="$1" + shift + if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + log_error "std_run_with_timeout: timeout seconds must be a positive integer." + return 1 + fi + + printf -v printable_command "%q " "$@" + printable_command="${printable_command% }" + + if is_dry_run; then + log_info "[DRY-RUN] Would run with ${timeout_seconds}s timeout: ${printable_command}" + return 0 + fi + + if std_command_path timeout_path timeout || std_command_path timeout_path gtimeout; then + "$timeout_path" "$timeout_seconds" "$@" + else + __std_run_with_timeout_fallback__ "$timeout_seconds" "$@" + fi + exit_code=$? + + if ((exit_code)); then + if ((exit_code == 124)); then + message="Command timed out after ${timeout_seconds}s: ${printable_command}" + else + message="Command failed (exit $exit_code): ${printable_command}" + fi + + if ((exit_on_failure)); then + exit_if_error "$exit_code" "$message" + else + if ((! quiet)); then + log_warn "$message (continuing)." + fi + return "$exit_code" + fi + fi + + return 0 +} + ############################################## FILE AND DIRECTORY HANDLING ############################################ # @@ -927,6 +1062,261 @@ safe_truncate() { return 0 } +######################################################## CLEANUP ####################################################### + +__std_get_exit_trap_command__() { + local result_name="$1" trap_spec="" + + trap_spec="$(trap -p EXIT || true)" + if [[ "$trap_spec" =~ ^trap\ --\ \'(.*)\'\ EXIT$ ]]; then + printf -v "$result_name" '%s' "${BASH_REMATCH[1]}" + else + printf -v "$result_name" '%s' "" + fi +} + +__std_return_status__() { + return "$1" +} + +__std_run_cleanup_hooks__() { + local exit_status=$? hook cleanup_path + + if [[ -n "${__std_original_exit_trap:-}" ]]; then + __std_return_status__ "$exit_status" + eval "$__std_original_exit_trap" + fi + + for hook in "${__std_cleanup_hooks[@]}"; do + if ! "$hook"; then + log_warn "Cleanup hook '$hook' failed." + fi + done + + for cleanup_path in "${__std_cleanup_paths[@]}"; do + [[ -e "$cleanup_path" || -L "$cleanup_path" ]] || continue + if ! rm -rf -- "$cleanup_path"; then + log_warn "Cleanup path '$cleanup_path' could not be removed." + fi + done + + return "$exit_status" 2>/dev/null || exit "$exit_status" +} + +__std_install_cleanup_dispatcher__() { + if ((__std_cleanup_dispatcher_installed)); then + return 0 + fi + + __std_get_exit_trap_command__ __std_original_exit_trap + trap '__std_run_cleanup_hooks__' EXIT + __std_cleanup_dispatcher_installed=1 + return 0 +} + +# +# std_register_cleanup_hook - Registers a function to run from the shared EXIT trap. +# +# Cleanup hooks run after any EXIT trap that existed before the first cleanup hook +# registration. Hooks are function names, not shell command strings. +# +# Usage: +# cleanup_workspace() { rm -rf -- "$workspace"; } +# std_register_cleanup_hook cleanup_workspace +# +std_register_cleanup_hook() { + local hook="${1-}" existing_hook + + if (($# != 1)); then + log_error "std_register_cleanup_hook: expected exactly one function name." + return 1 + fi + if ! __is_valid_variable_name__ "$hook" || ! declare -F "$hook" >/dev/null; then + log_error "std_register_cleanup_hook: '$hook' is not a defined cleanup function." + return 1 + fi + + for existing_hook in "${__std_cleanup_hooks[@]}"; do + [[ "$existing_hook" == "$hook" ]] && return 0 + done + + __std_cleanup_hooks+=("$hook") + __std_install_cleanup_dispatcher__ + return 0 +} + +# +# std_unregister_cleanup_hook - Removes a function from the shared EXIT cleanup hook list. +# +# Usage: +# std_unregister_cleanup_hook cleanup_workspace +# +std_unregister_cleanup_hook() { + local hook="${1-}" existing_hook + local -a remaining_hooks=() + + if (($# != 1)); then + log_error "std_unregister_cleanup_hook: expected exactly one function name." + return 1 + fi + + for existing_hook in "${__std_cleanup_hooks[@]}"; do + [[ "$existing_hook" == "$hook" ]] && continue + remaining_hooks+=("$existing_hook") + done + __std_cleanup_hooks=("${remaining_hooks[@]}") + return 0 +} + +__std_is_safe_cleanup_path__() { + local path="${1-}" + + [[ -n "$path" ]] || return 1 + [[ "$path" =~ ^/+$ ]] && return 1 + case "$path" in + . | .. | */.. | */../* | */. | */./*) + return 1 + ;; + esac + return 0 +} + +# +# std_register_cleanup_path - Registers files or directories for removal at shell exit. +# +# Paths are removed with `rm -rf --` from the shared EXIT trap. Empty paths, root +# paths, and paths containing current/parent directory traversal components are +# rejected to avoid broad accidental deletion. +# +# Usage: +# workspace="$(mktemp -d)" +# std_register_cleanup_path "$workspace" +# +std_register_cleanup_path() { + local path existing_path + + if (($# == 0)); then + log_warn "std_register_cleanup_path: No paths provided." + return 0 + fi + + for path; do + if ! __std_is_safe_cleanup_path__ "$path"; then + log_error "std_register_cleanup_path: refusing to register unsafe cleanup path '$path'." + return 1 + fi + done + + for path; do + local already_registered=0 + for existing_path in "${__std_cleanup_paths[@]}"; do + if [[ "$existing_path" == "$path" ]]; then + already_registered=1 + break + fi + done + ((already_registered)) || __std_cleanup_paths+=("$path") + done + + __std_install_cleanup_dispatcher__ + return 0 +} + +######################################################## TEMP FILES #################################################### + +__std_make_temp_path__() { + local helper_name="$1" path_kind="$2" + shift 2 + local keep=0 result_name prefix temp_root template temp_path + + while (($#)); do + case "${1-}" in + --keep) + keep=1 + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + + if (($# < 1 || $# > 2)); then + log_error "$helper_name: usage: $helper_name [--keep] [prefix]" + return 1 + fi + + result_name="$1" + prefix="${2:-base-bash-libs}" + + if ! __is_valid_variable_name__ "$result_name"; then + log_error "$helper_name: result variable name must be a valid Bash variable name." + return 1 + fi + if [[ -z "$prefix" || "$prefix" == */* ]]; then + log_error "$helper_name: prefix must be a non-empty filename prefix without '/'." + return 1 + fi + + temp_root="${TMPDIR:-/tmp}" + temp_root="${temp_root%/}" + if [[ -z "$temp_root" || ! -d "$temp_root" ]]; then + log_error "$helper_name: TMPDIR is not a directory: ${TMPDIR:-/tmp}" + return 1 + fi + + template="$temp_root/$prefix.XXXXXXXXXX" + if [[ "$path_kind" == "dir" ]]; then + temp_path="$(mktemp -d "$template" 2>/dev/null)" || { + log_error "$helper_name: failed to create temporary directory." + return 1 + } + else + temp_path="$(mktemp "$template" 2>/dev/null)" || { + log_error "$helper_name: failed to create temporary file." + return 1 + } + fi + + if ((! keep)); then + if ! std_register_cleanup_path "$temp_path"; then + rm -rf -- "$temp_path" + return 1 + fi + fi + + printf -v "$result_name" '%s' "$temp_path" + return 0 +} + +# +# std_make_temp_file - Creates a temporary file and stores its path in a named variable. +# +# The created file is registered for exit cleanup unless `--keep` is provided. +# +# Usage: +# std_make_temp_file [--keep] [prefix] +# +std_make_temp_file() { + __std_make_temp_path__ std_make_temp_file file "$@" +} + +# +# std_make_temp_dir - Creates a temporary directory and stores its path in a named variable. +# +# The created directory is registered for exit cleanup unless `--keep` is provided. +# +# Usage: +# std_make_temp_dir [--keep] [prefix] +# +std_make_temp_dir() { + __std_make_temp_path__ std_make_temp_dir dir "$@" +} + ####################################################### ASSERTIONS #################################################### __is_valid_variable_name__() { @@ -934,6 +1324,75 @@ __is_valid_variable_name__() { [[ "$var_name" =~ $var_name_re ]] } +##################################################### INTROSPECTION ################################################### + +# +# std_command_path - Resolves an external command path without exiting the caller. +# +# Usage: +# if std_command_path git_path git; then +# std_run "$git_path" status --short +# fi +# +std_command_path() { + local result_name="${1-}" command_name="${2-}" resolved_path="" + + if (($# != 2)); then + log_error "std_command_path: usage: std_command_path " + return 1 + fi + if ! __is_valid_variable_name__ "$result_name"; then + log_error "std_command_path: result variable name must be a valid Bash variable name." + return 1 + fi + + if [[ -n "$command_name" ]]; then + resolved_path="$(type -P "$command_name" 2>/dev/null || true)" + fi + printf -v "$result_name" '%s' "$resolved_path" + [[ -n "$resolved_path" ]] +} + +# +# std_function_exists - Checks whether a Bash function is currently defined. +# +std_function_exists() { + local function_name="${1-}" + + (($# == 1)) || return 1 + __is_valid_variable_name__ "$function_name" || return 1 + declare -F "$function_name" >/dev/null +} + +# +# assert_function_exists - Verifies that one or more Bash functions are defined. +# +# Usage: +# assert_function_exists main cleanup_workspace +# +assert_function_exists() { + local missing_functions=() function_name + + if (($# == 0)); then + fatal_error "assert_function_exists: No function names provided for validation." + fi + + for function_name in "$@"; do + if ! __is_valid_variable_name__ "$function_name"; then + fatal_error "assert_function_exists expects function names; one or more arguments are not valid Bash function names." + fi + if ! std_function_exists "$function_name"; then + missing_functions+=("$function_name") + fi + done + + if ((${#missing_functions[@]} > 0)); then + fatal_error "Required functions are not defined: ${missing_functions[*]}" + fi + + return 0 +} + # # assert_not_null - Checks that one or more variables are not empty. # @@ -1345,6 +1804,7 @@ wait_for_enter() { # The only function that would be called upon sourcing of the library # __stdlib_init__ +readonly BASE_BASH_LIBS_STDLIB_LOADED=1 # This is the crucial step: it resets the positional parameters ($@, $1, etc.) # of the *calling script* to the new, filtered list of arguments. diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index 5052970..fa62fe1 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -174,6 +174,11 @@ EOF readonly -p BASE_BASH_LIBS_VERSION >/dev/null } +@test "stdlib exposes readonly loaded marker" { + [ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" = "1" ] + readonly -p BASE_BASH_LIBS_STDLIB_LOADED >/dev/null +} + @test "is_interactive is false in a non-interactive subprocess" { local script="$TEST_TMPDIR/non-interactive.sh" @@ -883,6 +888,91 @@ EOF [[ "$output" != *"after"* ]] } +@test "std_run_with_timeout runs commands and preserves arguments" { + local output_file="$TEST_TMPDIR/timeout-output.txt" + + std_run_with_timeout 5 bash -c 'printf "%s\n" "$1" > "$2"' _ "hello world" "$output_file" + + [ "$(cat "$output_file")" = "hello world" ] +} + +@test "std_run_with_timeout --no-exit returns 124 when the command times out" { + local stderr_file="$TEST_TMPDIR/timeout.err" + local rc + + if std_run_with_timeout --no-exit --quiet 1 sleep 2 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 124 ] + [ ! -s "$stderr_file" ] +} + +@test "std_run_with_timeout exits on command failure by default" { + local script="$TEST_TMPDIR/timeout-fail.sh" + + create_script "$script" < "$output_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$output_file")" = "fallback" ] +} + +@test "std_run_with_timeout rejects invalid timeouts" { + local stderr_file="$TEST_TMPDIR/timeout-invalid.err" + local rc + + if std_run_with_timeout --no-exit nope bash -c 'exit 0' 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run_with_timeout: timeout seconds must be a positive integer."* ]] +} + @test "run compatibility wrapper delegates to std_run behavior" { local stderr_file="$TEST_TMPDIR/run-compat.err" local rc @@ -1012,6 +1102,248 @@ EOF [[ "$output" == *"Failed to truncate the following files"* ]] } +@test "cleanup hooks run on exit without replacing an existing EXIT trap" { + local script="$TEST_TMPDIR/cleanup-hooks.sh" + local log_file="$TEST_TMPDIR/cleanup-hooks.log" + + create_script "$script" <> "$log_file"' EXIT +cleanup_one() { printf "cleanup-one\n" >> "$log_file"; } +cleanup_two() { printf "cleanup-two\n" >> "$log_file"; } +std_register_cleanup_hook cleanup_one +std_register_cleanup_hook cleanup_two +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$log_file")" = $'existing\ncleanup-one\ncleanup-two' ] +} + +@test "cleanup hook registration ignores duplicates and supports removal" { + local script="$TEST_TMPDIR/cleanup-hook-remove.sh" + local log_file="$TEST_TMPDIR/cleanup-hook-remove.log" + + create_script "$script" <> "$log_file"; } +cleanup_drop() { printf "drop\n" >> "$log_file"; } +std_register_cleanup_hook cleanup_keep +std_register_cleanup_hook cleanup_keep +std_register_cleanup_hook cleanup_drop +std_unregister_cleanup_hook cleanup_drop +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$log_file")" = "keep" ] +} + +@test "cleanup path registration removes files and directories on exit" { + local script="$TEST_TMPDIR/cleanup-paths.sh" + local target_file="$TEST_TMPDIR/cleanup-file.txt" + local target_dir="$TEST_TMPDIR/cleanup-dir" + + create_script "$script" < "$target_file" +printf 'nested\n' > "$target_dir/nested.txt" +std_register_cleanup_path "$target_file" "$target_dir" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ ! -e "$target_file" ] + [ ! -e "$target_dir" ] +} + +@test "cleanup path registration rejects dangerous paths" { + local stderr_file="$TEST_TMPDIR/cleanup-path.err" + local rc + + if std_register_cleanup_path "" "/" 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_register_cleanup_path: refusing to register unsafe cleanup path"* ]] +} + +@test "std_make_temp_file creates a file under TMPDIR and cleans it up" { + local script="$TEST_TMPDIR/temp-file.sh" + local temp_root="$TEST_TMPDIR/temp-root" + local path_file="$TEST_TMPDIR/temp-file.path" + local created_path + + mkdir -p "$temp_root" + create_script "$script" < "$path_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + created_path="$(cat "$path_file")" + [[ "$created_path" == "$temp_root"/sample.* ]] + [ ! -e "$created_path" ] +} + +@test "std_make_temp_dir creates a directory under TMPDIR and cleans it up" { + local script="$TEST_TMPDIR/temp-dir.sh" + local temp_root="$TEST_TMPDIR/temp-dir-root" + local path_file="$TEST_TMPDIR/temp-dir.path" + local created_path + + mkdir -p "$temp_root" + create_script "$script" < "$path_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + created_path="$(cat "$path_file")" + [[ "$created_path" == "$temp_root"/workspace.* ]] + [ ! -e "$created_path" ] +} + +@test "std_make_temp_file --keep leaves the created file in place" { + local script="$TEST_TMPDIR/temp-file-keep.sh" + local temp_root="$TEST_TMPDIR/temp-keep-root" + local path_file="$TEST_TMPDIR/temp-file-keep.path" + local created_path + + mkdir -p "$temp_root" + create_script "$script" < "$path_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + created_path="$(cat "$path_file")" + [[ "$created_path" == "$temp_root"/kept.* ]] + [ -f "$created_path" ] +} + +@test "std_make_temp helpers reject invalid result variable names" { + local stderr_file="$TEST_TMPDIR/temp-invalid.err" + local rc + + if std_make_temp_file "not-valid" 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_make_temp_file: result variable name must be a valid Bash variable name."* ]] + + if std_make_temp_dir "also-not-valid" 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_make_temp_dir: result variable name must be a valid Bash variable name."* ]] +} + +@test "std_command_path stores executable paths and returns nonzero for missing commands" { + local command_path="" + + std_command_path command_path bash + + [ -n "$command_path" ] + [ -x "$command_path" ] + + if std_command_path command_path "__base_missing_command__$RANDOM"; then + return 1 + fi + + [ "$command_path" = "" ] +} + +@test "std_command_path rejects invalid result variable names" { + local stderr_file="$TEST_TMPDIR/command-path.err" + local rc + + if std_command_path "not-valid" bash 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_command_path: result variable name must be a valid Bash variable name."* ]] +} + +@test "std_function_exists checks defined Bash functions" { + local missing_name="__missing_function__$RANDOM" + + sample_introspection_function() { return 0; } + + std_function_exists sample_introspection_function + ! std_function_exists "$missing_name" + ! std_function_exists "not-valid" +} + +@test "assert_function_exists accepts defined functions and exits for missing ones" { + local script="$TEST_TMPDIR/assert-function-exists.sh" + + create_script "$script" </dev/null || ! declare -F log_debug >/dev/null; then +if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then printf '%s\n' "Error: lib_str.sh requires lib_std.sh to be sourced first." >&2 return 1 2>/dev/null || exit 1 fi diff --git a/lib/bash/str/tests/lib_str.bats b/lib/bash/str/tests/lib_str.bats index 058cb88..6a4273f 100644 --- a/lib/bash/str/tests/lib_str.bats +++ b/lib/bash/str/tests/lib_str.bats @@ -23,6 +23,14 @@ setup() { [[ "$output" != *"command not found"* ]] } +@test "lib_str requires the stdlib loaded marker" { + bats_run bash -c 'log_error() { :; }; log_debug() { :; }; source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/str/lib_str.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_str.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] +} + @test "string case helpers transform text without changing other characters" { [ "$(str_lower "Alpha BETA 123!?")" = "alpha beta 123!?" ] [ "$(str_upper "Alpha beta 123!?")" = "ALPHA BETA 123!?" ]