diff --git a/.claude/tasks/2025-07-07-bash-migration.md b/.claude/tasks/2025-07-07-bash-migration.md index edd7327c..dc2f15fa 100644 --- a/.claude/tasks/2025-07-07-bash-migration.md +++ b/.claude/tasks/2025-07-07-bash-migration.md @@ -189,6 +189,15 @@ test/install/test-{component}-installation.bats # Integration tests ## Current Work +### ✅ PR #19: Dry-run and Error Handling Utilities (Ready to Merge) +- **Files Created**: + - `bin/lib/dry-run-utils.bash` - Complete dry-run functionality + - `bin/lib/error-handling.bash` - Error handling and retry mechanisms + - `test/setup/test-dry-run-utils-bash.bats` - 20 tests for dry-run utilities (all passing) +- **Test Results**: 20/20 tests passing, zero shellcheck warnings +- **Status**: Ready to merge +- **Follow-up needed**: Add comprehensive tests for error-handling.bash utilities + ### 🔄 PR #15: SSH Installation Testing (Draft) - **Files Created**: - `lib/ssh-utils.bash` - SSH utilities with comprehensive functionality @@ -201,13 +210,14 @@ test/install/test-{component}-installation.bats # Integration tests ## Next Steps (Enhanced Architecture) -1. **Complete PR #15**: SSH installation migration (current work) -2. **Extract shared utilities**: Create `lib/homebrew-utils.bash`, `lib/npm-utils.bash`, `lib/symlink-utils.bash` -3. **Migrate utility libraries**: `bin/lib/*.zsh` → `bin/lib/*.bash` (machine-detection, prerequisite-validation, etc.) -4. **Create setup.bash**: Main entry point using shared utilities and bash install scripts -5. **Update bin/update/*.zsh**: Thin wrappers around shared utilities for interactive use -6. **Migrate CI workflow**: Custom zsh runner → bats with comprehensive test coverage -7. **Architectural validation**: Verify three-tier system meets all requirements +1. **Add error-handling tests**: Create `test/setup/test-error-handling-bash.bats` with comprehensive tests for all error handling utilities (capture_error, retry_with_backoff, handle_error) +2. **Complete PR #15**: SSH installation migration (current work) +3. **Extract shared utilities**: Create `lib/homebrew-utils.bash`, `lib/npm-utils.bash`, `lib/symlink-utils.bash` +4. **Migrate utility libraries**: `bin/lib/*.zsh` → `bin/lib/*.bash` (machine-detection, prerequisite-validation, etc.) +5. **Create setup.bash**: Main entry point using shared utilities and bash install scripts +6. **Update bin/update/*.zsh**: Thin wrappers around shared utilities for interactive use +7. **Migrate CI workflow**: Custom zsh runner → bats with comprehensive test coverage +8. **Architectural validation**: Verify three-tier system meets all requirements ## Enhanced Development Workflow diff --git a/bin/lib/dry-run-utils.bash b/bin/lib/dry-run-utils.bash new file mode 100644 index 00000000..5749e478 --- /dev/null +++ b/bin/lib/dry-run-utils.bash @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +# Dry-run utilities for dotfiles setup +# Provides read-only validation and logging capabilities + +set -euo pipefail + +# Parse command line arguments for dry-run flags +parse_dry_run_flags() { + # Default to false + export DRY_RUN="false" + + # Check all arguments for --dry-run flag + for arg in "$@"; do + if [[ "$arg" == "--dry-run" ]]; then + export DRY_RUN="true" + break + fi + done +} + +# Log actions in dry-run mode without executing them +dry_run_log() { + local action="${1:-}" + + if [[ -z "$action" ]]; then + echo "Error: No action provided to dry_run_log" >&2 + return 1 + fi + + # Always log the action with DRY RUN prefix + echo "DRY RUN: $action" + + # In dry-run mode, we don't execute the action + return 0 +} + +# Execute commands conditionally based on dry-run mode +dry_run_execute() { + local command="${1:-}" + + if [[ -z "$command" ]]; then + echo "Error: No command provided to dry_run_execute" >&2 + return 1 + fi + + # Check if we're in dry-run mode + if [[ "${DRY_RUN:-false}" == "true" ]]; then + # In dry-run mode, just log the command + dry_run_log "$command" + return 0 + else + # In normal mode, execute the command + eval "$command" + return $? + fi +} \ No newline at end of file diff --git a/bin/lib/error-handling.bash b/bin/lib/error-handling.bash new file mode 100644 index 00000000..c2c799b9 --- /dev/null +++ b/bin/lib/error-handling.bash @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +# Error handling utilities for dotfiles setup +# Provides graceful error handling and recovery mechanisms + +set -euo pipefail + +# Capture and handle command errors with context +capture_error() { + local command="${1:-}" + local context="${2:-Command execution}" + local exit_code + local output + + if [[ -z "$command" ]]; then + echo "Error: No command provided to capture_error" >&2 + return 1 + fi + + # Execute the command and capture output + output=$(eval "$command" 2>&1) + exit_code=$? + + # Show the output first (if any) + if [[ -n "$output" ]]; then + echo "$output" + fi + + # Check if command failed + if [[ $exit_code -ne 0 ]]; then + echo "Error: $context failed (exit code: $exit_code)" >&2 + echo "Command: $command" >&2 + return $exit_code + fi + + return 0 +} + +# Retry a command with exponential backoff +retry_with_backoff() { + local command="${1:-}" + local max_attempts="${2:-3}" + local initial_delay="${3:-1}" + local attempt=1 + local delay=$initial_delay + + if [[ -z "$command" ]]; then + echo "Error: No command provided to retry_with_backoff" >&2 + return 1 + fi + + while [[ $attempt -le $max_attempts ]]; do + # Try the command + if eval "$command" 2>/dev/null; then + return 0 + fi + + # If we've exhausted attempts, fail + if [[ $attempt -eq $max_attempts ]]; then + echo "Error: Command failed after $max_attempts attempts" >&2 + echo "Command: $command" >&2 + return 1 + fi + + # Wait before retry with exponential backoff + echo "Attempt $attempt failed, retrying in ${delay}s..." >&2 + sleep "$delay" + + # Increase delay for next attempt + delay=$((delay * 2)) + ((attempt++)) + done +} + +# Provide user-friendly error messages with helpful suggestions +handle_error() { + local command="${1:-}" + local error_code="${2:-}" + local error_message="${3:-Unknown error}" + + if [[ -z "$command" ]]; then + echo "Error: No command provided to handle_error" >&2 + return 1 + fi + + echo "❌ Error occurred while running: $command" >&2 + echo " Error: $error_message" >&2 + + # Provide helpful suggestions based on error type + case "$error_code" in + "EACCES"|"EPERM") + echo " 💡 Suggestion: Try running with sudo or check file permissions" >&2 + ;; + "ENOENT") + echo " 💡 Suggestion: Check if the file or directory exists" >&2 + ;; + "ECONNREFUSED"|"ETIMEDOUT") + echo " 💡 Suggestion: Check your internet connection or try again later" >&2 + ;; + "ENOSPC") + echo " 💡 Suggestion: Free up disk space and try again" >&2 + ;; + *) + echo " 💡 Suggestion: Check the command syntax and try again" >&2 + ;; + esac + + return 1 +} \ No newline at end of file diff --git a/test/setup/test-dry-run-utils-bash.bats b/test/setup/test-dry-run-utils-bash.bats new file mode 100644 index 00000000..26c36fef --- /dev/null +++ b/test/setup/test-dry-run-utils-bash.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats + +# Dry-run utilities tests (bash version) +# Tests the bash version of dry-run functionality + +# Load the dry-run utilities +load "../../bin/lib/dry-run-utils.bash" + +setup() { + # Create temporary directory for each test + export TEST_TEMP_DIR + TEST_TEMP_DIR="$(mktemp -d)" + + # Save original environment + export ORIGINAL_DRY_RUN="${DRY_RUN:-}" + + # Clear dry-run environment variables for clean testing + unset DRY_RUN +} + +teardown() { + # Restore original environment + if [[ -n "$ORIGINAL_DRY_RUN" ]]; then + export DRY_RUN="$ORIGINAL_DRY_RUN" + else + unset DRY_RUN + fi + + # Clean up temporary directory + if [[ -n "${TEST_TEMP_DIR:-}" && -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Unit Tests for parse_dry_run_flags function + +@test "parse_dry_run_flags sets DRY_RUN=true when --dry-run flag present" { + # Function sets variables in current shell, don't use run + parse_dry_run_flags "--dry-run" + [ "$DRY_RUN" = "true" ] +} + +@test "parse_dry_run_flags sets DRY_RUN=true when --dry-run is among multiple args" { + # Function sets variables in current shell, don't use run + parse_dry_run_flags "--verbose" "--dry-run" "--force" + [ "$DRY_RUN" = "true" ] +} + +@test "parse_dry_run_flags sets DRY_RUN=false when --dry-run flag not present" { + # Function sets variables in current shell, don't use run + parse_dry_run_flags "--verbose" "--force" + [ "$DRY_RUN" = "false" ] +} + +@test "parse_dry_run_flags sets DRY_RUN=false when no arguments provided" { + # Function sets variables in current shell, don't use run + parse_dry_run_flags + [ "$DRY_RUN" = "false" ] +} + +@test "parse_dry_run_flags handles empty string arguments" { + # Function sets variables in current shell, don't use run + parse_dry_run_flags "" "--dry-run" "" + [ "$DRY_RUN" = "true" ] +} + +# Unit Tests for dry_run_log function + +@test "dry_run_log outputs action with DRY RUN prefix" { + run dry_run_log "test action" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: test action" ]] +} + +@test "dry_run_log handles multi-word actions" { + run dry_run_log "install homebrew packages" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: install homebrew packages" ]] +} + +@test "dry_run_log fails when no action provided" { + run dry_run_log "" + [ "$status" -eq 1 ] + [[ "$output" == *"No action provided to dry_run_log"* ]] +} + +@test "dry_run_log fails when action is missing" { + run dry_run_log + [ "$status" -eq 1 ] + [[ "$output" == *"No action provided to dry_run_log"* ]] +} + +# Unit Tests for dry_run_execute function + +@test "dry_run_execute logs command in dry-run mode" { + export DRY_RUN="true" + + run dry_run_execute "echo 'test command'" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: echo 'test command'" ]] +} + +@test "dry_run_execute executes command in normal mode" { + export DRY_RUN="false" + + run dry_run_execute "echo 'actual output'" + [ "$status" -eq 0 ] + [[ "$output" == "actual output" ]] +} + +@test "dry_run_execute executes command when DRY_RUN not set" { + unset DRY_RUN + + run dry_run_execute "echo 'default behavior'" + [ "$status" -eq 0 ] + [[ "$output" == "default behavior" ]] +} + +@test "dry_run_execute returns command exit code in normal mode" { + export DRY_RUN="false" + + run dry_run_execute "exit 42" + [ "$status" -eq 42 ] +} + +@test "dry_run_execute returns 0 in dry-run mode regardless of command" { + export DRY_RUN="true" + + run dry_run_execute "exit 42" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: exit 42" ]] +} + +@test "dry_run_execute fails when no command provided" { + run dry_run_execute "" + [ "$status" -eq 1 ] + [[ "$output" == *"No command provided to dry_run_execute"* ]] +} + +@test "dry_run_execute fails when command is missing" { + run dry_run_execute + [ "$status" -eq 1 ] + [[ "$output" == *"No command provided to dry_run_execute"* ]] +} + +# Integration Tests + +@test "dry_run_execute with complex command in dry-run mode" { + export DRY_RUN="true" + + run dry_run_execute "mkdir -p /tmp/test && echo 'directory created'" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: mkdir -p /tmp/test && echo 'directory created'" ]] +} + +@test "dry_run_execute with complex command in normal mode" { + export DRY_RUN="false" + + # Use test temp dir for actual execution + run dry_run_execute "mkdir -p '$TEST_TEMP_DIR/test' && echo 'directory created'" + [ "$status" -eq 0 ] + [[ "$output" == "directory created" ]] + # Verify directory was actually created + [ -d "$TEST_TEMP_DIR/test" ] +} + +@test "workflow integration: parse flags then execute commands" { + # Parse --dry-run flag + parse_dry_run_flags "--dry-run" "--verbose" + + # Verify dry-run mode was set + [ "$DRY_RUN" = "true" ] + + # Execute commands in dry-run mode + run dry_run_execute "echo 'first command'" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: echo 'first command'" ]] + + run dry_run_execute "mkdir /tmp/testdir" + [ "$status" -eq 0 ] + [[ "$output" == "DRY RUN: mkdir /tmp/testdir" ]] +} + +@test "workflow integration: parse normal flags then execute commands" { + # Parse flags without --dry-run + parse_dry_run_flags "--verbose" "--force" + + # Verify normal mode was set + [ "$DRY_RUN" = "false" ] + + # Execute commands in normal mode + run dry_run_execute "echo 'actual execution'" + [ "$status" -eq 0 ] + [[ "$output" == "actual execution" ]] +} \ No newline at end of file