diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 306756c..f1d21d8 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -189,17 +189,6 @@ - - - - - - - - andmev - - - @@ -210,7 +199,7 @@ ndbroadbent - + @@ -221,7 +210,7 @@ ppauel - + @@ -232,7 +221,18 @@ shakeelmohamed - + + + + + + + + + Sizk + + + @@ -243,7 +243,7 @@ Harsh-Kapoorr - + @@ -254,7 +254,7 @@ thijsvanhal - + @@ -265,7 +265,7 @@ TomP0 - + @@ -276,7 +276,7 @@ yuzeguitarist - + @@ -287,7 +287,7 @@ zeldrisho - + @@ -298,7 +298,7 @@ bikraj2 - + @@ -309,7 +309,7 @@ bunizao - + @@ -320,7 +320,7 @@ rans0 - + @@ -331,7 +331,7 @@ frozturk - + @@ -342,7 +342,7 @@ huyixi - + @@ -353,7 +353,7 @@ purofle - + @@ -364,7 +364,7 @@ yamamel - + @@ -375,7 +375,7 @@ NanmiCoder - + @@ -386,6 +386,17 @@ imnotnoahhh + + + + + + + + + andmev + + diff --git a/README.md b/README.md index d843f78..67ca726 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ mo clean --dry-run --debug # Detailed preview with risk levels and file info mo optimize --dry-run # Preview optimization actions mo optimize --debug # Run with detailed operation logs mo optimize --whitelist # Manage protected optimization rules +mo uninstall --dry-run # Preview app uninstall actions +mo purge --dry-run # Preview project artifact purge +mo installer --dry-run # Preview installer cleanup actions +mo touchid enable --dry-run # Preview Touch ID sudo config changes +mo completion --dry-run # Preview shell completion file updates +mo remove --dry-run # Preview Mole self-removal mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` @@ -75,7 +81,7 @@ mo analyze /Volumes # Analyze external drives only ## Tips - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. -- Safety first: Deletions are permanent. Review carefully and preview with `mo clean --dry-run`. See [Security Audit](SECURITY_AUDIT.md). +- Safety first: Deletions are permanent. Review carefully with dry-run before applying changes. See [Security Audit](SECURITY_AUDIT.md). - Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. diff --git a/bin/completion.sh b/bin/completion.sh index 0a187e3..1feec15 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -32,8 +32,33 @@ emit_fish_completions() { printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" } +DRY_RUN_MODE=false +if [[ $# -gt 0 ]]; then + normalized_args=() + for arg in "$@"; do + case "$arg" in + "--dry-run" | "-n") + DRY_RUN_MODE=true + ;; + *) + normalized_args+=("$arg") + ;; + esac + done + if [[ ${#normalized_args[@]} -gt 0 ]]; then + set -- "${normalized_args[@]}" + else + set -- + fi +fi + # Auto-install mode when run without arguments if [[ $# -eq 0 ]]; then + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified" + echo "" + fi + # Detect current shell current_shell="${SHELL##*/}" if [[ -z "$current_shell" ]]; then @@ -73,16 +98,21 @@ if [[ $# -eq 0 ]]; then if [[ -z "$completion_name" ]]; then if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then - original_mode="" - original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" - temp_file="$(mktemp)" - grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true - mv "$temp_file" "$config_file" - if [[ -n "$original_mode" ]]; then - chmod "$original_mode" "$config_file" 2> /dev/null || true + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}" + echo "" + else + original_mode="" + original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" + temp_file="$(mktemp)" + grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true + mv "$temp_file" "$config_file" + if [[ -n "$original_mode" ]]; then + chmod "$original_mode" "$config_file" 2> /dev/null || true + fi + echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" + echo "" fi - echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" - echo "" fi log_error "mole not found in PATH, install Mole before enabling completion" exit 1 @@ -90,6 +120,12 @@ if [[ $# -eq 0 ]]; then # Check if already installed and normalize to latest line if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}" + echo "" + exit 0 + fi + original_mode="" original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" temp_file="$(mktemp)" @@ -114,6 +150,11 @@ if [[ $# -eq 0 ]]; then echo -e "${GRAY}Will add to ${config_file}:${NC}" echo " $completion_line" echo "" + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " IFS= read -r -s -n1 key || key="" drain_pending_input @@ -227,6 +268,7 @@ Setup shell tab completion for mole and mo commands. Auto-install: mole completion # Auto-detect shell and install + mole completion --dry-run # Preview config changes without writing files Manual install: mole completion bash # Generate bash completion script diff --git a/bin/installer.sh b/bin/installer.sh index 1b5645b..864404a 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -650,13 +650,22 @@ perform_installers() { show_summary() { local summary_heading="Installers cleaned" local -a summary_details=() + local dry_run_mode="${MOLE_DRY_RUN:-0}" + + if [[ "$dry_run_mode" == "1" ]]; then + summary_heading="Dry run complete - no changes made" + fi if [[ $total_deleted -gt 0 ]]; then local freed_mb freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') - summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Your Mac is cleaner now!") + if [[ "$dry_run_mode" == "1" ]]; then + summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}") + else + summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") + summary_details+=("Your Mac is cleaner now!") + fi else summary_details+=("No installers were removed") fi @@ -675,6 +684,9 @@ main() { "--debug") export MO_DEBUG=1 ;; + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; *) echo "Unknown option: $arg" exit 1 @@ -682,6 +694,11 @@ main() { esac done + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed" + printf '\n' + fi + hide_cursor perform_installers local exit_code=$? diff --git a/bin/purge.sh b/bin/purge.sh index ba8c746..1f6c664 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -205,11 +205,18 @@ perform_purge() { rm -f "$stats_dir/purge_count" fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + summary_heading="Dry run complete - no changes made" + fi + if [[ $total_size_cleaned -gt 0 ]]; then local freed_gb freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + summary_line="Would free: ${GREEN}${freed_gb}GB${NC}" + fi [[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned" summary_line+=" | Free: $(get_free_space)" summary_details+=("$summary_line") @@ -233,6 +240,7 @@ show_help() { echo "" echo -e "${YELLOW}Options:${NC}" echo " --paths Edit custom scan directories" + echo " --dry-run Preview purge actions without making changes" echo " --debug Enable debug logging" echo " --help Show this help message" echo "" @@ -262,6 +270,9 @@ main() { "--debug") export MO_DEBUG=1 ;; + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; *) echo "Unknown option: $arg" echo "Use 'mo purge --help' for usage information" @@ -271,6 +282,10 @@ main() { done start_purge + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed" + printf '\n' + fi hide_cursor perform_purge show_cursor diff --git a/bin/touchid.sh b/bin/touchid.sh index 8b377d1..76b5cc2 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -60,6 +60,10 @@ supports_touchid() { return 1 } +touchid_dry_run_enabled() { + [[ "${MOLE_DRY_RUN:-0}" == "1" ]] +} + # Show current Touch ID status show_status() { if is_touchid_configured; then @@ -74,6 +78,16 @@ enable_touchid() { # Cleanup trap handled by global EXIT trap local temp_file="" + if touchid_dry_run_enabled; then + if is_touchid_configured; then + echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled, no changes needed${NC}" + else + echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would enable Touch ID for sudo${NC}" + echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}" + fi + return 0 + fi + # First check if system supports Touch ID if ! supports_touchid; then log_warning "This Mac may not support Touch ID" @@ -201,6 +215,16 @@ disable_touchid() { # Cleanup trap handled by global EXIT trap local temp_file="" + if touchid_dry_run_enabled; then + if ! is_touchid_configured; then + echo -e "${YELLOW}Touch ID is not currently enabled${NC}" + else + echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would disable Touch ID for sudo${NC}" + echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}" + fi + return 0 + fi + if ! is_touchid_configured; then echo -e "${YELLOW}Touch ID is not currently enabled${NC}" return 0 @@ -303,12 +327,39 @@ show_menu() { # Main main() { - local command="${1:-}" + local command="" + local arg + + for arg in "$@"; do + case "$arg" in + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; + "--help" | "-h") + show_touchid_help + return 0 + ;; + enable | disable | status) + if [[ -z "$command" ]]; then + command="$arg" + else + log_error "Only one touchid command is supported per run" + return 1 + fi + ;; + *) + log_error "Unknown command: $arg" + return 1 + ;; + esac + done + + if touchid_dry_run_enabled; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No sudo authentication files will be modified" + echo "" + fi case "$command" in - "--help" | "-h") - show_touchid_help - ;; enable) enable_touchid ;; diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 9d8960d..b1b4f01 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -822,10 +822,17 @@ main() { "--debug") export MO_DEBUG=1 ;; + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; esac done hide_cursor + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified" + printf '\n' + fi local first_scan=true while true; do diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 67e117b..3d768e5 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -1367,6 +1367,7 @@ clean_project_artifacts() { echo "" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local cleaned_count=0 + local dry_run_mode="${MOLE_DRY_RUN:-0}" for idx in "${selected_indices[@]}"; do local item_path="${item_paths[idx]}" local artifact_type=$(basename "$item_path") @@ -1388,7 +1389,7 @@ clean_project_artifacts() { fi if [[ -e "$item_path" ]]; then safe_remove "$item_path" true - if [[ ! -e "$item_path" ]]; then + if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" cleaned_count=$((cleaned_count + 1)) @@ -1396,7 +1397,11 @@ clean_project_artifacts() { fi if [[ -t 1 ]]; then stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + if [[ "$dry_run_mode" == "1" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + else + echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + fi fi done # Update count diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 8b826f3..144aac4 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1419,6 +1419,11 @@ force_kill_app() { local app_name="$1" local app_path="${2:-""}" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would terminate running app: $app_name" + return 0 + fi + # Get the executable name from bundle if app_path is provided local exec_name="" if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then diff --git a/lib/core/help.sh b/lib/core/help.sh index 13d2b17..2c0932e 100644 --- a/lib/core/help.sh +++ b/lib/core/help.sh @@ -18,6 +18,7 @@ show_installer_help() { echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)." echo "" echo "Options:" + echo " --dry-run Preview installer cleanup without making changes" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" } @@ -45,6 +46,7 @@ show_touchid_help() { echo " status Show current Touch ID status" echo "" echo "Options:" + echo " --dry-run Preview Touch ID changes without modifying sudo config" echo " -h, --help Show this help message" echo "" echo "If no command is provided, an interactive menu is shown." @@ -56,6 +58,7 @@ show_uninstall_help() { echo "Interactively remove applications and their leftover files." echo "" echo "Options:" + echo " --dry-run Preview app uninstallation without making changes" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" } diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 08f1bfd..cb4f79e 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. +get_lsregister_path() { + echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +} + +is_uninstall_dry_run() { + [[ "${MOLE_DRY_RUN:-0}" == "1" ]] +} + # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { @@ -77,6 +85,11 @@ stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id" + return 0 + fi + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) @@ -152,6 +165,11 @@ remove_login_item() { local app_name="$1" local bundle_id="$2" + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}" + return 0 + fi + # Skip if no identifiers provided [[ -z "$app_name" && -z "$bundle_id" ]] && return 0 @@ -201,7 +219,12 @@ remove_file_list() { safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true else if [[ "$use_sudo" == "true" ]]; then - safe_sudo_remove "$file" && ((++count)) || true + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would sudo remove: $file" + ((++count)) + else + safe_sudo_remove "$file" && ((++count)) || true + fi else safe_remove "$file" true && ((++count)) || true fi @@ -437,7 +460,7 @@ batch_uninstall_applications() { export MOLE_UNINSTALL_MODE=1 # Request sudo if needed. - if [[ ${#sudo_apps[@]} -gt 0 ]]; then + if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then if ! sudo -n true 2> /dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" @@ -547,12 +570,18 @@ batch_uninstall_applications() { fi fi else - local ret=0 - safe_sudo_remove "$app_path" || ret=$? - if [[ $ret -ne 0 ]]; then - local diagnosis - diagnosis=$(diagnose_removal_failure "$ret" "$app_name") - IFS='|' read -r reason suggestion <<< "$diagnosis" + if is_uninstall_dry_run; then + if ! safe_remove "$app_path" true; then + reason="dry-run path validation failed" + fi + else + local ret=0 + safe_sudo_remove "$app_path" || ret=$? + if [[ $ret -ne 0 ]]; then + local diagnosis + diagnosis=$(diagnose_removal_failure "$ret" "$app_name") + IFS='|' read -r reason suggestion <<< "$diagnosis" + fi fi fi else @@ -583,10 +612,14 @@ batch_uninstall_applications() { remove_file_list "$system_all" "true" > /dev/null fi - # Clean up macOS defaults (preference domains). + # Defaults writes are side effects that should never run in dry-run mode. if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then - if defaults read "$bundle_id" &> /dev/null; then - defaults delete "$bundle_id" 2> /dev/null || true + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would clear defaults domain: $bundle_id" + else + if defaults read "$bundle_id" &> /dev/null; then + defaults delete "$bundle_id" 2> /dev/null || true + fi fi # ByHost preferences (machine-specific). @@ -644,8 +677,15 @@ batch_uninstall_applications() { local success_text="app" [[ $success_count -gt 1 ]] && success_text="apps" local success_line="Removed ${success_count} ${success_text}" + if is_uninstall_dry_run; then + success_line="Would remove ${success_count} ${success_text}" + fi if [[ -n "$freed_display" ]]; then - success_line+=", freed ${GREEN}${freed_display}${NC}" + if is_uninstall_dry_run; then + success_line+=", would free ${GREEN}${freed_display}${NC}" + else + success_line+=", freed ${GREEN}${freed_display}${NC}" + fi fi # Format app list with max 3 per line. @@ -730,24 +770,48 @@ batch_uninstall_applications() { if [[ "$summary_status" == "warn" ]]; then title="Uninstall incomplete" fi + if is_uninstall_dry_run; then + title="Uninstall dry run complete" + fi echo "" print_summary_block "$title" "${summary_details[@]}" printf '\n' - if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then - # Kick off LaunchServices rebuild in background immediately after summary. - # The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish - # before the user returns to the app list — fixes stale Spotlight entries (#490). - ( - refresh_launch_services_after_uninstall 2> /dev/null || true - remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true - ) > /dev/null 2>&1 & + # Auto-run brew autoremove if Homebrew casks were uninstalled + if [[ $brew_apps_removed -gt 0 ]]; then + if is_uninstall_dry_run; then + log_info "[DRY RUN] Would run brew autoremove" + else + # Show spinner while checking for orphaned dependencies + if [[ -t 1 ]]; then + start_inline_spinner "Checking brew dependencies..." + fi + + local autoremove_output removed_count + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true + removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) + removed_count=${removed_count:-0} + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + if [[ $removed_count -gt 0 ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" + echo "" + fi + fi fi - # brew autoremove can be slow — run in background so the prompt returns quickly. - if [[ $brew_apps_removed -gt 0 ]]; then - (HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) & + # Clean up Dock entries for uninstalled apps. + if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + if is_uninstall_dry_run; then + log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries" + else + remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true + refresh_launch_services_after_uninstall 2> /dev/null || true + fi fi _cleanup_sudo_keepalive diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 0e7ae90..87cc62c 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -168,6 +168,11 @@ brew_uninstall_cask() { local cask_name="$1" local app_path="${2:-}" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name" + return 0 + fi + is_homebrew_available || return 1 [[ -z "$cask_name" ]] && return 1 diff --git a/mole b/mole index 64ca032..e503965 100755 --- a/mole +++ b/mole @@ -234,10 +234,16 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" + printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall" + printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge" + printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup" + printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup" + printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" + printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" @@ -462,6 +468,8 @@ update_mole() { # Remove flow (Homebrew + manual + config/cache). remove_mole() { + local dry_run_mode="${1:-false}" + if [[ -t 1 ]]; then start_inline_spinner "Detecting Mole installations..." else @@ -571,6 +579,31 @@ remove_mole() { esac local has_error=false + if [[ "$dry_run_mode" == "true" ]]; then + echo "" + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" + + if [[ "$is_homebrew" == "true" ]]; then + echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}" + fi + + if [[ ${manual_count:-0} -gt 0 ]]; then + for install in "${manual_installs[@]}"; do + [[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}" + done + fi + if [[ ${alias_count:-0} -gt 0 ]]; then + for alias in "${alias_installs[@]}"; do + [[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}" + done + fi + [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" + [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}" + + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + if [[ "$is_homebrew" == "true" ]]; then if [[ -z "$brew_cmd" ]]; then log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." @@ -859,7 +892,18 @@ main() { exit 0 ;; "remove") - remove_mole + local dry_run_remove=false + for arg in "${args[@]:1}"; do + case "$arg" in + "--dry-run" | "-n") dry_run_remove=true ;; + *) + echo "Unknown remove option: $arg" + echo "Use 'mole remove [--dry-run]' for supported options." + exit 1 + ;; + esac + done + remove_mole "$dry_run_remove" ;; "help" | "--help" | "-h") show_help diff --git a/tests/cli.bats b/tests/cli.bats index 5ed897b..92c92ec 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -1,39 +1,39 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } create_fake_utils() { - local dir="$1" - mkdir -p "$dir" + local dir="$1" + mkdir -p "$dir" - cat > "$dir/sudo" <<'SCRIPT' + cat >"$dir/sudo" <<'SCRIPT' #!/usr/bin/env bash if [[ "$1" == "-n" || "$1" == "-v" ]]; then exit 0 fi exec "$@" SCRIPT - chmod +x "$dir/sudo" + chmod +x "$dir/sudo" - cat > "$dir/bioutil" <<'SCRIPT' + cat >"$dir/bioutil" <<'SCRIPT' #!/usr/bin/env bash if [[ "$1" == "-r" ]]; then echo "Touch ID: 1" @@ -41,138 +41,152 @@ if [[ "$1" == "-r" ]]; then fi exit 0 SCRIPT - chmod +x "$dir/bioutil" + chmod +x "$dir/bioutil" } setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" + rm -rf "$HOME/.config" + mkdir -p "$HOME" } @test "mole --help prints command overview" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo clean"* ]] - [[ "$output" == *"mo analyze"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo clean"* ]] + [[ "$output" == *"mo analyze"* ]] } @test "mole --version reports script version" { - expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" - run env HOME="$HOME" "$PROJECT_ROOT/mole" --version - [ "$status" -eq 0 ] - [[ "$output" == *"$expected_version"* ]] + expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" + run env HOME="$HOME" "$PROJECT_ROOT/mole" --version + [ "$status" -eq 0 ] + [[ "$output" == *"$expected_version"* ]] } @test "mole unknown command returns error" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command - [ "$status" -ne 0 ] - [[ "$output" == *"Unknown command: unknown-command"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: unknown-command"* ]] } @test "touchid status reports current configuration" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status - [ "$status" -eq 0 ] - [[ "$output" == *"Touch ID"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status + [ "$status" -eq 0 ] + [[ "$output" == *"Touch ID"* ]] } @test "mo optimize command is recognized" { - run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" - [ "$status" -eq 0 ] + run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" + [ "$status" -eq 0 ] } @test "mo analyze binary is valid" { - if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then - [ -x "$PROJECT_ROOT/bin/analyze-go" ] - run file "$PROJECT_ROOT/bin/analyze-go" - [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] - else - skip "analyze-go binary not built" - fi + if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then + [ -x "$PROJECT_ROOT/bin/analyze-go" ] + run file "$PROJECT_ROOT/bin/analyze-go" + [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] + else + skip "analyze-go binary not built" + fi } @test "mo clean --debug creates debug log file" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - MOLE_OUTPUT="$output" + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + MOLE_OUTPUT="$output" - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - [ -f "$DEBUG_LOG" ] + DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + [ -f "$DEBUG_LOG" ] - run grep "Mole Debug Session" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "Mole Debug Session" "$DEBUG_LOG" + [ "$status" -eq 0 ] - [[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]] + [[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]] } @test "mo clean without debug does not show debug log path" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] - [[ "$output" != *"Debug session log saved to"* ]] + [[ "$output" != *"Debug session log saved to"* ]] } @test "mo clean --debug logs system info" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - run grep "User:" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "User:" "$DEBUG_LOG" + [ "$status" -eq 0 ] - run grep "Architecture:" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "Architecture:" "$DEBUG_LOG" + [ "$status" -eq 0 ] } @test "touchid status reflects pam file contents" { - pam_file="$HOME/pam_test" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_test" + cat >"$pam_file" <<'EOF' auth sufficient pam_opendirectory.so EOF - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"not configured"* ]] + run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status + [ "$status" -eq 0 ] + [[ "$output" == *"not configured"* ]] - cat > "$pam_file" <<'EOF' + cat >"$pam_file" <<'EOF' auth sufficient pam_tid.so EOF - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"enabled"* ]] + run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status + [ "$status" -eq 0 ] + [[ "$output" == *"enabled"* ]] } @test "enable_touchid inserts pam_tid line in pam file" { - pam_file="$HOME/pam_enable" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_enable" + cat >"$pam_file" <<'EOF' auth sufficient pam_opendirectory.so EOF - fake_bin="$HOME/fake-bin" - create_fake_utils "$fake_bin" + fake_bin="$HOME/fake-bin" + create_fake_utils "$fake_bin" - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable - [ "$status" -eq 0 ] - grep -q "pam_tid.so" "$pam_file" - [[ -f "${pam_file}.mole-backup" ]] + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_file" + [[ -f "${pam_file}.mole-backup" ]] } @test "disable_touchid removes pam_tid line" { - pam_file="$HOME/pam_disable" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_disable" + cat >"$pam_file" <<'EOF' auth sufficient pam_tid.so auth sufficient pam_opendirectory.so EOF - fake_bin="$HOME/fake-bin-disable" - create_fake_utils "$fake_bin" + fake_bin="$HOME/fake-bin-disable" + create_fake_utils "$fake_bin" - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable - [ "$status" -eq 0 ] - run grep "pam_tid.so" "$pam_file" - [ "$status" -ne 0 ] + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable + [ "$status" -eq 0 ] + run grep "pam_tid.so" "$pam_file" + [ "$status" -ne 0 ] +} + +@test "touchid enable --dry-run does not modify pam file" { + pam_file="$HOME/pam_enable_dry_run" + cat >"$pam_file" <<'EOF' +auth sufficient pam_opendirectory.so +EOF + + run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] + + run grep "pam_tid.so" "$pam_file" + [ "$status" -ne 0 ] } diff --git a/tests/completion.bats b/tests/completion.bats index d586bcd..562a731 100755 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -1,160 +1,165 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - ORIGINAL_PATH="${PATH:-}" - export ORIGINAL_PATH + ORIGINAL_PATH="${PATH:-}" + export ORIGINAL_PATH - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" - PATH="$PROJECT_ROOT:$PATH" - export PATH + PATH="$PROJECT_ROOT:$PATH" + export PATH } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi - if [[ -n "${ORIGINAL_PATH:-}" ]]; then - export PATH="$ORIGINAL_PATH" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi + if [[ -n "${ORIGINAL_PATH:-}" ]]; then + export PATH="$ORIGINAL_PATH" + fi } setup() { - rm -rf "$HOME/.config" - rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" - mkdir -p "$HOME" + rm -rf "$HOME/.config" + rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" + mkdir -p "$HOME" } @test "completion script exists and is executable" { - [ -f "$PROJECT_ROOT/bin/completion.sh" ] - [ -x "$PROJECT_ROOT/bin/completion.sh" ] + [ -f "$PROJECT_ROOT/bin/completion.sh" ] + [ -x "$PROJECT_ROOT/bin/completion.sh" ] } @test "completion script has valid bash syntax" { - run bash -n "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] + run bash -n "$PROJECT_ROOT/bin/completion.sh" + [ "$status" -eq 0 ] } @test "completion --help shows usage" { - run "$PROJECT_ROOT/bin/completion.sh" --help - [ "$status" -ne 0 ] - [[ "$output" == *"Usage: mole completion"* ]] - [[ "$output" == *"Auto-install"* ]] + run "$PROJECT_ROOT/bin/completion.sh" --help + [ "$status" -ne 0 ] + [[ "$output" == *"Usage: mole completion"* ]] + [[ "$output" == *"Auto-install"* ]] } @test "completion bash generates valid bash script" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"_mole_completions"* ]] + [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } @test "completion bash script includes all commands" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"optimize"* ]] - [[ "$output" == *"clean"* ]] - [[ "$output" == *"uninstall"* ]] - [[ "$output" == *"analyze"* ]] - [[ "$output" == *"status"* ]] - [[ "$output" == *"purge"* ]] - [[ "$output" == *"touchid"* ]] - [[ "$output" == *"completion"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"optimize"* ]] + [[ "$output" == *"clean"* ]] + [[ "$output" == *"uninstall"* ]] + [[ "$output" == *"analyze"* ]] + [[ "$output" == *"status"* ]] + [[ "$output" == *"purge"* ]] + [[ "$output" == *"touchid"* ]] + [[ "$output" == *"completion"* ]] } @test "completion bash script supports mo command" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } @test "completion bash can be loaded in bash" { - run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] + run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" + [ "$status" -eq 0 ] + [[ "$output" == *"_mole_completions"* ]] } @test "completion zsh generates valid zsh script" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"#compdef mole mo"* ]] - [[ "$output" == *"_mole()"* ]] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"#compdef mole mo"* ]] + [[ "$output" == *"_mole()"* ]] } @test "completion zsh includes command descriptions" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"optimize:Check and maintain system"* ]] - [[ "$output" == *"clean:Free up disk space"* ]] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"optimize:Check and maintain system"* ]] + [[ "$output" == *"clean:Free up disk space"* ]] } @test "completion fish generates valid fish script" { - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] - [[ "$output" == *"complete -c mole"* ]] - [[ "$output" == *"complete -c mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] + [[ "$output" == *"complete -c mole"* ]] + [[ "$output" == *"complete -c mo"* ]] } @test "completion fish includes both mole and mo commands" { - output="$("$PROJECT_ROOT/bin/completion.sh" fish)" - mole_count=$(echo "$output" | grep -c "complete -c mole") - mo_count=$(echo "$output" | grep -c "complete -c mo") + output="$("$PROJECT_ROOT/bin/completion.sh" fish)" + mole_count=$(echo "$output" | grep -c "complete -c mole") + mo_count=$(echo "$output" | grep -c "complete -c mo") - [ "$mole_count" -gt 0 ] - [ "$mo_count" -gt 0 ] + [ "$mole_count" -gt 0 ] + [ "$mo_count" -gt 0 ] } @test "completion auto-install detects zsh" { - # shellcheck disable=SC2030,SC2031 - export SHELL=/bin/zsh + # shellcheck disable=SC2030,SC2031 + export SHELL=/bin/zsh - # Simulate auto-install (no interaction) - run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" + # Simulate auto-install (no interaction) + run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" - if [[ "$output" == *"Already configured"* ]]; then - skip "Already configured from previous test" - fi + if [[ "$output" == *"Already configured"* ]]; then + skip "Already configured from previous test" + fi - [ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc" + [ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc" - run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc" - [ "$status" -eq 0 ] + run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc" + [ "$status" -eq 0 ] } @test "completion auto-install detects already installed" { - # shellcheck disable=SC2031 - export SHELL=/bin/zsh - mkdir -p "$HOME" - # shellcheck disable=SC2016 - echo 'eval "$(mole completion zsh)"' > "$HOME/.zshrc" + mkdir -p "$HOME" + # shellcheck disable=SC2016 + echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc" - run "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"updated"* ]] + run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"updated"* ]] +} + +@test "completion --dry-run previews changes without writing config" { + run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] + [ ! -f "$HOME/.zshrc" ] } @test "completion script handles invalid shell argument" { - run "$PROJECT_ROOT/bin/completion.sh" invalid-shell - [ "$status" -ne 0 ] + run "$PROJECT_ROOT/bin/completion.sh" invalid-shell + [ "$status" -ne 0 ] } @test "completion subcommand supports bash/zsh/fish" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] } diff --git a/tests/installer.bats b/tests/installer.bats index e26b876..1e26595 100644 --- a/tests/installer.bats +++ b/tests/installer.bats @@ -1,49 +1,56 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - export TERM="xterm-256color" - export MO_DEBUG=0 + export TERM="xterm-256color" + export MO_DEBUG=0 - # Create standard scan directories - mkdir -p "$HOME/Downloads" - mkdir -p "$HOME/Desktop" - mkdir -p "$HOME/Documents" - mkdir -p "$HOME/Public" - mkdir -p "$HOME/Library/Downloads" + # Create standard scan directories + mkdir -p "$HOME/Downloads" + mkdir -p "$HOME/Desktop" + mkdir -p "$HOME/Documents" + mkdir -p "$HOME/Public" + mkdir -p "$HOME/Library/Downloads" - # Clear previous test files - rm -rf "${HOME:?}/Downloads"/* - rm -rf "${HOME:?}/Desktop"/* - rm -rf "${HOME:?}/Documents"/* + # Clear previous test files + rm -rf "${HOME:?}/Downloads"/* + rm -rf "${HOME:?}/Desktop"/* + rm -rf "${HOME:?}/Documents"/* } # Test arguments @test "installer.sh rejects unknown options" { - run "$PROJECT_ROOT/bin/installer.sh" --unknown-option + run "$PROJECT_ROOT/bin/installer.sh" --unknown-option - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown option"* ]] + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown option"* ]] +} + +@test "installer.sh accepts --dry-run option" { + run env HOME="$HOME" TERM="xterm-256color" "$PROJECT_ROOT/bin/installer.sh" --dry-run + + [[ "$status" -eq 0 || "$status" -eq 2 ]] + [[ "$output" == *"DRY RUN MODE"* ]] } # Test scan_installers_in_path function directly @@ -53,187 +60,187 @@ setup() { # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @test "scan_installers_in_path (fallback find): finds .dmg files" { - touch "$HOME/Downloads/Chrome.dmg" + touch "$HOME/Downloads/Chrome.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome.dmg"* ]] } @test "scan_installers_in_path (fallback find): finds multiple installer types" { - touch "$HOME/Downloads/App1.dmg" - touch "$HOME/Downloads/App2.pkg" - touch "$HOME/Downloads/App3.iso" - touch "$HOME/Downloads/App.mpkg" + touch "$HOME/Downloads/App1.dmg" + touch "$HOME/Downloads/App2.pkg" + touch "$HOME/Downloads/App3.iso" + touch "$HOME/Downloads/App.mpkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] - [[ "$output" == *"App.mpkg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"App1.dmg"* ]] + [[ "$output" == *"App2.pkg"* ]] + [[ "$output" == *"App3.iso"* ]] + [[ "$output" == *"App.mpkg"* ]] } @test "scan_installers_in_path (fallback find): respects max depth" { - mkdir -p "$HOME/Downloads/level1/level2/level3" - touch "$HOME/Downloads/shallow.dmg" - touch "$HOME/Downloads/level1/mid.dmg" - touch "$HOME/Downloads/level1/level2/deep.dmg" - touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" + mkdir -p "$HOME/Downloads/level1/level2/level3" + touch "$HOME/Downloads/shallow.dmg" + touch "$HOME/Downloads/level1/mid.dmg" + touch "$HOME/Downloads/level1/level2/deep.dmg" + touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] - [[ "$output" != *"too-deep.dmg"* ]] + [ "$status" -eq 0 ] + # Default max depth is 2 + [[ "$output" == *"shallow.dmg"* ]] + [[ "$output" == *"mid.dmg"* ]] + [[ "$output" == *"deep.dmg"* ]] + [[ "$output" != *"too-deep.dmg"* ]] } @test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { - mkdir -p "$HOME/Downloads/level1" - touch "$HOME/Downloads/top.dmg" - touch "$HOME/Downloads/level1/nested.dmg" + mkdir -p "$HOME/Downloads/level1" + touch "$HOME/Downloads/top.dmg" + touch "$HOME/Downloads/level1/nested.dmg" - run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] - [[ "$output" != *"nested.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"top.dmg"* ]] + [[ "$output" != *"nested.dmg"* ]] } @test "scan_installers_in_path (fallback find): handles non-existent directory" { - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent" - [ "$status" -eq 0 ] - [[ -z "$output" ]] + [ "$status" -eq 0 ] + [[ -z "$output" ]] } @test "scan_installers_in_path (fallback find): ignores non-installer files" { - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.jpg" - touch "$HOME/Downloads/archive.tar.gz" - touch "$HOME/Downloads/Installer.dmg" + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.jpg" + touch "$HOME/Downloads/archive.tar.gz" + touch "$HOME/Downloads/Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] - [[ "$output" == *"Installer.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" != *"document.pdf"* ]] + [[ "$output" != *"image.jpg"* ]] + [[ "$output" != *"archive.tar.gz"* ]] + [[ "$output" == *"Installer.dmg"* ]] } @test "scan_all_installers: handles missing paths gracefully" { - # Don't create all scan directories, some may not exist - # Only create Downloads, delete others if they exist - rm -rf "$HOME/Desktop" - rm -rf "$HOME/Documents" - rm -rf "$HOME/Public" - rm -rf "$HOME/Public/Downloads" - rm -rf "$HOME/Library/Downloads" - mkdir -p "$HOME/Downloads" + # Don't create all scan directories, some may not exist + # Only create Downloads, delete others if they exist + rm -rf "$HOME/Desktop" + rm -rf "$HOME/Documents" + rm -rf "$HOME/Public" + rm -rf "$HOME/Public/Downloads" + rm -rf "$HOME/Library/Downloads" + mkdir -p "$HOME/Downloads" - # Add an installer to the one directory that exists - touch "$HOME/Downloads/test.dmg" + # Add an installer to the one directory that exists + touch "$HOME/Downloads/test.dmg" - run bash -euo pipefail -c ' + run bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_all_installers ' bash "$PROJECT_ROOT/bin/installer.sh" - # Should succeed even with missing paths - [ "$status" -eq 0 ] - # Should still find the installer in the existing directory - [[ "$output" == *"test.dmg"* ]] + # Should succeed even with missing paths + [ "$status" -eq 0 ] + # Should still find the installer in the existing directory + [[ "$output" == *"test.dmg"* ]] } # Test edge cases @test "scan_installers_in_path (fallback find): handles filenames with spaces" { - touch "$HOME/Downloads/My App Installer.dmg" + touch "$HOME/Downloads/My App Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"My App Installer.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"My App Installer.dmg"* ]] } @test "scan_installers_in_path (fallback find): handles filenames with special characters" { - touch "$HOME/Downloads/App-v1.2.3_beta.pkg" + touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] } @test "scan_installers_in_path (fallback find): returns empty for directory with no installers" { - # Create some non-installer files - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.png" + # Create some non-installer files + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.png" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ -z "$output" ]] + [ "$status" -eq 0 ] + [[ -z "$output" ]] } # Symlink handling tests @test "scan_installers_in_path (fallback find): skips symlinks to regular files" { - touch "$HOME/Downloads/real.dmg" - ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" - ln -s /nonexistent "$HOME/Downloads/dangling.lnk" + touch "$HOME/Downloads/real.dmg" + ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" + ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] - [[ "$output" != *"dangling.lnk"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"real.dmg"* ]] + [[ "$output" != *"symlink.dmg"* ]] + [[ "$output" != *"dangling.lnk"* ]] } diff --git a/tests/purge.bats b/tests/purge.bats index 9e0ea96..63b0529 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -1,35 +1,35 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - mkdir -p "$HOME/www" - mkdir -p "$HOME/dev" - mkdir -p "$HOME/.cache/mole" + mkdir -p "$HOME/www" + mkdir -p "$HOME/dev" + mkdir -p "$HOME/.cache/mole" - rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* + rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* } @test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -37,11 +37,11 @@ setup() { echo 'SAFE' fi ") - [[ "$result" == "SAFE" ]] + [[ "$result" == "SAFE" ]] } @test "is_safe_project_artifact: allows proper project artifacts" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -49,11 +49,11 @@ setup() { echo 'BLOCKED' fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: rejects non-absolute paths" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -61,11 +61,11 @@ setup() { echo 'SAFE' fi ") - [[ "$result" == "SAFE" ]] + [[ "$result" == "SAFE" ]] } @test "is_safe_project_artifact: validates depth calculation" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -73,14 +73,14 @@ setup() { echo 'BLOCKED' fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: allows direct child when search path is project root" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/single-project/node_modules' '$HOME/single-project'; then echo 'ALLOWED' @@ -89,15 +89,15 @@ setup() { fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: accepts physical path under symlinked search root" { - mkdir -p "$HOME/www/real/proj/node_modules" - touch "$HOME/www/real/proj/package.json" - ln -s "$HOME/www/real" "$HOME/www/link" + mkdir -p "$HOME/www/real/proj/node_modules" + touch "$HOME/www/real/proj/package.json" + ln -s "$HOME/www/real" "$HOME/www/link" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/real/proj/node_modules' '$HOME/www/link/proj'; then echo 'ALLOWED' @@ -106,43 +106,43 @@ setup() { fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "filter_nested_artifacts: removes nested node_modules" { - mkdir -p "$HOME/www/project/node_modules/package/node_modules" + mkdir -p "$HOME/www/project/node_modules/package/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - [[ "$result" == "1" ]] + [[ "$result" == "1" ]] } @test "filter_nested_artifacts: keeps independent artifacts" { - mkdir -p "$HOME/www/project1/node_modules" - mkdir -p "$HOME/www/project2/target" + mkdir -p "$HOME/www/project1/node_modules" + mkdir -p "$HOME/www/project2/target" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - [[ "$result" == "2" ]] + [[ "$result" == "2" ]] } @test "filter_nested_artifacts: removes Xcode build subdirectories (Mac projects)" { - # Simulate Mac Xcode project with nested .build directories: - # ~/www/testapp/build - # ~/www/testapp/build/Framework.build - # ~/www/testapp/build/Package.build - mkdir -p "$HOME/www/testapp/build/Framework.build" - mkdir -p "$HOME/www/testapp/build/Package.build" + # Simulate Mac Xcode project with nested .build directories: + # ~/www/testapp/build + # ~/www/testapp/build/Framework.build + # ~/www/testapp/build/Package.build + mkdir -p "$HOME/www/testapp/build/Framework.build" + mkdir -p "$HOME/www/testapp/build/Package.build" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' \ '$HOME/www/testapp/build' \ @@ -150,20 +150,20 @@ setup() { '$HOME/www/testapp/build/Package.build' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - - # Should only keep the top-level 'build' directory, filtering out nested .build dirs - [[ "$result" == "1" ]] + + # Should only keep the top-level 'build' directory, filtering out nested .build dirs + [[ "$result" == "1" ]] } # Vendor protection unit tests @test "is_rails_project_root: detects valid Rails project" { - mkdir -p "$HOME/www/test-rails/config" - mkdir -p "$HOME/www/test-rails/bin" - touch "$HOME/www/test-rails/config/application.rb" - touch "$HOME/www/test-rails/Gemfile" - touch "$HOME/www/test-rails/bin/rails" + mkdir -p "$HOME/www/test-rails/config" + mkdir -p "$HOME/www/test-rails/bin" + touch "$HOME/www/test-rails/config/application.rb" + touch "$HOME/www/test-rails/Gemfile" + touch "$HOME/www/test-rails/bin/rails" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/test-rails'; then echo 'YES' @@ -172,14 +172,14 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_rails_project_root: rejects non-Rails directory" { - mkdir -p "$HOME/www/not-rails" - touch "$HOME/www/not-rails/package.json" + mkdir -p "$HOME/www/not-rails" + touch "$HOME/www/not-rails/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/not-rails'; then echo 'YES' @@ -188,14 +188,14 @@ setup() { fi ") - [[ "$result" == "NO" ]] + [[ "$result" == "NO" ]] } @test "is_go_project_root: detects valid Go project" { - mkdir -p "$HOME/www/test-go" - touch "$HOME/www/test-go/go.mod" + mkdir -p "$HOME/www/test-go" + touch "$HOME/www/test-go/go.mod" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_go_project_root '$HOME/www/test-go'; then echo 'YES' @@ -204,14 +204,14 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_php_project_root: detects valid PHP Composer project" { - mkdir -p "$HOME/www/test-php" - touch "$HOME/www/test-php/composer.json" + mkdir -p "$HOME/www/test-php" + touch "$HOME/www/test-php/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_php_project_root '$HOME/www/test-php'; then echo 'YES' @@ -220,17 +220,17 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_protected_vendor_dir: protects Rails vendor" { - mkdir -p "$HOME/www/rails-app/vendor" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - touch "$HOME/www/rails-app/config/environment.rb" + mkdir -p "$HOME/www/rails-app/vendor" + mkdir -p "$HOME/www/rails-app/config" + touch "$HOME/www/rails-app/config/application.rb" + touch "$HOME/www/rails-app/Gemfile" + touch "$HOME/www/rails-app/config/environment.rb" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/rails-app/vendor'; then echo 'PROTECTED' @@ -239,14 +239,14 @@ setup() { fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_vendor_dir: does not protect PHP vendor" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -255,11 +255,11 @@ setup() { fi ") - [[ "$result" == "NOT_PROTECTED" ]] + [[ "$result" == "NOT_PROTECTED" ]] } @test "is_project_container detects project indicators" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/Workspace2/project" @@ -269,12 +269,12 @@ if is_project_container "$HOME/Workspace2" 2; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"yes"* ]] } @test "discover_project_dirs includes detected containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/CustomProjects/app" @@ -282,22 +282,22 @@ touch "$HOME/CustomProjects/app/go.mod" discover_project_dirs | grep -q "$HOME/CustomProjects" EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "save_discovered_paths writes config with tilde" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" save_discovered_paths "$HOME/Projects" grep -q "^~/" "$HOME/.config/mole/purge_paths" EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "select_purge_categories returns failure on empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" if select_purge_categories; then @@ -305,7 +305,7 @@ if select_purge_categories; then fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "select_purge_categories restores caller EXIT/INT/TERM traps" { @@ -369,10 +369,10 @@ EOF } @test "is_protected_vendor_dir: protects Go vendor" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" + mkdir -p "$HOME/www/go-app/vendor" + touch "$HOME/www/go-app/go.mod" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then echo 'PROTECTED' @@ -381,13 +381,13 @@ EOF fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_vendor_dir: protects unknown vendor (conservative)" { - mkdir -p "$HOME/www/unknown-app/vendor" + mkdir -p "$HOME/www/unknown-app/vendor" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/unknown-app/vendor'; then echo 'PROTECTED' @@ -396,14 +396,14 @@ EOF fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_purge_artifact: handles vendor directories correctly" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -412,14 +412,14 @@ EOF fi ") - # PHP vendor should not be protected - [[ "$result" == "NOT_PROTECTED" ]] + # PHP vendor should not be protected + [[ "$result" == "NOT_PROTECTED" ]] } @test "is_protected_purge_artifact: returns false for non-vendor artifacts" { - mkdir -p "$HOME/www/app/node_modules" + mkdir -p "$HOME/www/app/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/app/node_modules'; then echo 'PROTECTED' @@ -428,23 +428,23 @@ EOF fi ") - # node_modules is not in the protected list - [[ "$result" == "NOT_PROTECTED" ]] + # node_modules is not in the protected list + [[ "$result" == "NOT_PROTECTED" ]] } # Integration tests @test "scan_purge_targets: skips Rails vendor directory" { - mkdir -p "$HOME/www/rails-app/vendor/javascript" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - mkdir -p "$HOME/www/rails-app/bin" - touch "$HOME/www/rails-app/bin/rails" + mkdir -p "$HOME/www/rails-app/vendor/javascript" + mkdir -p "$HOME/www/rails-app/config" + touch "$HOME/www/rails-app/config/application.rb" + touch "$HOME/www/rails-app/Gemfile" + mkdir -p "$HOME/www/rails-app/bin" + touch "$HOME/www/rails-app/bin/rails" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/rails-app/vendor' '$scan_output'; then @@ -454,19 +454,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: cleans PHP Composer vendor directory" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/php-app/vendor' '$scan_output'; then @@ -476,20 +476,20 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: skips Go vendor directory" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" - touch "$HOME/www/go-app/go.sum" + mkdir -p "$HOME/www/go-app/vendor" + touch "$HOME/www/go-app/go.mod" + touch "$HOME/www/go-app/go.sum" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/go-app/vendor' '$scan_output'; then @@ -499,19 +499,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: skips unknown vendor directory" { - # Create a vendor directory without any project file - mkdir -p "$HOME/www/unknown-app/vendor" + # Create a vendor directory without any project file + mkdir -p "$HOME/www/unknown-app/vendor" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/unknown-app/vendor' '$scan_output'; then @@ -521,20 +521,20 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - # Unknown vendor should be protected (conservative approach) - [[ "$result" == "SKIPPED" ]] + # Unknown vendor should be protected (conservative approach) + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: finds direct-child artifacts in project root with find mode" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -544,19 +544,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: supports trailing slash search path in find mode" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project/' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -566,16 +566,16 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "is_recently_modified: detects recent projects" { - mkdir -p "$HOME/www/project/node_modules" - touch "$HOME/www/project/package.json" + mkdir -p "$HOME/www/project/node_modules" + touch "$HOME/www/project/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' if is_recently_modified '$HOME/www/project/node_modules'; then @@ -584,66 +584,66 @@ EOF echo 'OLD' fi ") - [[ "$result" == "RECENT" ]] + [[ "$result" == "RECENT" ]] } @test "is_recently_modified: marks old projects correctly" { - mkdir -p "$HOME/www/old-project/node_modules" - mkdir -p "$HOME/www/old-project" + mkdir -p "$HOME/www/old-project/node_modules" + mkdir -p "$HOME/www/old-project" - bash -c " + bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' is_recently_modified '$HOME/www/old-project/node_modules' || true " - local exit_code=$? - [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] + local exit_code=$? + [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] } @test "purge targets are configured correctly" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' echo \"\${PURGE_TARGETS[@]}\" ") - [[ "$result" == *"node_modules"* ]] - [[ "$result" == *"target"* ]] + [[ "$result" == *"node_modules"* ]] + [[ "$result" == *"target"* ]] } @test "get_dir_size_kb: calculates directory size" { - mkdir -p "$HOME/www/test-project/node_modules" - dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null + mkdir -p "$HOME/www/test-project/node_modules" + dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/test-project/node_modules' ") - [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] + [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] } @test "get_dir_size_kb: handles non-existent paths gracefully" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/non-existent' ") - [[ "$result" == "0" ]] + [[ "$result" == "0" ]] } @test "get_dir_size_kb: returns TIMEOUT when size calculation hangs" { - mkdir -p "$HOME/www/stuck-project/node_modules" + mkdir -p "$HOME/www/stuck-project/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' run_with_timeout() { return 124; } get_dir_size_kb '$HOME/www/stuck-project/node_modules' ") - [[ "$result" == "TIMEOUT" ]] + [[ "$result" == "TIMEOUT" ]] } @test "clean_project_artifacts: restores caller INT/TERM traps" { - result=$(bash -c " + result=$(bash -c " set -euo pipefail export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' @@ -669,92 +669,108 @@ EOF fi ") - [[ "$result" == *"PASS"* ]] + [[ "$result" == *"PASS"* ]] } @test "clean_project_artifacts: handles empty directory gracefully" { - run bash -c " + run bash -c " export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' clean_project_artifacts - " < /dev/null + " /dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi - mkdir -p "$HOME/www/test-project/node_modules/package1" - echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js" + mkdir -p "$HOME/www/test-project/node_modules/package1" + echo "test data" >"$HOME/www/test-project/node_modules/package1/index.js" - mkdir -p "$HOME/www/test-project" + mkdir -p "$HOME/www/test-project" - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run bash -c " export HOME='$HOME' $timeout_cmd 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true " - [[ "$output" =~ "Scanning" ]] || - [[ "$output" =~ "Purge complete" ]] || - [[ "$output" =~ "No old" ]] || - [[ "$output" =~ "Great" ]] + [[ "$output" =~ "Scanning" ]] || + [[ "$output" =~ "Purge complete" ]] || + [[ "$output" =~ "No old" ]] || + [[ "$output" =~ "Great" ]] } @test "mo purge: command exists and is executable" { - [ -x "$PROJECT_ROOT/mole" ] - [ -f "$PROJECT_ROOT/bin/purge.sh" ] + [ -x "$PROJECT_ROOT/mole" ] + [ -f "$PROJECT_ROOT/bin/purge.sh" ] } @test "mo purge: shows in help text" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo purge"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo purge"* ]] } @test "mo purge: accepts --debug flag" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run bash -c " export HOME='$HOME' $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true " - true + true +} + +@test "mo purge: accepts --dry-run flag" { + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi + + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + + run bash -c " + export HOME='$HOME' + $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true + " + + [[ "$output" == *"DRY RUN MODE"* ]] || [[ "$output" == *"Dry run complete"* ]] } @test "mo purge: creates cache directory for stats" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - bash -c " + bash -c " export HOME='$HOME' $timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true " - [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] + [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] } # .NET bin directory detection tests @test "is_dotnet_bin_dir: finds .NET context in parent directory with Debug dir" { - mkdir -p "$HOME/www/dotnet-app/bin/Debug" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin/Debug" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -763,14 +779,14 @@ EOF fi ") - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "is_dotnet_bin_dir: requires .csproj AND Debug/Release" { - mkdir -p "$HOME/www/dotnet-app/bin" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -779,15 +795,15 @@ EOF fi ") - # Should not find it because Debug/Release directories don't exist - [[ "$result" == "NOT_FOUND" ]] + # Should not find it because Debug/Release directories don't exist + [[ "$result" == "NOT_FOUND" ]] } @test "is_dotnet_bin_dir: rejects non-bin directories" { - mkdir -p "$HOME/www/dotnet-app/obj" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/obj" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/obj'; then echo 'FOUND' @@ -795,19 +811,18 @@ EOF echo 'NOT_FOUND' fi ") - [[ "$result" == "NOT_FOUND" ]] + [[ "$result" == "NOT_FOUND" ]] } - # Integration test for bin scanning @test "scan_purge_targets: includes .NET bin directories with Debug/Release" { - mkdir -p "$HOME/www/dotnet-app/bin/Debug" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin/Debug" + touch "$HOME/www/dotnet-app/MyProject.csproj" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/dotnet-app/bin' '$scan_output'; then @@ -817,19 +832,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: skips generic bin directories (non-.NET)" { - mkdir -p "$HOME/www/ruby-app/bin" - touch "$HOME/www/ruby-app/Gemfile" + mkdir -p "$HOME/www/ruby-app/bin" + touch "$HOME/www/ruby-app/Gemfile" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/ruby-app/bin' '$scan_output'; then @@ -839,6 +854,6 @@ EOF fi ") - rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + rm -f "$scan_output" + [[ "$result" == "SKIPPED" ]] } diff --git a/tests/uninstall.bats b/tests/uninstall.bats index 3c98927..bd71faa 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -1,67 +1,67 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats - if [[ -z "$ORIGINAL_HOME" ]]; then - ORIGINAL_HOME="${HOME:-}" - fi - export ORIGINAL_HOME + ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" + export HOME } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - export TERM="dumb" - rm -rf "${HOME:?}"/* - mkdir -p "$HOME" + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" } create_app_artifacts() { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Containers/com.example.TestApp" - mkdir -p "$HOME/Library/Preferences" - touch "$HOME/Library/Preferences/com.example.TestApp.plist" - mkdir -p "$HOME/Library/Preferences/ByHost" - touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" - mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" - mkdir -p "$HOME/Library/LaunchAgents" - touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Containers/com.example.TestApp" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/com.example.TestApp.plist" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" } @test "find_app_files discovers user-level leftovers" { - create_app_artifacts + create_app_artifacts - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' + result="$( + HOME="$HOME" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" find_app_files "com.example.TestApp" "TestApp" EOF - )" + )" - [[ "$result" == *"Application Support/TestApp"* ]] - [[ "$result" == *"Caches/TestApp"* ]] - [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] - [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] - [[ "$result" == *"Containers/com.example.TestApp"* ]] - [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] + [[ "$result" == *"Application Support/TestApp"* ]] + [[ "$result" == *"Caches/TestApp"* ]] + [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] + [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] + [[ "$result" == *"Containers/com.example.TestApp"* ]] + [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] } @test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -92,16 +92,16 @@ result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir") [[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "calculate_total_size returns aggregate kilobytes" { - mkdir -p "$HOME/sized" - dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 > /dev/null 2>&1 - dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 > /dev/null 2>&1 + mkdir -p "$HOME/sized" + dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 >/dev/null 2>&1 + dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1 - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' + result="$( + HOME="$HOME" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" files="$(printf '%s @@ -109,15 +109,15 @@ files="$(printf '%s ' "$HOME/sized/file1" "$HOME/sized/file2")" calculate_total_size "$files" EOF - )" + )" - [ "$result" -ge 3 ] + [ "$result" -ge 3 ] } @test "batch_uninstall_applications removes selected app data" { - create_app_artifacts + create_app_artifacts - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -155,22 +155,22 @@ batch_uninstall_applications [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "batch_uninstall_applications preview shows full related file list" { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Logs/TestApp" - touch "$HOME/Library/Logs/TestApp/log1.log" - touch "$HOME/Library/Logs/TestApp/log2.log" - touch "$HOME/Library/Logs/TestApp/log3.log" - touch "$HOME/Library/Logs/TestApp/log4.log" - touch "$HOME/Library/Logs/TestApp/log5.log" - touch "$HOME/Library/Logs/TestApp/log6.log" + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Logs/TestApp" + touch "$HOME/Library/Logs/TestApp/log1.log" + touch "$HOME/Library/Logs/TestApp/log2.log" + touch "$HOME/Library/Logs/TestApp/log3.log" + touch "$HOME/Library/Logs/TestApp/log4.log" + touch "$HOME/Library/Logs/TestApp/log5.log" + touch "$HOME/Library/Logs/TestApp/log6.log" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -210,28 +210,27 @@ total_size_cleaned=0 printf 'q' | batch_uninstall_applications EOF - [ "$status" -eq 0 ] - [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] - [[ "$output" != *"more files"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] + [[ "$output" != *"more files"* ]] } @test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" + mkdir -p "$HOME/test_dir" + touch "$HOME/test_dir/file.txt" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" safe_remove "$HOME/test_dir" [[ ! -d "$HOME/test_dir" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } - @test "decode_file_list validates base64 encoding" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -242,11 +241,11 @@ result=$(decode_file_list "$valid_data" "TestApp") [[ -n "$result" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list rejects invalid base64" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -258,11 +257,11 @@ else fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list handles empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -272,11 +271,11 @@ result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true [[ -z "$result" ]] EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list rejects non-absolute paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -289,11 +288,11 @@ else fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list handles both BSD and GNU base64 formats" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -311,16 +310,16 @@ result=$(decode_file_list "$encoded_data" "TestApp") [[ -n "$result" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "remove_mole deletes manual binaries and caches" { - mkdir -p "$HOME/.local/bin" - touch "$HOME/.local/bin/mole" - touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" + mkdir -p "$HOME/.local/bin" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' set -euo pipefail start_inline_spinner() { :; } stop_inline_spinner() { :; } @@ -355,9 +354,31 @@ export -f start_inline_spinner stop_inline_spinner rm sudo printf '\n' | "$PROJECT_ROOT/mole" remove EOF - [ "$status" -eq 0 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] + [ "$status" -eq 0 ] + [ ! -f "$HOME/.local/bin/mole" ] + [ ! -f "$HOME/.local/bin/mo" ] + [ ! -d "$HOME/.config/mole" ] + [ ! -d "$HOME/.cache/mole" ] +} + +@test "remove_mole dry-run keeps manual binaries and caches" { + mkdir -p "$HOME/.local/bin" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] + [ -f "$HOME/.local/bin/mole" ] + [ -f "$HOME/.local/bin/mo" ] + [ -d "$HOME/.config/mole" ] + [ -d "$HOME/.cache/mole" ] }