diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8eeeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.vscode/ diff --git a/cli/bash/lib/file/README.md b/cli/bash/lib/file/README.md new file mode 100644 index 0000000..6516bed --- /dev/null +++ b/cli/bash/lib/file/README.md @@ -0,0 +1,33 @@ +# `lib_file.sh` + +File-oriented Bash helpers shared by CLI commands. + +## Dependency + +Source `lib/std/lib_std.sh` before this library so logging and error helpers are available. + +## Public API + +- `update_file_section` + Idempotently add, replace, or remove a marker-delimited block inside a file. + +## Usage + +```bash +source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" +source "/absolute/path/to/cli/bash/lib/file/lib_file.sh" + +update_file_section ~/.bash_profile "# BEGIN APP" "# END APP" \ + "export APP_HOME=/opt/app" \ + "alias appctl='app status'" +``` + +## Behavior Notes + +- Returns success when the target file does not exist and there is nothing to remove. +- Replaces only the first matching marked section when markers already exist. +- Appends the marked block when markers are not present. + +## Tests + +BATS coverage lives in `tests/lib_file.bats`. diff --git a/cli/bash/lib/file/lib_file.sh b/cli/bash/lib/file/lib_file.sh new file mode 100644 index 0000000..684e914 --- /dev/null +++ b/cli/bash/lib/file/lib_file.sh @@ -0,0 +1,162 @@ +# +# lib_file.sh - Bash library of generic file manipulation functions. +# + +# +# update_file_section - Idempotently manages a block of text within a file, +# demarcated by start and end markers. +# +# This function can add, update, or remove a section of text in a file. +# It is designed to be safe to run multiple times. If the section already +# exists, it will be replaced. If it doesn't exist, it will be appended. +# +# Usage: +# update_file_section [options] [content_lines...] +# +# Options: +# -r : Remove the section defined by the markers instead of adding/updating it. +# +# Arguments: +# target_file: The path to the file to be modified. +# start_marker: The exact string that marks the beginning of the section. +# end_marker: The exact string that marks the end of the section. +# content_lines: (Optional) One or more strings, each representing a line of +# content to be placed inside the section. +# +# Examples: +# +# # Add/update a section in .bash_profile +# local commands=("export FOO=bar" "alias myalias='echo hello'") +# update_file_section ~/.bash_profile "# START" "# END" "${commands[@]}" +# +# # Remove the same section +# update_file_section -r ~/.bash_profile "# START" "# END" +# +update_file_section() { + local remove_section=false + local new_content_array=() + + if [[ "$1" == "-r" ]]; then + remove_section=true + shift # consume -r + fi + + if [[ $# -lt 3 ]]; then + log_error "Insufficient arguments." + if [[ "$remove_section" == true ]]; then + log_info "Usage: update_file_section -r " + else + log_info "Usage: update_file_section [new_lines...]" + fi + return 1 + fi + + local target_file="$1" beginning_marker="$2" end_marker="$3" + shift 3 # consume target_file, beginning_marker, end_marker + if [[ "$remove_section" == true ]]; then + if [[ $# -gt 0 ]]; then + log_error "When -r flag is used, no content arguments should be provided." + log_info "Usage: update_file_section -r " + return 1 + fi + else + new_content_array=("$@") # Capture remaining arguments as new_lines + fi + + if [[ ! -f "$target_file" ]]; then + log_debug "Target file '$target_file' does not exist." + return 0 + fi + + log_info "Updating '$target_file'" + local new_content_string="" + if [[ "$remove_section" == false ]]; then + if [[ ${#new_content_array[@]} -gt 0 ]]; then + # Use printf to join array elements with newlines, adding a final newline. + # This ensures proper multi-line insertion. + printf -v new_content_string '%s\n' "${new_content_array[@]}" + fi + fi + + local temp_file + temp_file=$(mktemp "${target_file}.XXXXXX") + if [[ ! -f "$temp_file" ]]; then + log_error "Failed to create temporary file for '$target_file'." + return 1 + fi + + if grep -qF -- "$beginning_marker" "$target_file" && grep -qF -- "$end_marker" "$target_file"; then + if [[ "$remove_section" == true ]]; then + awk -v START_M="$beginning_marker" -v END_M="$end_marker" ' + BEGIN { in_section = 0 } + $0 == START_M { in_section = 1; next } + $0 == END_M { in_section = 0; next } + { + if (in_section == 0) { + print $0 + } + } + ' "$target_file" > "$temp_file" + else + # FIX: This awk script now correctly handles multiple sections. It only replaces the first one. + export AWK_NEW_TEXT="$new_content_string" + awk -v START_M="$beginning_marker" -v END_M="$end_marker" ' + BEGIN { + processed = 0 # 0 = not yet processed, 1 = processing, 2 = done + } + $0 == START_M && processed == 0 { + print START_M + printf "%s", ENVIRON["AWK_NEW_TEXT"] # Insert new content + processed = 1 # We are now inside the section to be replaced + next + } + $0 == END_M && processed == 1 { + print END_M + processed = 2 # We are done with the replacement + next + } + processed != 1 { # Print the line if we are not inside the section being replaced + print $0 + } + ' "$target_file" > "$temp_file" + + unset AWK_NEW_TEXT + fi + + if [[ $? -eq 0 ]]; then + mv -f "$temp_file" "$target_file" + return 0 + else + log_error "Failed to process sections in '$target_file'." + rm -f "$temp_file" + return 1 + fi + else + # Markers not found in the file + if [[ "$remove_section" == true ]]; then + rm -f "$temp_file" + return 0 + else + cp "$target_file" "$temp_file" + + if [[ $(tail -c 1 "$temp_file" 2>/dev/null | wc -l) -eq 0 ]]; then + echo "" >> "$temp_file" + fi + + { + echo "$beginning_marker" + printf "%s" "$new_content_string" + echo "$end_marker" + } >> "$temp_file" + + if [[ $? -eq 0 ]]; then + mv -f "$temp_file" "$target_file" + return 0 + else + log_error "Failed to add new section to '$target_file'." + rm -f "$temp_file" + return 1 + fi + fi + fi +} diff --git a/cli/bash/lib/file/tests/lib_file.bats b/cli/bash/lib/file/tests/lib_file.bats new file mode 100644 index 0000000..1b78d48 --- /dev/null +++ b/cli/bash/lib/file/tests/lib_file.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats + +load ../../../tests/test_helper.bash + +setup() { + setup_test_tmpdir + source "$BANYAN_BASH_DIR/lib/std/lib_std.sh" + source "$BANYAN_BASH_DIR/lib/file/lib_file.sh" +} + +@test "update_file_section appends a new marked block when markers are absent" { + local target="$TEST_TMPDIR/config.txt" + printf 'line-one' > "$target" + + update_file_section "$target" "# BEGIN" "# END" "first" "second" + + [ "$(cat "$target")" = $'line-one\n# BEGIN\nfirst\nsecond\n# END' ] +} + +@test "update_file_section replaces the first matching section" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +old +# END +after +EOF + + update_file_section "$target" "# BEGIN" "# END" "new" + + [ "$(cat "$target")" = $'before\n# BEGIN\nnew\n# END\nafter' ] +} + +@test "update_file_section removes a marked block with -r" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +remove-me +# END +after +EOF + + update_file_section -r "$target" "# BEGIN" "# END" + + [ "$(cat "$target")" = $'before\nafter' ] +} + +@test "update_file_section is a no-op for a missing target file" { + local target="$TEST_TMPDIR/missing.txt" + + bats_run update_file_section "$target" "# BEGIN" "# END" "value" + + [ "$status" -eq 0 ] + [ ! -e "$target" ] +} + +@test "update_file_section rejects content arguments when removing a section" { + local target="$TEST_TMPDIR/config.txt" + touch "$target" + + bats_run update_file_section -r "$target" "# BEGIN" "# END" "unexpected" + + [ "$status" -eq 1 ] + [[ "$output" == *"When -r flag is used"* ]] +} diff --git a/cli/bash/lib/git/README.md b/cli/bash/lib/git/README.md new file mode 100644 index 0000000..e3ebe55 --- /dev/null +++ b/cli/bash/lib/git/README.md @@ -0,0 +1,41 @@ +# `lib_git.sh` + +Git helpers for Bash commands that need lightweight repository inspection or update behavior. + +## Dependency + +Source `lib/std/lib_std.sh` before this library so logging and shared error handling are available. + +## Public API + +- `git_update_repo` + Update a repository on branch `master`, optionally allowing tracked changes in one specific path. +- `git_get_current_branch` + Return the current branch name through a caller-provided variable, or `detached head`. +- `check_script_up_to_date` + Check whether a tracked script appears current relative to its configured upstream. + +## Internal Helper + +- `_git_only_path_dirty` + Internal predicate used by `git_update_repo` when an allowed dirty path is provided. + +## Usage + +```bash +source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" +source "/absolute/path/to/cli/bash/lib/git/lib_git.sh" + +branch="" +git_get_current_branch "$PWD" branch +log_info "Current branch: $branch" +``` + +## Behavior Notes + +- `git_update_repo` currently only attempts updates when the checked-out branch is `master`. +- `check_script_up_to_date` treats missing git state, untracked scripts, or missing upstreams as skip conditions rather than hard failures. + +## Tests + +BATS coverage lives in `tests/lib_git.bats`. diff --git a/cli/bash/lib/git/lib_git.sh b/cli/bash/lib/git/lib_git.sh new file mode 100644 index 0000000..dd17e84 --- /dev/null +++ b/cli/bash/lib/git/lib_git.sh @@ -0,0 +1,254 @@ +# +# lib_git.sh: Git operations +# + +# +# Returns success when tracked changes are limited to one repo-relative path. +# +# @param $1 allowed_path Path in repository root that may be dirty (for example "shared"). +# +_git_only_path_dirty() { + local allowed_path="$1" + local status_output line path + + status_output="$(git status --porcelain --untracked-files=no --ignore-submodules=none)" + [[ -z "$status_output" ]] && return 1 + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + path="${line:3}" + if [[ "$path" == *" -> "* ]]; then + path="${path#* -> }" + fi + if [[ "$path" != "$allowed_path" ]]; then + return 1 + fi + done <<< "$status_output" + + return 0 +} + +# +# Safely updates a Git repository and its submodules after checking if the current branch is 'master'. +# +# @param $1 git_repo The path to the local git repository. +# @param $2 allowed_dirty_path Optional repo-relative path that may be dirty. +# +git_update_repo() { + local git_repo="$1" + local allowed_dirty_path="${2:-}" + if [[ -z "$git_repo" ]]; then + log_error "No git repository path provided." + log_info "Usage: update_repo /path/to/repo [allowed_dirty_path]" + return 1 + fi + + if [[ ! -d "$git_repo" ]]; then + log_error "Git repo not found at '$git_repo'" + return 1 + fi + + git_log=$(mktemp -p /tmp) + if ! pushd "$git_repo" > /dev/null; then + # If cd fails, we can't proceed. + return 1 + fi + + # Check if it's a valid git repo + if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then + log_error "'$git_repo' is not a Git repository." + popd > /dev/null + return 1 + fi + + # Make sure the current branch is master + local current_branch + current_branch=$(git rev-parse --abbrev-ref HEAD) + if [[ "$current_branch" != "master" ]]; then + log_debug "Current branch of '$git_repo' is '${current_branch}', not 'master'. Skipping update." + popd > /dev/null + return 1 + fi + + local dirty=false + if ! git diff --quiet; then + dirty=true + fi + if ! git diff --cached --quiet; then + dirty=true + fi + if [[ "$dirty" == true ]]; then + if [[ -n "$allowed_dirty_path" ]] && _git_only_path_dirty "$allowed_dirty_path"; then + log_debug "Repo '$git_repo' only has tracked changes in '$allowed_dirty_path'; attempting git pull." + else + log_debug "Repo '$git_repo' has local changes; skipping auto-update. Commit or stash to enable git pull." + popd > /dev/null + return 0 + fi + fi + + # sometimes git pull throws warnings and we need a second git pull to address it + { git pull || git pull; } >"$git_log" 2>&1 + if (($? != 0)); then + log_error "git pull failed on repo '$git_repo'" + [[ -s "$git_log" ]] && log_info_file "$git_log" + popd > /dev/null + return 1 + fi + + # it is safe to run submodule commands even if the repo has no submodules + { git submodule init && git submodule sync && git submodule update; } >/dev/null + if (($? != 0)); then + log_error "git submodule update failed on repo '$git_repo'" + [[ -s "$git_log" ]] && log_info_file "$git_log" + popd > /dev/null + return 1 + fi + + log_debug "Git repo '$git_repo' updated to latest master" + popd > /dev/null + return 0 +} + +# +# Gets the currently checked-out branch of a Git repository without using a subshell. +# +# This function safely checks a directory, determines if it's a Git repository, +# and returns the current branch name via a name reference (nameref). +# +# @param $1 target_dir The path to the directory to check. +# @param $2 result_var_name The name of the variable in the calling scope +# that will receive the output. +# +# Returns: +# - The branch name (e.g., "master", "feature/login") is stored in the result variable. +# - "detached head" if the repository is in a detached HEAD state. +# - An empty string "" if the directory doesn't exist or is not a Git repo. +# - The function itself returns an exit code of 0 on success, 1 on invalid usage. +# +git_get_current_branch() { + local target_dir="$1" + # Create a name reference to the variable name passed as the second argument. + local -n result_var="$2" + result_var="" + + # --- Argument Validation --- + if [[ -z "$target_dir" || -z "$2" ]]; then + log_error "Usage: get_git_branch " + return 1 + fi + + if [[ ! -d "$target_dir" ]]; then + return 1 + fi + + # --- Core Logic without Subshell --- + # Use pushd to change directory and add the current dir to a stack. + # Redirect output to /dev/null to keep it clean. + if ! pushd "$target_dir" > /dev/null; then + # If cd fails, we can't proceed. + return 1 + fi + + # Check if we are inside a Git repository. + if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then + # Not a Git repo, result is already an empty string. + popd > /dev/null + return 0 + fi + + # Use 'git symbolic-ref' to get the branch name. + # It's the most reliable way to distinguish a branch from a detached HEAD. + # -q (--quiet) suppresses errors and returns a non-zero exit code on failure. + local branch_name + if branch_name=$(git symbolic-ref --short -q HEAD); then + # Success: We are on a named branch. + result_var="$branch_name" + else + # Failure: We are in a detached HEAD state. + result_var="detached head" + fi + + popd > /dev/null + return 0 +} + +# +# Checks whether a script appears up to date with its git upstream and logs status. +# +# @param $1 script_path The path to a script file tracked in a git repo. +# +# Returns: +# - 0 if up to date or the check is skipped (no git, no upstream, not a repo). +# - 1 on invalid usage. +# - 2 if the repo is behind its upstream (script may be stale). +# - 3 if the script has local modifications. +# +check_script_up_to_date() { + local script_path="$1" + if [[ -z "$script_path" ]]; then + log_error "Usage: check_script_up_to_date " + return 1 + fi + + if [[ ! -e "$script_path" ]]; then + log_warn "Script '$script_path' not found; skipping latest-version check." + return 0 + fi + + if ! command -v git &> /dev/null; then + log_info "git not available; skipping latest-version check." + return 0 + fi + + local script_dir repo_root prefix rel_path + script_dir=$(dirname "$script_path") + repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) || { + log_info "Not in a git repo; skipping latest-version check." + return 0 + } + prefix=$(git -C "$script_dir" rev-parse --show-prefix 2>/dev/null) || { + log_info "Unable to resolve repo-relative path; skipping latest-version check." + return 0 + } + rel_path="${prefix}$(basename "$script_path")" + + if ! git -C "$repo_root" ls-files --error-unmatch "$rel_path" >/dev/null 2>&1; then + log_info "Script '$rel_path' is not tracked in git; skipping latest-version check." + return 0 + fi + + local dirty=false + if ! git -C "$repo_root" diff --quiet -- "$rel_path"; then + dirty=true + fi + if ! git -C "$repo_root" diff --cached --quiet -- "$rel_path"; then + dirty=true + fi + if [[ "$dirty" == true ]]; then + log_warn "Script '$rel_path' has local modifications; version may not match repo." + fi + + local upstream behind ahead + upstream=$(git -C "$repo_root" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null) || { + log_info "No upstream branch configured; skipping latest-version check." + return 0 + } + + behind=$(git -C "$repo_root" rev-list --count HEAD.."$upstream" 2>/dev/null) + ahead=$(git -C "$repo_root" rev-list --count "$upstream"..HEAD 2>/dev/null) + if [[ -n "$behind" && "$behind" -gt 0 ]]; then + log_warn "Repository is $behind commit(s) behind $upstream. Script may be out of date." + return 2 + elif [[ -n "$ahead" && "$ahead" -gt 0 ]]; then + log_info "Repository is $ahead commit(s) ahead of $upstream." + else + log_info "Repository is up to date with $upstream." + fi + + if [[ "$dirty" == true ]]; then + return 3 + fi + + return 0 +} diff --git a/cli/bash/lib/git/tests/lib_git.bats b/cli/bash/lib/git/tests/lib_git.bats new file mode 100644 index 0000000..191d500 --- /dev/null +++ b/cli/bash/lib/git/tests/lib_git.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats + +load ../../../tests/test_helper.bash + +setup() { + setup_test_tmpdir + source "$BANYAN_BASH_DIR/lib/std/lib_std.sh" + source "$BANYAN_BASH_DIR/lib/git/lib_git.sh" +} + +@test "git_get_current_branch returns the current branch name" { + local repo="$TEST_TMPDIR/repo" + local branch="" + + init_git_repo "$repo" + git_get_current_branch "$repo" branch + + [ "$branch" = "master" ] +} + +@test "git_get_current_branch reports detached head" { + local repo="$TEST_TMPDIR/repo" + local branch="" + + init_git_repo "$repo" + printf 'hello\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + git -C "$repo" checkout --detach >/dev/null 2>&1 + + git_get_current_branch "$repo" branch + + [ "$branch" = "detached head" ] +} + +@test "git_update_repo skips dirty repositories when no dirty path is allowed" { + local repo="$TEST_TMPDIR/repo" + + init_git_repo "$repo" + printf 'base\n' > "$repo/data.txt" + commit_all "$repo" "Initial commit" + printf 'local change\n' > "$repo/data.txt" + set_log_level DEBUG + + bats_run git_update_repo "$repo" + + [ "$status" -eq 0 ] + [[ "$output" == *"has local changes; skipping auto-update"* ]] +} + +@test "check_script_up_to_date reports success for an up-to-date tracked script" { + local repo="$TEST_TMPDIR/repo" + local remote="$TEST_TMPDIR/remote.git" + local script_path="$repo/scripts/tool.sh" + + create_tracked_repo_with_upstream "$repo" "$remote" "scripts/tool.sh" "#!/usr/bin/env bash" + + bats_run check_script_up_to_date "$script_path" + + [ "$status" -eq 0 ] + [[ "$output" == *"Repository is up to date with origin/master."* ]] +} + +@test "check_script_up_to_date returns 3 for a dirty tracked script" { + local repo="$TEST_TMPDIR/repo" + local remote="$TEST_TMPDIR/remote.git" + local script_path="$repo/scripts/tool.sh" + + create_tracked_repo_with_upstream "$repo" "$remote" "scripts/tool.sh" "#!/usr/bin/env bash" + printf 'echo dirty\n' >> "$script_path" + + bats_run check_script_up_to_date "$script_path" + + [ "$status" -eq 3 ] + [[ "$output" == *"has local modifications"* ]] +} diff --git a/cli/bash/lib/std/README.md b/cli/bash/lib/std/README.md new file mode 100644 index 0000000..82e70f9 --- /dev/null +++ b/cli/bash/lib/std/README.md @@ -0,0 +1,37 @@ +# `lib_std.sh` + +Shared foundation library for Bash code under `cli/bash`. + +## What It Provides + +- Bash version checking and one-time initialization when sourced +- Shared globals for callers: `__SCRIPT_ARGS__` and `__SCRIPT_DIR__` +- Library importing with `import` +- PATH helpers: `add_to_path`, `dedupe_path`, `print_path` +- Structured logging with `set_log_level`, `log_*`, and `print_*` +- Failure helpers: `exit_if_error`, `fatal_error`, `dump_trace` +- Safe command execution via `run` +- Filesystem helpers such as `safe_mkdir`, `safe_touch`, `safe_truncate`, `safe_cd` +- Validation helpers such as `assert_not_null`, `assert_integer`, `assert_integer_range`, `assert_arg_count` +- Small interactive helpers such as `ask_yes_no` and `wait_for_enter` + +## Usage + +```bash +source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" + +add_to_path -p "/opt/my-tools/bin" +set_log_level DEBUG +run echo "hello" +``` + +## Notes + +- Requires Bash 4.0 or newer. +- Sourcing the file runs `__stdlib_init__`. +- Wrapper-level flags such as `--debug-wrapper` and `--verbose-wrapper` are consumed during initialization. +- Other Bash libraries in this tree rely on this file for logging and error handling. + +## Tests + +BATS coverage lives in `tests/lib_std.bats`. diff --git a/cli/bash/lib/std/lib_std.sh b/cli/bash/lib/std/lib_std.sh new file mode 100644 index 0000000..eaff1a2 --- /dev/null +++ b/cli/bash/lib/std/lib_std.sh @@ -0,0 +1,1139 @@ +# +# lib_std.sh - Foundation library for Bash scripts +# Requires Bash version 4.0 or higher. +# +# This library provides a standardized set of functions for common tasks, +# ensuring consistency and robustness across multiple scripts. +# +# Areas covered: +# - PATH manipulation +# - Logging (with levels and colors) +# - Error handling and stack tracing +# - Bash version checking +# - Library importing +# - Miscellaneous helpers +# +# Quick Reference +# -------------------------------------------------------------------------------------------------------------------- +# Sourcing: +# source "/shared/scripts/lib_std.sh" +# +# Caller-visible globals: +# __SCRIPT_ARGS__ Original "$@" before lib_std consumed global flags. +# __SCRIPT_DIR__ Absolute path to the script that sourced the library. +# +# Core helpers: +# run [--no-exit] cmd ... # Safe command runner with dry-run & failure handling. +# exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status). +# fatal_error msg... # Convenience wrapper: exit with last status or 1. +# 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). +# safe_touch file [...] # touch wrapper that exits on failure (same for safe_truncate). +# assert_* utilities # Validation helpers (assert_not_null / assert_integer / ...). +# +# Patterns: +# run some_cmd # exits on failure; DRY_RUN=true prints instead. +# some_cmd || fatal_error ... # preserves failing exit code before terminating. +# add_to_path -p "/opt/tools" # inject directories without duplicates. +# +# Notes: +# - Global options --debug/--verbose/--utc/--color are stripped from "$@" automatically. +# + +################################################# INITIALIZATION ####################################################### + +# +# Make sure we do nothing in case the library is sourced more than once in the same shell. +# This prevents functions from being redefined and initialization from running multiple times. +# +[[ -n "${__stdlib_sourced__-}" ]] && return +__stdlib_sourced__=1 +readonly __LIB_STD_PATH__="${BASH_SOURCE[0]}" + +# +# Memorize the original script arguments at the very beginning. +# This allows the library to parse global options before the main script does. +# We retain the original arguments in __SCRIPT_ARGS__ and the script source directory in __SCRIPT_DIR__ as readonly +# variables which could be used by the caller. +# +readonly __SCRIPT_ARGS__=("$@") +__new_args__=() +readonly __SCRIPT_DIR__=$(cd -- "$(dirname -- "${BASH_SOURCE[1]}" )" &>/dev/null && pwd -P) + +############################################ BASH VERSION CHECKER ####################################################### + +# +# is_interactive - Checks if the current shell is interactive. +# +# An interactive shell is one where the user is typing commands directly. +# This is used to determine if we can safely prompt the user for input. +# +# Returns: +# 0 (true) if the shell is interactive. +# 1 (false) if the shell is not interactive (e.g., running in a cron job). +# +is_interactive() { + [[ -t 0 ]] +} + +# +# check_bash_version_and_upgrade - Verifies the Bash version and prompts for an upgrade if necessary. +# +# This function checks if the running Bash interpreter is version 4.0 or higher. +# If the version is too old and the shell is interactive, it will offer to +# install/upgrade Bash via Homebrew. If the shell is not interactive, or if the OSTYPE is not darwin, +# it will exit with an error. +# +# Note: This function is called before logging is initialized, so it uses `echo` to stderr. +# +check_bash_version_and_upgrade() { + local -r major_version=${BASH_VERSINFO[0]} + if ((major_version < 4)); then + if ! is_interactive; then + { + echo "Error: This script requires Bash 4.0 or higher." + echo "Your version ($BASH_VERSION) is not compatible." + echo "Upgrade Bash manually or run the script in interactive mode for guided upgrade." + } >&2 + exit 1 + fi + + # -- Interactive Upgrade Process -- + echo "Warning: This script requires Bash version 4.0 or higher to run correctly." >&2 + echo "Your current version is $BASH_VERSION." >&2 + + local install_cmd + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if command -v apt-get &>/dev/null; then + install_cmd="sudo apt-get update && sudo apt-get install bash" + elif command -v yum &>/dev/null; then + install_cmd="sudo yum install bash" + fi + + echo "On your system, you can likely upgrade by running:" >&2 + echo " $install_cmd" >&2 + exit 1 + elif [[ "$OSTYPE" == "darwin"* ]]; then + read -p "Would you like to attempt an upgrade using Homebrew? (y/n) " -n 1 -r + echo >&2 + if [[ $REPLY =~ ^[Yy]$ ]]; then + if ! command -v brew &>/dev/null; then + echo "Homebrew is not installed." >&2 + read -p "May I install Homebrew for you? (y/n) " -n 1 -r + echo >&2 + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Installing Homebrew..." >&2 + if ! /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then + echo "Error: Homebrew installation failed. Please install it manually and try again." >&2 + exit 1 + fi + echo "Homebrew installed successfully." >&2 + else + echo "Aborting. Homebrew is required to proceed." >&2 + exit 1 + fi + fi + + echo "Updating Homebrew and installing Bash..." >&2 + brew update && brew install bash + if [[ $? -ne 0 ]]; then + echo "Error: Failed to install Bash via Homebrew." >&2 + exit 1 + fi + + echo "Bash installed successfully." >&2 + + local new_bash_path + new_bash_path="$(brew --prefix)/bin/bash" + if [[ -f "$new_bash_path" ]]; then + echo "Relaunching script with the new Bash from: $new_bash_path ${__SCRIPT_ARGS__[@]}" >&2 + exec "$new_bash_path" "$0" "${__SCRIPT_ARGS__[@]}" + else + echo "Error: Could not find the new Bash executable at '$new_bash_path'." >&2 + exit 1 + fi + else + echo "Aborting. Please upgrade Bash to version 4.0 or higher to run this script." >&2 + exit 1 + fi + else + echo "Unsupported OSTYPE: [$OSTYPE]" >&2 + exit 1 + fi + fi +} + +###################################################### INIT ############################################################ + +# +# __stdlib_init__ - The main initialization function for this library. +# +# This is the only function that executes when the library is sourced. +# It sets up the environment by: +# 1. Checking the Bash version. +# 2. Initializing the logging system. +# 3. Parsing global command-line options like --debug, --verbose, --color. +# +__stdlib_init__() { + check_bash_version_and_upgrade + __log_init__ + + # + # Handle global arguments and strip them from the list before passing control to the main script. + # The environment variables LOG_DEBUG and LOG_UTC are recognized by the Python logging framework: + # - LOG_DEBUG=1 sets the log level to DEBUG (Bash logging has VERBOSE but Python has only DEBUG) + # - LOG_UTC=1 forces timestamps to use UTC + # + local arg + __color__=0 + for arg in "${__SCRIPT_ARGS__[@]}"; do + case "$arg" in + --debug-wrapper) + set_log_level DEBUG + export LOG_DEBUG=1 + ;; + --verbose-wrapper) + set_log_level VERBOSE + export LOG_DEBUG=1 + ;; + --utc-wrapper) + export LOG_UTC=1 + ;; + --color) + __color__=1 + ;; + *) + __new_args__+=("$arg") + ;; + esac + done + __init_colors__ + log_debug "Command line: $0 ${__SCRIPT_ARGS__[*]}" + return 0 +} + +################################################# LIBRARY IMPORTER ##################################################### + +# +# import - Sources one or more other library files. +# +# This function provides a robust way to include other shell libraries. It handles +# both absolute and relative paths. Relative paths are resolved from the directory +# of the main script that sourced this library. +# +# Usage: +# import /path/to/absolute/lib.sh +# import relative/path/to/lib2.sh +# +# IMPORTANT NOTE: If your library has global variables declared with 'declare', +# you must add the -g flag (e.g., `declare -gA my_map`). Since the library is +# sourced inside this function, globals declared without -g would become local +# to the function and be unavailable to other functions. +# +import() { + local lib + for lib; do + local pushed=0 + # Unless an absolute library path is given, make it relative to the script's location + if [[ "$lib" != /* ]]; then + [[ $__SCRIPT_DIR__ ]] || { printf '%s\n' "ERROR: __SCRIPT_DIR__ not set; import functionality needs it" >&2; exit 1; } + pushd "$__SCRIPT_DIR__" >/dev/null + pushed=1 + fi + if [[ -f "$lib" ]]; then + source "$lib" + exit_if_error $? "Import of library '$lib' not successful." + ((pushed)) && popd >/dev/null + else + exit_if_error 1 "Library '$lib' does not exist" + fi + done + return 0 +} + +################################################# PATH MANIPULATION #################################################### + +# +# add_to_path - Adds one or more directories to the system PATH. +# +# This function safely adds directories to the PATH, avoiding duplicates. +# +# Usage: +# add_to_path [options] /path/to/dir1 /path/to/dir2 ... +# +# Options: +# -p : Prepend the directory to the PATH instead of appending. +# -n : Do not check if the directory exists before adding it. +# +add_to_path() { + local dir prepend=0 opt strict=1 + local -a path_dirs + OPTIND=1 + while getopts np opt; do + case "$opt" in + n) strict=0 ;; # don't care if directory exists or not before adding it to PATH + p) prepend=1 ;; # prepend the directory to PATH instead of appending + *) log_error "add_to_path: invalid option '$opt'" + return 1 + ;; + esac + done + + shift $((OPTIND-1)) + + for dir; do + local in_path=0 + ((strict)) && [[ ! -d $dir ]] && continue + IFS=: read -ra path_dirs <<< "$PATH" + for path_dir in "${path_dirs[@]}"; do + if [[ "$path_dir" == "$dir" ]]; then + in_path=1 + break + fi + done + + if ((! in_path)); then + ((prepend)) && PATH="$dir:$PATH" || PATH="$PATH:$dir" + fi + done + + # It's good practice to de-duplicate the path after adding to it + dedupe_path + return 0 +} + +# +# dedupe_path - Removes duplicate entries from the PATH variable. +# +dedupe_path() { + local -A seen + local IFS=':' new_path dir + for dir in $PATH; do + if [[ -n "$dir" && -z "${seen[$dir]}" ]]; then + new_path="${new_path:+$new_path:}$dir" + seen["$dir"]=1 + fi + done + PATH="$new_path" +} + +# +# print_path - Prints each directory in the PATH on a new line. +# +print_path() { + local IFS=':' dirs dir + IFS=: read -ra dirs <<< "$PATH" + for dir in "${dirs[@]}"; do printf '%s\n' "$dir"; done +} + +#################################################### LOGGING ########################################################### + +# +# __log_init__ - Initializes the logging system. +# +# Sets up colors for interactive terminals and defines the log level hierarchy. +# This is called automatically by __stdlib_init__. +# +__log_init__() { + # Map log level strings (FATAL, ERROR, etc.) to numeric values. + # Note the '-g' option passed to declare is essential for global scope. + unset _log_levels _loggers_level_map + declare -gA _log_levels _loggers_level_map + _log_levels=([FATAL]=0 [ERROR]=1 [WARN]=2 [INFO]=3 [DEBUG]=4 [VERBOSE]=5) + + # Hash to map loggers to their log levels. + # The default logger "default" has INFO as its default log level. + _loggers_level_map["default"]=3 +} + +# +# __join_message__ - Join message fragments with a stable single-space separator. +# +__join_message__() { + local IFS=' ' + printf '%s' "$*" +} + +# +# __init_colors__ - Initialize colors used for logging +# This is called from __stdlib_init__ +# +__init_colors__() { + # If --color was not passed, or if the output is not a terminal, disable colors. + if [[ "$__color__" != 1 || ! -t 1 ]]; then + COLOR_BOLD="" + COLOR_RED="" + COLOR_GREEN="" + COLOR_YELLOW="" + COLOR_BLUE="" + COLOR_OFF="" + else + # colors for logging in interactive mode + COLOR_BOLD="\033[1m" + COLOR_RED="\033[0;31m" + COLOR_GREEN="\033[0;32m" + COLOR_YELLOW="\033[0;33m" + COLOR_BLUE="\033[0;36m" + COLOR_OFF="\033[0m" + fi + readonly COLOR_BOLD COLOR_RED COLOR_GREEN COLOR_YELLOW COLOR_BLUE COLOR_OFF +} + +# +# set_log_level - Sets the logging verbosity for a given logger. +# +# Usage: +# set_log_level [level] +# set_log_level -l [logger_name] [level] +# +# Arguments: +# level: One of FATAL, ERROR, WARN, INFO, DEBUG, VERBOSE. Default is INFO. +# -l logger_name: (Optional) Specify a named logger. Default is 'default'. +# +set_log_level() { + local logger=default in_level l + if [[ "${1-}" == "-l" ]]; then + if [[ -z "${2-}" ]]; then + printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ + "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + return 0 + fi + logger=$2 + shift 2 2>/dev/null + fi + in_level="${1:-INFO}" + if [[ $logger ]]; then + l="${_log_levels[$in_level]}" + if [[ $l ]]; then + _loggers_level_map[$logger]=$l + else + printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ + "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Unknown log level '$in_level' for logger '$logger'; setting to INFO" >&2 + _loggers_level_map[$logger]=3 + fi + else + printf '%(%Y-%m-%d:%H:%M:%S)T %-7s %s\n' -1 WARN \ + "${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + fi +} + +# +# _print_log - Core and private log printing logic. +# +# This is the internal engine for the logging functions. It formats the log +# message with a timestamp, log level, and source location. It should not +# be called directly; use the `log_*` helper functions instead. +# +_print_log() { + local in_level="${1-}" + [[ -n "$in_level" ]] || return 1 + shift + local logger=default log_level_set log_level color + if [[ "${1-}" == "-l" ]]; then + if [[ -z "${2-}" ]]; then + printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + return 1 + fi + logger=$2 + shift 2 + fi + log_level="${_log_levels[$in_level]}" + log_level_set="${_loggers_level_map[$logger]:-3}" + + if ((log_level_set >= log_level)); then + # Select color based on log level + case "$in_level" in + FATAL|ERROR) color="$COLOR_RED";; + WARN) color="$COLOR_YELLOW";; + INFO) color="$COLOR_GREEN";; + DEBUG) color="$COLOR_BLUE";; + *) color="";; # No color for VERBOSE or others + esac + + local source_path="" source_line="" frame=1 caller_info caller_line caller_func caller_file + while caller_info=$(caller "$frame"); do + read -r caller_line caller_func caller_file <<<"$caller_info" + if [[ -n "$caller_file" && "$caller_file" != "$__LIB_STD_PATH__" ]]; then + source_path="$caller_file" + source_line="$caller_line" + break + fi + ((frame++)) + done + + if [[ -z "$source_path" ]]; then + source_path="${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-unknown}}}" + source_line="${BASH_LINENO[1]:-${BASH_LINENO[0]:-0}}" + fi + + source_path="${source_path#"$__SCRIPT_DIR__"/}" + source_path="${source_path#./}" + + local message + message="$(__join_message__ "$@")" + { + printf '%b' "$color" + printf '%(%Y-%m-%d %H:%M:%S)T %-7s %s ' -1 "$in_level" "${source_path}:${source_line}" + printf '%s' "$message" + printf '%b\n' "$COLOR_OFF" + } >&2 + fi +} + +# +# _print_log_file - Core function for logging the contents of a file. +# +# Internal helper to be called by `log_info_file`, etc. +# +_print_log_file() { + local in_level="${1-}" + [[ -n "$in_level" ]] || return 1 + shift + local logger=default log_level_set log_level file + if [[ "${1-}" == "-l" ]]; then + if [[ -z "${2-}" ]]; then + printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[1]}:${BASH_LINENO[0]} Option '-l' needs an argument" >&2 + return 1 + fi + logger=$2 + shift 2 + fi + file="${1-}" + log_level="${_log_levels[$in_level]}" + log_level_set="${_loggers_level_map[$logger]}" + if [[ $log_level_set ]]; then + if ((log_level_set >= log_level)) && [[ -f $file ]]; then + _print_log "$in_level" -l "$logger" "Contents of file '$file':" + cat -- "$file" >&2 + fi + else + printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "WARN ${BASH_SOURCE[2]}:${BASH_LINENO[1]} Unknown logger '$logger'" >&2 + fi +} + +# +# Public logging functions. +# These are the primary functions scripts should use for logging. +# +log_fatal() { _print_log FATAL "$@"; } +log_error() { _print_log ERROR "$@"; } +log_warn() { _print_log WARN "$@"; } +log_info() { _print_log INFO "$@"; } +log_debug() { _print_log DEBUG "$@"; } +log_verbose() { _print_log VERBOSE "$@"; } + +# +# Public functions for logging the content of a file. +# +log_info_file() { _print_log_file INFO "$@"; } +log_debug_file() { _print_log_file DEBUG "$@"; } +log_verbose_file() { _print_log_file VERBOSE "$@"; } + +# +# Public functions for logging function entry and exit points. +# +log_info_enter() { _print_log INFO "Entering function ${FUNCNAME[1]}"; } +log_debug_enter() { _print_log DEBUG "Entering function ${FUNCNAME[1]}"; } +log_verbose_enter() { _print_log VERBOSE "Entering function ${FUNCNAME[1]}"; } +log_info_leave() { _print_log INFO "Leaving function ${FUNCNAME[1]}"; } +log_debug_leave() { _print_log DEBUG "Leaving function ${FUNCNAME[1]}"; } +log_verbose_leave() { _print_log VERBOSE "Leaving function ${FUNCNAME[1]}"; } + +# +# Simple print routines that do not prefix messages with timestamps or levels. +# +print_error() { local message; message="$(__join_message__ "$@")"; { printf '%bERROR: %s%b\n' "$COLOR_RED" "$message" "$COLOR_OFF"; } >&2; } +print_warn() { local message; message="$(__join_message__ "$@")"; { printf '%bWARN: %s%b\n' "$COLOR_YELLOW" "$message" "$COLOR_OFF"; } >&2; } +print_info() { local message; message="$(__join_message__ "$@")"; { printf '%b%s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } +print_success() { local message; message="$(__join_message__ "$@")"; { printf '%bSUCCESS: %s%b\n' "$COLOR_GREEN" "$message" "$COLOR_OFF"; } >&2; } +print_bold() { local message; message="$(__join_message__ "$@")"; printf '%b%s%b\n' "$COLOR_BOLD" "$message" "$COLOR_OFF"; } +print_message() { printf '%s\n' "$@"; } + +# +# print_tty - Prints a message only if the output is going to a terminal. +# +print_tty() { + if [[ -t 1 ]]; then + printf '%s\n' "$(__join_message__ "$@")" + fi +} + +################################################## ERROR HANDLING ###################################################### + +# +# dump_trace - Prints a stack trace of the Bash function calls. +# +# This is useful for debugging to see the sequence of function calls +# that led to an error. +# +dump_trace() { + local frame=0 line func source n=0 + while caller "$frame"; do + ((frame++)) + done | while read -r line func source; do + ((n++ == 0)) && { + printf 'Encountered a fatal error\n' + } + printf '%4s at %s\n' " " "$func ($source:$line)" + done >&2 +} + +# +# exit_if_error - Exits the script if the provided exit code is non-zero. +# +# This is the primary error handling function. It checks a command's exit +# code and, if it indicates failure, logs a fatal message, dumps a stack +# trace, and exits the script. +# +# Usage: +# command_that_might_fail +# exit_if_error $? "A descriptive error message." +# +# Arguments: +# $1: The exit code to check (typically $?). +# $@: The error message to log if the exit code is non-zero. +# +exit_if_error() { + (($#)) || return + local num_re='^[0-9]+' + local rc=$1; shift + local message="${@:-No message specified}" + if ! [[ $rc =~ $num_re ]]; then + log_error "'$rc' is not a valid exit code; it needs to be a number greater than zero. Treating it as 1." + rc=1 + fi + ((rc)) && { + log_fatal "$message" + dump_trace + exit $rc + } + return 0 +} + +# +# fatal_error - A convenience wrapper around exit_if_error. +# +# This function immediately triggers a fatal error, using the exit code +# of the last command if it was non-zero, or 1 otherwise. +# +# Usage: +# [[ -f "$my_file" ]] || fatal_error "Required file '$my_file' not found." +# +fatal_error() { + local ec=$? # grab the current exit code + ((ec == 0)) && ec=1 # if it is zero, set exit code to 1 + exit_if_error "$ec" "$@" +} + +#################################################### COMMAND EXECUTION ################################################# + +# +# run - Safely executes a simple command with its arguments. +# +# This function is designed to be a secure and robust replacement for using +# `eval` or simple command execution. It correctly handles arguments with +# spaces and special characters. +# +# Features: +# - Secure: Does not use `eval`, preventing arbitrary code execution. +# - Argument Safe: Correctly handles spaces and special characters in arguments. +# - Dry-Run Mode: If the global variable DRY_RUN (or dry_run) is true, it prints the +# command instead of running it. +# - Exit on Failure: By default, it will exit the script if the command +# returns a non-zero exit code. +# - Optional No-Exit: If the first argument is `--no-exit`, the function +# will not exit on failure, allowing the calling script to handle the error. +# +# Usage: +# run [options] command [arg1] [arg2] ... +# +# Options: +# --no-exit If provided as the very first argument, the script will not +# exit if the command fails. The function will return the +# command's original exit code. +# +# Examples: +# # Run a simple command. Exits if `ls` fails. +# run ls -l /tmp +# +# # Run a command with spaces in an argument. +# run touch "a file with spaces.txt" +# +# # Run a command but don't exit the script on failure. +# if ! run --no-exit grep "not_found" /etc/hosts; then +# log "INFO" "The text was not found, but we are continuing." +# fi +# +# # In a script where DRY_RUN=true, this will only print the command. +# DRY_RUN=true +# run rm -rf /some/important/path +# +################################################################################ +run() { + local exit_on_failure=1 + + # Check for the optional --no-exit flag. + if [[ "${1-}" == "--no-exit" ]]; then + exit_on_failure=0 + shift # Remove the --no-exit flag from the arguments list. + fi + + # Check if the command is empty. + if [[ $# -eq 0 ]]; then + log_error "run: No command provided." + return 1 + fi + + local printable_command + printf -v printable_command "%q " "$@" + printable_command="${printable_command% }" + + # --- Dry-Run Handling --- + if [[ "${DRY_RUN-}" == true || "${dry_run-}" == true ]]; then + # Use printf with the %q format specifier. This is the safest way to + # print a command and its arguments in a way that is unambiguous and + # could be copied and pasted back into a shell. + log_info "[DRY-RUN] Would run: ${printable_command}" + return 0 + fi + + # --- Execution --- + # Execute the command. Using "$@" is the key. It expands each argument + # as a separate, quoted string, preserving spaces and special characters. + # This is the safe, modern alternative to using `eval`. + "$@" + local exit_code=$? + if ((exit_code)); then + if ((exit_on_failure)); then + exit_if_error "$exit_code" "Command failed (exit $exit_code): ${printable_command}" + else + log_warn "Command failed (exit $exit_code): ${printable_command} (continuing)." + return $exit_code + fi + fi + + return 0 +} + +############################################## FILE AND DIRECTORY HANDLING ############################################ + +# +# safe_mkdir: Attempt to create directories and exit on failure. +# Creates as many directories as possible. +# +# Usage: safe_mkdir [-p] dir1 dir2 ... +# +safe_mkdir() { + local p dir failed_dirs=() + if [[ "${1-}" == "-p" ]]; then + shift + p="-p" + fi + for dir; do + [[ -d "$dir" ]] && continue + mkdir $p -- "$dir" + (($?)) && failed_dirs+=("$dir") + done + ((${#failed_dirs[@]} > 0)) && exit_if_error 1 "Failed to create directories: ${failed_dirs[*]}" + return 0 +} + +# +# safe_touch - Creates or updates the timestamp of one or more files. +# +# This function iterates through all provided file paths. It attempts to +# 'touch' each file. If any operation fails (e.g., due to permissions), +# it collects the names of the failed files and reports them all in a +# single fatal error at the end. +# +# Usage: +# safe_touch "/tmp/file1.log" "/var/run/app.pid" +# +# Arguments: +# $@: One or more file paths to touch. +# +safe_touch() { + local failed_files=() + local file + + if (($# == 0)); then + log_warn "safe_touch: No files provided to touch." + return 0 + fi + + for file; do + if ! touch "$file" 2>/dev/null; then + failed_files+=("$file") + fi + done + + if ((${#failed_files[@]} > 0)); then + fatal_error "Failed to touch the following files: ${failed_files[*]}" + fi + + return 0 +} + +# +# safe_truncate - Truncates one or more files to zero bytes. +# +# This function iterates through all provided file paths. It attempts to +# truncate each file. If any operation fails (e.g., due to permissions), +# it collects the names of the failed files and reports them all in a +# single fatal error at the end. +# +# Usage: +# safe_truncate "/var/log/app.log" "/tmp/data.tmp" +# +# Arguments: +# $@: One or more file paths to truncate. +# +safe_truncate() { + local failed_files=() + local file + + if (($# == 0)); then + log_warn "safe_truncate: No files provided to truncate." + return 0 + fi + + for file; do + # The > redirection is the simplest way to truncate a file. + # We redirect stderr to /dev/null to suppress system error messages, + # as we will provide our own comprehensive error message. + if ! > "$file" 2>/dev/null; then + failed_files+=("$file") + fi + done + + if ((${#failed_files[@]} > 0)); then + fatal_error "Failed to truncate the following files: ${failed_files[*]}" + fi + + return 0 +} + +####################################################### ASSERTIONS #################################################### + +# +# assert_not_null - Checks that one or more variables are not empty. +# +# This function takes the *name* of one or more variables and checks that +# each one has a non-empty value. It is useful for validating required +# script inputs or configuration variables. Unlike other assertions, it +# checks all provided variables and reports all failures at once. +# +# Usage: +# USER="admin" +# TOKEN="" +# assert_not_null USER # This will succeed. +# assert_not_null USER TOKEN # This will fail, listing TOKEN as empty. +# +# Arguments: +# $@: One or more variable names to check. +# +assert_not_null() { + local unset_vars=() var_name + if (($# == 0)); then + fatal_error "assert_not_null: No variable names provided for validation." + fi + + for var_name in "$@"; do + # Use indirection to get the value of the variable whose name is stored in var_name. + # The -v check is for unset variables, -z is for empty strings. + # We check for empty string as per the request. + if [[ ! -v $var_name || -z "${!var_name-}" ]]; then + unset_vars+=("$var_name") + fi + done + + if ((${#unset_vars[@]} > 0)); then + fatal_error "These required variables are not set or are empty: ${unset_vars[*]}" + fi + + return 0 +} + +# +# assert_integer - Checks if the values of one or more variables are valid integers. +# +assert_integer() { + local var_name int_re='^[-+]?[0-9]+$' + (($# == 0)) && fatal_error "assert_integer: No variable names provided." + for var_name in "$@"; do + local value="${!var_name-}" + ! [[ "$value" =~ $int_re ]] && fatal_error "Variable '$var_name' with value '$value' is not a valid integer." + done + return 0 +} + +# +# assert_integer_range - Checks if a variable's value is an integer within a specified range. +# +# Arguments: +# $1: The NAME of the variable to check. +# $2: The minimum value. +# $3: The maximum value. +# +assert_integer_range() { + local var_name="${1-}" min="${2-}" max="${3-}" + (($# != 3)) && fatal_error "assert_integer_range: Expected 3 arguments, got $#." + local value="${!var_name-}" + assert_integer "$var_name" min max + ((value < min || value > max)) && fatal_error "Variable '$var_name' ($value) is out of range [$min, $max]." + return 0 +} + +# +# assert_arg_count - Checks that the number of arguments falls within a given range. +# +# Usage: +# assert_arg_count $# 2 # Fails if arg count is not exactly 2 +# assert_arg_count $# 1 3 # Fails if arg count is not between 1 and 3 (inclusive) +# +# Arguments: +# $1: The actual number of arguments (typically $#). +# $2: The exact expected count, or the minimum count for a range. +# $3: (Optional) The maximum count for a range. +# +assert_arg_count() { + local arg_count="${1-}" count1="${2-}" count2="${3-}" argc=$# + + # Check the number of arguments passed to this function itself. + if ((argc < 2 || argc > 3)); then + fatal_error "assert_arg_count: Incorrect usage. Expected 2 or 3 arguments, but got $argc." + fi + + # Create temporary named variables for assert_integer to check + local __assert_arg_count_val="$arg_count" __assert_count1_val="$count1" + assert_integer __assert_arg_count_val __assert_count1_val + + if [[ -n "$count2" ]]; then + local __assert_count2_val="$count2" + assert_integer __assert_count2_val + fi + + if [[ -z "$count2" ]]; then + # Exact match case + if ((arg_count != count1)); then + fatal_error "Argument count mismatch: expected $count1 but got $arg_count arguments" + fi + else + # Range match case + if ((arg_count < count1 || arg_count > count2)); then + fatal_error "Argument count mismatch: expected between $count1 and $count2 arguments, but got $arg_count" + fi + fi + return 0 +} + +# +# assert_command_exists - Checks that one or more commands are available in the system's PATH. +# +# This function iterates through all provided command names and uses 'command -v' +# to verify their existence. If any command is not found, it collects the names +# and reports them all in a single fatal error. +# +# Usage: +# assert_command_exists git curl jq +# +# Arguments: +# $@: One or more command names to check. +# +assert_command_exists() { + local missing_commands=() + local cmd + + if (($# == 0)); then + log_warn "assert_command_exists: No commands provided to check." + return 0 + fi + + for cmd; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_commands+=("$cmd") + fi + done + + if ((${#missing_commands[@]} > 0)); then + fatal_error "These required commands were not found in your PATH: ${missing_commands[*]}" + fi + + return 0 +} + +# +# assert_file_exists - Checks that one or more paths exist and are regular files. +# +# This function iterates through all provided paths. If any path does not +# exist or is not a regular file (e.g., it's a directory or a symlink to +# a non-file), it collects the names and reports them all in a single fatal error. +# +# Usage: +# assert_file_exists "/etc/hosts" "./my_script.sh" +# +# Arguments: +# $@: One or more file paths to check. +# +assert_file_exists() { + local missing_files=() + local file + + if (($# == 0)); then + log_warn "assert_file_exists: No files provided to check." + return 0 + fi + + for file; do + if [[ ! -f "$file" ]]; then + missing_files+=("$file") + fi + done + + if ((${#missing_files[@]} > 0)); then + fatal_error "These required files do not exist or are not regular files: ${missing_files[*]}" + fi + + return 0 +} + +# +# assert_dir_exists - Checks that one or more paths exist and are directories. +# +# This function iterates through all provided paths. If any path does not +# exist or is not a directory, it collects the names and reports them all +# in a single fatal error. +# +# Usage: +# assert_dir_exists "/tmp" "/var/log" +# +# Arguments: +# $@: One or more directory paths to check. +# +assert_dir_exists() { + local missing_dirs=() + local dir + + if (($# == 0)); then + log_warn "assert_dir_exists: No directories provided to check." + return 0 + fi + + for dir; do + if [[ ! -d "$dir" ]]; then + missing_dirs+=("$dir") + fi + done + + if ((${#missing_dirs[@]} > 0)); then + fatal_error "These required directories do not exist: ${missing_dirs[*]}" + fi + + return 0 +} + +################################################# MISC FUNCTIONS ####################################################### + +# +# safe_cd - A safe version of the 'cd' command that exits on failure. +# +safe_cd() { + local dir="${1-}" + [[ "$dir" ]] || fatal_error "No arguments or an empty string passed to safe_cd" + cd -- "$dir" || fatal_error "Can't cd to '$dir'" +} + +# +# safe_unalias - Safely unaliases a command, without erroring if it doesn't exist. +# +safe_unalias() { + # Ref: https://stackoverflow.com/a/61471333/6862601 + local alias_name + for alias_name; do + [[ ${BASH_ALIASES[$alias_name]-} ]] && unalias "$alias_name" + done + return 0 +} + +# +# get_my_source_dir - Returns the absolute path to the directory of the calling script through the passed variable name. +# +# Usage: +# get_my_source_dir var_name +# +get_my_source_dir() { + local result_name="${1-}" + [[ -n "$result_name" ]] || fatal_error "get_my_source_dir: No result variable name provided." + local -n result="$result_name" + # Reference: https://stackoverflow.com/a/246128/6862601 + result="$(cd "$(dirname "${BASH_SOURCE[1]}")" >/dev/null 2>&1 && pwd -P)" +} + +# +# ask_yes_no - Get user's confirmation +# +# Prompts the user with a given message for a yes/no answer and returns 0 or 1 +# based on user's choice of yes or no. It reads a single character without +# requiring the user to press Enter. +# +# Arguments: +# $1: The message string to display as the prompt. +# +# Usage: +# +# if ask_yes_no "Do you want to continue?"; then +# echo "User chose to continue." +# else +# echo "User chose not to continue." +# fi +# +ask_yes_no() { + if (("$#" != 1)); then + log_error "ask_yes_no: invalid arguments" + log_info "Usage: ask_yes_no " + return 1 + fi + + local message=$1 user_input + while true; do + # Prompt the user for input. + # -n 1: Reads only one character. + # -r: Prevents backslash from acting as an escape character. + # -p: Displays the prompt string. + # The text "[y/N]" suggests that 'N' is the default choice. + read -r -n 1 -p "$message [y/N]: " user_input + + # Add a newline since the user won't press Enter. + echo + + case "$user_input" in + [yY]) return 0;; + [nN]) return 1;; + *) echo "Invalid input. Please enter 'y' or 'n'.";; + esac + done +} + +# +# wait_for_enter - Pauses the script and waits for the user to press the Enter key. +# +# Arguments: +# $1: (Optional) The prompt to display. Defaults to "Press Enter to continue". +# +wait_for_enter() { + local prompt=${1:-"Press Enter to continue"} + read -r -s -p "$prompt" /dev/null 2>&1 +} diff --git a/cli/bash/tests/test_helper.bash b/cli/bash/tests/test_helper.bash new file mode 100644 index 0000000..efe3caf --- /dev/null +++ b/cli/bash/tests/test_helper.bash @@ -0,0 +1,49 @@ +# Common helpers for Bash library BATS suites. + +# Preserve BATS' built-in `run` helper before lib_std.sh defines its own. +if declare -f run >/dev/null 2>&1; then + eval "$(declare -f run | sed '1 s/^run /bats_run /')" +fi + +readonly BANYAN_BASH_TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly BANYAN_BASH_DIR="$(cd "$BANYAN_BASH_TESTS_DIR/.." && pwd -P)" +readonly BANYAN_REPO_ROOT="$(cd "$BANYAN_BASH_DIR/../.." && pwd -P)" + +setup_test_tmpdir() { + TEST_TMPDIR="${BATS_TEST_TMPDIR}/workspace" + mkdir -p "$TEST_TMPDIR" +} + +init_git_repo() { + local repo_dir="$1" + + mkdir -p "$repo_dir" + git init "$repo_dir" >/dev/null 2>&1 + git -C "$repo_dir" checkout -B master >/dev/null 2>&1 + git -C "$repo_dir" config user.name "Bats Test" + git -C "$repo_dir" config user.email "bats@example.com" +} + +commit_all() { + local repo_dir="$1" + local message="${2:-test commit}" + + git -C "$repo_dir" add -A + git -C "$repo_dir" commit -m "$message" >/dev/null 2>&1 +} + +create_tracked_repo_with_upstream() { + local repo_dir="$1" + local remote_dir="$2" + local rel_path="$3" + local content="${4:-sample content}" + + init_git_repo "$repo_dir" + mkdir -p "$(dirname "$repo_dir/$rel_path")" + printf '%s\n' "$content" > "$repo_dir/$rel_path" + commit_all "$repo_dir" "Initial commit" + + git init --bare "$remote_dir" >/dev/null 2>&1 + git -C "$repo_dir" remote add origin "$remote_dir" + git -C "$repo_dir" push -u origin master >/dev/null 2>&1 +}