sr-search-replace 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sr.sh ADDED
@@ -0,0 +1,4186 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Search and Replace (sr) - Universal text replacement tool
4
+ # Version: 6.1.0
5
+ # Author: Mikhail Deynekin [ Deynekin.com ]
6
+ # REPO: https://github.com/paulmann
7
+ # Description: Recursively replaces text in files with proper escaping
8
+ # Enhanced with: Multi-layer binary detection, session-based rollback, predictable parsing
9
+ # New in 6.1.0: Enhanced configuration, extended tool parameters, improved compatibility
10
+
11
+ set -euo pipefail # Strict mode: exit on error, unset variables, pipe failures
12
+
13
+ # ============================================================================
14
+ # ENHANCED CONFIGURABLE DEFAULTS - EDIT THESE TO CHANGE SCRIPT BEHAVIOR
15
+ # ============================================================================
16
+
17
+ # Default behavior settings
18
+ readonly SESSION_VERSION="6.1.0"
19
+ readonly DEFAULT_DEBUG_MODE=false
20
+ readonly DEFAULT_RECURSIVE_MODE=true
21
+ readonly DEFAULT_DRY_RUN=false
22
+ readonly DEFAULT_CREATE_BACKUPS=true
23
+ readonly DEFAULT_BACKUP_IN_FOLDER=true
24
+ readonly DEFAULT_FORCE_BACKUP=false
25
+ readonly DEFAULT_PRESERVE_OWNERSHIP=true
26
+ readonly DEFAULT_MAX_DEPTH=100
27
+ readonly DEFAULT_TIMESTAMP_FORMAT="%Y%m%d_%H%M%S"
28
+ readonly DEFAULT_BACKUP_PREFIX="sr.backup"
29
+ readonly DEFAULT_TEMP_DIR="/tmp"
30
+ readonly DEFAULT_SED_DELIMITER="|"
31
+ readonly DEFAULT_SKIP_HIDDEN_FILES=false
32
+ readonly DEFAULT_SKIP_BINARY_FILES=true
33
+ readonly DEFAULT_MAX_FILE_SIZE_MB=100
34
+ readonly DEFAULT_ENCODING="UTF-8"
35
+ readonly DEFAULT_SEARCH_DIR="."
36
+ readonly DEFAULT_REPLACE_MODE="inplace" # inplace, copy, or backup_only
37
+
38
+ # Binary detection settings (NEW)
39
+ readonly DEFAULT_BINARY_DETECTION_METHOD="multi_layer" # multi_layer, file_only, grep_only
40
+ readonly DEFAULT_BINARY_CHECK_SIZE=1024 # Check first N bytes for binary detection
41
+ readonly DEFAULT_ALLOW_BINARY=false # NEW: Require explicit flag to process binary files
42
+
43
+ # Rollback settings (NEW)
44
+ readonly DEFAULT_ROLLBACK_ENABLED=true
45
+ readonly DEFAULT_MAX_BACKUPS=10 # Keep last N backups
46
+
47
+ # ============================================================================
48
+ # ENHANCED SEARCH/REPLACE PARAMETERS - APPLIED TO ALL OPERATIONS
49
+ # ============================================================================
50
+
51
+ # Additional parameters that will be applied to all search/replace operations
52
+ readonly DEFAULT_IGNORE_CASE=false # Case-insensitive search
53
+ readonly DEFAULT_MULTILINE_MATCH=false # Multi-line mode for regex
54
+ readonly DEFAULT_EXTENDED_REGEX=false # Use extended regular expressions
55
+ readonly DEFAULT_WORD_BOUNDARY=false # Match whole words only
56
+ readonly DEFAULT_LINE_NUMBERS=false # Show line numbers in output
57
+ readonly DEFAULT_DOT_ALL=false # Dot matches newline (sed 's' flag)
58
+ readonly DEFAULT_GLOBAL_REPLACE=true # Replace all occurrences (global)
59
+
60
+ # ============================================================================
61
+ # TOOL CONFIGURATION - BASE COMMANDS AND DEFAULT FLAGS
62
+ # ============================================================================
63
+
64
+ # Base tool commands - can be overridden if needed
65
+ readonly FIND_TOOL="find"
66
+ readonly SED_TOOL="sed"
67
+ readonly GREP_TOOL="grep"
68
+
69
+ # Default tool flags (can be extended via command-line options)
70
+ readonly DEFAULT_FIND_FLAGS="" # Additional find flags
71
+ readonly DEFAULT_SED_FLAGS="" # Additional sed flags
72
+ readonly DEFAULT_GREP_FLAGS="-F" # Default: Fixed string matching
73
+
74
+ # ============================================================================
75
+ # COLOR SCHEME CUSTOMIZATION
76
+ # ============================================================================
77
+
78
+ readonly COLOR_INFO='\033[0;34m' # Blue
79
+ readonly COLOR_SUCCESS='\033[0;32m' # Green
80
+ readonly COLOR_WARNING='\033[0;33m' # Yellow
81
+ readonly COLOR_ERROR='\033[0;31m' # Red
82
+ readonly COLOR_DEBUG='\033[0;36m' # Cyan
83
+ readonly COLOR_HEADER='\033[0;35m' # Magenta
84
+ readonly COLOR_RESET='\033[0m'
85
+
86
+ # ============================================================================
87
+ # EXCLUSION PATTERNS
88
+ # ============================================================================
89
+
90
+ # File patterns to exclude by default (space-separated)
91
+ readonly DEFAULT_EXCLUDE_PATTERNS=".git .svn .hg .DS_Store *.bak *.backup"
92
+ readonly DEFAULT_EXCLUDE_DIRS="node_modules __pycache__ .cache .idea .vscode"
93
+
94
+ # ============================================================================
95
+ # GLOBAL VARIABLES (Initialized with defaults)
96
+ # ============================================================================
97
+
98
+ # Core variables
99
+ declare -g SED_INPLACE_FLAG="-i"
100
+ declare -g DEBUG_MODE="$DEFAULT_DEBUG_MODE"
101
+ declare -g RECURSIVE_MODE="$DEFAULT_RECURSIVE_MODE"
102
+ declare -g FILE_PATTERN=""
103
+ declare -g SEARCH_STRING=""
104
+ declare -g REPLACE_STRING=""
105
+ declare -gi PROCESSED_FILES=0
106
+ declare -gi MODIFIED_FILES=0
107
+ declare -gi TOTAL_REPLACEMENTS=0
108
+ declare -g DRY_RUN="$DEFAULT_DRY_RUN"
109
+ declare -g CREATE_BACKUPS="$DEFAULT_CREATE_BACKUPS"
110
+ declare -g BACKUP_IN_FOLDER="$DEFAULT_BACKUP_IN_FOLDER"
111
+ declare -g FORCE_BACKUP="$DEFAULT_FORCE_BACKUP"
112
+ declare -g PRESERVE_OWNERSHIP="$DEFAULT_PRESERVE_OWNERSHIP"
113
+ declare -g BACKUP_DIR=""
114
+ declare -g FIRST_FILE_OWNER=""
115
+ declare -g FIRST_FILE_GROUP=""
116
+ declare -g MAX_DEPTH="$DEFAULT_MAX_DEPTH"
117
+ declare -g TIMESTAMP_FORMAT="$DEFAULT_TIMESTAMP_FORMAT"
118
+ declare -g BACKUP_PREFIX="$DEFAULT_BACKUP_PREFIX"
119
+ declare -g TEMP_DIR="$DEFAULT_TEMP_DIR"
120
+ declare -g SED_DELIMITER="$DEFAULT_SED_DELIMITER"
121
+ declare -g SKIP_HIDDEN_FILES="$DEFAULT_SKIP_HIDDEN_FILES"
122
+ declare -g SKIP_BINARY_FILES="$DEFAULT_SKIP_BINARY_FILES"
123
+ declare -gi MAX_FILE_SIZE="$((DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024))"
124
+ declare -g EXCLUDE_PATTERNS="$DEFAULT_EXCLUDE_PATTERNS"
125
+ declare -g EXCLUDE_DIRS="$DEFAULT_EXCLUDE_DIRS"
126
+ declare -g SEARCH_DIR="$DEFAULT_SEARCH_DIR"
127
+ declare -g REPLACE_MODE="$DEFAULT_REPLACE_MODE"
128
+ declare -g OUTPUT_DIR=""
129
+
130
+ # Enhanced functionality variables
131
+ declare -g ALLOW_BINARY="$DEFAULT_ALLOW_BINARY"
132
+ declare -g BINARY_DETECTION_METHOD="$DEFAULT_BINARY_DETECTION_METHOD"
133
+ declare -gi BINARY_CHECK_SIZE="$DEFAULT_BINARY_CHECK_SIZE"
134
+ declare -gi MAX_BACKUPS="$DEFAULT_MAX_BACKUPS"
135
+ declare -g VERBOSE_MODE=false
136
+ declare -g SESSION_ID=""
137
+ declare -g SESSION_START_TIME=""
138
+ declare -ga SESSION_INITIAL_ARGS=()
139
+ declare -ga SESSION_MODIFIED_FILES=()
140
+ declare -gi RESTORED_COUNT=0
141
+
142
+ # ============================================================================
143
+ # ENHANCED SEARCH/REPLACE VARIABLES (NEW IN 6.1.0)
144
+ # ============================================================================
145
+
146
+ declare -g IGNORE_CASE="$DEFAULT_IGNORE_CASE"
147
+ declare -g MULTILINE_MATCH="$DEFAULT_MULTILINE_MATCH"
148
+ declare -g EXTENDED_REGEX="$DEFAULT_EXTENDED_REGEX"
149
+ declare -g WORD_BOUNDARY="$DEFAULT_WORD_BOUNDARY"
150
+ declare -g LINE_NUMBERS="$DEFAULT_LINE_NUMBERS"
151
+ declare -g DOT_ALL="$DEFAULT_DOT_ALL"
152
+ declare -g GLOBAL_REPLACE="$DEFAULT_GLOBAL_REPLACE"
153
+
154
+ # Tool-specific configuration variables
155
+ declare -g FIND_FLAGS="$DEFAULT_FIND_FLAGS"
156
+ declare -g SED_FLAGS="$DEFAULT_SED_FLAGS"
157
+ declare -g GREP_FLAGS="$DEFAULT_GREP_FLAGS"
158
+
159
+ # ============================================================================
160
+ # LOGGING FUNCTIONS
161
+ # ============================================================================
162
+
163
+ log_info() {
164
+ echo -e "${COLOR_INFO}[INFO]${COLOR_RESET} $*"
165
+ }
166
+
167
+ log_success() {
168
+ echo -e "${COLOR_SUCCESS}[SUCCESS]${COLOR_RESET} $*"
169
+ }
170
+
171
+ log_warning() {
172
+ echo -e "${COLOR_WARNING}[WARNING]${COLOR_RESET} $*"
173
+ }
174
+
175
+ log_error() {
176
+ echo -e "${COLOR_ERROR}[ERROR]${COLOR_RESET} $*" >&2
177
+ }
178
+
179
+ log_debug() {
180
+ if [[ "$DEBUG_MODE" == true ]]; then
181
+ echo -e "${COLOR_DEBUG}[DEBUG]${COLOR_RESET} $*" >&2
182
+ fi
183
+ }
184
+
185
+ log_verbose() {
186
+ if [[ "$VERBOSE_MODE" == true ]]; then
187
+ echo -e "${COLOR_INFO}[VERBOSE]${COLOR_RESET} $*"
188
+ fi
189
+ }
190
+
191
+ log_header() {
192
+ echo -e "${COLOR_HEADER}$*${COLOR_RESET}"
193
+ }
194
+
195
+ # ============================================================================
196
+ # ENHANCED UTILITY FUNCTIONS
197
+ # ============================================================================
198
+
199
+ # Generate unique session ID
200
+ generate_session_id() {
201
+ date +"%Y%m%d_%H%M%S_%N"
202
+ }
203
+
204
+ confirm_action_timeout() {
205
+ local prompt="${1:?Prompt required}"
206
+ local default="${2:-n}"
207
+ local timeout="${3:-0}"
208
+
209
+ # Support yes characters in multiple languages
210
+ local yes_chars='yYндδчμźжןมќอყςяﻱゅッзЗึីུྭᴐउᴕัิೆඇෂხขฺஸᴑॄेxნេхХᴅኔିุើሐჯОعوዪтٸzയႤଂଠ०ுஹՀйЙ'
211
+ local no_chars='nNтТхХுೆೇಿݢৎᴗขნिኔოེỶᴞํԛઊാԺюцࢲັིᴌଂೢັනცತีถูხืΧuዘᴧὴܘᴎτ'
212
+
213
+ local full_prompt="$prompt"
214
+ [[ $timeout -gt 0 ]] && full_prompt+=" (timeout ${timeout}s)"
215
+
216
+ local response
217
+ local format_prompt
218
+
219
+ [[ "$default" == "y" ]] && format_prompt="$full_prompt [Y/n]: " || format_prompt="$full_prompt [y/N]: "
220
+
221
+ if [[ $timeout -gt 0 ]]; then
222
+ read -t "$timeout" -p "$format_prompt" -r -n 1 response
223
+
224
+ if [[ $? -ne 0 ]]; then
225
+ echo
226
+ log_debug "Timeout: using default '$default'"
227
+ response="$default"
228
+ fi
229
+ else
230
+ read -p "$format_prompt" -r -n 1 response
231
+ fi
232
+
233
+ echo
234
+ response="${response:-$default}"
235
+
236
+ # Check against multilingual yes/no characters
237
+ if [[ "$yes_chars" == *"$response"* ]]; then
238
+ return 0
239
+ elif [[ "$no_chars" == *"$response"* ]]; then
240
+ return 1
241
+ else
242
+ [[ "$default" == "y" ]] && return 0 || return 1
243
+ fi
244
+ }
245
+
246
+ # Multi-layer binary file detection
247
+ is_binary_file() {
248
+ local file="$1"
249
+ local method="${2:-$BINARY_DETECTION_METHOD}"
250
+
251
+ [[ ! -f "$file" ]] && {
252
+ log_debug "is_binary_file: $file is not a file"
253
+ return 1
254
+ }
255
+ [[ ! -r "$file" ]] && {
256
+ log_debug "is_binary_file: $file is not readable"
257
+ return 1
258
+ }
259
+
260
+ log_debug "Checking if file is binary: $file, method: $method"
261
+
262
+ case "$method" in
263
+ "file_only")
264
+ # Method 1: Use file utility with MIME type
265
+ if command -v file >/dev/null 2>&1; then
266
+ local mime_type
267
+ mime_type=$(file --mime-type "$file" 2>/dev/null | cut -d: -f2 | xargs)
268
+ if [[ "$mime_type" == text/* ]]; then
269
+ return 1 # Not binary
270
+ elif [[ -n "$mime_type" ]]; then
271
+ log_verbose "Binary detected by file utility: $file ($mime_type)"
272
+ return 0 # Binary
273
+ fi
274
+ fi
275
+ ;;
276
+
277
+ "grep_only")
278
+ # Method 2: Use grep -I heuristic (fast and portable)
279
+ if ! head -c "$BINARY_CHECK_SIZE" "$file" 2>/dev/null | grep -qI .; then
280
+ log_verbose "Binary detected by grep heuristic: $file"
281
+ return 0 # Binary
282
+ fi
283
+ ;;
284
+
285
+ "multi_layer" | *)
286
+ # Method 3: Multi-layer detection (default)
287
+ # Layer 1: Quick size check
288
+ if [[ ! -s "$file" ]]; then
289
+ return 1 # Empty files are not binary for our purposes
290
+ fi
291
+
292
+ # Layer 2: Fast grep heuristic (main method)
293
+ if ! head -c "$BINARY_CHECK_SIZE" "$file" 2>/dev/null | grep -qI .; then
294
+ # Layer 3: Verify with file utility if available
295
+ if command -v file >/dev/null 2>&1; then
296
+ local mime_type
297
+ mime_type=$(file --mime-type "$file" 2>/dev/null | cut -d: -f2 | xargs)
298
+ if [[ "$mime_type" == text/* ]]; then
299
+ log_verbose "File utility overrides: $file is text ($mime_type)"
300
+ return 1 # Not binary (file utility says text)
301
+ elif [[ -n "$mime_type" ]]; then
302
+ log_verbose "Binary confirmed by file utility: $file ($mime_type)"
303
+ return 0 # Binary
304
+ fi
305
+ fi
306
+ log_verbose "Binary detected by multi-layer check: $file"
307
+ return 0 # Binary
308
+ fi
309
+ ;;
310
+ esac
311
+
312
+ return 1 # Not binary
313
+ }
314
+
315
+ # Session management
316
+ init_session() {
317
+ SESSION_ID=$(generate_session_id)
318
+ SESSION_START_TIME=$(date +"%Y-%m-%d %H:%M:%S")
319
+
320
+ log_debug "Session initialized: $SESSION_ID"
321
+ }
322
+
323
+ # Track modified file and update metadata immediately
324
+ track_modified_file() {
325
+ local file="$1"
326
+
327
+ # Initialize array if not already done
328
+ if ! declare -p SESSION_MODIFIED_FILES &>/dev/null 2>&1; then
329
+ declare -g SESSION_MODIFIED_FILES=()
330
+ log_debug "Initialized SESSION_MODIFIED_FILES array in track_modified_file"
331
+ fi
332
+
333
+ # Check if file is already tracked
334
+ local found=false
335
+ if [[ ${#SESSION_MODIFIED_FILES[@]} -gt 0 ]]; then
336
+ for existing_file in "${SESSION_MODIFIED_FILES[@]}"; do
337
+ if [[ "$existing_file" == "$file" ]]; then
338
+ found=true
339
+ break
340
+ fi
341
+ done
342
+ fi
343
+
344
+ # Add file to array if not already present
345
+ if [[ "$found" == false ]]; then
346
+ SESSION_MODIFIED_FILES+=("$file")
347
+ log_debug "Tracked modified file: $file (total: ${#SESSION_MODIFIED_FILES[@]})"
348
+ fi
349
+
350
+ # Update backup file list
351
+ if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
352
+ update_backup_filelist
353
+ fi
354
+ }
355
+
356
+ # Update backup file list
357
+ update_backup_filelist() {
358
+ [[ -z "$BACKUP_DIR" ]] && return 0
359
+ [[ ! -d "$BACKUP_DIR" ]] && return 0
360
+
361
+ local filelist_file="$BACKUP_DIR/.sr_modified_files"
362
+ local temp_file
363
+ temp_file=$(mktemp 2>/dev/null || echo "$BACKUP_DIR/.sr_temp_$$")
364
+
365
+ # Write all tracked files to temp file
366
+ if [[ -n "${SESSION_MODIFIED_FILES+set}" ]] && [[ ${#SESSION_MODIFIED_FILES[@]} -gt 0 ]]; then
367
+ for file in "${SESSION_MODIFIED_FILES[@]}"; do
368
+ local relative_path
369
+
370
+ # Get relative path
371
+ if command -v realpath >/dev/null 2>&1; then
372
+ relative_path=$(realpath --relative-to="." "$file" 2>/dev/null || echo "$file")
373
+ else
374
+ if [[ "$file" == /* ]]; then
375
+ relative_path="${file#$(pwd)/}"
376
+ [[ "$relative_path" == "$file" ]] && relative_path=$(basename "$file")
377
+ else
378
+ relative_path="$file"
379
+ fi
380
+ fi
381
+
382
+ # Clean up relative path
383
+ relative_path="${relative_path#./}"
384
+ echo "$relative_path" >>"$temp_file"
385
+ done
386
+ fi
387
+
388
+ # Move temp file to final location
389
+ if [[ -s "$temp_file" ]]; then
390
+ mv "$temp_file" "$filelist_file" 2>/dev/null || cp "$temp_file" "$filelist_file"
391
+ log_debug "Updated backup file list: ${#SESSION_MODIFIED_FILES[@]} files"
392
+ else
393
+ rm -f "$temp_file"
394
+ fi
395
+ }
396
+
397
+ # Save initial session metadata when backup directory is created
398
+ save_initial_session_metadata() {
399
+ local backup_dir="$1"
400
+ SESSION_METADATA_FILE="$backup_dir/.sr_session_metadata"
401
+
402
+ # Build full command
403
+ local full_command="$0"
404
+ for arg in "${SESSION_INITIAL_ARGS[@]}"; do
405
+ if [[ "$arg" =~ [[:space:]\;\&\|\<\>\(\)\{\}\[\]] ]]; then
406
+ full_command+=" \"$arg\""
407
+ else
408
+ full_command+=" $arg"
409
+ fi
410
+ done
411
+
412
+ cat >"$SESSION_METADATA_FILE" <<EOF
413
+ # Session metadata for sr.sh
414
+ SESSION_ID="$SESSION_ID"
415
+ SESSION_START_TIME="$SESSION_START_TIME"
416
+ SESSION_END_TIME=""
417
+ SESSION_COMMAND="$full_command"
418
+ SESSION_PATTERN="$FILE_PATTERN"
419
+ SESSION_SEARCH="$SEARCH_STRING"
420
+ SESSION_REPLACE="$REPLACE_STRING"
421
+ SESSION_RECURSIVE="$RECURSIVE_MODE"
422
+ SESSION_ALLOW_BINARY="$ALLOW_BINARY"
423
+ SESSION_CREATE_BACKUPS="$CREATE_BACKUPS"
424
+ SESSION_DRY_RUN="$DRY_RUN"
425
+ SESSION_IGNORE_CASE="$IGNORE_CASE"
426
+ SESSION_EXTENDED_REGEX="$EXTENDED_REGEX"
427
+ SESSION_WORD_BOUNDARY="$WORD_BOUNDARY"
428
+ SESSION_MULTILINE="$MULTILINE_MATCH"
429
+ SESSION_LINE_NUMBERS="$LINE_NUMBERS"
430
+ SESSION_MODIFIED_COUNT=0
431
+ SESSION_TOTAL_REPLACEMENTS=0
432
+ SESSION_VERSION="$SESSION_VERSION"
433
+ EOF
434
+
435
+ # Create empty modified files list file
436
+ local filelist_file="$backup_dir/.sr_modified_files"
437
+ >"$filelist_file"
438
+
439
+ # Create file with additional information
440
+ local fileinfo_file="$backup_dir/.sr_file_info"
441
+ cat >"$fileinfo_file" <<EOF
442
+ # File information for session $SESSION_ID
443
+ # Generated: $SESSION_START_TIME
444
+ # Command: $full_command
445
+ # Pattern: $FILE_PATTERN
446
+ # Search: $SEARCH_STRING
447
+ # Replace: $REPLACE_STRING
448
+ # Backup directory: $backup_dir
449
+ #
450
+ # Modified files will be listed below as they are processed:
451
+ EOF
452
+
453
+ log_debug "Initial session metadata saved: $SESSION_METADATA_FILE"
454
+ }
455
+
456
+ finalize_session_metadata() {
457
+ [[ -z "$BACKUP_DIR" ]] && return 0
458
+ [[ ! -d "$BACKUP_DIR" ]] && return 0
459
+
460
+ # Build full command
461
+ local full_command="$0"
462
+ for arg in "${SESSION_INITIAL_ARGS[@]}"; do
463
+ if [[ "$arg" =~ [[:space:]\;\&\|\<\>\(\)\{\}\[\]] ]]; then
464
+ full_command+=" \"$arg\""
465
+ else
466
+ full_command+=" $arg"
467
+ fi
468
+ done
469
+
470
+ # Get array size
471
+ local array_size=0
472
+ if [[ -n "${SESSION_MODIFIED_FILES+set}" ]]; then
473
+ array_size=${#SESSION_MODIFIED_FILES[@]}
474
+ log_debug "finalize_session_metadata: SESSION_MODIFIED_FILES has $array_size items"
475
+ else
476
+ log_warning "SESSION_MODIFIED_FILES array is not set, using 0"
477
+ fi
478
+
479
+ # Update session metadata
480
+ cat >"$BACKUP_DIR/.sr_session_metadata" <<EOF
481
+ # Session metadata for sr.sh
482
+ SESSION_ID="$SESSION_ID"
483
+ SESSION_START_TIME="$SESSION_START_TIME"
484
+ SESSION_END_TIME="$(date +"%Y-%m-%d %H:%M:%S")"
485
+ SESSION_COMMAND="$full_command"
486
+ SESSION_PATTERN="$FILE_PATTERN"
487
+ SESSION_SEARCH="$SEARCH_STRING"
488
+ SESSION_REPLACE="$REPLACE_STRING"
489
+ SESSION_RECURSIVE="$RECURSIVE_MODE"
490
+ SESSION_ALLOW_BINARY="$ALLOW_BINARY"
491
+ SESSION_CREATE_BACKUPS="$CREATE_BACKUPS"
492
+ SESSION_DRY_RUN="$DRY_RUN"
493
+ SESSION_IGNORE_CASE="$IGNORE_CASE"
494
+ SESSION_EXTENDED_REGEX="$EXTENDED_REGEX"
495
+ SESSION_WORD_BOUNDARY="$WORD_BOUNDARY"
496
+ SESSION_MULTILINE="$MULTILINE_MATCH"
497
+ SESSION_LINE_NUMBERS="$LINE_NUMBERS"
498
+ SESSION_MODIFIED_COUNT=$array_size
499
+ SESSION_TOTAL_REPLACEMENTS=$TOTAL_REPLACEMENTS
500
+ SESSION_VERSION="$SESSION_VERSION"
501
+ EOF
502
+
503
+ # Update file information
504
+ local fileinfo_file="$BACKUP_DIR/.sr_file_info"
505
+ if [[ -f "$fileinfo_file" ]]; then
506
+ cat >>"$fileinfo_file" <<EOF
507
+
508
+ # Processing completed at: $(date +"%Y-%m-%d %H:%M:%S")
509
+ # Total files modified: $array_size
510
+ # Total replacements made: $TOTAL_REPLACEMENTS
511
+
512
+ # List of all modified files:
513
+ EOF
514
+
515
+ if [[ -n "${SESSION_MODIFIED_FILES+set}" ]] && [[ $array_size -gt 0 ]]; then
516
+ for file in "${SESSION_MODIFIED_FILES[@]}"; do
517
+ local relative_path
518
+
519
+ # Get relative path
520
+ if command -v realpath >/dev/null 2>&1; then
521
+ relative_path=$(realpath --relative-to="." "$file" 2>/dev/null || echo "$file")
522
+ else
523
+ if [[ "$file" == /* ]]; then
524
+ relative_path="${file#$(pwd)/}"
525
+ [[ "$relative_path" == "$file" ]] && relative_path=$(basename "$file")
526
+ else
527
+ relative_path="$file"
528
+ fi
529
+ fi
530
+
531
+ relative_path="${relative_path#./}"
532
+ echo "$relative_path" >>"$fileinfo_file"
533
+ log_debug "Added to .sr_file_info: $relative_path"
534
+ done
535
+ else
536
+ echo "# No files were modified in this session" >>"$fileinfo_file"
537
+ fi
538
+ fi
539
+
540
+ log_debug "Finalized session metadata with $array_size files"
541
+ }
542
+
543
+ # ============================================================================
544
+ # ENHANCED CORE UTILITY FUNCTIONS WITH TOOL FLAGS SUPPORT
545
+ # ============================================================================
546
+
547
+ escape_regex() {
548
+ local string="$1"
549
+ echo "$string" | sed -e 's/[][\/.^$*+?{}|()]/\\&/g'
550
+ }
551
+
552
+ escape_replacement() {
553
+ local string="$1"
554
+ echo "$string" | sed -e 's/[\/&]/\\&/g' -e ':a;N;$!ba;s/\n/\\n/g'
555
+ }
556
+
557
+ # Function to check if a directory exists and is readable
558
+ check_directory() {
559
+ local dir="$1"
560
+ local desc="$2"
561
+
562
+ if [[ ! -d "$dir" ]]; then
563
+ log_error "$desc directory does not exist: $dir"
564
+ return 1
565
+ fi
566
+
567
+ if [[ ! -r "$dir" ]]; then
568
+ log_error "$desc directory is not readable: $dir"
569
+ return 1
570
+ fi
571
+
572
+ if [[ ! -x "$dir" ]]; then
573
+ log_error "$desc directory is not accessible: $dir"
574
+ return 1
575
+ fi
576
+ }
577
+
578
+ # Function to get absolute path
579
+ get_absolute_path() {
580
+ local path="$1"
581
+ if [[ "$path" == /* ]]; then
582
+ echo "$path"
583
+ else
584
+ echo "$(cd "$(dirname "$path")" && pwd)/$(basename "$path")"
585
+ fi
586
+ }
587
+
588
+ # ============================================================================
589
+ # ENHANCED ENVIRONMENT VALIDATION FUNCTION WITH TOOL CHECKING
590
+ # ============================================================================
591
+
592
+ validate_environment() {
593
+ local required_cmds=("$FIND_TOOL" "$SED_TOOL" "$GREP_TOOL")
594
+
595
+ for cmd in "${required_cmds[@]}"; do
596
+ if ! command -v "$cmd" >/dev/null 2>&1; then
597
+ log_error "Required command not found: $cmd"
598
+ exit 1
599
+ fi
600
+ done
601
+
602
+ # Check for file utility (used in binary detection)
603
+ if [[ "$BINARY_DETECTION_METHOD" == "file_only" || "$BINARY_DETECTION_METHOD" == "multi_layer" ]]; then
604
+ if ! command -v file >/dev/null 2>&1; then
605
+ log_warning "file utility not found. Binary detection may be limited."
606
+ log_warning "Consider using --binary-method=grep_only or installing file utility."
607
+ fi
608
+ fi
609
+
610
+ # Detect sed type (GNU vs BSD)
611
+ if sed --version 2>/dev/null | grep -q "GNU"; then
612
+ log_debug "Using GNU sed"
613
+ else
614
+ SED_INPLACE_FLAG="-i ''"
615
+ log_debug "Using BSD sed"
616
+ fi
617
+
618
+ # Validate MAX_DEPTH
619
+ if ! [[ "$MAX_DEPTH" =~ ^[0-9]+$ ]] || [[ "$MAX_DEPTH" -lt 1 ]]; then
620
+ log_warning "Invalid MAX_DEPTH ($MAX_DEPTH), using default: $DEFAULT_MAX_DEPTH"
621
+ MAX_DEPTH="$DEFAULT_MAX_DEPTH"
622
+ fi
623
+
624
+ # Validate TEMP_DIR
625
+ if [[ ! -d "$TEMP_DIR" ]] || [[ ! -w "$TEMP_DIR" ]]; then
626
+ log_warning "Temp directory $TEMP_DIR is not accessible, using /tmp"
627
+ TEMP_DIR="/tmp"
628
+ fi
629
+
630
+ # Validate search directory
631
+ if ! check_directory "$SEARCH_DIR" "Search"; then
632
+ exit 1
633
+ fi
634
+
635
+ # Validate output directory if specified
636
+ if [[ -n "$OUTPUT_DIR" ]]; then
637
+ if [[ ! -d "$OUTPUT_DIR" ]]; then
638
+ log_debug "Creating output directory: $OUTPUT_DIR"
639
+ mkdir -p "$OUTPUT_DIR" || {
640
+ log_error "Failed to create output directory: $OUTPUT_DIR"
641
+ exit 1
642
+ }
643
+ fi
644
+ if ! check_directory "$OUTPUT_DIR" "Output"; then
645
+ exit 1
646
+ fi
647
+ fi
648
+
649
+ # Adjust CREATE_BACKUPS based on replace mode
650
+ if [[ "$REPLACE_MODE" == "backup_only" ]]; then
651
+ CREATE_BACKUPS=true
652
+ FORCE_BACKUP=true
653
+ log_debug "Backup-only mode: backups forced"
654
+ fi
655
+
656
+ # Validate binary check size
657
+ if ! [[ "$BINARY_CHECK_SIZE" =~ ^[0-9]+$ ]] || [[ "$BINARY_CHECK_SIZE" -lt 1 ]]; then
658
+ log_warning "Invalid BINARY_CHECK_SIZE ($BINARY_CHECK_SIZE), using default: $DEFAULT_BINARY_CHECK_SIZE"
659
+ BINARY_CHECK_SIZE="$DEFAULT_BINARY_CHECK_SIZE"
660
+ fi
661
+
662
+ # Validate MAX_BACKUPS
663
+ if ! [[ "$MAX_BACKUPS" =~ ^[0-9]+$ ]] || [[ "$MAX_BACKUPS" -lt 0 ]]; then
664
+ log_warning "Invalid MAX_BACKUPS ($MAX_BACKUPS), using default: $DEFAULT_MAX_BACKUPS"
665
+ MAX_BACKUPS="$DEFAULT_MAX_BACKUPS"
666
+ fi
667
+ }
668
+
669
+ # ============================================================================
670
+ # ENHANCED ROLLBACK SYSTEM
671
+ # ============================================================================
672
+
673
+ # Enhanced rollback functionality with step-by-step debugging
674
+ perform_rollback() {
675
+ local target_backup="${1:-latest}"
676
+ local backup_dirs=()
677
+ local selected_backup=""
678
+ local files_to_restore=()
679
+
680
+ # Handle relative paths
681
+ if [[ ! -d "$target_backup" ]] && [[ "$target_backup" != "latest" ]]; then
682
+ if [[ -d "./$target_backup" ]]; then
683
+ target_backup="./$target_backup"
684
+ log_debug "DEBUG: Adjusted backup path to: '$target_backup'"
685
+ fi
686
+ fi
687
+
688
+ log_header "=== ROLLBACK SYSTEM ==="
689
+ log_debug "DEBUG [1/10]: Function perform_rollback started with arg: '$target_backup'"
690
+
691
+ # Find all backup directories
692
+ log_debug "DEBUG [2/10]: Searching for backup directories with pattern: ${BACKUP_PREFIX}.*"
693
+ while IFS= read -r -d '' dir; do
694
+ backup_dirs+=("$dir")
695
+ log_debug "DEBUG [2/10]: Found backup directory: $dir"
696
+ done < <(find . -maxdepth 1 -type d -name "${BACKUP_PREFIX}.*" -print0 2>/dev/null | sort -zr)
697
+
698
+ log_debug "DEBUG [2/10]: Total backup directories found: ${#backup_dirs[@]}"
699
+
700
+ if [[ ${#backup_dirs[@]} -eq 0 ]]; then
701
+ log_error "No backup directories found"
702
+ return 1
703
+ fi
704
+
705
+ # Select backup
706
+ if [[ "$target_backup" == "latest" ]]; then
707
+ selected_backup="${backup_dirs[0]}"
708
+ log_info "Selected latest backup: $selected_backup"
709
+ log_debug "DEBUG [3/10]: Selected latest backup: $selected_backup"
710
+ else
711
+ # Check if specific backup exists
712
+ if [[ -d "$target_backup" ]]; then
713
+ selected_backup="$target_backup"
714
+ log_info "Selected specified backup: $target_backup"
715
+ else
716
+ log_error "Backup not found: $target_backup"
717
+ log_info "Available backups:"
718
+ for dir in "${backup_dirs[@]}"; do
719
+ echo " $dir"
720
+ done
721
+ return 1
722
+ fi
723
+ fi
724
+
725
+ log_debug "DEBUG [4/10]: Selected backup: $selected_backup"
726
+ log_debug "DEBUG [4/10]: Backup directory exists: $([[ -d "$selected_backup" ]] && echo "YES" || echo "NO")"
727
+
728
+ # Load session metadata
729
+ local session_metadata_file="$selected_backup/.sr_session_metadata"
730
+ local filelist_file="$selected_backup/.sr_modified_files"
731
+ local fileinfo_file="$selected_backup/.sr_file_info"
732
+ files_to_restore=()
733
+
734
+ log_debug "DEBUG [5/10]: Looking for session metadata: $session_metadata_file"
735
+ log_debug "DEBUG [5/10]: Metadata file exists: $([[ -f "$session_metadata_file" ]] && echo "YES" || echo "NO")"
736
+
737
+ if [[ -f "$session_metadata_file" ]]; then
738
+ log_info "Loading session metadata..."
739
+ # Extract info from metadata
740
+ local session_id session_command file_count search_str replace_str
741
+ session_id=$(grep '^SESSION_ID=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"')
742
+ session_command=$(grep '^SESSION_COMMAND=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"')
743
+ file_count=$(grep '^SESSION_MODIFIED_COUNT=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"')
744
+ search_str=$(grep '^SESSION_SEARCH=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"')
745
+ replace_str=$(grep '^SESSION_REPLACE=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"')
746
+
747
+ log_info "Session ID: $session_id"
748
+ log_info "Original command: $session_command"
749
+ log_info "Files in session: ${file_count:-unknown}"
750
+ [[ -n "$search_str" ]] && [[ -n "$replace_str" ]] && log_info "Search: '$search_str' → '$replace_str'"
751
+
752
+ log_debug "DEBUG [5/10]: Metadata loaded: session_id='$session_id', file_count='$file_count'"
753
+ else
754
+ log_warning "No session metadata found, using legacy backup structure"
755
+ fi
756
+
757
+ # Determine which files to restore - PRIORITY 1: file list
758
+ log_debug "DEBUG [6/10]: Looking for file list: $filelist_file"
759
+ log_debug "DEBUG [6/10]: File list exists and readable: $([[ -f "$filelist_file" && -r "$filelist_file" ]] && echo "YES" || echo "NO")"
760
+
761
+ # CRITICAL FIX: Verify both existence AND readability upfront
762
+ if [[ -f "$filelist_file" && -r "$filelist_file" ]]; then
763
+ log_info "Reading modified files list from $filelist_file..."
764
+
765
+ # Create debug log with atomic initialization
766
+ local debug_log="/tmp/sr_rollback_debug_$$.log"
767
+ {
768
+ echo "=== START DEBUG LOG ==="
769
+ echo "Timestamp: $(date -u)"
770
+ echo "File: $filelist_file"
771
+ echo "User: $(whoami)"
772
+ echo "PID: $$"
773
+ echo "Bash version: ${BASH_VERSION}"
774
+ } >"$debug_log"
775
+
776
+ # File diagnostics with error suppression
777
+ {
778
+ echo -e "\n=== FILE INTEGRITY CHECK ==="
779
+ if command -v stat >/dev/null 2>&1; then
780
+ stat --format="Size: %s bytes\nInode: %i\nMode: %a\nUID: %u GID: %g" "$filelist_file" 2>/dev/null || echo "stat command failed"
781
+ else
782
+ echo "stat command not available"
783
+ ls -la "$filelist_file" 2>/dev/null || echo "ls command failed"
784
+ fi
785
+
786
+ if command -v file >/dev/null 2>&1; then
787
+ echo -e "\nFile type: $(file -b "$filelist_file" 2>/dev/null || echo "file check unavailable")"
788
+ fi
789
+
790
+ echo -e "\nFile size: $(wc -c <"$filelist_file" 2>/dev/null || echo "0") bytes"
791
+ echo "Line count: $(wc -l <"$filelist_file" 2>/dev/null || echo "0")"
792
+ } >>"$debug_log" 2>&1
793
+
794
+ # CRITICAL FIX: Robust file reading with multiple fallbacks
795
+ local files_to_restore_local=()
796
+ local read_success=false
797
+ local method_used=""
798
+
799
+ # PRIMARY METHOD: Bash mapfile (most efficient)
800
+ if command -v mapfile &>/dev/null; then
801
+ log_debug "DEBUG [6/10]: METHOD 1 - Trying native mapfile"
802
+ echo -e "\n=== METHOD 1: Native mapfile ===" >>"$debug_log"
803
+ if mapfile -t files_to_restore_local <"$filelist_file" 2>>"$debug_log"; then
804
+ read_success=true
805
+ method_used="mapfile"
806
+ echo "SUCCESS: Read ${#files_to_restore_local[@]} lines via mapfile" >>"$debug_log"
807
+ echo "First 3 lines:" >>"$debug_log"
808
+ for ((i = 0; i < ${#files_to_restore_local[@]} && i < 3; i++)); do
809
+ echo " $((i + 1)): '${files_to_restore_local[$i]}'" >>"$debug_log"
810
+ done
811
+ else
812
+ echo "FAILED: mapfile method failed" >>"$debug_log"
813
+ fi
814
+ fi
815
+
816
+ # FALLBACK 1: POSIX-compliant while-read loop
817
+ if ! $read_success; then
818
+ log_debug "DEBUG [6/10]: METHOD 2 - Fallback to POSIX read loop"
819
+ echo -e "\n=== METHOD 2: POSIX read loop ===" >>"$debug_log"
820
+ files_to_restore_local=()
821
+ local line_count=0
822
+
823
+ while IFS= read -r line || [ -n "$line" ]; do
824
+ files_to_restore_local+=("$line")
825
+ RESTORED_COUNT=$((RESTORED_COUNT + 1))
826
+ if [ $line_count -le 3 ]; then
827
+ echo "Read line $line_count: '$line'" >>"$debug_log"
828
+ fi
829
+ done < <(exec cat "$filelist_file" 2>>"$debug_log") && read_success=true
830
+
831
+ if $read_success; then
832
+ method_used="posix-loop"
833
+ echo "SUCCESS: Read ${#files_to_restore_local[@]} lines via POSIX loop" >>"$debug_log"
834
+ else
835
+ echo "FAILED: POSIX read loop failed" >>"$debug_log"
836
+ fi
837
+ fi
838
+
839
+ # FALLBACK 2: Process substitution with error isolation
840
+ if ! $read_success; then
841
+ log_debug "DEBUG [6/10]: METHOD 3 - Final fallback: process substitution with dd"
842
+ echo -e "\n=== METHOD 3: dd fallback ===" >>"$debug_log"
843
+ files_to_restore_local=()
844
+ local line_count=0
845
+
846
+ while IFS= read -r line || [ -n "$line" ]; do
847
+ files_to_restore_local+=("$line")
848
+ RESTORED_COUNT=$((RESTORED_COUNT + 1))
849
+ if [ $line_count -le 3 ]; then
850
+ echo "Read line $line_count: '$line'" >>"$debug_log"
851
+ fi
852
+ done < <(dd if="$filelist_file" bs=64K count=100 2>>"$debug_log" || echo "dd command failed") && read_success=true
853
+
854
+ if $read_success; then
855
+ method_used="dd-fallback"
856
+ echo "SUCCESS: Read ${#files_to_restore_local[@]} lines via dd fallback" >>"$debug_log"
857
+ else
858
+ echo "FAILED: All read methods failed" >>"$debug_log"
859
+ log_error "CRITICAL: Unable to read file list after 3 attempts"
860
+ fi
861
+ fi
862
+
863
+ # CRITICAL FIX: Centralized line processing with strict validation
864
+ if $read_success && [ ${#files_to_restore_local[@]} -gt 0 ]; then
865
+ echo -e "\n=== LINE PROCESSING ===" >>"$debug_log"
866
+ local valid_count=0
867
+ files_to_restore=() # Reset main array
868
+
869
+ for raw_line in "${files_to_restore_local[@]}"; do
870
+ # Robust whitespace trimming using POSIX parameter expansion
871
+ local trimmed="${raw_line#"${raw_line%%[![:space:]]*}"}"
872
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
873
+
874
+ # Skip empty lines and comments
875
+ if [ -z "$trimmed" ]; then
876
+ echo "SKIPPED: Empty line" >>"$debug_log"
877
+ continue
878
+ fi
879
+
880
+ if [[ "$trimmed" =~ ^[[:space:]]*# ]]; then
881
+ echo "SKIPPED: Comment line: '$trimmed'" >>"$debug_log"
882
+ continue
883
+ fi
884
+
885
+ # Validate path sanity before adding
886
+ if [[ "$trimmed" == /* ]] && [[ "$trimmed" =~ ^[[:print:]]+$ ]]; then
887
+ # Additional checks for dangerous patterns
888
+ if [[ "$trimmed" == *".."* ]] || [[ "$trimmed" == /proc/* ]] || [[ "$trimmed" == /sys/* ]] || [[ "$trimmed" == /dev/* ]]; then
889
+ echo "SKIPPED: Dangerous path pattern: '$trimmed'" >>"$debug_log"
890
+ continue
891
+ fi
892
+
893
+ # Reject paths with control characters
894
+ if [[ "$trimmed" =~ [[:cntrl:]] ]]; then
895
+ echo "SKIPPED: Contains control characters: '$trimmed'" >>"$debug_log"
896
+ continue
897
+ fi
898
+
899
+ files_to_restore+=("$trimmed")
900
+ RESTORED_COUNT=$((RESTORED_COUNT + 1))
901
+ echo "ACCEPTED: '$trimmed'" >>"$debug_log"
902
+ else
903
+ echo "SKIPPED: Invalid path format: '$trimmed'" >>"$debug_log"
904
+ fi
905
+ done
906
+
907
+ echo -e "\n=== PROCESSING RESULTS ===" >>"$debug_log"
908
+ echo "Valid paths found: $valid_count" >>"$debug_log"
909
+ echo "Total paths processed: ${#files_to_restore_local[@]}" >>"$debug_log"
910
+ echo "Method used: $method_used" >>"$debug_log"
911
+ else
912
+ echo "ERROR: All read methods failed for $filelist_file" >>"$debug_log"
913
+ log_error "CRITICAL: Unable to read file list after 3 attempts"
914
+ fi
915
+
916
+ # Final debug summary
917
+ {
918
+ echo -e "\n=== FINAL DEBUG SUMMARY ==="
919
+ echo "Files to restore: ${#files_to_restore[@]}"
920
+ if [ ${#files_to_restore[@]} -gt 0 ]; then
921
+ echo "First 5 files:"
922
+ for ((i = 0; i < ${#files_to_restore[@]} && i < 5; i++)); do
923
+ echo " $((i + 1)): '${files_to_restore[$i]}'"
924
+ done
925
+ if [ ${#files_to_restore[@]} -gt 5 ]; then
926
+ echo " ... (and $((${#files_to_restore[@]} - 5)) more)"
927
+ fi
928
+ else
929
+ echo "WARNING: No valid files found for restoration"
930
+ fi
931
+ } >>"$debug_log"
932
+
933
+ log_info "Found ${#files_to_restore[@]} valid file(s) in file list"
934
+ log_debug "DEBUG [6/10]: File list processing complete. See full debug at: $debug_log"
935
+
936
+ # Show summary of debug log in main log
937
+ log_debug "DEBUG [6/10]: First 15 lines of debug log:"
938
+ head -15 "$debug_log" | while IFS= read -r log_line; do
939
+ log_debug "DEBUG [6/10]: $log_line"
940
+ done
941
+
942
+ else
943
+ log_debug "DEBUG [6/10]: Primary file list unavailable or unreadable"
944
+ # PRIORITY 2: Fallback to fileinfo with same robustness
945
+ if [[ -f "$fileinfo_file" && -r "$fileinfo_file" ]]; then
946
+ log_info "Extracting file list from $fileinfo_file..."
947
+ log_debug "DEBUG [6/10]: Starting to read file info..."
948
+
949
+ # Create debug log for fileinfo processing
950
+ local debug_log_info="/tmp/sr_rollback_debug_info_$$.log"
951
+ {
952
+ echo "=== START FILEINFO DEBUG LOG ==="
953
+ echo "Timestamp: $(date -u)"
954
+ echo "File: $fileinfo_file"
955
+ echo "User: $(whoami)"
956
+ echo "PID: $$"
957
+ echo "Bash version: ${BASH_VERSION}"
958
+ } >"$debug_log_info"
959
+
960
+ {
961
+ echo -e "\n=== FILE INTEGRITY CHECK ==="
962
+ if command -v stat >/dev/null 2>&1; then
963
+ stat --format="Size: %s bytes\nInode: %i\nMode: %a\nUID: %u GID: %g" "$fileinfo_file" 2>/dev/null || echo "stat command failed"
964
+ else
965
+ echo "stat command not available"
966
+ ls -la "$fileinfo_file" 2>/dev/null || echo "ls command failed"
967
+ fi
968
+ } >>"$debug_log_info" 2>&1
969
+
970
+ local in_section=false
971
+ local line_num=0
972
+ files_to_restore=() # Reset array
973
+
974
+ while IFS= read -r line || [ -n "$line" ]; do
975
+ line_num=$((line_num + 1))
976
+ echo "Processing line $line_num: '$line'" >>"$debug_log_info"
977
+
978
+ if [[ "$line" == "# List of all modified files:" ]]; then
979
+ in_section=true
980
+ echo "SECTION START: Found file list section at line $line_num" >>"$debug_log_info"
981
+ continue
982
+ elif [[ "$in_section" == true ]] && [[ "$line" =~ ^[[:space:]]*#$ ]]; then
983
+ # Empty comment line in section - continue
984
+ continue
985
+ elif [[ "$in_section" == true ]] && [[ "$line" =~ ^# ]]; then
986
+ # Non-empty comment line likely indicates end of section
987
+ echo "SECTION END: Comment line suggests end of section at line $line_num" >>"$debug_log_info"
988
+ break
989
+ elif [[ "$in_section" == true ]] && [[ -n "$line" ]]; then
990
+ # Clean up the line
991
+ local clean_line="${line#"${line%%[![:space:]]*}"}" # Trim leading whitespace
992
+ clean_line="${clean_line%"${clean_line##*[![:space:]]}"}" # Trim trailing whitespace
993
+
994
+ if [[ -z "$clean_line" ]]; then
995
+ echo "SKIPPED: Empty after trim at line $line_num" >>"$debug_log_info"
996
+ continue
997
+ fi
998
+
999
+ echo "CANDIDATE: '$clean_line' at line $line_num" >>"$debug_log_info"
1000
+
1001
+ # Validate path format - same robust method as above
1002
+ if [[ "$clean_line" == /* ]] && [[ "$clean_line" =~ ^[[:print:]]+$ ]]; then
1003
+ if [[ "$clean_line" == *".."* ]] || [[ "$clean_line" == /proc/* ]] || [[ "$clean_line" == /sys/* ]] || [[ "$clean_line" == /dev/* ]]; then
1004
+ echo "SKIPPED: Dangerous path pattern at line $line_num: '$clean_line'" >>"$debug_log_info"
1005
+ continue
1006
+ fi
1007
+
1008
+ if [[ "$clean_line" =~ [[:cntrl:]] ]]; then
1009
+ echo "SKIPPED: Contains control characters at line $line_num: '$clean_line'" >>"$debug_log_info"
1010
+ continue
1011
+ fi
1012
+
1013
+ files_to_restore+=("$clean_line")
1014
+ echo "ACCEPTED: '$clean_line' at line $line_num" >>"$debug_log_info"
1015
+ else
1016
+ echo "SKIPPED: Invalid path format at line $line_num: '$clean_line'" >>"$debug_log_info"
1017
+ fi
1018
+ fi
1019
+ done <"$fileinfo_file" 2>>"$debug_log_info"
1020
+
1021
+ # Final summary for fileinfo processing
1022
+ {
1023
+ echo -e "\n=== FILEINFO PROCESSING SUMMARY ==="
1024
+ echo "Files found in section: ${#files_to_restore[@]}"
1025
+ if [ ${#files_to_restore[@]} -gt 0 ]; then
1026
+ echo "First 3 files:"
1027
+ for ((i = 0; i < ${#files_to_restore[@]} && i < 3; i++)); do
1028
+ echo " $((i + 1)): '${files_to_restore[$i]}'"
1029
+ done
1030
+ fi
1031
+ } >>"$debug_log_info"
1032
+
1033
+ log_info "Found ${#files_to_restore[@]} file(s) in file info"
1034
+ log_debug "DEBUG [6/10]: File info processing complete. Debug log: $debug_log_info"
1035
+
1036
+ # Show summary of debug log in main log
1037
+ log_debug "DEBUG [6/10]: First 10 lines of fileinfo debug log:"
1038
+ head -10 "$debug_log_info" | while IFS= read -r log_line; do
1039
+ log_debug "DEBUG [6/10]: $log_line"
1040
+ done
1041
+ else
1042
+ log_debug "DEBUG [6/10]: File info not found or unreadable: $fileinfo_file"
1043
+ log_error "ERROR: Neither file list ($filelist_file) nor file info ($fileinfo_file) is available for restoration"
1044
+ return 1
1045
+ fi
1046
+ fi
1047
+
1048
+ # Final validation and logging
1049
+ if [ ${#files_to_restore[@]} -eq 0 ]; then
1050
+ log_warning "WARNING: No valid files were found for restoration"
1051
+ log_debug "DEBUG [6/10]: files_to_restore array is empty after all processing"
1052
+ else
1053
+ log_debug "DEBUG [6/10]: Files to restore array contents:"
1054
+ for ((i = 0; i < ${#files_to_restore[@]}; i++)); do
1055
+ log_debug "DEBUG [6/10]: [$i] '${files_to_restore[$i]}'"
1056
+ done
1057
+ fi
1058
+
1059
+ # DEBUG [7/10]: Processing find output...
1060
+ log_debug "DEBUG [7/10]: Starting directory scan..."
1061
+
1062
+ # Get absolute path of backup directory
1063
+ local backup_dir_abs
1064
+ backup_dir_abs=$(cd "$selected_backup" && pwd 2>/dev/null || echo "$selected_backup")
1065
+ log_debug "DEBUG [7/10]: Absolute backup path: '$backup_dir_abs'"
1066
+
1067
+ # Create temporary file list
1068
+ local temp_files_list
1069
+ temp_files_list=$(mktemp 2>/dev/null || echo "/tmp/sr_files_list_$$")
1070
+
1071
+ # Find all non-metadata files in backup
1072
+ log_debug "DEBUG [7/10]: Executing: find '$backup_dir_abs' -type f -not -name '.sr_*'"
1073
+ find "$backup_dir_abs" -type f -not -name ".sr_*" 2>/dev/null >"$temp_files_list"
1074
+
1075
+ # Process found files
1076
+ local file_count=0
1077
+ if [[ -s "$temp_files_list" ]]; then
1078
+ file_count=$(wc -l <"$temp_files_list" 2>/dev/null || echo "0")
1079
+ log_debug "DEBUG [7/10]: Found $file_count file(s)"
1080
+
1081
+ # Log all found files in debug mode
1082
+ if [[ "$file_count" -gt 0 ]]; then
1083
+ log_debug "DEBUG [7/10]: All files found:"
1084
+ cat "$temp_files_list" | while IFS= read -r file; do
1085
+ log_debug "DEBUG [7/10]: '$file'"
1086
+ done
1087
+ fi
1088
+ fi
1089
+
1090
+ # Add files from find command to restore list
1091
+ if [[ "$file_count" -gt 0 ]]; then
1092
+ local added=0
1093
+
1094
+ # Create temp file for processed paths
1095
+ local processed_paths_file
1096
+ processed_paths_file=$(mktemp 2>/dev/null || echo "/tmp/sr_processed_$$")
1097
+
1098
+ # Process each file found
1099
+ while IFS= read -r full_path || [[ -n "$full_path" ]]; do
1100
+ # Skip empty lines
1101
+ [[ -z "$full_path" ]] && continue
1102
+
1103
+ log_debug "DEBUG [7/10]: Processing: '$full_path'"
1104
+
1105
+ # Get relative path
1106
+ local relative_path="${full_path#$backup_dir_abs/}"
1107
+
1108
+ # Handle edge cases
1109
+ if [[ "$relative_path" == "$full_path" ]]; then
1110
+ log_debug "DEBUG [7/10]: Conversion failed (not inside backup directory)"
1111
+
1112
+ # Try alternative method
1113
+ if command -v realpath >/dev/null 2>&1; then
1114
+ relative_path=$(realpath --relative-to="." "$full_path" 2>/dev/null || echo "$full_path")
1115
+ fi
1116
+
1117
+ # Clean up path
1118
+ relative_path="${relative_path#$selected_backup/}"
1119
+ log_debug "DEBUG [7/10]: Using alternative: '$relative_path'"
1120
+ fi
1121
+
1122
+ # Normalize path
1123
+ relative_path="${relative_path#./}"
1124
+ [[ "$relative_path" != "/" ]] && relative_path="${relative_path%/}"
1125
+
1126
+ # Process the path if it meets all validation criteria
1127
+ if [[ -n "$relative_path" && "$relative_path" != "$full_path" ]] &&
1128
+ [[ ! "$relative_path" =~ \.sr_ ]] && [[ ! "${relative_path##*/}" =~ ^\.sr_ ]]; then
1129
+
1130
+ # Ensure 'added' variable is numeric before incrementing
1131
+ if [[ ! "$added" =~ ^[0-9]+$ ]]; then
1132
+ # Initialize or reset 'added' if it's not a valid number
1133
+ added=0
1134
+ log_debug "DEBUG [7/10]: Initialized/reset 'added' counter to 0"
1135
+ fi
1136
+
1137
+ # Safely append the path to the file
1138
+ if printf '%s\n' "$relative_path" >>"$processed_paths_file" 2>/dev/null; then
1139
+ # Perform the increment only if 'added' is confirmed to be numeric
1140
+ added=$((added + 1))
1141
+ log_debug "DEBUG [7/10]: [$added] Added: '$relative_path'"
1142
+ else
1143
+ log_error "CRITICAL: Failed to write path '$relative_path' to list file '$processed_paths_file'."
1144
+ return 1
1145
+ fi
1146
+
1147
+ else
1148
+ # Log the specific reason for skipping the path
1149
+ if [[ -z "$relative_path" ]]; then
1150
+ log_debug "DEBUG [7/10]: Skipping empty path."
1151
+ elif [[ "$relative_path" == "$full_path" ]]; then
1152
+ log_debug "DEBUG [7/10]: Skipping path as it did not resolve to a relative path: '$full_path'."
1153
+ elif [[ "$relative_path" =~ \.sr_ ]] || [[ "${relative_path##*/}" =~ ^\.sr_ ]]; then
1154
+ log_debug "DEBUG [7/10]: Skipping metadata path: '$relative_path'."
1155
+ fi
1156
+ fi
1157
+
1158
+ done <"$temp_files_list"
1159
+
1160
+ # Read processed paths and add to array
1161
+ if [[ "$added" -gt 0 ]] && [[ -s "$processed_paths_file" ]]; then
1162
+ log_debug "DEBUG [7/10]: Reading $added processed path(s) from temp file..."
1163
+
1164
+ local processed_count=0
1165
+ while IFS= read -r relative_path || [[ -n "$relative_path" ]]; do
1166
+ # Skip empty lines
1167
+ [[ -z "$relative_path" ]] && continue
1168
+
1169
+ # Validate and add to array
1170
+ if [[ -n "$relative_path" ]]; then
1171
+ files_to_restore+=("$relative_path")
1172
+ # Increment only after successful array operation
1173
+ processed_count=$((processed_count + 1))
1174
+
1175
+ if [[ $processed_count -le 3 ]]; then
1176
+ log_debug "DEBUG [7/10]: [$processed_count] Loaded to array: '$relative_path'"
1177
+ fi
1178
+ fi
1179
+ done <"$processed_paths_file"
1180
+
1181
+ log_info "Found $processed_count file(s) in backup directory"
1182
+ else
1183
+ log_warning "No valid files found in backup directory after processing"
1184
+ fi
1185
+
1186
+ # Clean up temp files
1187
+ rm -f "$processed_paths_file" 2>/dev/null
1188
+
1189
+ else
1190
+ log_warning "No files found in backup directory"
1191
+ fi
1192
+
1193
+ # Clean up temporary files
1194
+ rm -f "$temp_files_list" 2>/dev/null
1195
+
1196
+ # DEBUG [7.5/10]: TRANSITION CHECK - After directory scan
1197
+ log_debug "DEBUG [7.5/10]: TRANSITION CHECK - After directory scan"
1198
+ log_debug "DEBUG [7.5/10]: files_to_restore array status:"
1199
+ log_debug "DEBUG [7.5/10]: Array is set: $([[ -n "${files_to_restore+set}" ]] && echo "YES" || echo "NO")"
1200
+ log_debug "DEBUG [7.5/10]: Array size: ${#files_to_restore[@]}"
1201
+
1202
+ if [[ ${#files_to_restore[@]} -gt 0 ]]; then
1203
+ log_debug "DEBUG [7.5/10]: First 5 files in array:"
1204
+ for i in {0..4}; do
1205
+ [[ $i -lt ${#files_to_restore[@]} ]] &&
1206
+ log_debug "DEBUG [7.5/10]: [$i] '${files_to_restore[$i]}'"
1207
+ done
1208
+ else
1209
+ log_debug "DEBUG [7.5/10]: WARNING: files_to_restore array is empty!"
1210
+
1211
+ # Debug: list backup directory contents
1212
+ log_debug "DEBUG [7.5/10]: Listing backup directory:"
1213
+ find "$selected_backup" -type f 2>/dev/null | head -10 | while read -r f; do
1214
+ log_debug "DEBUG [7.5/10]: $f"
1215
+ done
1216
+ fi
1217
+
1218
+ # Check permissions
1219
+ log_debug "DEBUG [7.5/10]: Current directory write permission: $([[ -w "." ]] && echo "YES" || echo "NO")"
1220
+ log_debug "DEBUG [7.5/10]: Current directory: $(pwd)"
1221
+
1222
+ # Check session modified files
1223
+ if [[ -n "${SESSION_MODIFIED_FILES+set}" ]]; then
1224
+ log_debug "DEBUG [7.5/10]: SESSION_MODIFIED_FILES size: ${#SESSION_MODIFIED_FILES[@]}"
1225
+ else
1226
+ log_debug "DEBUG [7.5/10]: SESSION_MODIFIED_FILES is NOT set"
1227
+ fi
1228
+
1229
+ # DEBUG [8/10]: Verifying backup files...
1230
+ log_debug "DEBUG [8/10]: Verifying backup files..."
1231
+
1232
+ # Ensure files_to_restore array is set
1233
+ if [[ -z "${files_to_restore+set}" ]]; then
1234
+ log_error "CRITICAL: files_to_restore array is not set! Initializing empty array."
1235
+ files_to_restore=()
1236
+ fi
1237
+
1238
+ # Debug array details
1239
+ log_debug "DEBUG [8/10]: Array details:"
1240
+ log_debug "DEBUG [8/10]: Reference: ${!files_to_restore[@]}"
1241
+ log_debug "DEBUG [8/10]: Size: ${#files_to_restore[@]}"
1242
+ log_debug "DEBUG [8/10]: Content (first 5):"
1243
+ for i in {0..4}; do
1244
+ if [[ $i -lt ${#files_to_restore[@]} ]]; then
1245
+ log_debug "DEBUG [8/10]: [$i] = '${files_to_restore[$i]}'"
1246
+ fi
1247
+ done
1248
+
1249
+ if [[ ${#files_to_restore[@]} -eq 0 ]]; then
1250
+ log_warning "WARNING: No files to restore after directory scan"
1251
+
1252
+ # List backup directory contents
1253
+ log_debug "DEBUG [8/10]: Backup directory '$selected_backup' contents:"
1254
+ if [[ -d "$selected_backup" ]]; then
1255
+ find "$selected_backup" -maxdepth 2 -type f 2>/dev/null | while read -r f; do
1256
+ log_debug "DEBUG [8/10]: $f"
1257
+ done
1258
+ else
1259
+ log_debug "DEBUG [8/10]: Backup directory does not exist!"
1260
+ fi
1261
+
1262
+ # Try alternative method to get files
1263
+ log_debug "DEBUG [8/10]: Trying alternative method to get files..."
1264
+
1265
+ # Use find with null terminator
1266
+ local alt_files=()
1267
+ while IFS= read -r -d '' file; do
1268
+ [[ "$file" == *".sr_"* ]] && continue
1269
+ local rel_path="${file#$selected_backup/}"
1270
+ rel_path="${rel_path#./}"
1271
+ [[ -n "$rel_path" ]] && alt_files+=("$rel_path")
1272
+ done < <(find "$selected_backup" -type f -print0 2>/dev/null)
1273
+
1274
+ if [[ ${#alt_files[@]} -gt 0 ]]; then
1275
+ log_debug "DEBUG [8/10]: Alternative method found ${#alt_files[@]} files"
1276
+ files_to_restore=("${alt_files[@]}")
1277
+ else
1278
+ log_error "ERROR: No files could be found to restore"
1279
+ return 1
1280
+ fi
1281
+ fi
1282
+
1283
+ log_debug "DEBUG [8/10]: Will verify ${#files_to_restore[@]} file(s)"
1284
+
1285
+ log_debug "DEBUG [8/10]: Preparing to verify backup files..."
1286
+
1287
+ # Final array validation before processing
1288
+ if [[ -z "${files_to_restore+set}" ]]; then
1289
+ log_error "ERROR: files_to_restore array is not set!"
1290
+ files_to_restore=()
1291
+ fi
1292
+
1293
+ if [[ ${#files_to_restore[@]} -eq 0 ]]; then
1294
+ log_warning "WARNING: No files to restore after processing"
1295
+
1296
+ # Check backup directory contents
1297
+ log_debug "DEBUG [8/10]: Checking backup directory contents..."
1298
+ if [[ -d "$selected_backup" ]]; then
1299
+ find "$selected_backup" -type f 2>/dev/null | head -10 | while read f; do
1300
+ log_debug "DEBUG [8/10]: Found: $f"
1301
+ done
1302
+ fi
1303
+
1304
+ return 1
1305
+ fi
1306
+
1307
+ log_debug "DEBUG [8/10]: Will verify ${#files_to_restore[@]} file(s)"
1308
+ log_debug "DEBUG [8/10]: Final files_to_restore count: ${#files_to_restore[@]}"
1309
+
1310
+ # Verify files exist in backup
1311
+ log_debug "DEBUG [9/10]: Verifying backup files exist..."
1312
+ local existing_files=()
1313
+ local missing_files=()
1314
+
1315
+ for file in "${files_to_restore[@]}"; do
1316
+ local backup_file="$selected_backup/$file"
1317
+ log_debug "DEBUG [9/10]: Checking: $file -> $backup_file"
1318
+
1319
+ # Check if file exists and is readable
1320
+ if [[ -f "$backup_file" ]] && [[ -r "$backup_file" ]]; then
1321
+ existing_files+=("$file")
1322
+ log_debug "DEBUG [9/10]: ✓ File exists and readable"
1323
+ else
1324
+ missing_files+=("$file")
1325
+ log_warning "Backup file not found or not readable: $backup_file"
1326
+ log_debug "DEBUG [9/10]: ✗ File missing or not readable"
1327
+ fi
1328
+ done
1329
+
1330
+ log_debug "DEBUG [9/10]: Verification complete. Existing: ${#existing_files[@]}, Missing: ${#missing_files[@]}"
1331
+
1332
+ if [[ ${#existing_files[@]} -eq 0 ]]; then
1333
+ log_error "No existing files found in backup to restore"
1334
+ return 1
1335
+ fi
1336
+
1337
+ files_to_restore=("${existing_files[@]}")
1338
+
1339
+ # Report missing files
1340
+ if [[ ${#missing_files[@]} -gt 0 ]]; then
1341
+ log_warning "${#missing_files[@]} file(s) not found in backup and will be skipped"
1342
+ fi
1343
+
1344
+ # Confirm rollback
1345
+ log_info "Found ${#files_to_restore[@]} file(s) to restore"
1346
+ log_debug "DEBUG [10/10]: Preparing confirmation prompt..."
1347
+
1348
+ if [[ "$DRY_RUN" != true ]]; then
1349
+ echo ""
1350
+ echo "The following files will be restored from backup:"
1351
+ echo "Backup location: $selected_backup"
1352
+ echo ""
1353
+
1354
+ local display_count=0
1355
+ local total_files=${#files_to_restore[@]}
1356
+ # Validate total_files is numeric
1357
+ if [[ ! "$total_files" =~ ^[0-9]+$ ]]; then
1358
+ total_files=0
1359
+ fi
1360
+
1361
+ local limit=20
1362
+ if [[ $total_files -lt $limit ]]; then
1363
+ limit=$total_files
1364
+ fi
1365
+
1366
+ for ((i = 0; i < limit; i++)); do
1367
+ local file="${files_to_restore[$i]}"
1368
+ local backup_file="$selected_backup/$file"
1369
+ local file_size=""
1370
+
1371
+ # Get file size
1372
+ if [[ -f "$backup_file" ]]; then
1373
+ file_size=$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")
1374
+ # Validate file_size is numeric
1375
+ if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
1376
+ file_size=0
1377
+ fi
1378
+
1379
+ if [[ $file_size -gt 1048576 ]]; then
1380
+ file_size="$((file_size / 1048576)) MB"
1381
+ elif [[ $file_size -gt 1024 ]]; then
1382
+ file_size="$((file_size / 1024)) KB"
1383
+ else
1384
+ file_size="${file_size} bytes"
1385
+ fi
1386
+ else
1387
+ file_size="missing"
1388
+ fi
1389
+
1390
+ # Validate i is numeric before arithmetic
1391
+ if [[ ! "$i" =~ ^[0-9]+$ ]]; then
1392
+ i=0
1393
+ fi
1394
+ printf " %3d. %-60s (%s)\n" $((i + 1)) "$file" "$file_size"
1395
+ display_count=$((display_count + 1))
1396
+ done
1397
+
1398
+ if [[ $total_files -gt 20 ]]; then
1399
+ local remaining=$((total_files - 20))
1400
+ echo " ... and $remaining more file(s)"
1401
+ fi
1402
+
1403
+ echo ""
1404
+ echo "Session information:"
1405
+ [[ -n "$session_id" ]] && echo " Session ID: $session_id"
1406
+ [[ -n "$search_str" ]] && [[ -n "$replace_str" ]] && echo " Search: '$search_str' → '$replace_str'"
1407
+ echo ""
1408
+
1409
+ # Validate missing_files count
1410
+ local missing_count=${#missing_files[@]}
1411
+ if [[ ! "$missing_count" =~ ^[0-9]+$ ]]; then
1412
+ missing_count=0
1413
+ fi
1414
+ if [[ $missing_count -gt 0 ]]; then
1415
+ echo "⚠ Warning: $missing_count file(s) will not be restored (not found in backup)"
1416
+ fi
1417
+
1418
+ echo ""
1419
+ log_debug "DEBUG [10/10]: ABOUT TO PROMPT USER FOR CONFIRMATION"
1420
+ if ! confirm_action_timeout "Continue with rollback?" "n" 30; then
1421
+ log_info "Rollback cancelled by user"
1422
+ return 0
1423
+ fi
1424
+ log_debug "DEBUG [10/10]: User confirmed, proceeding with rollback..."
1425
+ else
1426
+ local dry_run_count=${#files_to_restore[@]}
1427
+ if [[ ! "$dry_run_count" =~ ^[0-9]+$ ]]; then
1428
+ dry_run_count=0
1429
+ fi
1430
+ log_info "[DRY-RUN] Would restore $dry_run_count file(s) from $selected_backup"
1431
+ fi
1432
+
1433
+ # Perform rollback
1434
+ local restored_count=0
1435
+ local skipped_count=0
1436
+ local failed_count=0
1437
+
1438
+ log_info "Starting rollback from $selected_backup..."
1439
+
1440
+ for relative_path in "${files_to_restore[@]}"; do
1441
+ local backup_file="$selected_backup/$relative_path"
1442
+ local original_file="$relative_path"
1443
+
1444
+ log_debug "DEBUG [10/10]: Restoring file: $relative_path"
1445
+
1446
+ if [[ "$DRY_RUN" == true ]]; then
1447
+ log_info "[DRY-RUN] Would restore: $backup_file -> $original_file"
1448
+ restored_count=$((restored_count + 1))
1449
+ else
1450
+ # Create directory if needed
1451
+ local dest_dir
1452
+ dest_dir=$(dirname "$original_file")
1453
+ if [[ ! -d "$dest_dir" ]] && [[ "$dest_dir" != "." ]]; then
1454
+ mkdir -p "$dest_dir" 2>/dev/null || {
1455
+ log_warning "Cannot create directory: $dest_dir"
1456
+ failed_count=$((failed_count + 1))
1457
+ continue
1458
+ }
1459
+ fi
1460
+
1461
+ # Check if target file already exists and handle permissions
1462
+ if [[ -f "$original_file" ]]; then
1463
+ log_debug "Target file already exists: $original_file"
1464
+ log_debug "File permissions: $(ls -la "$original_file" 2>/dev/null | awk 'NR==1')"
1465
+
1466
+ # Check if we can write to the file
1467
+ if [[ ! -w "$original_file" ]]; then
1468
+ log_warning "Cannot write to existing file (permission denied): $original_file"
1469
+
1470
+ # Try to fix permissions if running as root
1471
+ if [[ $(id -u) -eq 0 ]]; then
1472
+ log_debug "Running as root, attempting to fix permissions..."
1473
+
1474
+ # Backup current permissions
1475
+ local current_perms
1476
+ current_perms=$(stat -c "%a" "$original_file" 2>/dev/null || stat -f "%p" "$original_file" 2>/dev/null | sed 's/^[0-9]*//')
1477
+
1478
+ # Try to make writable
1479
+ if chmod +w "$original_file" 2>/dev/null; then
1480
+ log_debug "Made file writable temporarily"
1481
+ # We'll restore permissions after copy
1482
+ local restore_perms=true
1483
+ else
1484
+ log_error "Failed to make file writable: $original_file"
1485
+ failed_count=$((failed_count + 1))
1486
+ continue
1487
+ fi
1488
+ else
1489
+ log_error "Permission denied and not running as root"
1490
+ failed_count=$((failed_count + 1))
1491
+ continue
1492
+ fi
1493
+ fi
1494
+ fi
1495
+
1496
+ # Restore file
1497
+ if cp --preserve=all "$backup_file" "$original_file" 2>/dev/null || cp "$backup_file" "$original_file" 2>/dev/null; then
1498
+ log_success "Restored: $original_file"
1499
+
1500
+ # Extract preserve ownership setting from session metadata
1501
+ local session_preserve_ownership=""
1502
+ if [[ -f "$session_metadata_file" ]]; then
1503
+ # Read from metadata file (fallback to current script setting if not found)
1504
+ session_preserve_ownership=$(grep '^SESSION_PRESERVE_OWNERSHIP=' "$session_metadata_file" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
1505
+ fi
1506
+
1507
+ # Determine if ownership/permissions should be restored
1508
+ local should_restore_perms_ownership="$PRESERVE_OWNERSHIP" # Default to current script setting
1509
+
1510
+ # Override default if session metadata was found and explicitly set
1511
+ if [[ -n "$session_preserve_ownership" ]]; then # Check if variable is not empty
1512
+ if [[ "$session_preserve_ownership" == "false" ]]; then
1513
+ should_restore_perms_ownership="false"
1514
+ log_debug "Session metadata indicated ownership was NOT preserved originally, skipping restore for $original_file"
1515
+ else
1516
+ # If it was "true" or any other non-"false" value, confirm restoration
1517
+ should_restore_perms_ownership="true"
1518
+ log_debug "Session metadata indicated ownership WAS preserved originally, attempting restore for $original_file"
1519
+ fi
1520
+ else
1521
+ # session_preserve_ownership was empty (variable was empty after grep/cut/tr)
1522
+ # Fallback: Use the current script's setting
1523
+ log_debug "Session metadata for ownership missing or empty, using current script setting ($PRESERVE_OWNERSHIP) for $original_file"
1524
+ fi
1525
+
1526
+ # Now perform the restoration based on the determined setting
1527
+ if [[ "$should_restore_perms_ownership" == "true" ]]; then
1528
+ # Get permissions and ownership from the backup file itself
1529
+ local backup_perms backup_owner backup_group
1530
+ backup_perms=$(stat -c "%a" "$backup_file" 2>/dev/null)
1531
+ backup_owner=$(stat -c "%u" "$backup_file" 2>/dev/null)
1532
+ backup_group=$(stat -c "%g" "$backup_file" 2>/dev/null)
1533
+
1534
+ if [[ -n "$backup_perms" ]]; then
1535
+ chmod "$backup_perms" "$original_file" 2>/dev/null &&
1536
+ log_debug "Restored permissions from backup: $backup_perms for $original_file"
1537
+ fi
1538
+ if [[ -n "$backup_owner" ]] && [[ -n "$backup_group" ]]; then
1539
+ chown "$backup_owner:$backup_group" "$original_file" 2>/dev/null &&
1540
+ log_debug "Restored ownership from backup: $backup_owner:$backup_group for $original_file"
1541
+ fi
1542
+ else
1543
+ log_debug "Ownership/permissions restore disabled for $original_file (based on session or current setting)"
1544
+ fi
1545
+
1546
+ restored_count=$((restored_count + 1))
1547
+ else
1548
+ log_warning "Failed to restore: $original_file"
1549
+ failed_count=$((failed_count + 1))
1550
+ fi
1551
+ fi
1552
+ done
1553
+
1554
+ log_info "Rollback completed:"
1555
+ log_info " Successfully restored: $restored_count"
1556
+ log_info " Failed: $failed_count"
1557
+ log_info " Skipped: $skipped_count"
1558
+
1559
+ if [[ $failed_count -gt 0 ]]; then
1560
+ log_warning "Some files failed to restore. Check permissions and try again."
1561
+ fi
1562
+
1563
+ # Cleanup old backups if configured
1564
+ if [[ "$MAX_BACKUPS" -gt 0 ]] && [[ "$DRY_RUN" != true ]]; then
1565
+ cleanup_old_backups
1566
+ fi
1567
+
1568
+ log_debug "DEBUG [10/10]: Rollback function completed successfully"
1569
+ return 0
1570
+ }
1571
+
1572
+ # Cleanup old backups
1573
+ cleanup_old_backups() {
1574
+ local backup_dirs=()
1575
+
1576
+ # Find all backup directories sorted by time (newest first)
1577
+ while IFS= read -r -d '' dir; do
1578
+ backup_dirs+=("$dir")
1579
+ done < <(find . -maxdepth 1 -type d -name "${BACKUP_PREFIX}.*" -print0 2>/dev/null | sort -zr)
1580
+
1581
+ local count=${#backup_dirs[@]}
1582
+
1583
+ if [[ $count -gt $MAX_BACKUPS ]]; then
1584
+ log_info "Cleaning up old backups (keeping $MAX_BACKUPS)..."
1585
+ for ((i = MAX_BACKUPS; i < count; i++)); do
1586
+ local dir_to_remove="${backup_dirs[$i]}"
1587
+ if [[ "$DRY_RUN" == true ]]; then
1588
+ log_info "[DRY-RUN] Would remove: $dir_to_remove"
1589
+ else
1590
+ rm -rf "$dir_to_remove"
1591
+ log_verbose "Removed old backup: $dir_to_remove"
1592
+ fi
1593
+ done
1594
+ fi
1595
+ }
1596
+
1597
+ # List available backups with session info
1598
+ list_backups() {
1599
+ local backup_dirs=()
1600
+
1601
+ log_header "=== AVAILABLE BACKUPS ==="
1602
+
1603
+ # Find all backup directories
1604
+ while IFS= read -r -d '' dir; do
1605
+ backup_dirs+=("$dir")
1606
+ done < <(find . -maxdepth 1 -type d -name "${BACKUP_PREFIX}.*" -print0 2>/dev/null | sort -zr)
1607
+
1608
+ if [[ ${#backup_dirs[@]} -eq 0 ]]; then
1609
+ log_info "No backup directories found"
1610
+ return 0
1611
+ fi
1612
+
1613
+ log_info "Found ${#backup_dirs[@]} backup(s):"
1614
+ echo
1615
+
1616
+ for dir in "${backup_dirs[@]}"; do
1617
+ local file_count=0
1618
+ local size_kb=0
1619
+ local modified_files_count=0
1620
+
1621
+ # Count files and size
1622
+ if [[ -d "$dir" ]]; then
1623
+ file_count=$(find "$dir" -type f -not -name ".sr_*" 2>/dev/null | wc -l)
1624
+ size_kb=$(du -sk "$dir" 2>/dev/null | cut -f1)
1625
+ fi
1626
+
1627
+ # Get session metadata
1628
+ local session_info=""
1629
+ local session_id=""
1630
+ if [[ -f "$dir/.sr_session_metadata" ]]; then
1631
+ session_id=$(grep '^SESSION_ID=' "$dir/.sr_session_metadata" 2>/dev/null | cut -d= -f2 | tr -d '"')
1632
+ local pattern search replace
1633
+ pattern=$(grep '^SESSION_PATTERN=' "$dir/.sr_session_metadata" 2>/dev/null | cut -d= -f2 | tr -d '"')
1634
+ search=$(grep '^SESSION_SEARCH=' "$dir/.sr_session_metadata" 2>/dev/null | cut -d= -f2 | tr -d '"')
1635
+ replace=$(grep '^SESSION_REPLACE=' "$dir/.sr_session_metadata" 2>/dev/null | cut -d= -f2 | tr -d '"')
1636
+ modified_files_count=$(grep '^SESSION_MODIFIED_COUNT=' "$dir/.sr_session_metadata" 2>/dev/null | cut -d= -f2 | tr -d '"')
1637
+
1638
+ session_info="Session: $session_id"
1639
+ [[ -n "$pattern" ]] && session_info+=" | Pattern: $pattern"
1640
+ [[ -n "$search" ]] && session_info+=" | '$search' → '$replace'"
1641
+ [[ -n "$modified_files_count" ]] && session_info+=" | Files: $modified_files_count"
1642
+ fi
1643
+
1644
+ printf " %-40s %6d files %6d KB\n" "$dir" "$file_count" "$size_kb"
1645
+ if [[ -n "$session_info" ]]; then
1646
+ echo " $session_info"
1647
+ fi
1648
+ echo
1649
+ done
1650
+
1651
+ echo
1652
+ log_info "Commands:"
1653
+ log_info " Restore latest backup: $0 --rollback"
1654
+ log_info " Restore specific backup: $0 --rollback=BACKUP_DIR_NAME"
1655
+ log_info " Show this list again: $0 --rollback-list"
1656
+ }
1657
+
1658
+ # ============================================================================
1659
+ # ENHANCED PERFORM_REPLACE FUNCTION WITH TOOL FLAGS SUPPORT
1660
+ # ============================================================================
1661
+
1662
+ # Function to perform the actual search and replace operation with enhanced flags
1663
+ perform_replace() {
1664
+ local file="$1"
1665
+ local search_escaped="$2"
1666
+ local replace_escaped="$3"
1667
+ local timestamp="$4"
1668
+
1669
+ local file_owner file_group file_perms
1670
+ local before_count after_count replacements_in_file=0
1671
+
1672
+ log_debug "perform_replace: Processing file: $file"
1673
+
1674
+ # Check if file exists
1675
+ if [[ ! -f "$file" ]]; then
1676
+ log_error "File not found: $file"
1677
+ return 2
1678
+ fi
1679
+
1680
+ # Check file permissions
1681
+ if [[ ! -r "$file" ]]; then
1682
+ log_error "Cannot read file (permission denied): $file"
1683
+ return 3
1684
+ fi
1685
+
1686
+ # Skip binary files unless explicitly allowed
1687
+ if [[ "$SKIP_BINARY_FILES" == true ]] && is_binary_file "$file"; then
1688
+ if [[ "$ALLOW_BINARY" != true ]]; then
1689
+ log_verbose "Skipping binary file: $file (use --binary to process)"
1690
+ return 5
1691
+ fi
1692
+ fi
1693
+
1694
+ # Count occurrences before replacement with enhanced grep flags
1695
+ before_count=$(count_occurrences "$file" "$SEARCH_STRING")
1696
+ if [[ "$before_count" -eq 0 ]]; then
1697
+ log_debug "Search string not found in file: $file"
1698
+ return 6
1699
+ fi
1700
+
1701
+ # Get file metadata for backup
1702
+ if [[ "$PRESERVE_OWNERSHIP" == true ]]; then
1703
+ file_owner=$(stat -c "%u" "$file" 2>/dev/null)
1704
+ file_group=$(stat -c "%g" "$file" 2>/dev/null)
1705
+ file_perms=$(stat -c "%a" "$file" 2>/dev/null)
1706
+
1707
+ # Store first file owner for backup directory
1708
+ if [[ -z "$FIRST_FILE_OWNER" ]] && [[ -n "$file_owner" ]] && [[ -n "$file_group" ]]; then
1709
+ FIRST_FILE_OWNER="$file_owner"
1710
+ FIRST_FILE_GROUP="$file_group"
1711
+ log_debug "Set first file owner: $FIRST_FILE_OWNER:$FIRST_FILE_GROUP"
1712
+ fi
1713
+ fi
1714
+
1715
+ # Create backup if required
1716
+ if [[ "$CREATE_BACKUPS" == true ]] || [[ "$FORCE_BACKUP" == true ]]; then
1717
+ if ! create_backup "$file" "$timestamp" "$file_owner" "$file_group" "$file_perms"; then
1718
+ log_error "Failed to create backup for: $file"
1719
+ return 4
1720
+ fi
1721
+ fi
1722
+
1723
+ # Skip actual replacement in dry-run mode
1724
+ if [[ "$DRY_RUN" == true ]]; then
1725
+ log_info "[DRY-RUN] Would replace $before_count occurrence(s) in: $file"
1726
+ TOTAL_REPLACEMENTS=$((TOTAL_REPLACEMENTS + before_count))
1727
+ return 0
1728
+ fi
1729
+
1730
+ # Handle different replace modes
1731
+ case "$REPLACE_MODE" in
1732
+ "inplace")
1733
+ # Build enhanced sed command with flags
1734
+ local sed_cmd_flags="$SED_FLAGS"
1735
+
1736
+ # Add extended regex flag if needed
1737
+ if [[ "$EXTENDED_REGEX" == true ]]; then
1738
+ # Check if we're using GNU sed or BSD sed
1739
+ if sed --version 2>/dev/null | grep -q "GNU"; then
1740
+ sed_cmd_flags+=" -r" # GNU sed extended regex
1741
+ else
1742
+ sed_cmd_flags+=" -E" # BSD sed extended regex
1743
+ fi
1744
+ fi
1745
+
1746
+ # Build sed pattern with enhanced flags
1747
+ local sed_pattern="s${SED_DELIMITER}${search_escaped}${SED_DELIMITER}${replace_escaped}${SED_DELIMITER}"
1748
+
1749
+ # Add global replace flag if enabled
1750
+ if [[ "$GLOBAL_REPLACE" == true ]]; then
1751
+ sed_pattern+="g"
1752
+ fi
1753
+
1754
+ # Add ignore case flag if enabled (GNU sed only)
1755
+ if [[ "$IGNORE_CASE" == true ]]; then
1756
+ if sed --version 2>/dev/null | grep -q "GNU"; then
1757
+ sed_pattern+="i"
1758
+ else
1759
+ log_warning "Ignore case (-i) not supported for BSD sed. Consider using GNU sed."
1760
+ fi
1761
+ fi
1762
+
1763
+ # Perform in-place replacement with enhanced flags
1764
+ local sed_command="$SED_TOOL $SED_INPLACE_FLAG $sed_cmd_flags \"$sed_pattern\" \"$file\""
1765
+ log_debug "Executing sed command: $sed_command"
1766
+
1767
+ if eval "$sed_command" 2>/dev/null; then
1768
+ # Count replacements after operation
1769
+ after_count=$(count_occurrences "$file" "$SEARCH_STRING")
1770
+ replacements_in_file=$((before_count - after_count))
1771
+
1772
+ if [[ "$replacements_in_file" -gt 0 ]]; then
1773
+ log_success "Replaced $replacements_in_file occurrence(s) in: $file"
1774
+ TOTAL_REPLACEMENTS=$((TOTAL_REPLACEMENTS + replacements_in_file))
1775
+
1776
+ # Track modified file
1777
+ track_modified_file "$file"
1778
+ return 0
1779
+ else
1780
+ log_warning "No replacements made in file (possible sed error): $file"
1781
+ return 1
1782
+ fi
1783
+ else
1784
+ log_error "sed command failed for file: $file"
1785
+ return 1
1786
+ fi
1787
+ ;;
1788
+
1789
+ "copy")
1790
+ # Copy mode - create modified file in output directory
1791
+ if [[ -z "$OUTPUT_DIR" ]]; then
1792
+ log_error "Output directory not specified for copy mode"
1793
+ return 1
1794
+ fi
1795
+
1796
+ # Build sed command for copy mode
1797
+ local sed_cmd_flags="$SED_FLAGS"
1798
+
1799
+ if [[ "$EXTENDED_REGEX" == true ]]; then
1800
+ if sed --version 2>/dev/null | grep -q "GNU"; then
1801
+ sed_cmd_flags+=" -r"
1802
+ else
1803
+ sed_cmd_flags+=" -E"
1804
+ fi
1805
+ fi
1806
+
1807
+ local sed_pattern="s${SED_DELIMITER}${search_escaped}${SED_DELIMITER}${replace_escaped}${SED_DELIMITER}"
1808
+
1809
+ if [[ "$GLOBAL_REPLACE" == true ]]; then
1810
+ sed_pattern+="g"
1811
+ fi
1812
+
1813
+ if [[ "$IGNORE_CASE" == true ]]; then
1814
+ if sed --version 2>/dev/null | grep -q "GNU"; then
1815
+ sed_pattern+="i"
1816
+ fi
1817
+ fi
1818
+
1819
+ local modified_content
1820
+ modified_content=$($SED_TOOL $sed_cmd_flags "$sed_pattern" "$file" 2>/dev/null)
1821
+
1822
+ if [[ -n "$modified_content" ]]; then
1823
+ if create_output_file "$file" "$SEARCH_DIR" "$OUTPUT_DIR" "$modified_content" >/dev/null; then
1824
+ after_count=$(count_occurrences "$file" "$SEARCH_STRING")
1825
+ replacements_in_file=$((before_count - after_count))
1826
+
1827
+ if [[ "$replacements_in_file" -gt 0 ]]; then
1828
+ log_success "Created modified copy with $replacements_in_file replacement(s): $file"
1829
+ TOTAL_REPLACEMENTS=$((TOTAL_REPLACEMENTS + replacements_in_file))
1830
+ return 0
1831
+ else
1832
+ log_warning "No replacements in copy mode for file: $file"
1833
+ return 6
1834
+ fi
1835
+ else
1836
+ log_error "Failed to create output file: $file"
1837
+ return 1
1838
+ fi
1839
+ else
1840
+ log_error "Failed to modify content for file: $file"
1841
+ return 1
1842
+ fi
1843
+ ;;
1844
+
1845
+ "backup_only")
1846
+ # Backup only mode - just create backup, no replacement
1847
+ log_verbose "Backup created (no replacement): $file"
1848
+ return 0
1849
+ ;;
1850
+
1851
+ *)
1852
+ log_error "Unknown replace mode: $REPLACE_MODE"
1853
+ return 1
1854
+ ;;
1855
+ esac
1856
+ }
1857
+
1858
+ # ============================================================================
1859
+ # ENHANCED COUNT_OCCURRENCES FUNCTION WITH GREP FLAGS SUPPORT
1860
+ # ============================================================================
1861
+
1862
+ # Function to count occurrences with enhanced grep flags
1863
+ count_occurrences() {
1864
+ local file="$1"
1865
+ local pattern="$2"
1866
+ local count=0
1867
+
1868
+ # Build grep command with enhanced flags
1869
+ local grep_cmd="$GREP_TOOL $GREP_FLAGS"
1870
+
1871
+ # Add ignore case flag if enabled
1872
+ if [[ "$IGNORE_CASE" == true ]]; then
1873
+ grep_cmd+=" -i"
1874
+ fi
1875
+
1876
+ # Add extended regex flag if enabled
1877
+ if [[ "$EXTENDED_REGEX" == true ]]; then
1878
+ grep_cmd+=" -E"
1879
+ fi
1880
+
1881
+ # Add word boundary flag if enabled
1882
+ if [[ "$WORD_BOUNDARY" == true ]]; then
1883
+ grep_cmd+=" -w"
1884
+ fi
1885
+
1886
+ # Add line numbers flag if enabled
1887
+ if [[ "$LINE_NUMBERS" == true ]]; then
1888
+ grep_cmd+=" -n"
1889
+ fi
1890
+
1891
+ # Always add count flag for counting
1892
+ grep_cmd+=" -c"
1893
+
1894
+ # Use -a flag to treat binary files as text
1895
+ grep_cmd+=" -a"
1896
+
1897
+ log_debug "Counting occurrences with command: $grep_cmd \"$pattern\" \"$file\""
1898
+
1899
+ if count=$(eval "$grep_cmd \"\$pattern\" \"\$file\" 2>/dev/null"); then
1900
+ echo "$count"
1901
+ else
1902
+ echo "0"
1903
+ fi
1904
+ }
1905
+
1906
+ # ============================================================================
1907
+ # ENHANCED USAGE AND HELP FUNCTIONS
1908
+ # ============================================================================
1909
+
1910
+ show_usage() {
1911
+ cat <<EOF
1912
+ Usage: ${0##*/} [OPTIONS] <file_pattern> <search_string> <replace_string>
1913
+
1914
+ Search and replace text in files recursively with proper escaping of special characters.
1915
+
1916
+ IMPORTANT: Options must come BEFORE positional arguments for predictable parsing.
1917
+
1918
+ Positional arguments (required, in this order):
1919
+ <file_pattern> File pattern to match (e.g., *.html, *.txt)
1920
+ <search_string> Text to search for
1921
+ <replace_string> Text to replace with
1922
+
1923
+ Options (must come before positional arguments):
1924
+ Core functionality:
1925
+ -d, --debug Enable debug output
1926
+ -nr, --no-recursive Process only current directory (non-recursive)
1927
+ --binary Allow processing of binary files (REQUIRED for binary files)
1928
+ -v, --verbose Enable verbose output (less detailed than debug)
1929
+ --rollback[=BACKUP_DIR] Restore from backup (latest or specified)
1930
+ --rollback-list List available backups with session info
1931
+
1932
+ Search/replace enhancements (NEW in 6.1.0):
1933
+ -i, --ignore-case Case-insensitive search and replace
1934
+ -E, --extended-regex Use extended regular expressions (ERE)
1935
+ -w, --word-boundary Match whole words only (word boundaries)
1936
+ -m, --multiline Enable multi-line mode for regex
1937
+ -n, --line-numbers Show line numbers in debug output
1938
+ --dot-all Dot matches newline (sed 's' flag)
1939
+ --no-global Replace only first occurrence in each line (not global)
1940
+
1941
+ Tool-specific options (NEW in 6.1.0):
1942
+ --find-opts="FLAGS" Additional flags to pass to find command
1943
+ --sed-opts="FLAGS" Additional flags to pass to sed command
1944
+ --grep-opts="FLAGS" Additional flags to pass to grep command
1945
+
1946
+ Backup control:
1947
+ -nb, --no-backup Do not create backup files
1948
+ -fb, --force-backup Force backup creation even if disabled
1949
+ -nbf, --no-backup-folder Create backup files in same directory as original
1950
+
1951
+ Safety features:
1952
+ --binary-method=METHOD Binary detection method: multi_layer, file_only, grep_only
1953
+ --binary-check-size=N Bytes to check for binary detection (default: $DEFAULT_BINARY_CHECK_SIZE)
1954
+ --no-binary-skip DEPRECATED: Use --binary instead
1955
+ --max-backups=N Keep only N latest backups (default: $DEFAULT_MAX_BACKUPS)
1956
+
1957
+ Advanced options:
1958
+ -md, --max-depth NUM Maximum directory depth for recursive search
1959
+ -dry-run, --dry-run Show what would be changed without making modifications
1960
+ -no-preserve, --no-preserve-ownership Do not attempt to preserve file ownership
1961
+ -delim, --delimiter CHAR Use custom delimiter for sed
1962
+ -e, --encoding ENC File encoding
1963
+ -xh, --exclude-hidden Exclude hidden files and directories
1964
+ -xb, --exclude-binary DEPRECATED: Binary files are always excluded unless --binary is used
1965
+ -xs, --max-size MB Maximum file size in MB
1966
+ -xp, --exclude-patterns Exclude file patterns (space-separated)
1967
+ -xd, --exclude-dirs Exclude directory names (space-separated)
1968
+ -sd, --search-dir DIR Directory to search in
1969
+ -od, --output-dir DIR Directory to save modified files (instead of in-place)
1970
+ -mode, --replace-mode MODE Replacement mode: inplace, copy, or backup_only
1971
+
1972
+ Information:
1973
+ -h, --help Show this help message and exit
1974
+ -V, --version Show version information
1975
+
1976
+ Examples (note option order):
1977
+ ${0##*/} -v "*.html" "old text" "new text"
1978
+ ${0##*/} --binary "*.bin" "foo" "bar"
1979
+ ${0##*/} --rollback
1980
+ ${0##*/} --rollback="sr.backup.20231215_143022"
1981
+ ${0##*/} -nr --verbose "*.txt" "find" "replace"
1982
+ ${0##*/} --dry-run --binary-method=multi_layer "*.html" "search" "replace"
1983
+ ${0##*/} -i -E --find-opts="-type f -name" "*.txt" "search" "replace" # Enhanced example
1984
+
1985
+ Tool flag examples (NEW):
1986
+ ${0##*/} --find-opts="-type f -mtime -7" "*.log" "error" "warning"
1987
+ ${0##*/} --sed-opts="-e 's/foo/bar/' -e 's/baz/qux/'" "*.txt" "find" "replace"
1988
+ ${0##*/} --grep-opts="-v '^#'" "*.conf" "port" "8080"
1989
+
1990
+ Safety notes:
1991
+ - Binary files are SKIPPED by default for safety
1992
+ - Use --binary flag to explicitly allow binary file processing
1993
+ - Backups are created by default (use -nb to disable)
1994
+ - Use --dry-run to test before making changes
1995
+ - Use --rollback to restore from backup if something goes wrong
1996
+ - Each session creates a backup directory with metadata and file list
1997
+
1998
+ Session tracking:
1999
+ - Each run creates a unique session ID
2000
+ - All modified files are tracked in the session
2001
+ - Rollback restores ALL files modified in that session
2002
+ - Session metadata includes command, pattern, search/replace strings
2003
+
2004
+ Predictable parsing:
2005
+ Options -> Pattern -> Search -> Replace (in this exact order)
2006
+ EOF
2007
+ }
2008
+
2009
+ show_help() {
2010
+ show_usage
2011
+ cat <<EOF
2012
+
2013
+ Enhanced Features in v6.1.0:
2014
+ 1. Multi-layer binary file detection (grep + file utility)
2015
+ 2. Session-based rollback system with backup management
2016
+ 3. Predictable argument parsing (options before positional args)
2017
+ 4. Enhanced safety: binary files require explicit --binary flag
2018
+ 5. Real-time file tracking for reliable rollback
2019
+ 6. Extended search/replace options: ignore case, word boundaries, extended regex
2020
+ 7. Tool-specific parameter passing: --find-opts, --sed-opts, --grep-opts
2021
+ 8. Configurable tool commands and default flags
2022
+ 9. Improved compatibility with GNU/BSD sed variations
2023
+
2024
+ Binary Detection Methods:
2025
+ multi_layer (default): Use grep -I heuristic, verify with file utility
2026
+ file_only: Rely only on file --mime-type command
2027
+ grep_only: Use only grep -I heuristic (fastest)
2028
+
2029
+ Search/Replace Enhancements:
2030
+ -i, --ignore-case: Case-insensitive matching (grep -i, sed 'i' flag for GNU sed)
2031
+ -E, --extended-regex: Use extended regular expressions (grep -E, sed -r/-E)
2032
+ -w, --word-boundary: Match whole words only (grep -w)
2033
+ -m, --multiline: Multi-line mode for regex (affects sed pattern matching)
2034
+ -n, --line-numbers: Show line numbers in output (grep -n)
2035
+ --dot-all: Dot matches newline in regex (sed 's' flag)
2036
+ --no-global: Replace only first occurrence per line (disable 'g' flag)
2037
+
2038
+ Tool Configuration:
2039
+ Base tools can be configured via variables at script top:
2040
+ FIND_TOOL, SED_TOOL, GREP_TOOL - tool commands
2041
+ FIND_FLAGS, SED_FLAGS, GREP_FLAGS - default flags
2042
+
2043
+ Command-line overrides:
2044
+ --find-opts: Additional flags for find command
2045
+ --sed-opts: Additional flags for sed command
2046
+ --grep-opts: Additional flags for grep command
2047
+
2048
+ Rollback System:
2049
+ - Automatically creates metadata for each session
2050
+ - Tracks files in real-time during processing
2051
+ - List backups with --rollback-list
2052
+ - Restore with --rollback (latest) or --rollback=BACKUP_DIR
2053
+ - Automatic cleanup of old backups with --max-backups
2054
+
2055
+ Exit Codes:
2056
+ 0 - Success
2057
+ 1 - Invalid arguments or insufficient permissions
2058
+ 2 - No files found matching the pattern
2059
+ 3 - Search string not found in any files
2060
+ 4 - Critical error during processing
2061
+ 5 - Backup creation failed
2062
+ 6 - Binary file detected without --binary flag
2063
+ 7 - Rollback failed
2064
+
2065
+ Configuration:
2066
+ Default settings can be modified in the CONFIGURABLE DEFAULTS section
2067
+ Environment variables override script defaults
2068
+ Command-line options override both defaults and environment variables
2069
+
2070
+ Environment Variables (override defaults):
2071
+ SR_DEBUG Set to 'true' to enable debug mode
2072
+ SR_DRY_RUN Set to 'true' to enable dry-run mode
2073
+ SR_NO_BACKUP Set to 'true' to disable backups
2074
+ SR_FORCE_BACKUP Set to 'true' to force backup creation
2075
+ SR_MAX_DEPTH Maximum directory depth
2076
+ SR_MAX_FILE_SIZE_MB Maximum file size in MB
2077
+ SR_EXCLUDE_PATTERNS Space-separated exclude patterns
2078
+ SR_EXCLUDE_DIRS Space-separated exclude directories
2079
+ SR_SEARCH_DIR Directory to search in
2080
+ SR_OUTPUT_DIR Directory for output files
2081
+ SR_REPLACE_MODE Replacement mode
2082
+ SR_ALLOW_BINARY Set to 'true' to allow binary file processing
2083
+ SR_BINARY_METHOD Binary detection method
2084
+ SR_BINARY_CHECK_SIZE Bytes to check for binary detection
2085
+ SR_MAX_BACKUPS Maximum number of backups to keep
2086
+ SR_VERBOSE Set to 'true' for verbose output
2087
+
2088
+ # New in 6.1.0:
2089
+ SR_IGNORE_CASE Set to 'true' for case-insensitive search
2090
+ SR_EXTENDED_REGEX Set to 'true' for extended regex
2091
+ SR_WORD_BOUNDARY Set to 'true' for word boundary matching
2092
+ SR_MULTILINE Set to 'true' for multi-line mode
2093
+ SR_LINE_NUMBERS Set to 'true' to show line numbers
2094
+ SR_DOT_ALL Set to 'true' for dot matches newline
2095
+ SR_GLOBAL_REPLACE Set to 'false' to disable global replace
2096
+ SR_FIND_FLAGS Additional flags for find command
2097
+ SR_SED_FLAGS Additional flags for sed command
2098
+ SR_GREP_FLAGS Additional flags for grep command
2099
+
2100
+ Compatibility Notes:
2101
+ - Some features (ignore case in sed) require GNU sed
2102
+ - Extended regex syntax varies between GNU and BSD sed
2103
+ - Word boundary matching may differ between grep and sed
2104
+ - Tool-specific flags are passed directly; validate compatibility
2105
+
2106
+ Performance Tips:
2107
+ - Use --no-global for faster processing when only first match per line needed
2108
+ - Use --binary-method=grep_only for fastest binary detection
2109
+ - Use --max-depth to limit recursive search
2110
+ - Use --max-size to skip large files
2111
+ EOF
2112
+ }
2113
+
2114
+ show_version() {
2115
+ cat <<EOF
2116
+ ${0##*/} - Search and Replace Tool
2117
+ Version 6.1.0 (Enterprise Enhanced Edition)
2118
+ Professional text replacement utility with safety enhancements
2119
+
2120
+ New in v6.1.0:
2121
+ - Enhanced configuration: Tool commands and flags as variables
2122
+ - Extended search options: Ignore case, word boundaries, extended regex
2123
+ - Tool parameter passing: Direct flag passing to find/sed/grep
2124
+ - Improved compatibility: Better GNU/BSD sed handling
2125
+ - Enhanced documentation: Complete tool flag reference
2126
+ - Performance: Optimized flag handling and execution
2127
+
2128
+ New in v6.0.0:
2129
+ - Fixed: Missing perform_replace function added
2130
+ - Fixed: IGNORE_CASE variable reference removed
2131
+ - Fixed: File discovery now properly returns multiple files
2132
+ - Fixed: Array initialization issue resolved
2133
+ - Fixed: Count occurrences now works with special characters like +
2134
+ - Enhanced: Better handling of file paths with spaces
2135
+ - Improved: Session metadata includes complete command line
2136
+
2137
+ Core Features:
2138
+ - Professional argument parsing with 40+ options
2139
+ - Configurable defaults in script header
2140
+ - Force backup option to override defaults
2141
+ - Proper special character handling with custom delimiters
2142
+ - Configurable backup options (folder-based or in-place)
2143
+ - Detailed logging and statistics with color coding
2144
+ - Dry-run mode for safe testing
2145
+ - File timestamps preserved when no changes made
2146
+ - Preserves file ownership and permissions (configurable)
2147
+ - Exclude patterns and directories with wildcard support
2148
+ - Maximum depth and file size limits
2149
+ - Separate search and output directories
2150
+ - Multiple replacement modes: inplace, copy, backup_only
2151
+ - Support for multiple encodings
2152
+ - GNU/BSD sed compatibility detection
2153
+ - Session tracking for reliable rollbacks
2154
+ - Multi-layer binary file detection
2155
+ - Tool-specific parameter passing
2156
+ - Extended regex and search options
2157
+ EOF
2158
+ }
2159
+
2160
+ # ============================================================================
2161
+ # COMPREHENSIVE ARGUMENT PARSING WITH ENHANCED OPTIONS
2162
+ # ============================================================================
2163
+
2164
+ parse_arguments() {
2165
+ local args=()
2166
+ local parsing_options=true
2167
+ local rollback_target=""
2168
+
2169
+ # Store original arguments for session metadata
2170
+ SESSION_INITIAL_ARGS=("$@")
2171
+
2172
+ # Special handling for help and version before full parsing
2173
+ for arg in "$@"; do
2174
+ case "$arg" in
2175
+ -h | --help | help)
2176
+ show_help
2177
+ exit 0
2178
+ ;;
2179
+ -V | --version | version)
2180
+ show_version
2181
+ exit 0
2182
+ ;;
2183
+ --rollback-list)
2184
+ list_backups
2185
+ exit 0
2186
+ ;;
2187
+ esac
2188
+ done
2189
+
2190
+ # Parse all arguments
2191
+ while [[ $# -gt 0 ]]; do
2192
+ case "$1" in
2193
+ # Options that take arguments
2194
+ -md | --max-depth)
2195
+ if [[ $# -gt 1 && "${2}" =~ ^[0-9]+$ ]]; then
2196
+ MAX_DEPTH="$2"
2197
+ log_debug "Maximum depth set to: $MAX_DEPTH"
2198
+ shift 2
2199
+ else
2200
+ log_error "Missing or invalid value for --max-depth"
2201
+ exit 1
2202
+ fi
2203
+ ;;
2204
+
2205
+ --rollback)
2206
+ if [[ $# -gt 1 && "${2}" != -* && ! "$2" =~ ^-- ]]; then
2207
+ rollback_target="$2"
2208
+ shift 2
2209
+ else
2210
+ rollback_target="latest"
2211
+ shift
2212
+ fi
2213
+ ;;
2214
+
2215
+ --rollback=*)
2216
+ rollback_target="${1#*=}"
2217
+ shift
2218
+ ;;
2219
+
2220
+ --binary-method)
2221
+ if [[ $# -gt 1 && -n "$2" ]]; then
2222
+ BINARY_DETECTION_METHOD="$2"
2223
+ log_debug "Binary detection method: $BINARY_DETECTION_METHOD"
2224
+ shift 2
2225
+ else
2226
+ log_error "Missing value for --binary-method"
2227
+ exit 1
2228
+ fi
2229
+ ;;
2230
+
2231
+ --binary-check-size)
2232
+ if [[ $# -gt 1 && "${2}" =~ ^[0-9]+$ ]]; then
2233
+ BINARY_CHECK_SIZE="$2"
2234
+ log_debug "Binary check size: $BINARY_CHECK_SIZE bytes"
2235
+ shift 2
2236
+ else
2237
+ log_error "Missing or invalid value for --binary-check-size"
2238
+ exit 1
2239
+ fi
2240
+ ;;
2241
+
2242
+ --max-backups)
2243
+ if [[ $# -gt 1 && "${2}" =~ ^[0-9]+$ ]]; then
2244
+ MAX_BACKUPS="$2"
2245
+ log_debug "Maximum backups to keep: $MAX_BACKUPS"
2246
+ shift 2
2247
+ else
2248
+ log_error "Missing or invalid value for --max-backups"
2249
+ exit 1
2250
+ fi
2251
+ ;;
2252
+
2253
+ # Tool-specific options (NEW in 6.1.0)
2254
+ --find-opts)
2255
+ if [[ $# -gt 1 && -n "$2" ]]; then
2256
+ FIND_FLAGS="$2"
2257
+ log_debug "Find flags set to: $FIND_FLAGS"
2258
+ shift 2
2259
+ else
2260
+ log_error "Missing value for --find-opts"
2261
+ exit 1
2262
+ fi
2263
+ ;;
2264
+
2265
+ --sed-opts)
2266
+ if [[ $# -gt 1 && -n "$2" ]]; then
2267
+ SED_FLAGS="$2"
2268
+ log_debug "Sed flags set to: $SED_FLAGS"
2269
+ shift 2
2270
+ else
2271
+ log_error "Missing value for --sed-opts"
2272
+ exit 1
2273
+ fi
2274
+ ;;
2275
+
2276
+ --grep-opts)
2277
+ if [[ $# -gt 1 && -n "$2" ]]; then
2278
+ GREP_FLAGS="$2"
2279
+ log_debug "Grep flags set to: $GREP_FLAGS"
2280
+ shift 2
2281
+ else
2282
+ log_error "Missing value for --grep-opts"
2283
+ exit 1
2284
+ fi
2285
+ ;;
2286
+
2287
+ # Search/replace enhancement options (NEW in 6.1.0)
2288
+ -i | --ignore-case)
2289
+ IGNORE_CASE=true
2290
+ log_debug "Case-insensitive search enabled"
2291
+ shift
2292
+ ;;
2293
+
2294
+ -E | --extended-regex)
2295
+ EXTENDED_REGEX=true
2296
+ log_debug "Extended regular expressions enabled"
2297
+ shift
2298
+ ;;
2299
+
2300
+ -w | --word-boundary)
2301
+ WORD_BOUNDARY=true
2302
+ log_debug "Word boundary matching enabled"
2303
+ shift
2304
+ ;;
2305
+
2306
+ -m | --multiline)
2307
+ MULTILINE_MATCH=true
2308
+ log_debug "Multi-line mode enabled"
2309
+ shift
2310
+ ;;
2311
+
2312
+ -n | --line-numbers)
2313
+ LINE_NUMBERS=true
2314
+ log_debug "Line numbers enabled"
2315
+ shift
2316
+ ;;
2317
+
2318
+ --dot-all)
2319
+ DOT_ALL=true
2320
+ log_debug "Dot matches newline enabled"
2321
+ shift
2322
+ ;;
2323
+
2324
+ --no-global)
2325
+ GLOBAL_REPLACE=false
2326
+ log_debug "Global replace disabled (replace first occurrence only)"
2327
+ shift
2328
+ ;;
2329
+
2330
+ # Standard options
2331
+ -d | --debug)
2332
+ DEBUG_MODE=true
2333
+ VERBOSE_MODE=true # Debug implies verbose
2334
+ log_debug "Debug mode enabled"
2335
+ shift
2336
+ ;;
2337
+
2338
+ -v | --verbose)
2339
+ VERBOSE_MODE=true
2340
+ log_debug "Verbose mode enabled"
2341
+ shift
2342
+ ;;
2343
+
2344
+ --binary)
2345
+ ALLOW_BINARY=true
2346
+ SKIP_BINARY_FILES=false # Override old behavior
2347
+ log_debug "Binary file processing enabled (explicit flag)"
2348
+ shift
2349
+ ;;
2350
+
2351
+ -nr | --no-recursive)
2352
+ RECURSIVE_MODE=false
2353
+ log_debug "Non-recursive mode enabled"
2354
+ shift
2355
+ ;;
2356
+
2357
+ -dry-run | --dry-run)
2358
+ DRY_RUN=true
2359
+ log_debug "Dry-run mode enabled"
2360
+ shift
2361
+ ;;
2362
+
2363
+ -nb | --no-backup)
2364
+ CREATE_BACKUPS=false
2365
+ log_debug "Backup creation disabled"
2366
+ shift
2367
+ ;;
2368
+
2369
+ -fb | --force-backup)
2370
+ FORCE_BACKUP=true
2371
+ log_debug "Force backup enabled"
2372
+ shift
2373
+ ;;
2374
+
2375
+ -nbf | --no-backup-folder)
2376
+ BACKUP_IN_FOLDER=false
2377
+ log_debug "Backup in same folder enabled"
2378
+ shift
2379
+ ;;
2380
+
2381
+ -no-preserve | --no-preserve-ownership)
2382
+ PRESERVE_OWNERSHIP=false
2383
+ log_debug "File ownership preservation disabled"
2384
+ shift
2385
+ ;;
2386
+
2387
+ -delim | --delimiter)
2388
+ if [[ $# -gt 1 && -n "$2" ]]; then
2389
+ SED_DELIMITER="$2"
2390
+ log_debug "Custom sed delimiter: $SED_DELIMITER"
2391
+ shift 2
2392
+ else
2393
+ log_error "Missing delimiter character"
2394
+ exit 1
2395
+ fi
2396
+ ;;
2397
+
2398
+ -e | --encoding)
2399
+ if [[ $# -gt 1 && -n "$2" ]]; then
2400
+ ENCODING="$2"
2401
+ log_debug "File encoding: $ENCODING"
2402
+ shift 2
2403
+ else
2404
+ log_error "Missing encoding specification"
2405
+ exit 1
2406
+ fi
2407
+ ;;
2408
+
2409
+ -xh | --exclude-hidden)
2410
+ SKIP_HIDDEN_FILES=true
2411
+ log_debug "Excluding hidden files"
2412
+ shift
2413
+ ;;
2414
+
2415
+ -xb | --exclude-binary)
2416
+ log_warning "-xb/--exclude-binary is deprecated. Binary files are now skipped by default."
2417
+ log_warning "Use --binary flag to explicitly allow binary file processing."
2418
+ SKIP_BINARY_FILES=true
2419
+ shift
2420
+ ;;
2421
+
2422
+ --no-binary-skip)
2423
+ log_warning "--no-binary-skip is deprecated. Use --binary instead."
2424
+ ALLOW_BINARY=true
2425
+ SKIP_BINARY_FILES=false
2426
+ shift
2427
+ ;;
2428
+
2429
+ -xs | --max-size)
2430
+ if [[ $# -gt 1 && "${2}" =~ ^[0-9]+$ ]]; then
2431
+ MAX_FILE_SIZE=$(($2 * 1024 * 1024))
2432
+ log_debug "Maximum file size: $2 MB"
2433
+ shift 2
2434
+ else
2435
+ log_error "Missing or invalid value for --max-size"
2436
+ exit 1
2437
+ fi
2438
+ ;;
2439
+
2440
+ -xp | --exclude-patterns)
2441
+ if [[ $# -gt 1 && -n "$2" ]]; then
2442
+ EXCLUDE_PATTERNS="$2"
2443
+ log_debug "Exclude patterns: $EXCLUDE_PATTERNS"
2444
+ shift 2
2445
+ else
2446
+ log_error "Missing exclude patterns"
2447
+ exit 1
2448
+ fi
2449
+ ;;
2450
+
2451
+ -xd | --exclude-dirs)
2452
+ if [[ $# -gt 1 && -n "$2" ]]; then
2453
+ EXCLUDE_DIRS="$2"
2454
+ log_debug "Exclude directories: $EXCLUDE_DIRS"
2455
+ shift 2
2456
+ else
2457
+ log_error "Missing exclude directories"
2458
+ exit 1
2459
+ fi
2460
+ ;;
2461
+
2462
+ -sd | --search-dir)
2463
+ if [[ $# -gt 1 && -n "$2" ]]; then
2464
+ SEARCH_DIR="$2"
2465
+ log_debug "Search directory: $SEARCH_DIR"
2466
+ shift 2
2467
+ else
2468
+ log_error "Missing search directory"
2469
+ exit 1
2470
+ fi
2471
+ ;;
2472
+
2473
+ -od | --output-dir)
2474
+ if [[ $# -gt 1 && -n "$2" ]]; then
2475
+ OUTPUT_DIR="$2"
2476
+ log_debug "Output directory: $OUTPUT_DIR"
2477
+ shift 2
2478
+ else
2479
+ log_error "Missing output directory"
2480
+ exit 1
2481
+ fi
2482
+ ;;
2483
+
2484
+ -mode | --replace-mode)
2485
+ if [[ $# -gt 1 && -n "$2" ]]; then
2486
+ REPLACE_MODE="$2"
2487
+ log_debug "Replace mode: $REPLACE_MODE"
2488
+ shift 2
2489
+ else
2490
+ log_error "Missing replace mode"
2491
+ exit 1
2492
+ fi
2493
+ ;;
2494
+
2495
+ --)
2496
+ shift
2497
+ # Everything after -- is treated as positional
2498
+ while [[ $# -gt 0 ]]; do
2499
+ args+=("$1")
2500
+ shift
2501
+ done
2502
+ break
2503
+ ;;
2504
+
2505
+ -*)
2506
+ # Unknown option
2507
+ log_error "Unknown option: $1"
2508
+ log_error "Options must come before positional arguments"
2509
+ show_usage
2510
+ exit 1
2511
+ ;;
2512
+
2513
+ *)
2514
+ # Positional argument - start collecting
2515
+ args+=("$1")
2516
+ shift
2517
+ # After first positional, stop parsing options
2518
+ parsing_options=false
2519
+ # Collect remaining as positional
2520
+ while [[ $# -gt 0 ]]; do
2521
+ args+=("$1")
2522
+ shift
2523
+ done
2524
+ break
2525
+ ;;
2526
+ esac
2527
+ done
2528
+
2529
+ # Handle rollback before regular operation
2530
+ if [[ -n "$rollback_target" ]]; then
2531
+ perform_rollback "$rollback_target"
2532
+ exit $?
2533
+ fi
2534
+
2535
+ # ============================================================================
2536
+ # UNIVERSAL ARGUMENT PARSING - ENHANCED FOR ALL USE CASES
2537
+ # ============================================================================
2538
+
2539
+ # This section handles all possible argument patterns:
2540
+ # 1. Standard 3-arg: pattern search replace
2541
+ # 2. Standard 2-arg: search replace (default pattern "*.*")
2542
+ # 3. Expanded shell patterns: pattern was expanded by shell, last two are search/replace
2543
+ # 4. Edge cases with mixed quoting and shell expansion
2544
+
2545
+ # Store original argument count for diagnostics
2546
+ local arg_count=${#args[@]}
2547
+ log_debug "Raw positional arguments received: $arg_count"
2548
+ log_debug "Original command line: $0 ${SESSION_INITIAL_ARGS[*]}"
2549
+ log_debug "Current directory: $(pwd)"
2550
+ log_debug "Shell expansion test - what does '*.html' expand to here: $(echo *.html)"
2551
+
2552
+ if [[ "$DEBUG_MODE" == true ]]; then
2553
+ for ((i = 0; i < arg_count; i++)); do
2554
+ log_debug " args[$i] = '${args[$i]}'"
2555
+ done
2556
+ # Check what files exist in current directory
2557
+ log_debug "Files in current directory matching *.html:"
2558
+ for file in *.html; do
2559
+ [[ -f "$file" ]] && log_debug " - $file"
2560
+ done
2561
+ fi
2562
+
2563
+ # Minimal validation
2564
+ if [[ $arg_count -lt 2 ]]; then
2565
+ log_error "Insufficient arguments. You must provide at least search and replace strings."
2566
+ log_error "Examples:"
2567
+ log_error " 3 arguments: ${0##*/} \"*.html\" \"search\" \"replace\""
2568
+ log_error " 2 arguments: ${0##*/} \"search\" \"replace\" (uses default pattern: *.*)"
2569
+ exit 1
2570
+ fi
2571
+
2572
+ # Function to detect if a string looks like a glob pattern
2573
+ is_glob_pattern() {
2574
+ local str="$1"
2575
+ [[ "$str" == *"*"* || "$str" == *"?"* || "$str" == *"["* || "$str" == *"]"* ]]
2576
+ }
2577
+
2578
+ # Function to detect if a string looks like a file path
2579
+ looks_like_filepath() {
2580
+ local str="$1"
2581
+ # Check for file extension pattern (something.something) without path separator
2582
+ [[ "$str" =~ ^[^/]*\.[a-zA-Z0-9]{1,10}$ ]] || [[ "$str" == *"/"* ]]
2583
+ }
2584
+
2585
+ # Function to check if argument looks like a search string (not a file)
2586
+ looks_like_search_string() {
2587
+ local str="$1"
2588
+ # If it contains glob patterns, it's not a pure search string
2589
+ if is_glob_pattern "$str"; then
2590
+ return 1
2591
+ fi
2592
+ # If it looks like a file path, it's not a search string
2593
+ if looks_like_filepath "$str"; then
2594
+ return 1
2595
+ fi
2596
+ # Otherwise, it's likely a search string
2597
+ return 0
2598
+ }
2599
+
2600
+ # DEBUG: Print detailed analysis
2601
+ log_debug "=== ARGUMENT ANALYSIS ==="
2602
+ log_debug "Total arguments: $arg_count"
2603
+ log_debug "Argument breakdown:"
2604
+ for ((i = 0; i < arg_count; i++)); do
2605
+ local arg="${args[$i]}"
2606
+ local type="unknown"
2607
+ if [[ $i -eq 0 ]]; then
2608
+ if is_glob_pattern "$arg"; then
2609
+ type="GLOB_PATTERN"
2610
+ elif looks_like_filepath "$arg"; then
2611
+ type="FILE_PATH"
2612
+ elif looks_like_search_string "$arg"; then
2613
+ type="SEARCH_STRING"
2614
+ fi
2615
+ elif [[ $i -eq $((arg_count - 2)) ]] || [[ $i -eq $((arg_count - 1)) ]]; then
2616
+ if looks_like_search_string "$arg"; then
2617
+ type="SEARCH/REPLACE"
2618
+ elif looks_like_filepath "$arg"; then
2619
+ type="FILE_PATH (possible search with dots)"
2620
+ fi
2621
+ else
2622
+ if looks_like_filepath "$arg"; then
2623
+ type="FILE_PATH (middle)"
2624
+ else
2625
+ type="UNKNOWN (middle)"
2626
+ fi
2627
+ fi
2628
+ log_debug " [$i] '$arg' - $type"
2629
+ done
2630
+
2631
+ # NEW APPROACH: Determine if shell expanded glob pattern
2632
+ # If we have more than 3 arguments and first argument is a simple filename (not glob)
2633
+ # but the last two look like search strings, then shell likely expanded a glob
2634
+ local shell_expanded=0
2635
+ local detected_pattern=""
2636
+
2637
+ # ============================================================================
2638
+ # ENHANCED ARGUMENT PARSING WITH SHELL EXPANSION DETECTION
2639
+ # ============================================================================
2640
+
2641
+ # Declare global variable for file list from shell expansion
2642
+ declare -g FILES_LIST=()
2643
+
2644
+ if [[ $arg_count -gt 3 ]]; then
2645
+ log_debug "=== SHELL EXPANSION ANALYSIS START ==="
2646
+ log_debug "Received $arg_count arguments (expected 2-3 for normal operation)"
2647
+ log_debug "First argument '${args[0]}' analysis:"
2648
+ log_debug " Is glob pattern: $(is_glob_pattern "${args[0]}" && echo "YES" || echo "NO")"
2649
+ log_debug " Looks like filepath: $(looks_like_filepath "${args[0]}" && echo "YES" || echo "NO")"
2650
+ log_debug " File exists: $([[ -f "${args[0]}" ]] && echo "YES" || echo "NO")"
2651
+ log_debug " Is directory: $([[ -d "${args[0]}" ]] && echo "YES" || echo "NO")"
2652
+
2653
+ log_debug "Last two arguments analysis:"
2654
+ log_debug " arg[$((arg_count - 2))]='${args[-2]}' - Looks like search: $(looks_like_search_string "${args[-2]}" && echo "YES" || echo "NO")"
2655
+ log_debug " arg[$((arg_count - 1))]='${args[-1]}' - Looks like search: $(looks_like_search_string "${args[-1]}" && echo "YES" || echo "NO")"
2656
+
2657
+ # Check if arguments (except last two) are files
2658
+ local all_are_files=1
2659
+ local non_file_args=() # Initialize array to avoid unbound variable error
2660
+
2661
+ log_debug "Checking if arguments 0..$((arg_count - 3)) are files:"
2662
+ for ((i = 0; i < arg_count - 2; i++)); do
2663
+ local arg="${args[$i]}"
2664
+ if [[ -f "$arg" ]]; then
2665
+ log_debug " [$i] '$arg' - EXISTS as file"
2666
+ FILES_LIST+=("$arg")
2667
+ elif [[ -e "$arg" ]]; then
2668
+ log_debug " [$i] '$arg' - EXISTS but not a regular file"
2669
+ all_are_files=0
2670
+ non_file_args+=("$arg")
2671
+ elif is_glob_pattern "$arg"; then
2672
+ log_debug " [$i] '$arg' - GLOB pattern (would be expanded by shell)"
2673
+ # Check if this pattern expands to existing files
2674
+ local expanded_files=()
2675
+ for expanded in $arg; do
2676
+ [[ -f "$expanded" ]] && expanded_files+=("$expanded")
2677
+ done
2678
+ if [[ ${#expanded_files[@]} -gt 0 ]]; then
2679
+ log_debug " Expands to ${#expanded_files[@]} file(s): ${expanded_files[*]}"
2680
+ FILES_LIST+=("${expanded_files[@]}")
2681
+ else
2682
+ log_debug " Does not expand to any existing files"
2683
+ all_are_files=0
2684
+ non_file_args+=("$arg")
2685
+ fi
2686
+ else
2687
+ log_debug " [$i] '$arg' - NOT a file or pattern"
2688
+ all_are_files=0
2689
+ non_file_args+=("$arg")
2690
+ fi
2691
+ done
2692
+
2693
+ log_debug "Shell expansion analysis results:"
2694
+ log_debug " All arguments (except last 2) are files: $([[ $all_are_files -eq 1 ]] && echo "YES" || echo "NO")"
2695
+ log_debug " FILES_LIST contains ${#FILES_LIST[@]} file(s)"
2696
+ log_debug " Non-file arguments: ${non_file_args[*]:-}" # Use :- to avoid error if array is empty
2697
+
2698
+ # DECISION LOGIC
2699
+ if [[ $all_are_files -eq 1 ]] || [[ ${#FILES_LIST[@]} -gt 0 ]]; then
2700
+ # Case 1: Shell expanded a pattern or explicit file list
2701
+ if [[ ${#FILES_LIST[@]} -gt 0 ]]; then
2702
+ FILE_PATTERN="" # Pattern not used when explicit file list is provided
2703
+ SEARCH_STRING="${args[-2]}"
2704
+ REPLACE_STRING="${args[-1]}"
2705
+
2706
+ log_info "SHELL EXPANSION DETECTED: Processing ${#FILES_LIST[@]} file(s) from command line"
2707
+ log_debug "Files to process:"
2708
+ for ((i = 0; i < ${#FILES_LIST[@]}; i++)); do
2709
+ log_debug " [$i] ${FILES_LIST[$i]}"
2710
+ done
2711
+
2712
+ # If all files have the same extension, guess the original pattern
2713
+ if [[ $all_are_files -eq 1 ]] && [[ ${#FILES_LIST[@]} -ge 2 ]]; then
2714
+ local first_ext="${FILES_LIST[0]##*.}"
2715
+ local same_ext=true
2716
+ for file in "${FILES_LIST[@]}"; do
2717
+ if [[ "${file##*.}" != "$first_ext" ]]; then
2718
+ same_ext=false
2719
+ break
2720
+ fi
2721
+ done
2722
+
2723
+ if [[ "$same_ext" == true ]]; then
2724
+ local guessed_pattern="*.${first_ext}"
2725
+ log_debug "All files have .$first_ext extension, guessing original pattern: $guessed_pattern"
2726
+ log_info "Hint: To avoid shell expansion, use quotes:"
2727
+ log_info " $0 \"$guessed_pattern\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2728
+ fi
2729
+ fi
2730
+ else
2731
+ # Case 2: There are non-file arguments among the first args
2732
+ log_warning "Mixed arguments detected. Assuming:"
2733
+ log_warning " Pattern: '${args[0]}'"
2734
+ log_warning " Search: '${args[-2]}'"
2735
+ log_warning " Replace: '${args[-1]}'"
2736
+ log_warning ""
2737
+ log_warning "If this is incorrect, please use one of these formats:"
2738
+ log_warning " For pattern matching: $0 \"PATTERN\" \"SEARCH\" \"REPLACE\""
2739
+ log_warning " For specific files: $0 -- FILE1 FILE2 ... \"SEARCH\" \"REPLACE\""
2740
+
2741
+ FILE_PATTERN="${args[0]}"
2742
+ SEARCH_STRING="${args[-2]}"
2743
+ REPLACE_STRING="${args[-1]}"
2744
+ fi
2745
+ else
2746
+ # Case 3: Could not determine - likely an error
2747
+ log_error "Cannot determine argument format. Multiple possibilities:"
2748
+ log_error " 1. Pattern '${args[0]}' with extra arguments"
2749
+ log_error " 2. Multiple files but some don't exist: ${non_file_args[*]:-}"
2750
+ log_error " 3. Invalid arguments"
2751
+ log_error ""
2752
+ log_error "Please use one of these formats:"
2753
+ log_error " Pattern mode: $0 \"*.html\" \"search\" \"replace\""
2754
+ log_error " File list mode: $0 -- file1.html file2.html \"search\" \"replace\""
2755
+ log_error ""
2756
+ log_error "Note: Use '--' to separate options from file list"
2757
+ exit 1
2758
+ fi
2759
+
2760
+ log_debug "=== SHELL EXPANSION ANALYSIS END ==="
2761
+ log_debug "Final decision:"
2762
+ log_debug " FILE_PATTERN: '$FILE_PATTERN'"
2763
+ log_debug " FILES_LIST: ${#FILES_LIST[@]} items"
2764
+ log_debug " SEARCH_STRING: '$SEARCH_STRING'"
2765
+ log_debug " REPLACE_STRING: '$REPLACE_STRING'"
2766
+
2767
+ # Case 1: Standard 2-argument call (search, replace)
2768
+ elif [[ $arg_count -eq 2 ]]; then
2769
+ FILE_PATTERN="*.*"
2770
+ SEARCH_STRING="${args[0]}"
2771
+ REPLACE_STRING="${args[1]}"
2772
+
2773
+ log_debug "Detected 2-argument syntax: using default pattern='$FILE_PATTERN'"
2774
+
2775
+ # Case 2: Standard 3-argument call (pattern, search, replace)
2776
+ elif [[ $arg_count -eq 3 ]]; then
2777
+ FILE_PATTERN="${args[0]}"
2778
+ SEARCH_STRING="${args[1]}"
2779
+ REPLACE_STRING="${args[2]}"
2780
+
2781
+ log_debug "Detected 3-argument syntax: pattern='$FILE_PATTERN'"
2782
+
2783
+ else
2784
+ # Less than 2 arguments - error
2785
+ log_error "Insufficient arguments. You must provide at least search and replace strings."
2786
+ log_error "Examples:"
2787
+ log_error " 3 arguments: ${0##*/} \"*.html\" \"search\" \"replace\""
2788
+ log_error " 2 arguments: ${0##*/} \"search\" \"replace\" (uses default pattern: *.*)"
2789
+ exit 1
2790
+ fi
2791
+
2792
+ # ============================================================================
2793
+ # ADDITIONAL DEBUGGING AND VALIDATION
2794
+ # ============================================================================
2795
+
2796
+ log_debug "=== FINAL ARGUMENT VALIDATION ==="
2797
+
2798
+ # Check that search string is not empty
2799
+ if [[ -z "$SEARCH_STRING" ]]; then
2800
+ log_error "Search string cannot be empty"
2801
+ exit 1
2802
+ fi
2803
+
2804
+ # If we have FILES_LIST, then FILE_PATTERN should be empty
2805
+ if [[ ${#FILES_LIST[@]} -gt 0 ]] && [[ -n "$FILE_PATTERN" ]]; then
2806
+ log_debug "WARNING: Both FILES_LIST (${#FILES_LIST[@]} files) and FILE_PATTERN ('$FILE_PATTERN') are set"
2807
+ log_debug "Using FILES_LIST, ignoring FILE_PATTERN"
2808
+ FILE_PATTERN=""
2809
+ fi
2810
+
2811
+ # If FILE_PATTERN contains wildcards, check if it's quoted
2812
+ if is_glob_pattern "$FILE_PATTERN" && [[ ${#FILES_LIST[@]} -eq 0 ]]; then
2813
+ log_debug "Pattern '$FILE_PATTERN' contains wildcards"
2814
+
2815
+ # Check if the pattern is expanded by the shell
2816
+ local expanded_count=0
2817
+ for file in $FILE_PATTERN; do
2818
+ [[ -f "$file" ]] && expanded_count=$((expanded_count + 1))
2819
+ done
2820
+
2821
+ if [[ $expanded_count -gt 0 ]]; then
2822
+ log_warning "WARNING: Pattern '$FILE_PATTERN' expands to $expanded_count file(s) via shell"
2823
+ log_warning "To process all matching files reliably, use quotes:"
2824
+ log_warning " $0 \"$FILE_PATTERN\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2825
+ fi
2826
+ fi
2827
+
2828
+ # Debug information about the final decision
2829
+ log_info "Final configuration:"
2830
+ if [[ ${#FILES_LIST[@]} -gt 0 ]]; then
2831
+ log_info " Processing mode: EXPLICIT FILE LIST"
2832
+ log_info " Files to process: ${#FILES_LIST[@]} file(s)"
2833
+ if [[ "$VERBOSE_MODE" == true ]]; then
2834
+ for ((i = 0; i < ${#FILES_LIST[@]} && i < 5; i++)); do
2835
+ log_info " - ${FILES_LIST[$i]}"
2836
+ done
2837
+ [[ ${#FILES_LIST[@]} -gt 5 ]] && log_info " ... and $((${#FILES_LIST[@]} - 5)) more"
2838
+ fi
2839
+ else
2840
+ log_info " Processing mode: PATTERN MATCHING"
2841
+ log_info " File pattern: $FILE_PATTERN"
2842
+ fi
2843
+ log_info " Search string: $SEARCH_STRING"
2844
+ log_info " Replace string: $REPLACE_STRING"
2845
+
2846
+ # DEBUG: Show what the pattern will match
2847
+ if [[ "$DEBUG_MODE" == true ]]; then
2848
+ log_debug "Pattern '$FILE_PATTERN' will match:"
2849
+ for file in $FILE_PATTERN; do
2850
+ [[ -f "$file" ]] && log_debug " - $file"
2851
+ done
2852
+ # Also try find command
2853
+ log_debug "Using find to locate files:"
2854
+ find . -maxdepth 1 -name "$FILE_PATTERN" 2>/dev/null | while read -r file; do
2855
+ log_debug " - $file"
2856
+ done
2857
+ fi
2858
+
2859
+ # Check if pattern contains wildcards
2860
+ if is_glob_pattern "$FILE_PATTERN" && [[ ${#FILES_LIST[@]} -eq 0 ]]; then
2861
+ # Test if pattern without wildcards exists
2862
+ local clean_pattern="${FILE_PATTERN//\*/}"
2863
+ clean_pattern="${clean_pattern//\?/}"
2864
+ clean_pattern="${clean_pattern//\[/}"
2865
+ clean_pattern="${clean_pattern//\]/}"
2866
+
2867
+ if [[ -e "$clean_pattern" ]] && [[ "$clean_pattern" != "$FILE_PATTERN" ]]; then
2868
+ log_warning "Pattern '$FILE_PATTERN' contains wildcards, but '$clean_pattern' exists."
2869
+ log_warning "If shell expands this pattern, only '$clean_pattern' will be processed."
2870
+ log_warning "To process multiple files, quote the pattern:"
2871
+ log_warning " $0 \"$FILE_PATTERN\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2872
+ fi
2873
+
2874
+ # Check if we're likely processing only one file due to shell expansion
2875
+ local match_count=0
2876
+ for file in $FILE_PATTERN; do
2877
+ [[ -f "$file" ]] && match_count=$((match_count + 1))
2878
+ done
2879
+
2880
+ if [[ $match_count -le 1 ]] && [[ "$FILE_PATTERN" == *"*"* ]]; then
2881
+ log_warning "Pattern '$FILE_PATTERN' matches only $match_count file(s)."
2882
+ log_warning "Expected more files. Did shell expand the pattern?"
2883
+ log_warning "Try with quotes: $0 \"$FILE_PATTERN\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2884
+ fi
2885
+ fi
2886
+
2887
+ # Special warning for phone number patterns that look like file paths
2888
+ if [[ "$SEARCH_STRING" =~ \.[0-9]+ ]] || [[ "$REPLACE_STRING" =~ \.[0-9]+ ]]; then
2889
+ log_debug "Note: Search/replace strings contain dots followed by numbers (phone number format)"
2890
+ fi
2891
+
2892
+ # Check if pattern contains spaces (needs quotes)
2893
+ if [[ "$FILE_PATTERN" == *" "* ]] && [[ $arg_count -gt 3 ]]; then
2894
+ log_warning "Pattern contains spaces. For reliable parsing, use quotes:"
2895
+ log_warning " $0 \"$FILE_PATTERN\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2896
+ fi
2897
+
2898
+ # Check for empty search string
2899
+ if [[ -z "$SEARCH_STRING" ]]; then
2900
+ log_error "Search string cannot be empty"
2901
+ exit 1
2902
+ fi
2903
+
2904
+ # Final recommendations
2905
+ log_debug "=== PARSING COMPLETE ==="
2906
+ log_debug "Pattern: '$FILE_PATTERN'"
2907
+ log_debug "Search: '$SEARCH_STRING'"
2908
+ log_debug "Replace: '$REPLACE_STRING'"
2909
+ log_debug "Total args: $arg_count"
2910
+
2911
+ # Show warning about quoting for glob patterns
2912
+ if is_glob_pattern "$FILE_PATTERN" && [[ ${#FILES_LIST[@]} -eq 0 ]]; then
2913
+ log_info "Note: For glob patterns like '$FILE_PATTERN', always use quotes to prevent shell expansion."
2914
+ log_info " Correct syntax: $0 \"$FILE_PATTERN\" \"$SEARCH_STRING\" \"$REPLACE_STRING\""
2915
+ fi
2916
+
2917
+ # Apply environment variable overrides (after command line parsing)
2918
+ [[ "${SR_DEBUG:-}" == "true" ]] && DEBUG_MODE=true
2919
+ [[ "${SR_DRY_RUN:-}" == "true" ]] && DRY_RUN=true
2920
+ [[ "${SR_NO_BACKUP:-}" == "true" ]] && CREATE_BACKUPS=false
2921
+ [[ "${SR_FORCE_BACKUP:-}" == "true" ]] && FORCE_BACKUP=true
2922
+ [[ -n "${SR_MAX_DEPTH:-}" ]] && MAX_DEPTH="${SR_MAX_DEPTH}"
2923
+ [[ -n "${SR_MAX_FILE_SIZE_MB:-}" ]] && MAX_FILE_SIZE=$((SR_MAX_FILE_SIZE_MB * 1024 * 1024))
2924
+ [[ -n "${SR_EXCLUDE_PATTERNS:-}" ]] && EXCLUDE_PATTERNS="${SR_EXCLUDE_PATTERNS}"
2925
+ [[ -n "${SR_EXCLUDE_DIRS:-}" ]] && EXCLUDE_DIRS="${SR_EXCLUDE_DIRS}"
2926
+ [[ -n "${SR_SEARCH_DIR:-}" ]] && SEARCH_DIR="${SR_SEARCH_DIR}"
2927
+ [[ -n "${SR_OUTPUT_DIR:-}" ]] && OUTPUT_DIR="${SR_OUTPUT_DIR}"
2928
+ [[ -n "${SR_REPLACE_MODE:-}" ]] && REPLACE_MODE="${SR_REPLACE_MODE}"
2929
+ [[ -n "${SR_ALLOW_BINARY:-}" ]] && ALLOW_BINARY="${SR_ALLOW_BINARY}"
2930
+ [[ -n "${SR_BINARY_METHOD:-}" ]] && BINARY_DETECTION_METHOD="${SR_BINARY_METHOD}"
2931
+ [[ -n "${SR_BINARY_CHECK_SIZE:-}" ]] && BINARY_CHECK_SIZE="${SR_BINARY_CHECK_SIZE}"
2932
+ [[ -n "${SR_MAX_BACKUPS:-}" ]] && MAX_BACKUPS="${SR_MAX_BACKUPS}"
2933
+ [[ -n "${SR_VERBOSE:-}" ]] && VERBOSE_MODE="${SR_VERBOSE}"
2934
+
2935
+ # New environment variables in 6.1.0
2936
+ [[ -n "${SR_IGNORE_CASE:-}" ]] && IGNORE_CASE="${SR_IGNORE_CASE}"
2937
+ [[ -n "${SR_EXTENDED_REGEX:-}" ]] && EXTENDED_REGEX="${SR_EXTENDED_REGEX}"
2938
+ [[ -n "${SR_WORD_BOUNDARY:-}" ]] && WORD_BOUNDARY="${SR_WORD_BOUNDARY}"
2939
+ [[ -n "${SR_MULTILINE:-}" ]] && MULTILINE_MATCH="${SR_MULTILINE}"
2940
+ [[ -n "${SR_LINE_NUMBERS:-}" ]] && LINE_NUMBERS="${SR_LINE_NUMBERS}"
2941
+ [[ -n "${SR_DOT_ALL:-}" ]] && DOT_ALL="${SR_DOT_ALL}"
2942
+ [[ -n "${SR_GLOBAL_REPLACE:-}" ]] && GLOBAL_REPLACE="${SR_GLOBAL_REPLACE}"
2943
+ [[ -n "${SR_FIND_FLAGS:-}" ]] && FIND_FLAGS="${SR_FIND_FLAGS}"
2944
+ [[ -n "${SR_SED_FLAGS:-}" ]] && SED_FLAGS="${SR_SED_FLAGS}"
2945
+ [[ -n "${SR_GREP_FLAGS:-}" ]] && GREP_FLAGS="${SR_GREP_FLAGS}"
2946
+
2947
+ # Validate replace mode
2948
+ if [[ "$REPLACE_MODE" != "inplace" && "$REPLACE_MODE" != "copy" && "$REPLACE_MODE" != "backup_only" ]]; then
2949
+ log_error "Invalid replace mode: $REPLACE_MODE. Must be: inplace, copy, or backup_only"
2950
+ exit 1
2951
+ fi
2952
+
2953
+ # Validate binary detection method
2954
+ if [[ "$BINARY_DETECTION_METHOD" != "multi_layer" &&
2955
+ "$BINARY_DETECTION_METHOD" != "file_only" &&
2956
+ "$BINARY_DETECTION_METHOD" != "grep_only" ]]; then
2957
+ log_error "Invalid binary detection method: $BINARY_DETECTION_METHOD"
2958
+ log_error "Must be: multi_layer, file_only, or grep_only"
2959
+ exit 1
2960
+ fi
2961
+
2962
+ # Force backup overrides CREATE_BACKUPS if set
2963
+ if [[ "$FORCE_BACKUP" == true ]]; then
2964
+ CREATE_BACKUPS=true
2965
+ log_debug "Force backup overrides: backups are enabled"
2966
+ fi
2967
+
2968
+ # Initialize session
2969
+ init_session
2970
+
2971
+ # Log configuration
2972
+ log_debug "=== Configuration ==="
2973
+ log_debug "Session ID: $SESSION_ID"
2974
+ log_debug "File pattern: $FILE_PATTERN"
2975
+ log_debug "Search string: $SEARCH_STRING"
2976
+ log_debug "Replace string: $REPLACE_STRING"
2977
+ log_debug "Recursive mode: $RECURSIVE_MODE"
2978
+ log_debug "Max depth: $MAX_DEPTH"
2979
+ log_debug "Dry run mode: $DRY_RUN"
2980
+ log_debug "Create backups: $CREATE_BACKUPS"
2981
+ log_debug "Force backup: $FORCE_BACKUP"
2982
+ log_debug "Allow binary: $ALLOW_BINARY"
2983
+ log_debug "Binary detection: $BINARY_DETECTION_METHOD"
2984
+ log_debug "Binary check size: $BINARY_CHECK_SIZE bytes"
2985
+ log_debug "Max backups: $MAX_BACKUPS"
2986
+ log_debug "Verbose mode: $VERBOSE_MODE"
2987
+ log_debug "Backup in folder: $BACKUP_IN_FOLDER"
2988
+ log_debug "Preserve ownership: $PRESERVE_OWNERSHIP"
2989
+ log_debug "Skip hidden files: $SKIP_HIDDEN_FILES"
2990
+ log_debug "Skip binary files: $SKIP_BINARY_FILES"
2991
+ log_debug "Max file size: $((MAX_FILE_SIZE / 1024 / 1024)) MB"
2992
+ log_debug "Exclude patterns: $EXCLUDE_PATTERNS"
2993
+ log_debug "Exclude dirs: $EXCLUDE_DIRS"
2994
+ log_debug "Search directory: $SEARCH_DIR"
2995
+ log_debug "Output directory: $OUTPUT_DIR"
2996
+ log_debug "Replace mode: $REPLACE_MODE"
2997
+
2998
+ # New configuration in 6.1.0
2999
+ log_debug "Ignore case: $IGNORE_CASE"
3000
+ log_debug "Extended regex: $EXTENDED_REGEX"
3001
+ log_debug "Word boundary: $WORD_BOUNDARY"
3002
+ log_debug "Multiline match: $MULTILINE_MATCH"
3003
+ log_debug "Line numbers: $LINE_NUMBERS"
3004
+ log_debug "Dot all: $DOT_ALL"
3005
+ log_debug "Global replace: $GLOBAL_REPLACE"
3006
+ log_debug "Find flags: $FIND_FLAGS"
3007
+ log_debug "Sed flags: $SED_FLAGS"
3008
+ log_debug "Grep flags: $GREP_FLAGS"
3009
+ }
3010
+
3011
+ # ============================================================================
3012
+ # ENHANCED FILE PROCESSING FUNCTIONS WITH TOOL FLAGS SUPPORT
3013
+ # ============================================================================
3014
+
3015
+ should_exclude_file() {
3016
+ local file="$1"
3017
+ local filename
3018
+
3019
+ filename=$(basename "$file")
3020
+
3021
+ log_debug "Checking if should exclude file: $file (basename: $filename)"
3022
+
3023
+ # Skip hidden files if configured
3024
+ if [[ "$SKIP_HIDDEN_FILES" == true ]] && [[ "$filename" == .* ]]; then
3025
+ log_verbose "Excluding hidden file: $file"
3026
+ return 0
3027
+ fi
3028
+
3029
+ # Check against exclude patterns
3030
+ local pattern
3031
+ for pattern in $EXCLUDE_PATTERNS; do
3032
+ if [[ "$filename" == $pattern ]]; then
3033
+ log_debug "File $file matches exclude pattern: $pattern"
3034
+ log_verbose "Excluding file (pattern): $file"
3035
+ return 0
3036
+ fi
3037
+ done
3038
+
3039
+ # Check file size
3040
+ if [[ -f "$file" ]]; then
3041
+ local filesize
3042
+ filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)
3043
+ if [[ "$filesize" -gt "$MAX_FILE_SIZE" ]]; then
3044
+ log_debug "File $file too large: $filesize bytes (max: $MAX_FILE_SIZE)"
3045
+ log_verbose "Skipping large file: $file ($((filesize / 1024 / 1024)) MB)"
3046
+ return 0
3047
+ fi
3048
+ else
3049
+ log_debug "File $file not found or not a regular file"
3050
+ return 0
3051
+ fi
3052
+
3053
+ # Check if binary file (ENHANCED with multi-layer detection)
3054
+ if [[ "$SKIP_BINARY_FILES" == true ]] && [[ -f "$file" ]]; then
3055
+ if is_binary_file "$file"; then
3056
+ if [[ "$ALLOW_BINARY" == true ]]; then
3057
+ log_verbose "Binary file allowed (--binary flag): $file"
3058
+ return 1 # Don't exclude, allow processing
3059
+ else
3060
+ log_debug "File $file is binary, excluding"
3061
+ log_verbose "Excluding binary file (use --binary to allow): $file"
3062
+ return 0 # Exclude binary file
3063
+ fi
3064
+ else
3065
+ log_debug "File $file is not binary"
3066
+ fi
3067
+ fi
3068
+
3069
+ log_debug "File $file passed all exclusion checks"
3070
+ return 1
3071
+ }
3072
+
3073
+ should_exclude_dir() {
3074
+ local dir="$1"
3075
+ local dirname
3076
+
3077
+ dirname=$(basename "$dir")
3078
+
3079
+ # Skip hidden directories if configured
3080
+ if [[ "$SKIP_HIDDEN_FILES" == true ]] && [[ "$dirname" == .* ]]; then
3081
+ return 0
3082
+ fi
3083
+
3084
+ # Check against exclude directories
3085
+ local exclude_dir
3086
+ for exclude_dir in $EXCLUDE_DIRS; do
3087
+ if [[ "$dirname" == "$exclude_dir" ]]; then
3088
+ log_debug "Excluding directory: $dir"
3089
+ return 0
3090
+ fi
3091
+ done
3092
+
3093
+ return 1
3094
+ }
3095
+
3096
+ # Function to find files - properly handles debug output with enhanced find flags
3097
+ find_files_simple() {
3098
+ local pattern="$1"
3099
+ local search_dir="$2"
3100
+ local recursive="$3"
3101
+ local files=()
3102
+
3103
+ # Save current directory and change to search directory
3104
+ log_debug "Finding files with pattern: $pattern in $search_dir"
3105
+
3106
+ local original_dir
3107
+ original_dir=$(pwd)
3108
+ cd "$search_dir" || {
3109
+ log_error "Cannot change to search directory: $search_dir"
3110
+ return 1
3111
+ }
3112
+
3113
+ # Enable shell options for globbing
3114
+ shopt -s nullglob 2>/dev/null || true
3115
+ shopt -s dotglob 2>/dev/null || true
3116
+
3117
+ if [[ "$recursive" == true ]]; then
3118
+ # Enable globstar for recursive globbing
3119
+ if shopt -q globstar 2>/dev/null; then
3120
+ shopt -s globstar 2>/dev/null
3121
+ for file in **/$pattern; do
3122
+ [[ -f "$file" ]] && files+=("$file")
3123
+ done
3124
+ shopt -u globstar 2>/dev/null
3125
+ else
3126
+ # Fallback to find if globstar not available (with enhanced flags)
3127
+ while IFS= read -r -d '' file; do
3128
+ files+=("$file")
3129
+ done < <(find . -type f -name "$pattern" $FIND_FLAGS -print0 2>/dev/null)
3130
+ fi
3131
+ else
3132
+ # Non-recursive search
3133
+ for file in $pattern; do
3134
+ [[ -f "$file" ]] && files+=("$file")
3135
+ done
3136
+ fi
3137
+
3138
+ # Restore shell options
3139
+ shopt -u nullglob 2>/dev/null || true
3140
+ shopt -u dotglob 2>/dev/null || true
3141
+
3142
+ # Change back to original directory
3143
+ cd "$original_dir" || return 1
3144
+
3145
+ # Convert to absolute paths and print each on new line
3146
+ for file in "${files[@]}"; do
3147
+ local abs_file
3148
+ abs_file="$(cd "$search_dir" && pwd)/${file#./}"
3149
+ echo "$abs_file"
3150
+ done
3151
+
3152
+ log_debug "Found ${#files[@]} file(s) with pattern $pattern"
3153
+ }
3154
+
3155
+ # ENHANCED: Function to create backup with preserved ownership
3156
+ create_backup() {
3157
+ local file="$1"
3158
+ local timestamp="$2"
3159
+ local owner="$3"
3160
+ local group="$4"
3161
+ local perms="$5"
3162
+
3163
+ [[ "$CREATE_BACKUPS" == false ]] && return 0
3164
+
3165
+ if [[ "$BACKUP_IN_FOLDER" == true ]]; then
3166
+ if [[ -z "$BACKUP_DIR" ]]; then
3167
+ BACKUP_DIR="${BACKUP_PREFIX}.${SESSION_ID}"
3168
+
3169
+ if ! mkdir -p "$BACKUP_DIR"; then
3170
+ log_error "Failed to create backup directory: $BACKUP_DIR"
3171
+ return 1
3172
+ fi
3173
+
3174
+ # Save initial session metadata when backup directory is created
3175
+ save_initial_session_metadata "$BACKUP_DIR"
3176
+
3177
+ if [[ "$PRESERVE_OWNERSHIP" == true ]] && [[ -n "$FIRST_FILE_OWNER" ]] && [[ -n "$FIRST_FILE_GROUP" ]]; then
3178
+ chown "${FIRST_FILE_OWNER}:${FIRST_FILE_GROUP}" "$BACKUP_DIR" 2>/dev/null ||
3179
+ log_warning "Could not set backup directory ownership (running as non-root?)"
3180
+ fi
3181
+
3182
+ log_info "Created backup directory: $BACKUP_DIR"
3183
+ fi
3184
+
3185
+ local relative_path="${file#$SEARCH_DIR/}"
3186
+ [[ "$relative_path" == "$file" ]] && relative_path=$(basename "$file")
3187
+ local backup_path="$BACKUP_DIR/$relative_path"
3188
+ local backup_dir="${backup_path%/*}"
3189
+
3190
+ mkdir -p "$backup_dir" 2>/dev/null || {
3191
+ log_warning "Cannot create backup directory: $backup_dir"
3192
+ return 1
3193
+ }
3194
+
3195
+ if [[ "$PRESERVE_OWNERSHIP" == true ]] && [[ -n "$FIRST_FILE_OWNER" ]] && [[ -n "$FIRST_FILE_GROUP" ]]; then
3196
+ chown "${FIRST_FILE_OWNER}:${FIRST_FILE_GROUP}" "$backup_dir" 2>/dev/null || true
3197
+ fi
3198
+
3199
+ if cp --preserve=all "$file" "$backup_path" 2>/dev/null || cp "$file" "$backup_path" 2>/dev/null; then
3200
+ if [[ "$PRESERVE_OWNERSHIP" == true ]] && [[ -n "$owner" ]] && [[ -n "$group" ]]; then
3201
+ chown "${owner}:${group}" "$backup_path" 2>/dev/null || true
3202
+ fi
3203
+
3204
+ [[ -n "$perms" ]] && chmod "$perms" "$backup_path" 2>/dev/null || true
3205
+
3206
+ log_verbose "Created backup: $backup_path"
3207
+
3208
+ # Update file list after each successful backup
3209
+ if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
3210
+ update_backup_filelist
3211
+ fi
3212
+ else
3213
+ log_warning "Failed to create backup for: $file"
3214
+ return 1
3215
+ fi
3216
+ else
3217
+ local backup_file="${file}.${BACKUP_PREFIX}_${SESSION_ID}"
3218
+
3219
+ if cp --preserve=all "$file" "$backup_file" 2>/dev/null || cp "$file" "$backup_file" 2>/dev/null; then
3220
+ if [[ "$PRESERVE_OWNERSHIP" == true ]] && [[ -n "$owner" ]] && [[ -n "$group" ]]; then
3221
+ chown "${owner}:${group}" "$backup_file" 2>/dev/null || true
3222
+ fi
3223
+
3224
+ [[ -n "$perms" ]] && chmod "$perms" "$backup_file" 2>/dev/null || true
3225
+
3226
+ log_verbose "Created backup: $backup_file"
3227
+ else
3228
+ log_warning "Failed to create backup for: $file"
3229
+ return 1
3230
+ fi
3231
+ fi
3232
+
3233
+ return 0
3234
+ }
3235
+
3236
+ # Function to create output file with directory structure
3237
+ create_output_file() {
3238
+ local source_file="$1"
3239
+ local search_dir="$2"
3240
+ local output_dir="$3"
3241
+ local modified_content="$4"
3242
+
3243
+ # Get relative path from search directory
3244
+ local relative_path="${source_file#$search_dir/}"
3245
+ [[ "$relative_path" == "$source_file" ]] && relative_path=$(basename "$source_file")
3246
+
3247
+ local output_file="$output_dir/$relative_path"
3248
+ local output_file_dir="${output_file%/*}"
3249
+
3250
+ # Create directory structure
3251
+ mkdir -p "$output_file_dir" 2>/dev/null || {
3252
+ log_error "Cannot create output directory: $output_file_dir"
3253
+ return 1
3254
+ }
3255
+
3256
+ # Write modified content to output file
3257
+ echo "$modified_content" >"$output_file" || {
3258
+ log_error "Failed to write to output file: $output_file"
3259
+ return 1
3260
+ }
3261
+
3262
+ # Try to preserve permissions from source
3263
+ if [[ -f "$source_file" ]] && [[ "$PRESERVE_OWNERSHIP" == true ]]; then
3264
+ local source_perms
3265
+ source_perms=$(stat -c "%a" "$source_file" 2>/dev/null)
3266
+ [[ -n "$source_perms" ]] && chmod "$source_perms" "$output_file" 2>/dev/null || true
3267
+ fi
3268
+
3269
+ log_verbose "Created output file: $output_file"
3270
+ echo "$output_file"
3271
+ }
3272
+
3273
+ # ============================================================================
3274
+ # ENHANCED FILE PROCESSING WITH DUAL MODE SUPPORT AND TOOL FLAGS
3275
+ # ============================================================================
3276
+
3277
+ process_files() {
3278
+ local timestamp files=()
3279
+ local search_escaped replace_escaped
3280
+ local start_time end_time processing_time
3281
+
3282
+ # Initialize escaped variables at the beginning
3283
+ search_escaped=$(escape_regex "$SEARCH_STRING")
3284
+ replace_escaped=$(escape_replacement "$REPLACE_STRING")
3285
+
3286
+ start_time=$(date +%s.%N)
3287
+ timestamp=$(date +"$TIMESTAMP_FORMAT")
3288
+
3289
+ log_header "=== FILE PROCESSING STARTED ==="
3290
+ log_info "Session ID: $SESSION_ID"
3291
+ log_info "Start time: $(date)"
3292
+ log_verbose "Current directory: $(pwd)"
3293
+ log_debug "Search directory: $SEARCH_DIR"
3294
+ log_debug "Recursive mode: $RECURSIVE_MODE"
3295
+ log_debug "Max depth: $MAX_DEPTH"
3296
+ log_debug "Find flags: $FIND_FLAGS"
3297
+ log_debug "Sed flags: $SED_FLAGS"
3298
+ log_debug "Grep flags: $GREP_FLAGS"
3299
+
3300
+ # MODE 1: EXPLICIT FILE LIST FROM SHELL EXPANSION OR USER INPUT
3301
+ if [[ ${#FILES_LIST[@]} -gt 0 ]]; then
3302
+ log_info "=== MODE: EXPLICIT FILE LIST ==="
3303
+ log_info "Processing ${#FILES_LIST[@]} file(s) from command line"
3304
+
3305
+ if [[ "$DEBUG_MODE" == true ]]; then
3306
+ log_debug "FILES_LIST detailed analysis:"
3307
+ for ((i = 0; i < ${#FILES_LIST[@]}; i++)); do
3308
+ local file="${FILES_LIST[$i]}"
3309
+ local file_status=""
3310
+
3311
+ if [[ ! -e "$file" ]]; then
3312
+ file_status="DOES NOT EXIST"
3313
+ elif [[ -d "$file" ]]; then
3314
+ file_status="DIRECTORY (will be skipped)"
3315
+ elif [[ ! -f "$file" ]]; then
3316
+ file_status="NOT REGULAR FILE"
3317
+ elif [[ ! -r "$file" ]]; then
3318
+ file_status="NOT READABLE"
3319
+ else
3320
+ file_status="OK - $(stat -c "%s" "$file" 2>/dev/null || echo "?") bytes"
3321
+ fi
3322
+
3323
+ log_debug " [$i] '$file' - $file_status"
3324
+ done
3325
+ fi
3326
+
3327
+ # Filter and validate files
3328
+ local valid_files=()
3329
+ local skipped_files=()
3330
+ local skip_reasons=()
3331
+
3332
+ for file in "${FILES_LIST[@]}"; do
3333
+ local skip_reason=""
3334
+ local normalized_file
3335
+
3336
+ # Convert to absolute path for consistent checking
3337
+ if [[ "$file" == /* ]]; then
3338
+ normalized_file="$file"
3339
+ else
3340
+ normalized_file="$(pwd)/${file#./}"
3341
+ fi
3342
+
3343
+ log_debug "Validating file: $file (normalized: $normalized_file)"
3344
+
3345
+ # Skip backup directories (check if file is inside a backup directory)
3346
+ local in_backup_dir=false
3347
+ local path_part="$normalized_file"
3348
+ while [[ "$path_part" != "/" ]] && [[ -n "$path_part" ]]; do
3349
+ local dir_name=$(basename "$path_part")
3350
+ if [[ "$dir_name" == "${BACKUP_PREFIX}."* ]]; then
3351
+ in_backup_dir=true
3352
+ skip_reason="inside backup directory '$dir_name'"
3353
+ log_debug " Skipping: File is inside backup directory: $dir_name"
3354
+ break
3355
+ fi
3356
+ path_part=$(dirname "$path_part")
3357
+ done
3358
+
3359
+ if [[ "$in_backup_dir" == true ]]; then
3360
+ skipped_files+=("$file")
3361
+ skip_reasons+=("$skip_reason")
3362
+ continue
3363
+ fi
3364
+
3365
+ # Basic file validation
3366
+ if [[ ! -e "$file" ]]; then
3367
+ skip_reason="file does not exist"
3368
+ elif [[ -d "$file" ]]; then
3369
+ skip_reason="is a directory"
3370
+ elif [[ ! -f "$file" ]]; then
3371
+ skip_reason="not a regular file"
3372
+ elif [[ ! -r "$file" ]]; then
3373
+ skip_reason="file not readable"
3374
+ elif should_exclude_file "$file"; then
3375
+ skip_reason="excluded by filters"
3376
+ fi
3377
+
3378
+ if [[ -n "$skip_reason" ]]; then
3379
+ skipped_files+=("$file")
3380
+ skip_reasons+=("$skip_reason")
3381
+ log_debug " Skipping: $skip_reason"
3382
+ else
3383
+ valid_files+=("$file")
3384
+ log_debug " Accepted: $file"
3385
+ fi
3386
+ done
3387
+
3388
+ # Proper array validation without unbound variable error
3389
+ if [[ ${#valid_files[@]} -gt 0 ]]; then
3390
+ files=("${valid_files[@]}")
3391
+ log_info "Will process ${#files[@]} valid file(s) from explicit list"
3392
+ else
3393
+ log_error "No valid files to process from the provided list"
3394
+ return 2
3395
+ fi
3396
+
3397
+ # Report skipped files
3398
+ if [[ ${#skipped_files[@]} -gt 0 ]]; then
3399
+ log_warning "Skipped ${#skipped_files[@]} invalid or excluded file(s):"
3400
+ for ((i = 0; i < ${#skipped_files[@]}; i++)); do
3401
+ log_warning " - ${skipped_files[$i]} (${skip_reasons[$i]})"
3402
+ done
3403
+ fi
3404
+
3405
+ # MODE 2: PATTERN-BASED FILE DISCOVERY WITH ENHANCED FIND FLAGS
3406
+ else
3407
+ log_info "=== MODE: PATTERN MATCHING ==="
3408
+ log_info "Searching for files with pattern: $FILE_PATTERN"
3409
+ log_info "Search directory: $SEARCH_DIR"
3410
+ log_info "Find flags: $FIND_FLAGS"
3411
+
3412
+ if [[ "$DEBUG_MODE" == true ]]; then
3413
+ log_debug "Pattern analysis:"
3414
+ log_debug " Raw pattern: '$FILE_PATTERN'"
3415
+ log_debug " Contains wildcards: $(is_glob_pattern "$FILE_PATTERN" && echo "YES" || echo "NO")"
3416
+ log_debug " Shell would expand to: $(for f in $FILE_PATTERN; do [[ -f "$f" ]] && echo -n "$f "; done)"
3417
+ fi
3418
+
3419
+ # Find files using the enhanced find function with flags
3420
+ log_debug "Starting file discovery with pattern: $FILE_PATTERN"
3421
+
3422
+ # Use temporary file for reliable handling of large file lists
3423
+ local temp_file_list
3424
+ temp_file_list=$(mktemp 2>/dev/null || echo "/tmp/sr_filelist_$$")
3425
+
3426
+ # Enhanced find with backup directory exclusion and user flags
3427
+ if [[ "$RECURSIVE_MODE" == true ]]; then
3428
+ log_debug "Using recursive find with max depth $MAX_DEPTH"
3429
+ local find_cmd="$FIND_TOOL \"$SEARCH_DIR\" -maxdepth $MAX_DEPTH"
3430
+ else
3431
+ log_debug "Using non-recursive find"
3432
+ local find_cmd="$FIND_TOOL \"$SEARCH_DIR\" -maxdepth 1"
3433
+ fi
3434
+
3435
+ # Build exclusion filters
3436
+ local exclude_filter=""
3437
+ if [[ -n "$EXCLUDE_DIRS" ]]; then
3438
+ for dir in $EXCLUDE_DIRS; do
3439
+ exclude_filter+=" -name \"$dir\" -prune -o"
3440
+ done
3441
+ fi
3442
+
3443
+ # Always exclude backup directories
3444
+ exclude_filter+=" -name \"${BACKUP_PREFIX}.*\" -prune -o"
3445
+
3446
+ # Execute find command with user flags
3447
+ local find_full_cmd="$find_cmd $exclude_filter -type f -name \"$FILE_PATTERN\" $FIND_FLAGS -print0 2>/dev/null"
3448
+ log_debug "Find command: $find_full_cmd"
3449
+
3450
+ # Execute and capture results
3451
+ local find_start=$(date +%s.%N)
3452
+ eval "$find_full_cmd" >"$temp_file_list.tmp"
3453
+
3454
+ # Read null-terminated files into array
3455
+ files=()
3456
+ if [[ -s "$temp_file_list.tmp" ]]; then
3457
+ while IFS= read -r -d '' file; do
3458
+ [[ -n "$file" ]] && files+=("$file")
3459
+ done <"$temp_file_list.tmp"
3460
+ fi
3461
+
3462
+ local find_end=$(date +%s.%N)
3463
+ local find_time=$(echo "$find_end - $find_start" | bc 2>/dev/null | awk '{printf "%.3f", $0}')
3464
+
3465
+ log_debug "File discovery completed in ${find_time}s"
3466
+ rm -f "$temp_file_list.tmp" 2>/dev/null
3467
+
3468
+ # Alternative method if find returns nothing
3469
+ if [[ ${#files[@]} -eq 0 ]]; then
3470
+ log_warning "No files found via find command, trying alternative methods..."
3471
+
3472
+ # Method 1: Simple shell globbing
3473
+ local glob_files=()
3474
+ if [[ "$RECURSIVE_MODE" == true ]] && shopt -q globstar 2>/dev/null; then
3475
+ log_debug "Trying globstar expansion"
3476
+ shopt -s globstar nullglob 2>/dev/null
3477
+ for file in "$SEARCH_DIR"/**/"$FILE_PATTERN"; do
3478
+ [[ -f "$file" ]] && glob_files+=("$file")
3479
+ done
3480
+ shopt -u globstar nullglob 2>/dev/null
3481
+ else
3482
+ log_debug "Trying simple glob expansion"
3483
+ shopt -s nullglob 2>/dev/null
3484
+ for file in "$SEARCH_DIR"/"$FILE_PATTERN"; do
3485
+ [[ -f "$file" ]] && glob_files+=("$file")
3486
+ done
3487
+ shopt -u nullglob 2>/dev/null
3488
+ fi
3489
+
3490
+ if [[ ${#glob_files[@]} -gt 0 ]]; then
3491
+ files=("${glob_files[@]}")
3492
+ log_debug "Found ${#files[@]} file(s) via shell globbing"
3493
+
3494
+ # Filter out backup directory files
3495
+ local filtered_files=()
3496
+ for file in "${files[@]}"; do
3497
+ local skip=false
3498
+ local path="$file"
3499
+
3500
+ # Check if file is inside backup directory
3501
+ while [[ "$path" != "/" ]] && [[ -n "$path" ]]; do
3502
+ local dir_name=$(basename "$path")
3503
+ if [[ "$dir_name" == "${BACKUP_PREFIX}."* ]]; then
3504
+ skip=true
3505
+ log_debug "Excluding file in backup directory: $file"
3506
+ break
3507
+ fi
3508
+ [[ "$path" == "." ]] && break
3509
+ path=$(dirname "$path")
3510
+ done
3511
+
3512
+ [[ "$skip" == false ]] && filtered_files+=("$file")
3513
+ done
3514
+
3515
+ files=("${filtered_files[@]}")
3516
+ fi
3517
+ fi
3518
+
3519
+ if [[ ${#files[@]} -eq 0 ]]; then
3520
+ log_error "No files found matching pattern '$FILE_PATTERN' in '$SEARCH_DIR'"
3521
+
3522
+ # Provide debugging information
3523
+ if [[ "$DEBUG_MODE" == true ]]; then
3524
+ log_debug "Directory listing of $SEARCH_DIR:"
3525
+ ls -la "$SEARCH_DIR" 2>/dev/null | head -20
3526
+
3527
+ log_debug "Files with similar patterns:"
3528
+ find "$SEARCH_DIR" -maxdepth 2 -type f -name "*" 2>/dev/null |
3529
+ grep -i "${FILE_PATTERN//\*/}" | head -10
3530
+ fi
3531
+
3532
+ return 2
3533
+ fi
3534
+
3535
+ log_info "Found ${#files[@]} file(s) matching pattern"
3536
+ fi
3537
+
3538
+ # ========================================================================
3539
+ # COMMON PROCESSING PHASE FOR BOTH MODES WITH ENHANCED FLAGS
3540
+ # ========================================================================
3541
+
3542
+ local file_count=${#files[@]}
3543
+ log_info "=== PROCESSING $file_count FILE(S) ==="
3544
+
3545
+ # Display file list with enhanced information
3546
+ if [[ "$VERBOSE_MODE" == true ]] && [[ $file_count -gt 0 ]]; then
3547
+ log_verbose "File list (first 10):"
3548
+ local display_limit=$((file_count > 10 ? 10 : file_count))
3549
+ for ((i = 0; i < display_limit; i++)); do
3550
+ local file="${files[$i]}"
3551
+ local file_size=""
3552
+ if [[ -f "$file" ]]; then
3553
+ file_size=$(stat -c "%s" "$file" 2>/dev/null || echo "?")
3554
+ if [[ "$file_size" =~ ^[0-9]+$ ]]; then
3555
+ if [[ $file_size -gt 1048576 ]]; then
3556
+ file_size="$((file_size / 1048576)) MB"
3557
+ elif [[ $file_size -gt 1024 ]]; then
3558
+ file_size="$((file_size / 1024)) KB"
3559
+ else
3560
+ file_size="${file_size} bytes"
3561
+ fi
3562
+ fi
3563
+ fi
3564
+ log_verbose " [$((i + 1))] ${files[$i]} ($file_size)"
3565
+ done
3566
+ [[ $file_count -gt 10 ]] && log_verbose " ... and $((file_count - 10)) more"
3567
+ fi
3568
+
3569
+ # Initialize counters
3570
+ local processed_count=0
3571
+ local modified_count=0
3572
+ local replacement_count=0
3573
+ local skipped_count=0
3574
+ local error_count=0
3575
+
3576
+ # Detailed statistics for enhanced reporting
3577
+ local stats_by_extension=()
3578
+ local stats_by_size=("small:0" "medium:0" "large:0")
3579
+ local stats_by_result=("success:0" "no_change:0" "error:0" "skipped:0")
3580
+
3581
+ # File processing performance tracking
3582
+ local total_file_size=0
3583
+ local largest_file=0
3584
+ local largest_file_name=""
3585
+ local smallest_file=0
3586
+ local smallest_file_name=""
3587
+ local first_file=true
3588
+
3589
+ # Create progress tracking variables
3590
+ local progress_interval=$((file_count > 100 ? file_count / 20 : 5))
3591
+ [[ $progress_interval -lt 1 ]] && progress_interval=1
3592
+
3593
+ log_debug "Progress reporting every $progress_interval files"
3594
+ log_debug "Search escaped: $search_escaped"
3595
+ log_debug "Replace escaped: $replace_escaped"
3596
+ log_debug "Search options: ignore_case=$IGNORE_CASE, extended_regex=$EXTENDED_REGEX, word_boundary=$WORD_BOUNDARY"
3597
+
3598
+ # Process each file with enhanced tracking
3599
+ for file_idx in "${!files[@]}"; do
3600
+ local file="${files[$file_idx]}"
3601
+ local file_display="${file#$SEARCH_DIR/}"
3602
+ [[ "$file_display" == "$file" ]] && file_display="$file"
3603
+
3604
+ # Enhanced progress reporting with time estimation
3605
+ if [[ $(((file_idx + 1) % progress_interval)) -eq 0 ]] || [[ $((file_idx + 1)) -eq $file_count ]]; then
3606
+ local progress_pct=$(((file_idx + 1) * 100 / file_count))
3607
+
3608
+ # Calculate estimated time remaining
3609
+ local current_time=$(date +%s.%N)
3610
+ local elapsed=$(echo "$current_time - $start_time" | bc 2>/dev/null || echo "0")
3611
+ local estimated_total=0
3612
+ if [[ $processed_count -gt 0 ]] && [[ $(echo "$elapsed > 0" | bc 2>/dev/null) -eq 1 ]]; then
3613
+ estimated_total=$(echo "scale=2; $elapsed * $file_count / $processed_count" | bc 2>/dev/null || echo "0")
3614
+ local remaining=$(echo "$estimated_total - $elapsed" | bc 2>/dev/null || echo "0")
3615
+ if [[ $(echo "$remaining > 0" | bc 2>/dev/null) -eq 1 ]]; then
3616
+ log_info "Progress: $((file_idx + 1))/$file_count files ($progress_pct%) - Modified: $modified_count, Replacements: $replacement_count - Est. remaining: ${remaining}s"
3617
+ else
3618
+ log_info "Progress: $((file_idx + 1))/$file_count files ($progress_pct%) - Modified: $modified_count, Replacements: $replacement_count"
3619
+ fi
3620
+ else
3621
+ log_info "Progress: $((file_idx + 1))/$file_count files ($progress_pct%) - Modified: $modified_count, Replacements: $replacement_count"
3622
+ fi
3623
+ fi
3624
+
3625
+ log_debug "Processing file [$((file_idx + 1))/$file_count]: $file_display"
3626
+
3627
+ # Detailed file analysis for debugging and statistics
3628
+ if [[ "$DEBUG_MODE" == true ]] || [[ "$VERBOSE_MODE" == true ]]; then
3629
+ local file_info=""
3630
+ if [[ -f "$file" ]]; then
3631
+ local file_size=$(stat -c "%s" "$file" 2>/dev/null || echo "?")
3632
+ local file_perm=$(stat -c "%a" "$file" 2>/dev/null || echo "?")
3633
+ local file_owner=$(stat -c "%U:%G" "$file" 2>/dev/null || echo "?:?")
3634
+ local file_mime=$(file -b --mime-type "$file" 2>/dev/null || echo "unknown")
3635
+
3636
+ # Track file size statistics
3637
+ if [[ "$file_size" =~ ^[0-9]+$ ]]; then
3638
+ total_file_size=$((total_file_size + file_size))
3639
+
3640
+ if [[ $first_file == true ]]; then
3641
+ largest_file=$file_size
3642
+ largest_file_name="$file_display"
3643
+ smallest_file=$file_size
3644
+ smallest_file_name="$file_display"
3645
+ first_file=false
3646
+ else
3647
+ if [[ $file_size -gt $largest_file ]]; then
3648
+ largest_file=$file_size
3649
+ largest_file_name="$file_display"
3650
+ fi
3651
+ if [[ $file_size -lt $smallest_file ]]; then
3652
+ smallest_file=$file_size
3653
+ smallest_file_name="$file_display"
3654
+ fi
3655
+ fi
3656
+
3657
+ # Update size categories
3658
+ if [[ $file_size -lt 10240 ]]; then
3659
+ stats_by_size[0]="small:$((${stats_by_size[0]#*:} + 1))"
3660
+ elif [[ $file_size -lt 1048576 ]]; then
3661
+ stats_by_size[1]="medium:$((${stats_by_size[1]#*:} + 1))"
3662
+ else
3663
+ stats_by_size[2]="large:$((${stats_by_size[2]#*:} + 1))"
3664
+ fi
3665
+ fi
3666
+
3667
+ file_info="size=${file_size}, perm=${file_perm}, owner=${file_owner}, mime=${file_mime}"
3668
+ else
3669
+ file_info="NOT FOUND"
3670
+ fi
3671
+ log_debug " File info: $file_info"
3672
+ fi
3673
+
3674
+ # Additional safety checks for backup directories
3675
+ local in_backup_dir=false
3676
+ local check_path="$file"
3677
+ while [[ "$check_path" != "/" ]] && [[ -n "$check_path" ]]; do
3678
+ local dir_name=$(basename "$check_path")
3679
+ if [[ "$dir_name" == "${BACKUP_PREFIX}."* ]]; then
3680
+ in_backup_dir=true
3681
+ log_warning "SAFETY CHECK: Skipping file in backup directory: $file"
3682
+ skipped_count=$((skipped_count + 1))
3683
+ stats_by_result[3]="skipped:$((${stats_by_result[3]#*:} + 1))"
3684
+ break
3685
+ fi
3686
+ [[ "$check_path" == "." ]] && break
3687
+ check_path=$(dirname "$check_path")
3688
+ done
3689
+
3690
+ [[ "$in_backup_dir" == true ]] && continue
3691
+
3692
+ # Check file permissions and accessibility before processing
3693
+ if [[ -f "$file" ]] && [[ ! -r "$file" ]]; then
3694
+ log_warning "Cannot read file (permission denied): $file"
3695
+ skipped_count=$((skipped_count + 1))
3696
+ stats_by_result[3]="skipped:$((${stats_by_result[3]#*:} + 1))"
3697
+ continue
3698
+ fi
3699
+
3700
+ if [[ -f "$file" ]] && [[ ! -w "$file" ]] && [[ "$REPLACE_MODE" == "inplace" ]]; then
3701
+ log_warning "Cannot write to file (permission denied): $file"
3702
+ if [[ "$DRY_RUN" != true ]]; then
3703
+ skipped_count=$((skipped_count + 1))
3704
+ stats_by_result[3]="skipped:$((${stats_by_result[3]#*:} + 1))"
3705
+ continue
3706
+ fi
3707
+ fi
3708
+
3709
+ # Perform the replacement with error handling and enhanced flags
3710
+ local result=0
3711
+ perform_replace "$file" "$search_escaped" "$replace_escaped" "$timestamp" || result=$?
3712
+
3713
+ processed_count=$((processed_count + 1))
3714
+
3715
+ # Detailed result processing with enhanced categorization
3716
+ case $result in
3717
+ 0)
3718
+ # File was modified successfully
3719
+ modified_count=$((modified_count + 1))
3720
+ replacement_count=$((replacement_count + TOTAL_REPLACEMENTS - replacement_count))
3721
+ stats_by_result[0]="success:$((${stats_by_result[0]#*:} + 1))"
3722
+
3723
+ # Track file extension statistics
3724
+ local ext="${file##*.}"
3725
+ [[ "$ext" == "$file" ]] && ext="no_extension"
3726
+ local found_ext=false
3727
+ for i in "${!stats_by_extension[@]}"; do
3728
+ if [[ "${stats_by_extension[$i]%%:*}" == "$ext" ]]; then
3729
+ local count="${stats_by_extension[$i]#*:}"
3730
+ stats_by_extension[$i]="${ext}:$((count + 1))"
3731
+ found_ext=true
3732
+ break
3733
+ fi
3734
+ done
3735
+ [[ "$found_ext" == false ]] && stats_by_extension+=("${ext}:1")
3736
+
3737
+ # Update session tracking
3738
+ if [[ -n "${SESSION_MODIFIED_FILES+set}" ]]; then
3739
+ local already_tracked=false
3740
+ for tracked_file in "${SESSION_MODIFIED_FILES[@]}"; do
3741
+ if [[ "$tracked_file" == "$file" ]]; then
3742
+ already_tracked=true
3743
+ break
3744
+ fi
3745
+ done
3746
+
3747
+ if [[ "$already_tracked" == false ]]; then
3748
+ SESSION_MODIFIED_FILES+=("$file")
3749
+ log_debug "Tracked modified file: $file (total: ${#SESSION_MODIFIED_FILES[@]})"
3750
+ fi
3751
+ fi
3752
+ ;;
3753
+ 1)
3754
+ # General error
3755
+ error_count=$((error_count + 1))
3756
+ stats_by_result[2]="error:$((${stats_by_result[2]#*:} + 1))"
3757
+ log_debug "General error processing file: $file"
3758
+ ;;
3759
+ 2)
3760
+ # File not found
3761
+ error_count=$((error_count + 1))
3762
+ stats_by_result[2]="error:$((${stats_by_result[2]#*:} + 1))"
3763
+ log_debug "File not found: $file"
3764
+ ;;
3765
+ 3)
3766
+ # Permission error
3767
+ error_count=$((error_count + 1))
3768
+ stats_by_result[2]="error:$((${stats_by_result[2]#*:} + 1))"
3769
+ log_debug "Permission error: $file"
3770
+ ;;
3771
+ 4)
3772
+ # Backup creation failed
3773
+ error_count=$((error_count + 1))
3774
+ stats_by_result[2]="error:$((${stats_by_result[2]#*:} + 1))"
3775
+ log_debug "Backup creation failed: $file"
3776
+ ;;
3777
+ 5)
3778
+ # Binary file skipped
3779
+ skipped_count=$((skipped_count + 1))
3780
+ stats_by_result[3]="skipped:$((${stats_by_result[3]#*:} + 1))"
3781
+ log_debug "Binary file skipped: $file"
3782
+ ;;
3783
+ 6)
3784
+ # No changes made (search string not found)
3785
+ stats_by_result[1]="no_change:$((${stats_by_result[1]#*:} + 1))"
3786
+ log_debug "No changes made: $file (search string not found)"
3787
+ ;;
3788
+ *)
3789
+ # Unknown result code
3790
+ error_count=$((error_count + 1))
3791
+ stats_by_result[2]="error:$((${stats_by_result[2]#*:} + 1))"
3792
+ log_warning "Unknown result code $result for file: $file"
3793
+ ;;
3794
+ esac
3795
+
3796
+ # Update backup file list in real-time
3797
+ if [[ "$CREATE_BACKUPS" == true ]] && [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
3798
+ update_backup_filelist
3799
+ fi
3800
+ done
3801
+
3802
+ # ========================================================================
3803
+ # PROCESSING COMPLETE - ENHANCED FINAL STATISTICS
3804
+ # ========================================================================
3805
+
3806
+ end_time=$(date +%s.%N)
3807
+ processing_time=$(echo "$end_time - $start_time" | bc 2>/dev/null | awk '{printf "%.3f", $0}')
3808
+
3809
+ log_header "=== PROCESSING COMPLETE ==="
3810
+ log_info "Total processing time: ${processing_time}s"
3811
+ log_info "Files processed: $processed_count"
3812
+ log_info "Files modified: $modified_count"
3813
+ log_info "Total replacements: $replacement_count"
3814
+ log_info "Files skipped: $skipped_count"
3815
+ log_info "Errors encountered: $error_count"
3816
+
3817
+ # Enhanced statistics reporting
3818
+ if [[ "$DEBUG_MODE" == true ]] || [[ "$VERBOSE_MODE" == true ]]; then
3819
+ log_verbose "=== DETAILED STATISTICS ==="
3820
+ log_verbose "By result:"
3821
+ for stat in "${stats_by_result[@]}"; do
3822
+ local type="${stat%%:*}"
3823
+ local count="${stat#*:}"
3824
+ local pct=0
3825
+ if [[ $processed_count -gt 0 ]]; then
3826
+ pct=$((count * 100 / processed_count))
3827
+ fi
3828
+ log_verbose " ${type}: $count file(s) (${pct}%)"
3829
+ done
3830
+
3831
+ log_verbose "By size category:"
3832
+ for stat in "${stats_by_size[@]}"; do
3833
+ local type="${stat%%:*}"
3834
+ local count="${stat#*:}"
3835
+ log_verbose " ${type}: $count file(s)"
3836
+ done
3837
+
3838
+ # File size statistics
3839
+ if [[ $processed_count -gt 0 ]]; then
3840
+ local avg_size=0
3841
+ if [[ $total_file_size -gt 0 ]]; then
3842
+ avg_size=$((total_file_size / processed_count))
3843
+ fi
3844
+
3845
+ log_verbose "File size statistics:"
3846
+ log_verbose " Total size: $((total_file_size / 1024)) KB"
3847
+ log_verbose " Average size: $((avg_size / 1024)) KB"
3848
+ if [[ -n "$largest_file_name" ]]; then
3849
+ log_verbose " Largest file: $largest_file_name ($((largest_file / 1024)) KB)"
3850
+ fi
3851
+ if [[ -n "$smallest_file_name" ]]; then
3852
+ log_verbose " Smallest file: $smallest_file_name ($((smallest_file / 1024)) KB)"
3853
+ fi
3854
+ fi
3855
+
3856
+ # Extension statistics
3857
+ if [[ ${#stats_by_extension[@]} -gt 0 ]]; then
3858
+ log_verbose "By extension (top 10):"
3859
+ # Sort by count (descending)
3860
+ local sorted_exts=()
3861
+ for ext in "${stats_by_extension[@]}"; do
3862
+ sorted_exts+=("$ext")
3863
+ done
3864
+
3865
+ # Simple bubble sort for small arrays
3866
+ local n=${#sorted_exts[@]}
3867
+ for ((i = 0; i < n-1; i++)); do
3868
+ for ((j = 0; j < n-i-1; j++)); do
3869
+ local count1="${sorted_exts[$j]#*:}"
3870
+ local count2="${sorted_exts[$((j+1))]#*:}"
3871
+ if [[ $count1 -lt $count2 ]]; then
3872
+ # Swap
3873
+ local temp="${sorted_exts[$j]}"
3874
+ sorted_exts[$j]="${sorted_exts[$((j+1))]}"
3875
+ sorted_exts[$((j+1))]="$temp"
3876
+ fi
3877
+ done
3878
+ done
3879
+
3880
+ local display_count=$(( ${#sorted_exts[@]} > 10 ? 10 : ${#sorted_exts[@]} ))
3881
+ for ((i = 0; i < display_count; i++)); do
3882
+ local ext="${sorted_exts[$i]%%:*}"
3883
+ local count="${sorted_exts[$i]#*:}"
3884
+ local pct=0
3885
+ if [[ $processed_count -gt 0 ]]; then
3886
+ pct=$((count * 100 / processed_count))
3887
+ fi
3888
+ log_verbose " .$ext: $count file(s) (${pct}%)"
3889
+ done
3890
+ fi
3891
+
3892
+ # Performance statistics
3893
+ if [[ $(echo "$processing_time > 0" | bc 2>/dev/null) -eq 1 ]]; then
3894
+ local rate=$(echo "scale=2; $processed_count / $processing_time" | bc 2>/dev/null)
3895
+ local mb_per_sec=0
3896
+ if [[ $total_file_size -gt 0 ]]; then
3897
+ mb_per_sec=$(echo "scale=2; $total_file_size / 1048576 / $processing_time" | bc 2>/dev/null)
3898
+ fi
3899
+
3900
+ log_verbose "Performance statistics:"
3901
+ log_verbose " Processing rate: ${rate} files/second"
3902
+ if [[ $(echo "$mb_per_sec > 0" | bc 2>/dev/null) -eq 1 ]]; then
3903
+ log_verbose " Data throughput: ${mb_per_sec} MB/second"
3904
+ fi
3905
+ log_verbose " Time per file: $(echo "scale=3; $processing_time / $processed_count" | bc 2>/dev/null)s"
3906
+ fi
3907
+
3908
+ # Session tracking information
3909
+ if [[ -n "$SESSION_MODIFIED_FILES+set" ]] && [[ ${#SESSION_MODIFIED_FILES[@]} -gt 0 ]]; then
3910
+ log_verbose "Session tracking:"
3911
+ log_verbose " Files in session: ${#SESSION_MODIFIED_FILES[@]}"
3912
+ if [[ "$VERBOSE_MODE" == true ]] && [[ ${#SESSION_MODIFIED_FILES[@]} -le 20 ]]; then
3913
+ log_verbose " Modified files:"
3914
+ for ((i = 0; i < ${#SESSION_MODIFIED_FILES[@]}; i++)); do
3915
+ local tracked_file="${SESSION_MODIFIED_FILES[$i]}"
3916
+ local display_file="${tracked_file#$SEARCH_DIR/}"
3917
+ [[ "$display_file" == "$tracked_file" ]] && display_file="$tracked_file"
3918
+ log_verbose " - $display_file"
3919
+ done
3920
+ fi
3921
+ fi
3922
+ fi
3923
+
3924
+ # Store final counts in global variables
3925
+ PROCESSED_FILES=$processed_count
3926
+ MODIFIED_FILES=$modified_count
3927
+ TOTAL_REPLACEMENTS=$replacement_count
3928
+
3929
+ # Final validation with enhanced error reporting
3930
+ if [[ $processed_count -eq 0 ]]; then
3931
+ log_error "No files were processed"
3932
+
3933
+ # Provide troubleshooting information
3934
+ if [[ ${#FILES_LIST[@]} -gt 0 ]]; then
3935
+ log_error "Troubleshooting for explicit file list mode:"
3936
+ log_error " - Check if files exist: ${FILES_LIST[*]:0:5}"
3937
+ log_error " - Check file permissions with: ls -la ${FILES_LIST[0]}"
3938
+ elif [[ -n "$FILE_PATTERN" ]]; then
3939
+ log_error "Troubleshooting for pattern matching mode:"
3940
+ log_error " - Pattern: $FILE_PATTERN"
3941
+ log_error " - Search directory: $SEARCH_DIR"
3942
+ log_error " - Test pattern manually: find \"$SEARCH_DIR\" -name \"$FILE_PATTERN\" -type f | head -5"
3943
+ fi
3944
+
3945
+ return 2
3946
+ fi
3947
+
3948
+ if [[ $modified_count -eq 0 ]] && [[ "$DRY_RUN" != true ]]; then
3949
+ log_warning "Search pattern not found in any processed files"
3950
+ log_warning " Search string: '$SEARCH_STRING'"
3951
+ log_warning " Replace string: '$REPLACE_STRING'"
3952
+ log_warning " Search options: ignore_case=$IGNORE_CASE, extended_regex=$EXTENDED_REGEX, word_boundary=$WORD_BOUNDARY"
3953
+
3954
+ return 3
3955
+ fi
3956
+
3957
+ # Success summary with session information
3958
+ if [[ "$CREATE_BACKUPS" == true ]] && [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
3959
+ log_info "Backup created: $BACKUP_DIR"
3960
+ log_info " Rollback command: $0 --rollback=$BACKUP_DIR"
3961
+
3962
+ # Count files in backup
3963
+ local backup_file_count=$(find "$BACKUP_DIR" -type f -not -name ".sr_*" 2>/dev/null | wc -l)
3964
+ if [[ $backup_file_count -gt 0 ]]; then
3965
+ log_info " Backup contains $backup_file_count file(s)"
3966
+ fi
3967
+ fi
3968
+
3969
+ return 0
3970
+ }
3971
+
3972
+ # ============================================================================
3973
+ # COMPREHENSIVE SUMMARY WITH ENHANCED OPTIONS
3974
+ # ============================================================================
3975
+
3976
+ show_summary() {
3977
+ echo ""
3978
+ log_header "=== SEARCH AND REPLACE SUMMARY ==="
3979
+
3980
+ # Get tracked files count from file
3981
+ local tracked_files_count=0
3982
+ if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
3983
+ local modified_list="$BACKUP_DIR/.sr_modified_files"
3984
+ if [[ -f "$modified_list" ]]; then
3985
+ tracked_files_count=$(wc -l <"$modified_list" 2>/dev/null || echo "0")
3986
+ fi
3987
+ fi
3988
+
3989
+ echo "Session ID: $SESSION_ID"
3990
+ echo "Files scanned: $PROCESSED_FILES"
3991
+ echo "Files modified: $MODIFIED_FILES ($tracked_files_count tracked in backup)"
3992
+ echo "Total replacements: $TOTAL_REPLACEMENTS"
3993
+ echo "Search pattern: '$SEARCH_STRING'"
3994
+ echo "Replace with: '$REPLACE_STRING'"
3995
+ echo "File pattern: $FILE_PATTERN"
3996
+ echo "Search directory: $SEARCH_DIR"
3997
+ echo "Mode: $([[ "$RECURSIVE_MODE" == true ]] && echo "Recursive (depth: $MAX_DEPTH)" || echo "Non-recursive")"
3998
+ echo "Binary detection: $BINARY_DETECTION_METHOD"
3999
+ echo "Allow binary: $([[ "$ALLOW_BINARY" == true ]] && echo "Yes (--binary used)" || echo "No (skipped if detected)")"
4000
+ echo "Search options:"
4001
+ echo " Ignore case: $([[ "$IGNORE_CASE" == true ]] && echo "Yes" || echo "No")"
4002
+ echo " Extended regex: $([[ "$EXTENDED_REGEX" == true ]] && echo "Yes" || echo "No")"
4003
+ echo " Word boundary: $([[ "$WORD_BOUNDARY" == true ]] && echo "Yes" || echo "No")"
4004
+ echo " Multiline: $([[ "$MULTILINE_MATCH" == true ]] && echo "Yes" || echo "No")"
4005
+ echo " Line numbers: $([[ "$LINE_NUMBERS" == true ]] && echo "Yes" || echo "No")"
4006
+ echo " Global replace: $([[ "$GLOBAL_REPLACE" == true ]] && echo "Yes" || echo "No")"
4007
+ echo "Tool flags:"
4008
+ echo " Find flags: $FIND_FLAGS"
4009
+ echo " Sed flags: $SED_FLAGS"
4010
+ echo " Grep flags: $GREP_FLAGS"
4011
+ echo "Backups: $([[ "$CREATE_BACKUPS" == true ]] && echo "Enabled" || echo "Disabled")"
4012
+ echo "Force backup: $([[ "$FORCE_BACKUP" == true ]] && echo "Yes" || echo "No")"
4013
+ echo "Preserve ownership: $([[ "$PRESERVE_OWNERSHIP" == true ]] && echo "Yes" || echo "No")"
4014
+ echo "Replace mode: $REPLACE_MODE"
4015
+ echo "Verbose mode: $([[ "$VERBOSE_MODE" == true ]] && echo "Yes" || echo "No")"
4016
+
4017
+ if [[ -n "$OUTPUT_DIR" ]]; then
4018
+ echo "Output directory: $OUTPUT_DIR"
4019
+ fi
4020
+
4021
+ if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
4022
+ echo "Backup directory: $BACKUP_DIR"
4023
+ echo "Backup metadata: $([[ -f "$BACKUP_DIR/.sr_session_metadata" ]] && echo "Yes" || echo "No")"
4024
+ local filelist_count=0
4025
+ if [[ -f "$BACKUP_DIR/.sr_modified_files" ]]; then
4026
+ filelist_count=$(wc -l <"$BACKUP_DIR/.sr_modified_files" 2>/dev/null || echo "0")
4027
+ fi
4028
+ echo "Files tracked: $filelist_count"
4029
+ [[ -n "$FIRST_FILE_OWNER" ]] &&
4030
+ echo "Backup owner: $FIRST_FILE_OWNER:$FIRST_FILE_GROUP"
4031
+ fi
4032
+
4033
+ echo "Excluded patterns: $EXCLUDE_PATTERNS"
4034
+ echo "Excluded dirs: $EXCLUDE_DIRS"
4035
+
4036
+ # Show modified files if verbose
4037
+ if [[ "$VERBOSE_MODE" == true ]] && [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
4038
+ local modified_list="$BACKUP_DIR/.sr_modified_files"
4039
+ if [[ -f "$modified_list" ]] && [[ $tracked_files_count -gt 0 ]]; then
4040
+ echo ""
4041
+ log_info "Modified files in this session:"
4042
+ local display_count=0
4043
+ while IFS= read -r line; do
4044
+ [[ $display_count -ge 10 ]] && break
4045
+ echo " - $line"
4046
+ RESTORED_COUNT=$((RESTORED_COUNT + 1))
4047
+ done <"$modified_list"
4048
+ [[ $tracked_files_count -gt 10 ]] &&
4049
+ echo " ... and $((tracked_files_count - 10)) more"
4050
+ fi
4051
+ fi
4052
+
4053
+ if [[ "$DRY_RUN" == true ]]; then
4054
+ echo ""
4055
+ log_warning "NOTE: Dry-run mode was active."
4056
+ echo " No files were actually modified."
4057
+ fi
4058
+
4059
+ if [[ "$DEBUG_MODE" == true ]]; then
4060
+ echo ""
4061
+ log_warning "NOTE: Debug mode was active."
4062
+ fi
4063
+
4064
+ if [[ "$FORCE_BACKUP" == true ]] && [[ "$CREATE_BACKUPS" == true ]]; then
4065
+ echo ""
4066
+ log_warning "NOTE: Backups were forced with --force-backup option."
4067
+ fi
4068
+
4069
+ if [[ "$REPLACE_MODE" == "copy" ]] && [[ -n "$OUTPUT_DIR" ]]; then
4070
+ echo ""
4071
+ log_warning "NOTE: Copy mode active. Original files unchanged."
4072
+ echo " Modified files saved to: $OUTPUT_DIR"
4073
+ fi
4074
+
4075
+ if [[ "$REPLACE_MODE" == "backup_only" ]]; then
4076
+ echo ""
4077
+ log_warning "NOTE: Backup-only mode active. Original files unchanged."
4078
+ fi
4079
+
4080
+ # Rollback information
4081
+ if [[ "$CREATE_BACKUPS" == true ]] && [[ -n "$BACKUP_DIR" ]]; then
4082
+ echo ""
4083
+ log_info "Rollback commands:"
4084
+ echo " Restore this session: $0 --rollback=$BACKUP_DIR"
4085
+ echo " Restore latest: $0 --rollback"
4086
+ echo " List all backups: $0 --rollback-list"
4087
+ fi
4088
+
4089
+ if [[ "$MODIFIED_FILES" -eq 0 ]] && [[ "$DRY_RUN" != true ]]; then
4090
+ log_warning "No replacements were made. Search pattern not found in any files."
4091
+ exit 3
4092
+ fi
4093
+
4094
+ [[ "$PROCESSED_FILES" -gt 0 ]] && log_success "Operation completed successfully"
4095
+ }
4096
+
4097
+ # ============================================================================
4098
+ # MAIN EXECUTION FUNCTION
4099
+ # ============================================================================
4100
+
4101
+ main() {
4102
+ # Force initialize arrays for safety
4103
+ declare -g SESSION_MODIFIED_FILES=()
4104
+ declare -g SESSION_INITIAL_ARGS=()
4105
+
4106
+ log_debug "Main function started"
4107
+ log_debug "Arguments: $@"
4108
+ log_debug "SESSION_MODIFIED_FILES initialized: ${#SESSION_MODIFIED_FILES[@]} items"
4109
+
4110
+ local start_time end_time duration
4111
+
4112
+ start_time=$(date +%s.%N)
4113
+
4114
+ validate_environment
4115
+ parse_arguments "$@"
4116
+
4117
+ log_debug "After parse_arguments: SESSION_MODIFIED_FILES has ${#SESSION_MODIFIED_FILES[@]} items"
4118
+
4119
+ # Mode warnings
4120
+ [[ "$DRY_RUN" == true ]] && log_warning "DRY RUN MODE - No files will be modified"
4121
+ [[ "$DEBUG_MODE" == true ]] && log_warning "DEBUG MODE - Detailed logging enabled"
4122
+ [[ "$VERBOSE_MODE" == true ]] && log_warning "VERBOSE MODE - Detailed output enabled"
4123
+ [[ "$CREATE_BACKUPS" == false ]] && log_warning "BACKUP CREATION DISABLED - No backup files will be created"
4124
+ [[ "$FORCE_BACKUP" == true ]] && log_warning "FORCE BACKUP ENABLED - Overriding backup settings"
4125
+ [[ "$REPLACE_MODE" != "inplace" ]] && log_warning "REPLACE MODE: $REPLACE_MODE"
4126
+ [[ "$ALLOW_BINARY" == true ]] && log_warning "BINARY PROCESSING ALLOWED - Binary files will be modified"
4127
+ [[ "$IGNORE_CASE" == true ]] && log_warning "IGNORE CASE ENABLED - Case-insensitive search"
4128
+ [[ "$EXTENDED_REGEX" == true ]] && log_warning "EXTENDED REGEX ENABLED - Using extended regular expressions"
4129
+ [[ "$WORD_BOUNDARY" == true ]] && log_warning "WORD BOUNDARY ENABLED - Matching whole words only"
4130
+
4131
+ # Binary detection method info
4132
+ log_info "Binary detection method: $BINARY_DETECTION_METHOD"
4133
+ if [[ "$BINARY_DETECTION_METHOD" == "file_only" ]] && ! command -v file >/dev/null 2>&1; then
4134
+ log_warning "file utility not found. Binary detection may fail."
4135
+ fi
4136
+
4137
+ process_files
4138
+
4139
+ log_debug "After process_files: SESSION_MODIFIED_FILES has ${#SESSION_MODIFIED_FILES[@]} items"
4140
+
4141
+ # Final metadata save after processing all files
4142
+ if [[ "$CREATE_BACKUPS" == true ]] && [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
4143
+ finalize_session_metadata
4144
+ fi
4145
+
4146
+ end_time=$(date +%s.%N)
4147
+ duration=$(echo "$end_time - $start_time" | bc 2>/dev/null || echo "0" | awk '{printf "%.2f", $0}')
4148
+
4149
+ show_summary
4150
+ log_verbose "Execution time: ${duration} seconds"
4151
+
4152
+ # Cleanup old backups
4153
+ if [[ "$CREATE_BACKUPS" == true ]] && [[ "$MAX_BACKUPS" -gt 0 ]] && [[ "$DRY_RUN" != true ]]; then
4154
+ cleanup_old_backups
4155
+ fi
4156
+
4157
+ exit 0
4158
+ }
4159
+
4160
+ # ============================================================================
4161
+ # ERROR HANDLING AND ENTRY POINT
4162
+ # ============================================================================
4163
+
4164
+ trap 'log_error "Script interrupted by user"; exit 1' INT TERM
4165
+ trap 'log_error "Error occurred at line $LINENO"; exit 4' ERR
4166
+
4167
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
4168
+ [[ $# -eq 0 ]] && {
4169
+ show_help
4170
+ exit 0
4171
+ }
4172
+
4173
+ # Quick help/version checks without full parsing
4174
+ for arg in "$@"; do
4175
+ if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
4176
+ show_help
4177
+ exit 0
4178
+ fi
4179
+ if [[ "$arg" == "-V" || "$arg" == "--version" ]]; then
4180
+ show_version
4181
+ exit 0
4182
+ fi
4183
+ done
4184
+
4185
+ main "$@"
4186
+ fi