diff --git a/.gitignore b/.gitignore index a384879..8efd5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,10 @@ tests/tmp-*/ tests/*.tmp tests/*.log +# Go test coverage files +*.out +coverage.out +coverage.html + session.json run_tests.ps1 diff --git a/bin/log.sh b/bin/log.sh new file mode 100755 index 0000000..8619853 --- /dev/null +++ b/bin/log.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# Mole - Operations Log Viewer +# Query and analyze operation logs + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="${SCRIPT_DIR}/../lib" + +# shellcheck source=lib/core/base.sh +source "$LIB_DIR/core/base.sh" +# shellcheck source=lib/core/log.sh +source "$LIB_DIR/core/log.sh" + +show_help() { + cat < Show last N entries (default: 50) + --search Search for specific pattern + --stats Show operation statistics + --today Show only today's operations + --command Filter by command (clean/uninstall/optimize/purge) + --help Show this help message + +EXAMPLES: + mo log # Show last 50 operations + mo log --tail 100 # Show last 100 operations + mo log --search node_modules # Search for node_modules operations + mo log --stats # Show statistics + mo log --today # Show today's operations only + mo log --command clean # Show only clean operations + +LOG LOCATION: + ${OPERATIONS_LOG_FILE} + +EOF +} + +show_tail() { + local count="${1:-50}" + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + echo "Run some commands (e.g., mo clean) to generate logs." + return 0 + fi + + echo -e "${BLUE}Last ${count} operations:${NC}" + echo "────────────────────────────────────────────────────────────────" + tail -n "$count" "$OPERATIONS_LOG_FILE" +} + +search_log() { + local term="$1" + + if [[ -z "$term" ]]; then + echo -e "${RED}Error: Search term required${NC}" + echo "Usage: mo log --search " + return 1 + fi + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Searching for: ${term}${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep -iF -- "$term" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No matches found.${NC}" + else + echo "$results" + fi +} + +show_stats() { + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Operation Statistics${NC}" + echo "────────────────────────────────────────────────────────────────" + + local total_lines + total_lines=$(grep -c '^\[' "$OPERATIONS_LOG_FILE" 2>/dev/null || echo 0) + echo -e "${GREEN}Total operations:${NC} $total_lines" + echo "" + + echo -e "${GREEN}By command:${NC}" + grep -o '\[clean\]\|\[uninstall\]\|\[optimize\]\|\[purge\]' "$OPERATIONS_LOG_FILE" 2>/dev/null | + sort | uniq -c | sort -rn | sed 's/\[//g; s/\]//g' | + awk '{printf " %-15s %s\n", $2":", $1}' || echo " No command data" + echo "" + + echo -e "${GREEN}By action:${NC}" + grep -o 'REMOVED\|SKIPPED\|FAILED\|REBUILT' "$OPERATIONS_LOG_FILE" 2>/dev/null | + sort | uniq -c | sort -rn | + awk '{printf " %-15s %s\n", $2":", $1}' || echo " No action data" + echo "" + + echo -e "${GREEN}Recent sessions:${NC}" + grep 'session started' "$OPERATIONS_LOG_FILE" 2>/dev/null | tail -n 5 || echo " No session data" +} + +show_today() { + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + local today + today=$(date '+%Y-%m-%d') + + echo -e "${BLUE}Today's operations (${today}):${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep "^\[$today" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No operations today.${NC}" + else + echo "$results" + fi +} + +filter_by_command() { + local cmd="$1" + + if [[ -z "$cmd" ]]; then + echo -e "${RED}Error: Command name required${NC}" + echo "Usage: mo log --command " + echo "Available commands: clean, uninstall, optimize, purge" + return 1 + fi + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Operations for command: ${cmd}${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep -F -- "[$cmd]" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No operations found for ${cmd}.${NC}" + else + echo "$results" + fi +} + +main() { + if [[ "${MO_NO_OPLOG:-}" == "1" ]]; then + echo -e "${YELLOW}Operation logging is disabled (MO_NO_OPLOG=1).${NC}" + echo "Enable it by unsetting the MO_NO_OPLOG environment variable." + exit 0 + fi + + if [[ $# -eq 0 ]]; then + show_tail 50 + exit 0 + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --tail) + shift + show_tail "${1:-50}" + exit 0 + ;; + --search) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --search requires an argument${NC}" + exit 1 + fi + search_log "$1" + exit 0 + ;; + --stats) + show_stats + exit 0 + ;; + --today) + show_today + exit 0 + ;; + --command) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --command requires an argument${NC}" + exit 1 + fi + filter_by_command "$1" + exit 0 + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use 'mo log --help' for usage information." + exit 1 + ;; + esac + # shellcheck disable=SC2317 + shift + done +} + +main "$@" diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index f665952..9fc7ca9 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -39,6 +39,95 @@ func TestFormatRate(t *testing.T) { } } +func TestColorizePercent(t *testing.T) { + tests := []struct { + name string + percent float64 + input string + expectDanger bool + expectWarn bool + expectOk bool + }{ + {"low usage", 30.0, "30%", false, false, true}, + {"just below warn", 59.9, "59.9%", false, false, true}, + {"at warn threshold", 60.0, "60%", false, true, false}, + {"mid range", 70.0, "70%", false, true, false}, + {"just below danger", 84.9, "84.9%", false, true, false}, + {"at danger threshold", 85.0, "85%", true, false, false}, + {"high usage", 95.0, "95%", true, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizePercent(tt.percent, tt.input) + + if got == "" { + t.Errorf("colorizePercent(%v, %q) returned empty string", tt.percent, tt.input) + return + } + + expected := "" + if tt.expectDanger { + expected = dangerStyle.Render(tt.input) + } else if tt.expectWarn { + expected = warnStyle.Render(tt.input) + } else if tt.expectOk { + expected = okStyle.Render(tt.input) + } + + if got != expected { + t.Errorf("colorizePercent(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)", + tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk) + } + }) + } +} + +func TestColorizeBattery(t *testing.T) { + tests := []struct { + name string + percent float64 + input string + expectDanger bool + expectWarn bool + expectOk bool + }{ + {"critical low", 10.0, "10%", true, false, false}, + {"just below low", 19.9, "19.9%", true, false, false}, + {"at low threshold", 20.0, "20%", false, true, false}, + {"mid range", 35.0, "35%", false, true, false}, + {"just below ok", 49.9, "49.9%", false, true, false}, + {"at ok threshold", 50.0, "50%", false, false, true}, + {"healthy", 80.0, "80%", false, false, true}, + {"full", 100.0, "100%", false, false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizeBattery(tt.percent, tt.input) + + if got == "" { + t.Errorf("colorizeBattery(%v, %q) returned empty string", tt.percent, tt.input) + return + } + + expected := "" + if tt.expectDanger { + expected = dangerStyle.Render(tt.input) + } else if tt.expectWarn { + expected = warnStyle.Render(tt.input) + } else if tt.expectOk { + expected = okStyle.Render(tt.input) + } + + if got != expected { + t.Errorf("colorizeBattery(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)", + tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk) + } + }) + } +} + func TestShorten(t *testing.T) { tests := []struct { name string diff --git a/lib/core/commands.sh b/lib/core/commands.sh index 3d2559e..a17b878 100644 --- a/lib/core/commands.sh +++ b/lib/core/commands.sh @@ -9,6 +9,7 @@ MOLE_COMMANDS=( "status:Monitor system health" "purge:Remove old project artifacts" "installer:Find and remove installer files" + "log:View operation logs" "touchid:Configure Touch ID for sudo" "completion:Setup shell tab completion" "update:Update to latest version" diff --git a/lib/core/log.sh b/lib/core/log.sh index cc933cd..769edb5 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -44,17 +44,25 @@ rotate_log_once() { export MOLE_LOG_ROTATED=1 local max_size="$LOG_MAX_SIZE_DEFAULT" - if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then - mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true - ensure_user_file "$LOG_FILE" + if [[ -f "$LOG_FILE" ]]; then + local size + size=$(get_file_size "$LOG_FILE") + if [[ "$size" -gt "$max_size" ]]; then + mv "$LOG_FILE" "${LOG_FILE}.old" 2>/dev/null || true + ensure_user_file "$LOG_FILE" + fi fi # Rotate operations log (5MB limit) if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT" - if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then - mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true - ensure_user_file "$OPERATIONS_LOG_FILE" + if [[ -f "$OPERATIONS_LOG_FILE" ]]; then + local size + size=$(get_file_size "$OPERATIONS_LOG_FILE") + if [[ "$size" -gt "$oplog_max_size" ]]; then + mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2>/dev/null || true + ensure_user_file "$OPERATIONS_LOG_FILE" + fi fi fi } @@ -63,51 +71,62 @@ rotate_log_once() { # Logging Functions # ============================================================================ +# Get current timestamp (centralized for consistency) +get_timestamp() { + date '+%Y-%m-%d %H:%M:%S' +} + # Log informational message log_info() { echo -e "${BLUE}$1${NC}" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] INFO: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] INFO: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } # Log success message log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] SUCCESS: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] SUCCESS: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } -# Log warning message +# shellcheck disable=SC2329 log_warning() { echo -e "${YELLOW}$1${NC}" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] WARNING: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] WARNING: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } -# Log error message +# shellcheck disable=SC2329 log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] ERROR: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] ERROR: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } -# Debug logging (active when MO_DEBUG=1) +# shellcheck disable=SC2329 debug_log() { if [[ "${MO_DEBUG:-}" == "1" ]]; then echo -e "${GRAY}[DEBUG]${NC} $*" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] DEBUG: $*" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -139,12 +158,12 @@ log_operation() { [[ -z "$path" ]] && return 0 local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) local log_line="[$timestamp] [$command] $action $path" [[ -n "$detail" ]] && log_line+=" ($detail)" - echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + echo "$log_line" >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true } # Log session start marker @@ -154,16 +173,15 @@ log_operation_session_start() { local command="${1:-mole}" local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) { echo "" echo "# ========== $command session started at $timestamp ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true } -# Log session end with summary -# Usage: log_operation_session_end +# shellcheck disable=SC2329 log_operation_session_end() { oplog_enabled || return 0 @@ -171,18 +189,18 @@ log_operation_session_end() { local items="${2:-0}" local size="${3:-0}" local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) local size_human="" if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size * 1024))" 2> /dev/null || echo "${size}KB") + size_human=$(bytes_to_human "$((size * 1024))" 2>/dev/null || echo "${size}KB") else size_human="0B" fi { echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true } # Enhanced debug logging for operations @@ -200,7 +218,7 @@ debug_operation_start() { echo "" echo "=== $operation_name ===" [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + } >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -214,7 +232,7 @@ debug_operation_detail() { echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 # Also log to file - echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "$detail_type: $detail_value" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -234,7 +252,7 @@ debug_file_action() { echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 # Also log to file - echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "$action: $msg" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -246,16 +264,16 @@ debug_risk_level() { if [[ "${MO_DEBUG:-}" == "1" ]]; then local color="$GRAY" case "$risk_level" in - LOW) color="$GREEN" ;; - MEDIUM) color="$YELLOW" ;; - HIGH) color="$RED" ;; + LOW) color="$GREEN" ;; + MEDIUM) color="$YELLOW" ;; + HIGH) color="$RED" ;; esac # Output to stderr with color echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY}, $reason${NC}" >&2 # Also log to file - echo "Risk Level: $risk_level, $reason" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "Risk Level: $risk_level, $reason" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -267,7 +285,7 @@ log_system_info() { # Reset debug log file for this new session ensure_user_file "$DEBUG_LOG_FILE" - if ! : > "$DEBUG_LOG_FILE" 2> /dev/null; then + if ! : >"$DEBUG_LOG_FILE" 2>/dev/null; then echo -e "${YELLOW}${ICON_WARNING}${NC} Debug log not writable: $DEBUG_LOG_FILE" >&2 fi @@ -280,19 +298,19 @@ log_system_info() { echo "Hostname: $(hostname)" echo "Architecture: $(uname -m)" echo "Kernel: $(uname -r)" - if command -v sw_vers > /dev/null; then + if command -v sw_vers >/dev/null; then echo "macOS: $(sw_vers -productVersion), $(sw_vers -buildVersion)" fi echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" # Check sudo status non-interactively - if sudo -n true 2> /dev/null; then + if sudo -n true 2>/dev/null; then echo "Sudo Access: Active" else echo "Sudo Access: Required" fi echo "----------------------------------------------------------------------" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + } >>"$DEBUG_LOG_FILE" 2>/dev/null || true # Notification to stderr echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 @@ -304,7 +322,7 @@ log_system_info() { # Run command silently (ignore errors) run_silent() { - "$@" > /dev/null 2>&1 || true + "$@" >/dev/null 2>&1 || true } # Run command with error logging @@ -312,12 +330,12 @@ run_logged() { local cmd="$1" # Log to main file, and also to debug file if enabled if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" >/dev/null; then log_warning "Command failed: $cmd" return 1 fi else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" >/dev/null; then log_warning "Command failed: $cmd" return 1 fi diff --git a/mole b/mole index 8761cb1..b9c291a 100755 --- a/mole +++ b/mole @@ -777,6 +777,9 @@ main() { "installer") exec "$SCRIPT_DIR/bin/installer.sh" "${args[@]:1}" ;; + "log") + exec "$SCRIPT_DIR/bin/log.sh" "${args[@]:1}" + ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;;