From 78ca7b4d8b59c1235b16b926deb8661b214361ba Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Tue, 10 Mar 2026 00:14:14 -0500 Subject: [PATCH] Big bang --- .gitignore | 16 ++ HELP | 39 +++ LICENSE | 21 ++ README.md | 9 + example.env.example | 30 +++ generate-env.sh | 77 ++++++ lib/colors.sh | 20 ++ lib/output.sh | 28 +++ lib/parse.sh | 87 +++++++ lib/prompt.sh | 217 +++++++++++++++++ lib/validate.sh | 90 +++++++ tests/fixtures/all_types.env.example | 21 ++ tests/fixtures/basic.env.example | 3 + tests/fixtures/empty.env.example | 0 tests/fixtures/multiline_desc.env.example | 3 + tests/fixtures/no_annotations.env.example | 2 + tests/fixtures/orphaned_comments.env.example | 4 + tests/fixtures/value_with_equals.env.example | 3 + tests/framework.sh | 163 +++++++++++++ tests/run_tests.sh | 75 ++++++ tests/test_e2e.sh | 242 +++++++++++++++++++ tests/test_output.sh | 192 +++++++++++++++ tests/test_parse.sh | 138 +++++++++++ tests/test_prompt.sh | 190 +++++++++++++++ tests/test_validate.sh | 131 ++++++++++ 25 files changed, 1801 insertions(+) create mode 100644 .gitignore create mode 100644 HELP create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example.env.example create mode 100755 generate-env.sh create mode 100644 lib/colors.sh create mode 100644 lib/output.sh create mode 100644 lib/parse.sh create mode 100644 lib/prompt.sh create mode 100644 lib/validate.sh create mode 100644 tests/fixtures/all_types.env.example create mode 100644 tests/fixtures/basic.env.example create mode 100644 tests/fixtures/empty.env.example create mode 100644 tests/fixtures/multiline_desc.env.example create mode 100644 tests/fixtures/no_annotations.env.example create mode 100644 tests/fixtures/orphaned_comments.env.example create mode 100644 tests/fixtures/value_with_equals.env.example create mode 100644 tests/framework.sh create mode 100755 tests/run_tests.sh create mode 100755 tests/test_e2e.sh create mode 100755 tests/test_output.sh create mode 100755 tests/test_parse.sh create mode 100755 tests/test_prompt.sh create mode 100755 tests/test_validate.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d94a06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Generated files +.env +.env.local +.env.*.local + +# IDEs +.idea/ +.vscode/ + +# macOS +.DS_Store + +# Temporary files +*.swp +*.swo +*~ diff --git a/HELP b/HELP new file mode 100644 index 0000000..d30ffa9 --- /dev/null +++ b/HELP @@ -0,0 +1,39 @@ +generate-env.sh — Interactively generate a .env file from a .env.example template. + +Usage: + ./generate-env.sh [options] [input_file] + +Arguments: + input_file Path to the .env.example file (default: .env.example) + +Options: + -o Output file path (default: .env) + -h Show this help message + +Annotation syntax in .env.example: + # Description text for the variable + # @required + # @type + VAR_NAME=default_value + +Supported types: + string Plain text (default when @type is absent) + number Numeric value; validated on entry + bool Boolean; prompts y/n, stored as true/false + secret Hidden input with confirmation; stored as-is + url Text; warns if value doesn't start with http(s):// + email Text; validated as user@domain.tld + enum:a,b,c Presents a numbered menu of choices + +Using as a git submodule: + Add to your project: + git submodule add https://code.garrettmills.dev/garrettmills/interactive-env + + Run from your project root: + ./interactive-env/generate-env.sh + + Update to the latest version: + git submodule update --remote interactive-env + + Clone a project that uses this submodule: + git clone --recurse-submodules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba3f304 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Garrett Mills + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2b8a75 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# `interactive-env` + +Interactively generate a `.env` file from a `.env.example` template. + +It is implemented as a portable bash script. Variables are annotated with structured comments (`@type`, `@required`) to drive prompts, validation, and type-specific input (booleans, enums, secrets, etc.). + +See [`HELP`](./HELP) for full usage and annotation syntax. + +This project is licensed under the [MIT License](./LICENSE). diff --git a/example.env.example b/example.env.example new file mode 100644 index 0000000..acb64f5 --- /dev/null +++ b/example.env.example @@ -0,0 +1,30 @@ +# The name of the application +# @required +APP_NAME=MyApp + +# The environment to run in +# @type enum:development,staging,production +# @required +APP_ENV=development + +# Port number the server listens on +# @type number +PORT=3000 + +# Enable debug logging +# @type bool +DEBUG=false + +# Secret key for signing JWTs +# @type secret +# @required +JWT_SECRET= + +# Database connection URL +# @type url +# @required +DATABASE_URL=postgres://localhost:5432/myapp + +# Optional email address for admin notifications +# @type email +ADMIN_EMAIL= diff --git a/generate-env.sh b/generate-env.sh new file mode 100755 index 0000000..e6b7021 --- /dev/null +++ b/generate-env.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# generate-env.sh — Interactively generate a .env file from a .env.example template. +# Run with -h for usage information. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib/colors.sh +source "${SCRIPT_DIR}/lib/colors.sh" +# shellcheck source=lib/parse.sh +source "${SCRIPT_DIR}/lib/parse.sh" +# shellcheck source=lib/validate.sh +source "${SCRIPT_DIR}/lib/validate.sh" +# shellcheck source=lib/prompt.sh +source "${SCRIPT_DIR}/lib/prompt.sh" +# shellcheck source=lib/output.sh +source "${SCRIPT_DIR}/lib/output.sh" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +INPUT_FILE=".env.example" +OUTPUT_FILE=".env" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +usage() { + cat "${SCRIPT_DIR}/HELP" >&2 + exit 0 +} + +while getopts ":o:h" opt; do + case "$opt" in + o) OUTPUT_FILE="$OPTARG" ;; + h) usage ;; + :) printf "${RED}Error: option -%s requires an argument.${NC}\n" "$OPTARG" >&2; exit 1 ;; + \?) printf "${RED}Error: unknown option -%s${NC}\n" "$OPTARG" >&2; exit 1 ;; + esac +done +shift $((OPTIND - 1)) + +if [[ $# -gt 0 ]]; then + INPUT_FILE="$1" +fi + +# --------------------------------------------------------------------------- +# Guard: don't silently overwrite an existing .env without confirmation +# --------------------------------------------------------------------------- +if [[ -f "$OUTPUT_FILE" ]]; then + printf "${YELLOW}Warning: %s already exists.${NC}\n" "$OUTPUT_FILE" >&2 + read -r -p "Overwrite? [y/N]: " confirm >&2 + case "${confirm,,}" in + y|yes) ;; + *) printf "Aborted.\n" >&2; exit 0 ;; + esac +fi + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +printf "${BOLD}generate-env${NC} %s → %s\n" "$INPUT_FILE" "$OUTPUT_FILE" >&2 +printf "${DIM}Fill in each variable. Press Enter to accept the default value.${NC}\n" >&2 + +parse_env_example "$INPUT_FILE" + +if [[ ${#ENV_VARS[@]} -eq 0 ]]; then + printf "${YELLOW}No variables found in %s.${NC}\n" "$INPUT_FILE" >&2 + exit 0 +fi + +printf "${DIM}Found %d variable(s).${NC}\n" "${#ENV_VARS[@]}" >&2 + +prompt_all_variables + +write_env_file "$OUTPUT_FILE" diff --git a/lib/colors.sh b/lib/colors.sh new file mode 100644 index 0000000..1a319af --- /dev/null +++ b/lib/colors.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Terminal color and formatting constants. +# Source this file; do not execute it directly. + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' # No Color / Reset + +# Print a colored message to stderr +# Usage: color_echo +color_echo() { + local color="$1" + local msg="$2" + printf "${color}%s${NC}\n" "$msg" >&2 +} diff --git a/lib/output.sh b/lib/output.sh new file mode 100644 index 0000000..bf57358 --- /dev/null +++ b/lib/output.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Writes collected ENV_VALUE_* variables to a .env file. +# Source this file; do not execute it directly. +# Requires colors.sh to be sourced first. + +# write_env_file +# Writes all populated ENV_VALUE_ entries to . +write_env_file() { + local output_file="$1" + + { + printf "# Generated by generate-env on %s\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf "\n" + + local var_name + for var_name in "${ENV_VARS[@]}"; do + local desc_var="ENV_DESC_${var_name}" + local desc="${!desc_var}" + local value_var="ENV_VALUE_${var_name}" + local value="${!value_var}" + [[ -n "$desc" ]] && printf "# %s\n" "$desc" + printf "%s=%s\n" "$var_name" "$value" + printf "\n" + done + } > "$output_file" + + printf "\n${GREEN}✓ Written to %s${NC}\n" "$output_file" >&2 +} diff --git a/lib/parse.sh b/lib/parse.sh new file mode 100644 index 0000000..ab9b9ed --- /dev/null +++ b/lib/parse.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Parses a .env.example file into global metadata arrays. +# Source this file; do not execute it directly. +# +# After calling parse_env_example, the following globals are populated: +# ENV_VARS - ordered array of variable names +# ENV_DESC_ - description string +# ENV_TYPE_ - type annotation (string if absent) +# ENV_REQUIRED_ - "true" or "false" +# ENV_DEFAULT_ - default value (may be empty) + +# parse_env_example +# Reads the given .env.example file and populates global metadata. +parse_env_example() { + local file="$1" + + if [[ ! -f "$file" ]]; then + printf "Error: file not found: %s\n" "$file" >&2 + return 1 + fi + + ENV_VARS=() + + local desc="" + local type="" + local required="false" + local in_block=0 # 1 once we've started accumulating comments for a var + + while IFS= read -r line || [[ -n "$line" ]]; do + + # --- Empty line: flush pending comment block --- + if [[ -z "$line" ]]; then + if [[ $in_block -eq 1 ]]; then + desc="" + type="" + required="false" + in_block=0 + fi + continue + fi + + # --- Comment line --- + if [[ "$line" =~ ^[[:space:]]*#(.*) ]]; then + local comment="${BASH_REMATCH[1]}" + # Trim one leading space if present + comment="${comment# }" + in_block=1 + + if [[ "$comment" =~ ^@required([[:space:]]|$) ]]; then + required="true" + elif [[ "$comment" =~ ^@type[[:space:]]+(.+)$ ]]; then + type="${BASH_REMATCH[1]}" + # Trim trailing whitespace + type="${type%"${type##*[![:space:]]}"}" + else + # Regular description text — append + if [[ -n "$desc" ]]; then + desc="${desc} ${comment}" + else + desc="$comment" + fi + fi + continue + fi + + # --- KEY=VALUE line --- + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + local var_name="${BASH_REMATCH[1]}" + local default="${BASH_REMATCH[2]}" + + ENV_VARS+=("$var_name") + + printf -v "ENV_DESC_${var_name}" '%s' "$desc" + printf -v "ENV_TYPE_${var_name}" '%s' "${type:-string}" + printf -v "ENV_REQUIRED_${var_name}" '%s' "$required" + printf -v "ENV_DEFAULT_${var_name}" '%s' "$default" + + # Reset for next block + desc="" + type="" + required="false" + in_block=0 + continue + fi + + done < "$file" +} diff --git a/lib/prompt.sh b/lib/prompt.sh new file mode 100644 index 0000000..e9da524 --- /dev/null +++ b/lib/prompt.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Interactive per-variable prompting. +# Source this file; do not execute it directly. +# Requires colors.sh and validate.sh to be sourced first. + +# _prompt_enum +# Prints a numbered menu and returns the chosen value via stdout. +_prompt_enum() { + local var_name="$1" + local options_csv="$2" + local default="$3" + + local -a opts + local IFS=',' + read -ra opts <<< "$options_csv" + unset IFS + + local default_idx=0 + local i + for i in "${!opts[@]}"; do + printf " %d) %s" $((i + 1)) "${opts[$i]}" >&2 + if [[ "${opts[$i]}" == "$default" ]]; then + printf " ${DIM}(default)${NC}" >&2 + default_idx=$((i + 1)) + fi + printf "\n" >&2 + done + + local choice value + while true; do + if [[ $default_idx -gt 0 ]]; then + read -r -p " Choice [1-${#opts[@]}, default ${default_idx}]: " choice >&2 + else + read -r -p " Choice [1-${#opts[@]}]: " choice >&2 + fi + + # Empty input → use default + if [[ -z "$choice" && $default_idx -gt 0 ]]; then + echo "${opts[$((default_idx - 1))]}" + return 0 + fi + + # Numeric selection + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#opts[@]} )); then + echo "${opts[$((choice - 1))]}" + return 0 + fi + + # Direct string match + local opt + for opt in "${opts[@]}"; do + if [[ "$choice" == "$opt" ]]; then + echo "$opt" + return 0 + fi + done + + printf "${RED} Invalid choice. Enter a number between 1 and %d.${NC}\n" "${#opts[@]}" >&2 + done +} + +# _prompt_bool +# Prompts y/n and returns canonical "true" or "false" via stdout. +_prompt_bool() { + local default="$1" + local hint + + case "${default,,}" in + true|yes|1|y) hint="Y/n" ;; + false|no|0|n) hint="y/N" ;; + *) hint="y/n" ;; + esac + + local input + while true; do + read -r -p " Enable? [${hint}]: " input >&2 + if [[ -z "$input" && -n "$default" ]]; then + normalize_bool "$default" + return 0 + fi + case "${input,,}" in + y|yes|true|1) echo "true"; return 0 ;; + n|no|false|0) echo "false"; return 0 ;; + *) printf "${RED} Enter y or n.${NC}\n" >&2 ;; + esac + done +} + +# _prompt_secret +# Reads a secret (hidden) value, with optional confirmation, via stdout. +_prompt_secret() { + local var_name="$1" + local default="$2" + local value confirm + + while true; do + if [[ -n "$default" ]]; then + read -r -s -p " Value (leave blank to keep default): " value >&2 + else + read -r -s -p " Value: " value >&2 + fi + printf "\n" >&2 + + if [[ -z "$value" && -n "$default" ]]; then + echo "$default" + return 0 + fi + + if [[ -n "$value" ]]; then + read -r -s -p " Confirm value: " confirm >&2 + printf "\n" >&2 + if [[ "$value" == "$confirm" ]]; then + echo "$value" + return 0 + fi + printf "${RED} Values do not match. Try again.${NC}\n" >&2 + else + printf "${RED} Value cannot be empty for a secret field with no default.${NC}\n" >&2 + fi + done +} + +# prompt_variable +# Interactively prompts the user for a value and stores it in ENV_VALUE_. +prompt_variable() { + local var_name="$1" + + # Dereference metadata + local desc_var="ENV_DESC_${var_name}" + local type_var="ENV_TYPE_${var_name}" + local required_var="ENV_REQUIRED_${var_name}" + local default_var="ENV_DEFAULT_${var_name}" + + local desc="${!desc_var}" + local type="${!type_var}" + local required="${!required_var}" + local default="${!default_var}" + + # ---- Header ---- + printf "\n" >&2 + printf "${BOLD}${CYAN}%s${NC}" "$var_name" >&2 + if [[ "$required" == "true" ]]; then + printf " ${RED}(required)${NC}" >&2 + else + printf " ${DIM}(optional)${NC}" >&2 + fi + if [[ -n "$type" && "$type" != "string" ]]; then + printf " ${DIM}[%s]${NC}" "$type" >&2 + fi + printf "\n" >&2 + + if [[ -n "$desc" ]]; then + printf " ${DIM}%s${NC}\n" "$desc" >&2 + fi + + # ---- Type-specific prompt ---- + local value + + case "$type" in + bool) + value="$(_prompt_bool "$default")" + ;; + + secret) + value="$(_prompt_secret "$var_name" "$default")" + ;; + + enum:*) + local options="${type#enum:}" + printf " ${DIM}Options:${NC}\n" >&2 + value="$(_prompt_enum "$var_name" "$options" "$default")" + ;; + + *) + # Generic text input (string, number, url, email) + local prompt_str + if [[ -n "$default" ]]; then + prompt_str=" Value (default: ${default}): " + else + prompt_str=" Value: " + fi + + while true; do + read -r -p "$prompt_str" value >&2 + if [[ -z "$value" ]]; then + value="$default" + fi + + if validate_value "$var_name" "$value" "$type" "$required"; then + break + fi + done + ;; + esac + + # For bool/enum/secret we still run the required check after collecting value + if [[ "$type" == "bool" || "$type" == "secret" || "$type" == enum:* ]]; then + while ! validate_value "$var_name" "$value" "$type" "$required"; do + case "$type" in + bool) value="$(_prompt_bool "$default")" ;; + secret) value="$(_prompt_secret "$var_name" "$default")" ;; + enum:*) value="$(_prompt_enum "$var_name" "${type#enum:}" "$default")" ;; + esac + done + fi + + printf -v "ENV_VALUE_${var_name}" '%s' "$value" +} + +# prompt_all_variables +# Iterates over ENV_VARS and prompts for each one. +prompt_all_variables() { + local var_name + for var_name in "${ENV_VARS[@]}"; do + prompt_variable "$var_name" + done +} diff --git a/lib/validate.sh b/lib/validate.sh new file mode 100644 index 0000000..725dec6 --- /dev/null +++ b/lib/validate.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Value validation helpers, keyed by @type annotation. +# Source this file; do not execute it directly. +# Requires colors.sh to be sourced first. + +# validate_value +# Returns 0 if valid, 1 if invalid (prints a message to stderr). +validate_value() { + local var_name="$1" + local value="$2" + local type="$3" + local required="$4" + + # Required check + if [[ "$required" == "true" && -z "$value" ]]; then + printf "${RED} Error: %s is required and cannot be empty.${NC}\n" "$var_name" >&2 + return 1 + fi + + # No further validation needed for empty optional values + if [[ -z "$value" ]]; then + return 0 + fi + + case "$type" in + number) + if ! [[ "$value" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then + printf "${RED} Error: Must be a number (got: %s).${NC}\n" "$value" >&2 + return 1 + fi + ;; + + bool) + case "${value,,}" in + true|false|yes|no|1|0|y|n) ;; + *) + printf "${RED} Error: Must be a boolean value (true/false/yes/no).${NC}\n" >&2 + return 1 + ;; + esac + ;; + + url) + if ! [[ "$value" =~ ^https?:// ]]; then + printf "${YELLOW} Warning: Value doesn't look like a URL (expected http:// or https://).${NC}\n" >&2 + # Warn only — allow the user to proceed + fi + ;; + + email) + if ! [[ "$value" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then + printf "${RED} Error: Must be a valid email address (got: %s).${NC}\n" "$value" >&2 + return 1 + fi + ;; + + enum:*) + local options="${type#enum:}" + local valid=false + local IFS=',' + local opt + for opt in $options; do + if [[ "$value" == "$opt" ]]; then + valid=true + break + fi + done + if [[ "$valid" == "false" ]]; then + printf "${RED} Error: Must be one of: %s (got: %s).${NC}\n" "$options" "$value" >&2 + return 1 + fi + ;; + + secret|string|*) + # No structural validation for these types + ;; + esac + + return 0 +} + +# normalize_bool +# Converts a truthy/falsy string to canonical "true" or "false". +normalize_bool() { + case "${1,,}" in + true|yes|1|y) echo "true" ;; + false|no|0|n) echo "false" ;; + *) echo "$1" ;; # pass-through for validation to catch + esac +} diff --git a/tests/fixtures/all_types.env.example b/tests/fixtures/all_types.env.example new file mode 100644 index 0000000..76cedc1 --- /dev/null +++ b/tests/fixtures/all_types.env.example @@ -0,0 +1,21 @@ +# @type string +STR_VAR=hello + +# @type number +NUM_VAR=42 + +# @type bool +BOOL_VAR=false + +# @type secret +# @required +SECRET_VAR= + +# @type url +URL_VAR=https://example.com + +# @type email +EMAIL_VAR=user@example.com + +# @type enum:a,b,c +ENUM_VAR=a diff --git a/tests/fixtures/basic.env.example b/tests/fixtures/basic.env.example new file mode 100644 index 0000000..8e02d96 --- /dev/null +++ b/tests/fixtures/basic.env.example @@ -0,0 +1,3 @@ +# Description +# @required +BASIC_VAR=default diff --git a/tests/fixtures/empty.env.example b/tests/fixtures/empty.env.example new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/multiline_desc.env.example b/tests/fixtures/multiline_desc.env.example new file mode 100644 index 0000000..45e48aa --- /dev/null +++ b/tests/fixtures/multiline_desc.env.example @@ -0,0 +1,3 @@ +# First line +# Second line +MULTI_VAR=value diff --git a/tests/fixtures/no_annotations.env.example b/tests/fixtures/no_annotations.env.example new file mode 100644 index 0000000..31f1e14 --- /dev/null +++ b/tests/fixtures/no_annotations.env.example @@ -0,0 +1,2 @@ +PLAIN_VAR=value +ANOTHER_VAR= diff --git a/tests/fixtures/orphaned_comments.env.example b/tests/fixtures/orphaned_comments.env.example new file mode 100644 index 0000000..a109f1a --- /dev/null +++ b/tests/fixtures/orphaned_comments.env.example @@ -0,0 +1,4 @@ +# This comment has no variable +# It should be ignored + +REAL_VAR=value diff --git a/tests/fixtures/value_with_equals.env.example b/tests/fixtures/value_with_equals.env.example new file mode 100644 index 0000000..774ab95 --- /dev/null +++ b/tests/fixtures/value_with_equals.env.example @@ -0,0 +1,3 @@ +# A URL with query parameters (value contains = signs) +# @type url +DB_URL=postgres://host:5432/db?sslmode=require&connect_timeout=10 diff --git a/tests/framework.sh b/tests/framework.sh new file mode 100644 index 0000000..c289e74 --- /dev/null +++ b/tests/framework.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Minimal test framework. Source this file; do not execute it directly. +# +# Globals maintained per-process: +# _PASS — count of passing assertions +# _FAIL — count of failing assertions + +_PASS=0 +_FAIL=0 + +# Color codes (self-contained; do not rely on colors.sh) +_R='\033[0;31m' # red +_G='\033[0;32m' # green +_Y='\033[1;33m' # yellow +_B='\033[1m' # bold +_D='\033[2m' # dim +_N='\033[0m' # reset + +# suite +# Print a section header for a group of related tests. +suite() { + printf "\n${_B}%s${_N}\n" "$1" +} + +# pass / fail — low-level counters; prefer assert_* helpers instead +pass() { + _PASS=$((_PASS + 1)) + printf " ${_G}✓${_N} %s\n" "$1" +} + +fail() { + _FAIL=$((_FAIL + 1)) + printf " ${_R}✗${_N} %s\n" "$1" + [[ -n "${2:-}" ]] && printf " expected: %s\n" "$2" + [[ -n "${3:-}" ]] && printf " got: %s\n" "${3:-}" +} + +# --------------------------------------------------------------------------- +# Assertion helpers +# --------------------------------------------------------------------------- + +assert_eq() { + local expected="$1" actual="$2" msg="${3:-assert_eq}" + if [[ "$expected" == "$actual" ]]; then + pass "$msg" + else + fail "$msg" "$expected" "$actual" + fi +} + +assert_not_eq() { + local unexpected="$1" actual="$2" msg="${3:-assert_not_eq}" + if [[ "$unexpected" != "$actual" ]]; then + pass "$msg" + else + fail "$msg (expected values to differ)" "$unexpected" "$actual" + fi +} + +assert_empty() { + local actual="$1" msg="${2:-assert_empty}" + if [[ -z "$actual" ]]; then + pass "$msg" + else + fail "$msg (expected empty)" "" "$actual" + fi +} + +assert_not_empty() { + local actual="$1" msg="${2:-assert_not_empty}" + if [[ -n "$actual" ]]; then + pass "$msg" + else + fail "$msg (expected non-empty)" + fi +} + +assert_contains() { + local needle="$1" haystack="$2" msg="${3:-assert_contains}" + if [[ "$haystack" == *"$needle"* ]]; then + pass "$msg" + else + fail "$msg" "*${needle}*" "$haystack" + fi +} + +# assert_success [args...] +# Passes when the command exits 0. +assert_success() { + local msg="$1"; shift + local rc=0 + "$@" 2>/dev/null || rc=$? + if [[ $rc -eq 0 ]]; then + pass "$msg" + else + fail "$msg (expected exit 0, got $rc)" + fi +} + +# assert_failure [args...] +# Passes when the command exits non-zero. +assert_failure() { + local msg="$1"; shift + local rc=0 + "$@" 2>/dev/null || rc=$? + if [[ $rc -ne 0 ]]; then + pass "$msg" + else + fail "$msg (expected non-zero exit)" + fi +} + +assert_file_exists() { + local file="$1" msg="${2:-assert_file_exists}" + if [[ -f "$file" ]]; then + pass "$msg" + else + fail "$msg" "" "(not found)" + fi +} + +assert_file_contains() { + local pattern="$1" file="$2" msg="${3:-assert_file_contains}" + if [[ ! -f "$file" ]]; then + fail "$msg (file not found: $file)" + return + fi + if grep -qF "$pattern" "$file"; then + pass "$msg" + else + fail "$msg" "$pattern" "(not found in $file)" + fi +} + +assert_file_not_contains() { + local pattern="$1" file="$2" msg="${3:-assert_file_not_contains}" + if [[ ! -f "$file" ]]; then + fail "$msg (file not found: $file)" + return + fi + if ! grep -qF "$pattern" "$file"; then + pass "$msg" + else + fail "$msg" "(should not contain $pattern)" "(found in $file)" + fi +} + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +# print_summary +# Print pass/fail totals and exit 1 if any tests failed. +print_summary() { + local total=$((_PASS + _FAIL)) + printf "\n" + if [[ $_FAIL -eq 0 ]]; then + printf "${_G}%d/%d passed${_N}\n" "$_PASS" "$total" + else + printf "${_R}%d failed${_N}, %d passed, %d total\n" "$_FAIL" "$_PASS" "$total" + fi + [[ $_FAIL -eq 0 ]] +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..ca75f55 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Discovers and runs all test_*.sh files, printing a combined summary. +# +# Usage: +# ./tests/run_tests.sh # run all suites +# ./tests/run_tests.sh [file] # run a specific suite + +set -uo pipefail + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ---- Colors (self-contained) ---- +_R='\033[0;31m'; _G='\033[0;32m'; _B='\033[1m'; _D='\033[2m'; _N='\033[0m' + +total_pass=0 +total_fail=0 +failed_suites=() + +run_suite() { + local file="$1" + local name + name="$(basename "$file")" + + printf "\n${_B}══════ %s ══════${_N}\n" "$name" + + local output exit_code=0 + output=$(bash "$file" 2>&1) || exit_code=$? + + echo "$output" + + # Count results — strip ANSI codes first so the unicode chars are findable + local stripped p f + stripped=$(printf '%s' "$output" | sed 's/\x1b\[[0-9;]*[mGKHF]//g') + p=$(printf '%s\n' "$stripped" | grep -c '✓' || true) + f=$(printf '%s\n' "$stripped" | grep -c '✗' || true) + + total_pass=$((total_pass + p)) + total_fail=$((total_fail + f)) + + if [[ $exit_code -ne 0 ]]; then + failed_suites+=("$name") + fi +} + +# Collect files to run +if [[ $# -gt 0 ]]; then + files=("$@") +else + files=("$TESTS_DIR"/test_*.sh) +fi + +for f in "${files[@]}"; do + [[ -f "$f" ]] || { printf "${_R}Not found: %s${_N}\n" "$f" >&2; continue; } + run_suite "$f" +done + +# ---- Aggregate summary ---- +total=$((total_pass + total_fail)) + +printf "\n${_B}══════ Summary ══════${_N}\n" +printf "Suites run: %d\n" "${#files[@]}" +printf "Tests: %d total, " "$total" + +if [[ $total_fail -eq 0 ]]; then + printf "${_G}%d passed${_N}\n" "$total_pass" + printf "\n${_G}All tests passed.${_N}\n" + exit 0 +else + printf "${_G}%d passed${_N}, ${_R}%d failed${_N}\n" "$total_pass" "$total_fail" + printf "\n${_R}Failed suites:${_N}\n" + for s in "${failed_suites[@]}"; do + printf " - %s\n" "$s" + done + exit 1 +fi diff --git a/tests/test_e2e.sh b/tests/test_e2e.sh new file mode 100755 index 0000000..e957443 --- /dev/null +++ b/tests/test_e2e.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# End-to-end tests for generate-env.sh. +# +# Each test pipes a crafted stdin to generate-env.sh and inspects the output +# .env file. Input lines are counted to match each variable's prompt exactly. + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$TESTS_DIR")" +SCRIPT="$ROOT_DIR/generate-env.sh" + +source "$TESTS_DIR/framework.sh" + +TMPDIR_SELF="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_SELF"' EXIT + +# run_gen +# Runs generate-env.sh, feeding as stdin. +# Suppresses all terminal output. Removes first so the +# overwrite guard never triggers. +run_gen() { + local input="$1" + local in_file="$2" + local out_file="$3" + rm -f "$out_file" + # -o must come before the positional input-file argument so getopts sees it + printf "%s" "$input" | bash "$SCRIPT" -o "$out_file" "$in_file" 2>/dev/null +} + +# --------------------------------------------------------------------------- +suite "e2e: accept all defaults" +# --------------------------------------------------------------------------- + +# Fixture: three optional vars with defaults, no required fields +IN="$TMPDIR_SELF/defaults.env.example" +OUT="$TMPDIR_SELF/defaults.env" +cat > "$IN" << 'EOF' +# App name +APP_NAME=MyApp + +# Port +# @type number +PORT=3000 + +# Debug mode +# @type bool +DEBUG=false +EOF + +# Input: three Enter presses (accept all defaults) +run_gen $'\n\n\n' "$IN" "$OUT" + +assert_file_exists "$OUT" "output file created" +assert_file_contains "APP_NAME=MyApp" "$OUT" "string default accepted" +assert_file_contains "PORT=3000" "$OUT" "number default accepted" +assert_file_contains "DEBUG=false" "$OUT" "bool default accepted" + +# --------------------------------------------------------------------------- +suite "e2e: override defaults" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/override.env.example" +OUT="$TMPDIR_SELF/override.env" +cat > "$IN" << 'EOF' +APP_NAME=MyApp +PORT=3000 +EOF + +# Override both +run_gen $'CustomApp\n8080\n' "$IN" "$OUT" + +assert_file_contains "APP_NAME=CustomApp" "$OUT" "string default overridden" +assert_file_contains "PORT=8080" "$OUT" "number default overridden" + +# --------------------------------------------------------------------------- +suite "e2e: required fields must be filled" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/required.env.example" +OUT="$TMPDIR_SELF/required.env" +cat > "$IN" << 'EOF' +# @required +MUST_HAVE= +EOF + +# First send empty (should re-prompt), then a value +run_gen $'\nfilled\n' "$IN" "$OUT" + +assert_file_contains "MUST_HAVE=filled" "$OUT" "required field filled on retry" + +# --------------------------------------------------------------------------- +suite "e2e: bool values" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/bool.env.example" +OUT="$TMPDIR_SELF/bool.env" +cat > "$IN" << 'EOF' +# @type bool +FLAG_A=false + +# @type bool +FLAG_B=true +EOF + +# y for FLAG_A (-> true), Enter for FLAG_B (-> default true) +run_gen $'y\n\n' "$IN" "$OUT" + +assert_file_contains "FLAG_A=true" "$OUT" "y -> true" +assert_file_contains "FLAG_B=true" "$OUT" "empty accepts true default" + +# --------------------------------------------------------------------------- +suite "e2e: enum with numeric choice" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/enum.env.example" +OUT="$TMPDIR_SELF/enum.env" +cat > "$IN" << 'EOF' +# @type enum:development,staging,production +# @required +APP_ENV=development +EOF + +# Choose option 3 (production) +run_gen $'3\n' "$IN" "$OUT" + +assert_file_contains "APP_ENV=production" "$OUT" "enum numeric choice 3 -> production" + +# --------------------------------------------------------------------------- +suite "e2e: enum with string choice" +# --------------------------------------------------------------------------- + +OUT="$TMPDIR_SELF/enum_str.env" + +run_gen $'staging\n' "$IN" "$OUT" + +assert_file_contains "APP_ENV=staging" "$OUT" "enum string choice -> staging" + +# --------------------------------------------------------------------------- +suite "e2e: enum accepts default" +# --------------------------------------------------------------------------- + +OUT="$TMPDIR_SELF/enum_default.env" + +run_gen $'\n' "$IN" "$OUT" + +assert_file_contains "APP_ENV=development" "$OUT" "enum empty input uses default" + +# --------------------------------------------------------------------------- +suite "e2e: secret with confirmation" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/secret.env.example" +OUT="$TMPDIR_SELF/secret.env" +cat > "$IN" << 'EOF' +# @type secret +# @required +JWT_SECRET= +EOF + +# Enter value twice (entry + confirm) +run_gen $'supersecret\nsupersecret\n' "$IN" "$OUT" + +assert_file_contains "JWT_SECRET=supersecret" "$OUT" "secret stored after confirmation" + +# --------------------------------------------------------------------------- +suite "e2e: secret uses default on empty input" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/secret_default.env.example" +OUT="$TMPDIR_SELF/secret_default.env" +cat > "$IN" << 'EOF' +# @type secret +JWT_SECRET=existing_token +EOF + +# Empty input -> keep default +run_gen $'\n' "$IN" "$OUT" + +assert_file_contains "JWT_SECRET=existing_token" "$OUT" "secret empty input keeps default" + +# --------------------------------------------------------------------------- +suite "e2e: -o flag sets output path" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/flag.env.example" +CUSTOM_OUT="$TMPDIR_SELF/custom_output.env" +cat > "$IN" << 'EOF' +KEY=val +EOF + +rm -f "$CUSTOM_OUT" +printf "\n" | bash "$SCRIPT" -o "$CUSTOM_OUT" "$IN" 2>/dev/null + +assert_file_exists "$CUSTOM_OUT" "custom output path created via -o flag" +assert_file_contains "KEY=val" "$CUSTOM_OUT" "content written to custom path" + +# --------------------------------------------------------------------------- +suite "e2e: empty optional field written as KEY=" +# --------------------------------------------------------------------------- + +IN="$TMPDIR_SELF/optional.env.example" +OUT="$TMPDIR_SELF/optional.env" +cat > "$IN" << 'EOF' +# Optional field with no default +# @type email +ADMIN_EMAIL= +EOF + +# Leave empty +run_gen $'\n' "$IN" "$OUT" + +assert_file_contains "ADMIN_EMAIL=" "$OUT" "optional empty field written as KEY=" +assert_file_not_contains "ADMIN_EMAIL=x" "$OUT" "no stray value" + +# --------------------------------------------------------------------------- +suite "e2e: full example.env.example" +# --------------------------------------------------------------------------- +# +# Variable order and input count: +# 1. APP_NAME (string, required, default "MyApp") → "\n" accept default +# 2. APP_ENV (enum:…, required, default "development") → "\n" accept default (1) +# 3. PORT (number, optional, default "3000") → "\n" accept default +# 4. DEBUG (bool, optional, default "false") → "n\n" explicit n +# 5. JWT_SECRET(secret, required, no default) → "tok\ntok\n" entry+confirm +# 6. DATABASE_URL(url, required, has default) → "\n" accept default +# 7. ADMIN_EMAIL (email, optional, no default) → "\n" leave empty + +OUT="$TMPDIR_SELF/full.env" +INPUT=$'\n\n\nn\ntok\ntok\n\n\n' + +run_gen "$INPUT" "$ROOT_DIR/example.env.example" "$OUT" + +assert_file_exists "$OUT" "output file created" +assert_file_contains "APP_NAME=MyApp" "$OUT" "APP_NAME default" +assert_file_contains "APP_ENV=development" "$OUT" "APP_ENV default" +assert_file_contains "PORT=3000" "$OUT" "PORT default" +assert_file_contains "DEBUG=false" "$OUT" "DEBUG=false (n)" +assert_file_contains "JWT_SECRET=tok" "$OUT" "JWT_SECRET value" +assert_file_contains "DATABASE_URL=postgres://localhost:5432/myapp" "$OUT" "DATABASE_URL default" +assert_file_contains "ADMIN_EMAIL=" "$OUT" "ADMIN_EMAIL empty" +assert_file_contains "# Generated by generate-env" "$OUT" "header present" + +print_summary diff --git a/tests/test_output.sh b/tests/test_output.sh new file mode 100755 index 0000000..3533767 --- /dev/null +++ b/tests/test_output.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Unit tests for lib/output.sh + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$TESTS_DIR")" + +source "$TESTS_DIR/framework.sh" +source "$ROOT_DIR/lib/colors.sh" +source "$ROOT_DIR/lib/output.sh" + +TMPDIR_SELF="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_SELF"' EXIT + +# --------------------------------------------------------------------------- +suite "output: basic file creation" +# --------------------------------------------------------------------------- + +ENV_VARS=("FOO" "BAR") +ENV_DESC_FOO="" +ENV_DESC_BAR="" +ENV_VALUE_FOO="hello" +ENV_VALUE_BAR="world" + +OUT="$TMPDIR_SELF/basic.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_exists "$OUT" "output file created" +assert_file_contains "FOO=hello" "$OUT" "FOO written correctly" +assert_file_contains "BAR=world" "$OUT" "BAR written correctly" +assert_file_contains "# Generated by generate-env" "$OUT" "header present" + +# --------------------------------------------------------------------------- +suite "output: variable order matches ENV_VARS" +# --------------------------------------------------------------------------- + +ENV_VARS=("ALPHA" "BETA" "GAMMA") +ENV_VALUE_ALPHA="1" +ENV_VALUE_BETA="2" +ENV_VALUE_GAMMA="3" + +OUT="$TMPDIR_SELF/order.env" +write_env_file "$OUT" 2>/dev/null + +# grep -n returns the line number; verify order +alpha_line=$(grep -n "ALPHA=" "$OUT" | cut -d: -f1) +beta_line=$(grep -n "BETA=" "$OUT" | cut -d: -f1) +gamma_line=$(grep -n "GAMMA=" "$OUT" | cut -d: -f1) + +if (( alpha_line < beta_line && beta_line < gamma_line )); then + pass "variable order preserved" +else + fail "variable order preserved" "ALPHA < BETA < GAMMA" "lines $alpha_line, $beta_line, $gamma_line" +fi + +# --------------------------------------------------------------------------- +suite "output: empty values written as KEY=" +# --------------------------------------------------------------------------- + +ENV_VARS=("WITH_VALUE" "NO_VALUE") +ENV_VALUE_WITH_VALUE="set" +ENV_VALUE_NO_VALUE="" + +OUT="$TMPDIR_SELF/empty.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_contains "WITH_VALUE=set" "$OUT" "non-empty value written" +assert_file_contains "NO_VALUE=" "$OUT" "empty value written as KEY=" +assert_file_not_contains "NO_VALUE=x" "$OUT" "no stray value for empty var" + +# --------------------------------------------------------------------------- +suite "output: values containing special characters" +# --------------------------------------------------------------------------- + +ENV_VARS=("SPECIAL") +ENV_VALUE_SPECIAL='postgres://user:p@ss!#$@host:5432/db?ssl=true' + +OUT="$TMPDIR_SELF/special.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_contains "SPECIAL=postgres://user:p@ss" "$OUT" "special chars in value preserved" + +# --------------------------------------------------------------------------- +suite "output: values containing equals signs" +# --------------------------------------------------------------------------- + +ENV_VARS=("DB_URL") +ENV_VALUE_DB_URL="postgres://host/db?sslmode=require&timeout=10" + +OUT="$TMPDIR_SELF/equals.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_contains "DB_URL=postgres://host/db?sslmode=require&timeout=10" \ + "$OUT" "equals signs in value preserved" + +# --------------------------------------------------------------------------- +suite "output: header contains ISO-8601 timestamp" +# --------------------------------------------------------------------------- + +ENV_VARS=("X") +ENV_VALUE_X="1" + +OUT="$TMPDIR_SELF/ts.env" +write_env_file "$OUT" 2>/dev/null + +# Header should contain a UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ +if grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$OUT"; then + pass "header contains ISO-8601 UTC timestamp" +else + fail "header contains ISO-8601 UTC timestamp" \ + "YYYY-MM-DDTHH:MM:SSZ" "(not found in header)" +fi + +# --------------------------------------------------------------------------- +suite "output: single variable" +# --------------------------------------------------------------------------- + +ENV_VARS=("SOLO") +ENV_VALUE_SOLO="only" + +OUT="$TMPDIR_SELF/solo.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_exists "$OUT" "file created for single var" +assert_file_contains "SOLO=only" "$OUT" "single var written" + +# --------------------------------------------------------------------------- +suite "output: description written as comment before KEY=VALUE" +# --------------------------------------------------------------------------- + +ENV_VARS=("DESCRIBED" "NODESC") +ENV_DESC_DESCRIBED="Explains this variable" +ENV_DESC_NODESC="" +ENV_VALUE_DESCRIBED="val1" +ENV_VALUE_NODESC="val2" + +OUT="$TMPDIR_SELF/desc.env" +write_env_file "$OUT" 2>/dev/null + +assert_file_contains "# Explains this variable" "$OUT" "description written as comment" +assert_file_contains "DESCRIBED=val1" "$OUT" "KEY=VALUE follows comment" + +# The comment line should immediately precede the variable line +desc_line=$(grep -n "# Explains this variable" "$OUT" | cut -d: -f1) +var_line=$(grep -n "^DESCRIBED=" "$OUT" | cut -d: -f1) +if (( var_line == desc_line + 1 )); then + pass "comment is on the line directly before KEY=VALUE" +else + fail "comment is on the line directly before KEY=VALUE" \ + "$((desc_line + 1))" "$var_line" +fi + +# Variable with no description should have no comment line immediately before it +nodesc_line=$(grep -n "^NODESC=" "$OUT" | cut -d: -f1) +prev_line_content=$(sed -n "$((nodesc_line - 1))p" "$OUT") +if [[ -z "$prev_line_content" ]]; then + pass "variable with no description has blank line before it (no comment)" +else + fail "variable with no description has blank line before it (no comment)" \ + "" "$prev_line_content" +fi + +# --------------------------------------------------------------------------- +suite "output: blank line separates each variable block" +# --------------------------------------------------------------------------- + +ENV_VARS=("A" "B" "C") +ENV_DESC_A="First" +ENV_DESC_B="" +ENV_DESC_C="Third" +ENV_VALUE_A="1" +ENV_VALUE_B="2" +ENV_VALUE_C="3" + +OUT="$TMPDIR_SELF/spacing.env" +write_env_file "$OUT" 2>/dev/null + +# Each variable block ends with a blank line; verify A and C have their comments +assert_file_contains "# First" "$OUT" "first var description present" +assert_file_contains "# Third" "$OUT" "third var description present" + +a_line=$(grep -n "^A=" "$OUT" | cut -d: -f1) +b_line=$(grep -n "^B=" "$OUT" | cut -d: -f1) + +# There must be a blank line between A= and B= +between=$(sed -n "$((a_line + 1)),$((b_line - 1))p" "$OUT") +if [[ -z "$between" || "$between" =~ ^[[:space:]]*$ ]]; then + pass "blank line between variable blocks" +else + fail "blank line between variable blocks" "(blank)" "$between" +fi + +print_summary diff --git a/tests/test_parse.sh b/tests/test_parse.sh new file mode 100755 index 0000000..7b23cf8 --- /dev/null +++ b/tests/test_parse.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Unit tests for lib/parse.sh + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$TESTS_DIR")" +FIXTURES="$TESTS_DIR/fixtures" + +source "$TESTS_DIR/framework.sh" +source "$ROOT_DIR/lib/colors.sh" +source "$ROOT_DIR/lib/parse.sh" + +# --------------------------------------------------------------------------- +suite "parse: basic variable" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/basic.env.example" + +assert_eq "1" "${#ENV_VARS[@]}" "finds exactly one variable" +assert_eq "BASIC_VAR" "${ENV_VARS[0]}" "correct variable name" +assert_eq "default" "$ENV_DEFAULT_BASIC_VAR" "correct default value" +assert_eq "true" "$ENV_REQUIRED_BASIC_VAR" "required flag set" +assert_eq "Description" "$ENV_DESC_BASIC_VAR" "description captured" +assert_eq "string" "$ENV_TYPE_BASIC_VAR" "type defaults to string" + +# --------------------------------------------------------------------------- +suite "parse: all type annotations" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/all_types.env.example" + +assert_eq "string" "$ENV_TYPE_STR_VAR" "explicit string type" +assert_eq "number" "$ENV_TYPE_NUM_VAR" "number type" +assert_eq "bool" "$ENV_TYPE_BOOL_VAR" "bool type" +assert_eq "secret" "$ENV_TYPE_SECRET_VAR" "secret type" +assert_eq "url" "$ENV_TYPE_URL_VAR" "url type" +assert_eq "email" "$ENV_TYPE_EMAIL_VAR" "email type" +assert_eq "enum:a,b,c" "$ENV_TYPE_ENUM_VAR" "enum type with options" + +assert_eq "true" "$ENV_REQUIRED_SECRET_VAR" "required annotation on secret" +assert_eq "false" "$ENV_REQUIRED_STR_VAR" "non-required defaults to false" + +assert_eq "42" "$ENV_DEFAULT_NUM_VAR" "numeric default" +assert_eq "https://example.com" "$ENV_DEFAULT_URL_VAR" "URL default" +assert_empty "$ENV_DEFAULT_SECRET_VAR" "empty default preserved" + +# --------------------------------------------------------------------------- +suite "parse: variable order preserved" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/all_types.env.example" + +assert_eq "7" "${#ENV_VARS[@]}" "all seven variables found" +assert_eq "STR_VAR" "${ENV_VARS[0]}" "first variable" +assert_eq "NUM_VAR" "${ENV_VARS[1]}" "second variable" +assert_eq "BOOL_VAR" "${ENV_VARS[2]}" "third variable" +assert_eq "SECRET_VAR" "${ENV_VARS[3]}" "fourth variable" +assert_eq "URL_VAR" "${ENV_VARS[4]}" "fifth variable" +assert_eq "EMAIL_VAR" "${ENV_VARS[5]}" "sixth variable" +assert_eq "ENUM_VAR" "${ENV_VARS[6]}" "seventh variable" + +# --------------------------------------------------------------------------- +suite "parse: variable with no annotations" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/no_annotations.env.example" + +assert_eq "2" "${#ENV_VARS[@]}" "finds two variables" +assert_eq "string" "$ENV_TYPE_PLAIN_VAR" "type defaults to string" +assert_eq "false" "$ENV_REQUIRED_PLAIN_VAR" "required defaults to false" +assert_empty "$ENV_DESC_PLAIN_VAR" "no description" +assert_eq "value" "$ENV_DEFAULT_PLAIN_VAR" "correct default" +assert_empty "$ENV_DEFAULT_ANOTHER_VAR" "empty default preserved" + +# --------------------------------------------------------------------------- +suite "parse: multi-line description" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/multiline_desc.env.example" + +assert_contains "First line" "$ENV_DESC_MULTI_VAR" "first description line included" +assert_contains "Second line" "$ENV_DESC_MULTI_VAR" "second description line appended" + +# --------------------------------------------------------------------------- +suite "parse: orphaned comment block (no variable follows)" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/orphaned_comments.env.example" + +assert_eq "1" "${#ENV_VARS[@]}" "only one real variable found" +assert_eq "REAL_VAR" "${ENV_VARS[0]}" "correct variable captured" +assert_eq "value" "$ENV_DEFAULT_REAL_VAR" "correct default" + +# --------------------------------------------------------------------------- +suite "parse: empty file" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/empty.env.example" + +assert_eq "0" "${#ENV_VARS[@]}" "no variables in empty file" + +# --------------------------------------------------------------------------- +suite "parse: value containing equals signs" +# --------------------------------------------------------------------------- + +parse_env_example "$FIXTURES/value_with_equals.env.example" + +assert_eq "1" "${#ENV_VARS[@]}" "one variable" +assert_contains "sslmode=require" "$ENV_DEFAULT_DB_URL" "first query param preserved" +assert_contains "connect_timeout=10" "$ENV_DEFAULT_DB_URL" "second query param preserved" +assert_eq "url" "$ENV_TYPE_DB_URL" "type annotation on same var" + +# --------------------------------------------------------------------------- +suite "parse: missing file returns error" +# --------------------------------------------------------------------------- + +assert_failure "non-existent file returns non-zero" \ + parse_env_example "/nonexistent/path.env.example" + +# --------------------------------------------------------------------------- +suite "parse: real-world example.env.example" +# --------------------------------------------------------------------------- + +parse_env_example "$ROOT_DIR/example.env.example" + +assert_eq "7" "${#ENV_VARS[@]}" "all 7 variables" +assert_eq "APP_NAME" "${ENV_VARS[0]}" "first var is APP_NAME" +assert_eq "APP_ENV" "${ENV_VARS[1]}" "second var is APP_ENV" +assert_eq "true" "$ENV_REQUIRED_APP_NAME" "APP_NAME required" +assert_eq "true" "$ENV_REQUIRED_APP_ENV" "APP_ENV required" +assert_eq "false" "$ENV_REQUIRED_PORT" "PORT optional" +assert_eq "number" "$ENV_TYPE_PORT" "PORT is number type" +assert_eq "bool" "$ENV_TYPE_DEBUG" "DEBUG is bool type" +assert_eq "secret" "$ENV_TYPE_JWT_SECRET" "JWT_SECRET is secret type" +assert_eq "url" "$ENV_TYPE_DATABASE_URL" "DATABASE_URL is url type" +assert_eq "email" "$ENV_TYPE_ADMIN_EMAIL" "ADMIN_EMAIL is email type" +assert_eq "enum:development,staging,production" "$ENV_TYPE_APP_ENV" "APP_ENV enum options" + +print_summary diff --git a/tests/test_prompt.sh b/tests/test_prompt.sh new file mode 100755 index 0000000..733bcae --- /dev/null +++ b/tests/test_prompt.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Unit tests for prompt helper functions in lib/prompt.sh. +# +# All interactive functions write prompts to stderr and read from stdin. +# Tests pipe input via stdin and capture stdout, suppressing stderr. + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$TESTS_DIR")" + +source "$TESTS_DIR/framework.sh" +source "$ROOT_DIR/lib/colors.sh" +source "$ROOT_DIR/lib/validate.sh" +source "$ROOT_DIR/lib/prompt.sh" + +# Helpers: feed stdin to a function and capture its stdout +feed() { + # feed [args...] + local input="$1"; shift + printf "%s" "$input" | "$@" 2>/dev/null +} + +# --------------------------------------------------------------------------- +suite "_prompt_enum: numeric selection" +# --------------------------------------------------------------------------- + +assert_eq "a" "$(feed "1"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 1 -> first option" +assert_eq "b" "$(feed "2"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 2 -> second option" +assert_eq "c" "$(feed "3"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 3 -> third option" + +# --------------------------------------------------------------------------- +suite "_prompt_enum: string selection" +# --------------------------------------------------------------------------- + +assert_eq "a" "$(feed "a"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'a' selects a" +assert_eq "b" "$(feed "b"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'b' selects b" +assert_eq "c" "$(feed "c"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'c' selects c" + +# --------------------------------------------------------------------------- +suite "_prompt_enum: empty input uses default" +# --------------------------------------------------------------------------- + +assert_eq "a" "$(feed $'\n' _prompt_enum "V" "a,b,c" "a")" "empty -> first default" +assert_eq "b" "$(feed $'\n' _prompt_enum "V" "a,b,c" "b")" "empty -> middle default" +assert_eq "c" "$(feed $'\n' _prompt_enum "V" "a,b,c" "c")" "empty -> last default" + +# --------------------------------------------------------------------------- +suite "_prompt_enum: invalid choice then valid" +# --------------------------------------------------------------------------- + +# Send out-of-range number, then a valid number +assert_eq "b" "$(printf "99\n2\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \ + "invalid index followed by valid index" + +# Send unknown string, then valid string +assert_eq "c" "$(printf "zzz\nc\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \ + "invalid string followed by valid string" + +# --------------------------------------------------------------------------- +suite "_prompt_enum: real-world APP_ENV" +# --------------------------------------------------------------------------- + +assert_eq "development" \ + "$(feed $'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \ + "empty -> development (default)" + +assert_eq "production" \ + "$(feed "3"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \ + "choice 3 -> production" + +assert_eq "staging" \ + "$(feed "staging"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \ + "string 'staging' -> staging" + +# --------------------------------------------------------------------------- +suite "_prompt_bool: explicit inputs" +# --------------------------------------------------------------------------- + +assert_eq "true" "$(feed "y"$'\n' _prompt_bool "")" "y -> true" +assert_eq "true" "$(feed "yes"$'\n' _prompt_bool "")" "yes -> true" +assert_eq "true" "$(feed "true"$'\n' _prompt_bool "")" "true -> true" +assert_eq "true" "$(feed "1"$'\n' _prompt_bool "")" "1 -> true" +assert_eq "false" "$(feed "n"$'\n' _prompt_bool "")" "n -> false" +assert_eq "false" "$(feed "no"$'\n' _prompt_bool "")" "no -> false" +assert_eq "false" "$(feed "false"$'\n' _prompt_bool "")" "false -> false" +assert_eq "false" "$(feed "0"$'\n' _prompt_bool "")" "0 -> false" + +# --------------------------------------------------------------------------- +suite "_prompt_bool: empty input uses default" +# --------------------------------------------------------------------------- + +assert_eq "true" "$(feed $'\n' _prompt_bool "true")" "empty -> true default" +assert_eq "true" "$(feed $'\n' _prompt_bool "yes")" "empty -> yes default normalizes to true" +assert_eq "false" "$(feed $'\n' _prompt_bool "false")" "empty -> false default" +assert_eq "false" "$(feed $'\n' _prompt_bool "no")" "empty -> no default normalizes to false" + +# --------------------------------------------------------------------------- +suite "_prompt_bool: invalid then valid" +# --------------------------------------------------------------------------- + +assert_eq "true" "$(printf "banana\ny\n" | _prompt_bool "" 2>/dev/null)" \ + "invalid input followed by y" +assert_eq "false" "$(printf "maybe\nn\n" | _prompt_bool "" 2>/dev/null)" \ + "invalid input followed by n" + +# --------------------------------------------------------------------------- +suite "_prompt_secret: matching values accepted" +# --------------------------------------------------------------------------- + +assert_eq "mysecret" \ + "$(printf "mysecret\nmysecret\n" | _prompt_secret "V" "" 2>/dev/null)" \ + "matching entries return value" + +assert_eq "p@ss!#\$word" \ + "$(printf 'p@ss!#$word\np@ss!#$word\n' | _prompt_secret "V" "" 2>/dev/null)" \ + "special characters in secret preserved" + +# --------------------------------------------------------------------------- +suite "_prompt_secret: empty input uses default" +# --------------------------------------------------------------------------- + +assert_eq "defaultsecret" \ + "$(printf "\n" | _prompt_secret "V" "defaultsecret" 2>/dev/null)" \ + "empty input returns default" + +# --------------------------------------------------------------------------- +suite "_prompt_secret: mismatch retries" +# --------------------------------------------------------------------------- + +# First pair mismatches, second pair matches +assert_eq "correct" \ + "$(printf "wrong\nmismatch\ncorrect\ncorrect\n" | _prompt_secret "V" "" 2>/dev/null)" \ + "mismatch retries until match" + +# --------------------------------------------------------------------------- +suite "prompt_variable: string type stores value" +# --------------------------------------------------------------------------- +# Use process substitution (<(...)) so prompt_variable runs in the current +# shell and its variable assignments (ENV_VALUE_*) persist. + +ENV_VARS=("MYVAR") +ENV_DESC_MYVAR="A test variable" +ENV_TYPE_MYVAR="string" +ENV_REQUIRED_MYVAR="false" +ENV_DEFAULT_MYVAR="default_val" + +# Accept default +prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null +assert_eq "default_val" "$ENV_VALUE_MYVAR" "empty input stores default" + +# Provide a value +prompt_variable "MYVAR" < <(printf "custom\n") 2>/dev/null +assert_eq "custom" "$ENV_VALUE_MYVAR" "typed value stored" + +# --------------------------------------------------------------------------- +suite "prompt_variable: number type re-prompts on invalid input" +# --------------------------------------------------------------------------- + +ENV_TYPE_MYVAR="number" +ENV_DEFAULT_MYVAR="42" + +prompt_variable "MYVAR" < <(printf "abc\n99\n") 2>/dev/null +assert_eq "99" "$ENV_VALUE_MYVAR" "invalid number re-prompts, then stores valid" + +# --------------------------------------------------------------------------- +suite "prompt_variable: bool type" +# --------------------------------------------------------------------------- + +ENV_TYPE_MYVAR="bool" +ENV_DEFAULT_MYVAR="false" + +prompt_variable "MYVAR" < <(printf "y\n") 2>/dev/null +assert_eq "true" "$ENV_VALUE_MYVAR" "bool y stored as true" + +prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null +assert_eq "false" "$ENV_VALUE_MYVAR" "bool empty uses false default" + +# --------------------------------------------------------------------------- +suite "prompt_variable: enum type" +# --------------------------------------------------------------------------- + +ENV_TYPE_MYVAR="enum:x,y,z" +ENV_DEFAULT_MYVAR="x" + +prompt_variable "MYVAR" < <(printf "2\n") 2>/dev/null +assert_eq "y" "$ENV_VALUE_MYVAR" "enum choice 2 stores y" + +prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null +assert_eq "x" "$ENV_VALUE_MYVAR" "enum empty uses default x" + +print_summary diff --git a/tests/test_validate.sh b/tests/test_validate.sh new file mode 100755 index 0000000..dcc9d38 --- /dev/null +++ b/tests/test_validate.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Unit tests for lib/validate.sh + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$TESTS_DIR")" + +source "$TESTS_DIR/framework.sh" +source "$ROOT_DIR/lib/colors.sh" +source "$ROOT_DIR/lib/validate.sh" + +# Shorthand wrappers to reduce repetition +ok() { assert_success "$1" validate_value "VAR" "$2" "$3" "$4"; } +bad() { assert_failure "$1" validate_value "VAR" "$2" "$3" "$4"; } + +# --------------------------------------------------------------------------- +suite "validate: required field" +# --------------------------------------------------------------------------- + +bad "empty value is invalid when required" "" "string" "true" +ok "non-empty value is valid when required" "x" "string" "true" +ok "empty value is valid when optional" "" "string" "false" + +# --------------------------------------------------------------------------- +suite "validate: number" +# --------------------------------------------------------------------------- + +ok "positive integer" "42" "number" "false" +ok "zero" "0" "number" "false" +ok "negative integer" "-7" "number" "false" +ok "decimal" "3.14" "number" "false" +ok "negative decimal" "-0.5" "number" "false" +bad "plain string" "abc" "number" "false" +bad "mixed alphanumeric" "42abc" "number" "false" +bad "leading decimal" ".5" "number" "false" +bad "multiple dots" "1.2.3" "number" "false" +ok "empty optional number" "" "number" "false" +bad "empty required number" "" "number" "true" + +# --------------------------------------------------------------------------- +suite "validate: bool" +# --------------------------------------------------------------------------- + +ok "true" "true" "bool" "false" +ok "false" "false" "bool" "false" +ok "yes" "yes" "bool" "false" +ok "no" "no" "bool" "false" +ok "1" "1" "bool" "false" +ok "0" "0" "bool" "false" +ok "y" "y" "bool" "false" +ok "n" "n" "bool" "false" +ok "TRUE (uppercase)" "TRUE" "bool" "false" +ok "FALSE (uppercase)" "FALSE" "bool" "false" +bad "banana" "banana" "bool" "false" +bad "yes-no" "yes-no" "bool" "false" +ok "empty optional bool" "" "bool" "false" +bad "empty required bool" "" "bool" "true" + +# --------------------------------------------------------------------------- +suite "validate: url" +# --------------------------------------------------------------------------- + +# URL validation only warns — it always returns 0 +ok "http URL passes" "http://example.com" "url" "false" +ok "https URL passes" "https://example.com" "url" "false" +ok "non-URL still passes (warn only)" "not-a-url" "url" "false" +ok "empty optional url" "" "url" "false" +bad "empty required url" "" "url" "true" + +# --------------------------------------------------------------------------- +suite "validate: email" +# --------------------------------------------------------------------------- + +ok "simple email" "user@example.com" "email" "false" +ok "subdomain email" "user@mail.example.com" "email" "false" +ok "plus addressing" "user+tag@example.com" "email" "false" +bad "missing @" "userexample.com" "email" "false" +bad "missing domain" "user@" "email" "false" +bad "missing TLD" "user@example" "email" "false" +bad "spaces in email" "user @example.com" "email" "false" +ok "empty optional email" "" "email" "false" +bad "empty required email" "" "email" "true" + +# --------------------------------------------------------------------------- +suite "validate: enum" +# --------------------------------------------------------------------------- + +ok "first option" "a" "enum:a,b,c" "false" +ok "middle option" "b" "enum:a,b,c" "false" +ok "last option" "c" "enum:a,b,c" "false" +bad "not in list" "d" "enum:a,b,c" "false" +bad "partial match" "ab" "enum:a,b,c" "false" +bad "case mismatch" "A" "enum:a,b,c" "false" +ok "single-option enum" "only" "enum:only" "false" +bad "not the single option" "other" "enum:only" "false" +ok "empty optional enum" "" "enum:a,b,c" "false" +bad "empty required enum" "" "enum:a,b,c" "true" + +# Enum with real-world values +ok "development" "development" "enum:development,staging,production" "true" +ok "staging" "staging" "enum:development,staging,production" "true" +ok "production" "production" "enum:development,staging,production" "true" +bad "test env" "test" "enum:development,staging,production" "true" + +# --------------------------------------------------------------------------- +suite "validate: string and secret (no structural validation)" +# --------------------------------------------------------------------------- + +ok "any string value" "anything goes" "string" "false" +ok "string with specials" "f@o=bar!#$%" "string" "false" +ok "any secret value" "s3cr3t!@#" "secret" "false" +ok "empty optional string" "" "string" "false" +bad "empty required string" "" "string" "true" + +# --------------------------------------------------------------------------- +suite "normalize_bool" +# --------------------------------------------------------------------------- + +assert_eq "true" "$(normalize_bool "true")" "true -> true" +assert_eq "true" "$(normalize_bool "yes")" "yes -> true" +assert_eq "true" "$(normalize_bool "1")" "1 -> true" +assert_eq "true" "$(normalize_bool "y")" "y -> true" +assert_eq "true" "$(normalize_bool "TRUE")" "TRUE -> true (case insensitive)" +assert_eq "true" "$(normalize_bool "YES")" "YES -> true (case insensitive)" +assert_eq "false" "$(normalize_bool "false")" "false -> false" +assert_eq "false" "$(normalize_bool "no")" "no -> false" +assert_eq "false" "$(normalize_bool "0")" "0 -> false" +assert_eq "false" "$(normalize_bool "n")" "n -> false" +assert_eq "false" "$(normalize_bool "FALSE")" "FALSE -> false (case insensitive)" +assert_eq "banana" "$(normalize_bool "banana")" "unknown value passes through" + +print_summary