fortrangoingonforty/ferp / ed71a95

Browse files

integration testing

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ed71a957505955b52fd813e03044d35640eebb32
Parents
4811614
Tree
a3eb9f6

1 changed file

StatusFile+-
A tests/integration_test.sh 1973 0
tests/integration_test.shadded
1973 lines changed — click to load
@@ -0,0 +1,1973 @@
1
+#!/bin/bash
2
+#
3
+# FERP Integration Test Suite
4
+# Comprehensive tests for all command-line flags
5
+#
6
+# Usage: ./integration_test.sh [--compare-grep] [--verbose] [--filter PATTERN]
7
+#
8
+# Options:
9
+#   --compare-grep    Compare ferp output against GNU grep
10
+#   --verbose         Show detailed output for each test
11
+#   --filter PATTERN  Only run tests matching PATTERN
12
+#
13
+
14
+set -uo pipefail
15
+# Note: not using -e because we want tests to continue on failure
16
+
17
+# Colors for output
18
+RED='\033[0;31m'
19
+GREEN='\033[0;32m'
20
+YELLOW='\033[1;33m'
21
+BLUE='\033[0;34m'
22
+NC='\033[0m' # No Color
23
+
24
+# Test counters
25
+TESTS_RUN=0
26
+TESTS_PASSED=0
27
+TESTS_FAILED=0
28
+TESTS_SKIPPED=0
29
+
30
+# Options
31
+COMPARE_GREP=false
32
+VERBOSE=false
33
+FILTER=""
34
+
35
+# Paths
36
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37
+FERP="${SCRIPT_DIR}/../ferp"
38
+FIXTURES="${SCRIPT_DIR}/fixtures"
39
+
40
+# Parse arguments
41
+while [[ $# -gt 0 ]]; do
42
+    case $1 in
43
+        --compare-grep)
44
+            COMPARE_GREP=true
45
+            shift
46
+            ;;
47
+        --verbose)
48
+            VERBOSE=true
49
+            shift
50
+            ;;
51
+        --filter)
52
+            FILTER="$2"
53
+            shift 2
54
+            ;;
55
+        *)
56
+            echo "Unknown option: $1"
57
+            exit 1
58
+            ;;
59
+    esac
60
+done
61
+
62
+# Check ferp exists
63
+if [[ ! -x "$FERP" ]]; then
64
+    echo -e "${RED}Error: ferp binary not found at $FERP${NC}"
65
+    echo "Run 'make' first to build ferp"
66
+    exit 1
67
+fi
68
+
69
+#------------------------------------------------------------------------------
70
+# Test Framework Functions
71
+#------------------------------------------------------------------------------
72
+
73
+log_test() {
74
+    local name="$1"
75
+    if [[ "$VERBOSE" == "true" ]]; then
76
+        echo -e "${BLUE}Running:${NC} $name"
77
+    fi
78
+}
79
+
80
+pass() {
81
+    local name="$1"
82
+    ((TESTS_PASSED++))
83
+    ((TESTS_RUN++))
84
+    echo -e "${GREEN}PASS${NC}: $name"
85
+}
86
+
87
+fail() {
88
+    local name="$1"
89
+    local reason="${2:-}"
90
+    ((TESTS_FAILED++))
91
+    ((TESTS_RUN++))
92
+    echo -e "${RED}FAIL${NC}: $name"
93
+    if [[ -n "$reason" ]]; then
94
+        echo -e "      ${YELLOW}Reason:${NC} $reason"
95
+    fi
96
+}
97
+
98
+skip() {
99
+    local name="$1"
100
+    local reason="${2:-}"
101
+    ((TESTS_SKIPPED++))
102
+    echo -e "${YELLOW}SKIP${NC}: $name${reason:+ ($reason)}"
103
+}
104
+
105
+should_run() {
106
+    local name="$1"
107
+    if [[ -n "$FILTER" && ! "$name" =~ $FILTER ]]; then
108
+        return 1
109
+    fi
110
+    return 0
111
+}
112
+
113
+# Run ferp and check exit code
114
+# Usage: run_ferp_expect EXIT_CODE ARGS...
115
+run_ferp_expect() {
116
+    local expected_exit="$1"
117
+    shift
118
+    local actual_exit=0
119
+    "$FERP" "$@" >/dev/null 2>&1 || actual_exit=$?
120
+    [[ "$actual_exit" -eq "$expected_exit" ]]
121
+}
122
+
123
+# Run ferp and capture output
124
+# Usage: run_ferp ARGS...
125
+run_ferp() {
126
+    "$FERP" "$@" 2>/dev/null || true
127
+}
128
+
129
+# Compare ferp output to expected string
130
+# Usage: assert_output "expected" ferp_args...
131
+assert_output() {
132
+    local expected="$1"
133
+    shift
134
+    local actual
135
+    actual=$("$FERP" "$@" 2>/dev/null) || true
136
+    [[ "$actual" == "$expected" ]]
137
+}
138
+
139
+# Compare ferp output to grep output
140
+# Usage: assert_matches_grep ARGS...
141
+assert_matches_grep() {
142
+    if [[ "$COMPARE_GREP" != "true" ]]; then
143
+        return 0
144
+    fi
145
+    local ferp_out grep_out
146
+    ferp_out=$("$FERP" "$@" 2>/dev/null) || true
147
+    grep_out=$(grep "$@" 2>/dev/null) || true
148
+    [[ "$ferp_out" == "$grep_out" ]]
149
+}
150
+
151
+# Count output lines
152
+# Usage: assert_line_count EXPECTED ferp_args...
153
+assert_line_count() {
154
+    local expected="$1"
155
+    shift
156
+    local actual
157
+    actual=$("$FERP" "$@" 2>/dev/null | wc -l) || true
158
+    [[ "$actual" -eq "$expected" ]]
159
+}
160
+
161
+# Check that output contains a string
162
+# Usage: assert_contains "substring" ferp_args...
163
+assert_contains() {
164
+    local substring="$1"
165
+    shift
166
+    local output
167
+    output=$("$FERP" "$@" 2>/dev/null) || true
168
+    [[ "$output" == *"$substring"* ]]
169
+}
170
+
171
+# Check that output does NOT contain a string
172
+# Usage: assert_not_contains "substring" ferp_args...
173
+assert_not_contains() {
174
+    local substring="$1"
175
+    shift
176
+    local output
177
+    output=$("$FERP" "$@" 2>/dev/null) || true
178
+    [[ "$output" != *"$substring"* ]]
179
+}
180
+
181
+#------------------------------------------------------------------------------
182
+# Test Categories
183
+#------------------------------------------------------------------------------
184
+
185
+section() {
186
+    echo ""
187
+    echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
188
+    echo -e "${BLUE}  $1${NC}"
189
+    echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
190
+}
191
+
192
+#------------------------------------------------------------------------------
193
+# PATTERN TYPE TESTS (-E, -F, -G, -P)
194
+#------------------------------------------------------------------------------
195
+
196
+test_pattern_types() {
197
+    section "Pattern Type Tests (-E, -F, -G, -P)"
198
+    local name
199
+
200
+    # -G / --basic-regexp (BRE - default)
201
+    name="-G: BRE is default mode"
202
+    should_run "$name" && {
203
+        log_test "$name"
204
+        # "hello" should match lines containing "hello"
205
+        if assert_contains "hello world" "hello" "$FIXTURES/simple.txt"; then
206
+            pass "$name"
207
+        else
208
+            fail "$name"
209
+        fi
210
+    }
211
+
212
+    name="-G: BRE treats + as literal"
213
+    should_run "$name" && {
214
+        log_test "$name"
215
+        if assert_contains "a+b equals c" "a+b" "$FIXTURES/special_chars.txt"; then
216
+            pass "$name"
217
+        else
218
+            fail "$name"
219
+        fi
220
+    }
221
+
222
+    name="-G: BRE treats | as literal"
223
+    should_run "$name" && {
224
+        log_test "$name"
225
+        if assert_contains "pipe|character" "pipe|character" "$FIXTURES/special_chars.txt"; then
226
+            pass "$name"
227
+        else
228
+            fail "$name"
229
+        fi
230
+    }
231
+
232
+    name="-G: BRE escaped grouping \\(\\)"
233
+    should_run "$name" && {
234
+        log_test "$name"
235
+        if echo "abab" | "$FERP" '\(ab\)\{2\}' | grep -q "abab"; then
236
+            pass "$name"
237
+        else
238
+            fail "$name"
239
+        fi
240
+    }
241
+
242
+    # -E / --extended-regexp (ERE)
243
+    name="-E: ERE + quantifier"
244
+    should_run "$name" && {
245
+        log_test "$name"
246
+        if echo "helllo" | "$FERP" -E "hel+o" | grep -q "helllo"; then
247
+            pass "$name"
248
+        else
249
+            fail "$name"
250
+        fi
251
+    }
252
+
253
+    name="-E: ERE ? quantifier (match)"
254
+    should_run "$name" && {
255
+        log_test "$name"
256
+        if echo "helo" | "$FERP" -E "hel?o" | grep -q "helo"; then
257
+            pass "$name"
258
+        else
259
+            fail "$name"
260
+        fi
261
+    }
262
+
263
+    name="-E: ERE ? quantifier (zero match)"
264
+    should_run "$name" && {
265
+        log_test "$name"
266
+        if echo "heo" | "$FERP" -E "hel?o" | grep -q "heo"; then
267
+            pass "$name"
268
+        else
269
+            fail "$name"
270
+        fi
271
+    }
272
+
273
+    name="-E: ERE alternation |"
274
+    should_run "$name" && {
275
+        log_test "$name"
276
+        local out
277
+        out=$(printf "cat\ndog\nbird\n" | "$FERP" -E "cat|dog")
278
+        if [[ "$out" == $'cat\ndog' ]]; then
279
+            pass "$name"
280
+        else
281
+            fail "$name" "got: $out"
282
+        fi
283
+    }
284
+
285
+    name="-E: ERE unescaped grouping ()"
286
+    should_run "$name" && {
287
+        log_test "$name"
288
+        if echo "abab" | "$FERP" -E "(ab){2}" | grep -q "abab"; then
289
+            pass "$name"
290
+        else
291
+            fail "$name"
292
+        fi
293
+    }
294
+
295
+    name="--extended-regexp: long form works"
296
+    should_run "$name" && {
297
+        log_test "$name"
298
+        if echo "hello" | "$FERP" --extended-regexp "hel+" | grep -q "hello"; then
299
+            pass "$name"
300
+        else
301
+            fail "$name"
302
+        fi
303
+    }
304
+
305
+    # -F / --fixed-strings
306
+    name="-F: treats regex chars as literal"
307
+    should_run "$name" && {
308
+        log_test "$name"
309
+        if assert_contains "regex: foo.*bar" -F "foo.*bar" "$FIXTURES/special_chars.txt"; then
310
+            pass "$name"
311
+        else
312
+            fail "$name"
313
+        fi
314
+    }
315
+
316
+    name="-F: literal $ match"
317
+    should_run "$name" && {
318
+        log_test "$name"
319
+        if assert_contains 'price is $100' -F '$100' "$FIXTURES/special_chars.txt"; then
320
+            pass "$name"
321
+        else
322
+            fail "$name"
323
+        fi
324
+    }
325
+
326
+    name="-F: literal brackets match"
327
+    should_run "$name" && {
328
+        log_test "$name"
329
+        if assert_contains "brackets [test] here" -F "[test]" "$FIXTURES/special_chars.txt"; then
330
+            pass "$name"
331
+        else
332
+            fail "$name"
333
+        fi
334
+    }
335
+
336
+    name="--fixed-strings: long form works"
337
+    should_run "$name" && {
338
+        log_test "$name"
339
+        if assert_contains "a+b equals c" --fixed-strings "a+b" "$FIXTURES/special_chars.txt"; then
340
+            pass "$name"
341
+        else
342
+            fail "$name"
343
+        fi
344
+    }
345
+
346
+    # -P / --perl-regexp (PCRE)
347
+    name="-P: basic PCRE match"
348
+    should_run "$name" && {
349
+        log_test "$name"
350
+        if echo "hello world" | "$FERP" -P "hello" | grep -q "hello"; then
351
+            pass "$name"
352
+        else
353
+            fail "$name"
354
+        fi
355
+    }
356
+
357
+    name="-P: PCRE \\d digit class"
358
+    should_run "$name" && {
359
+        log_test "$name"
360
+        if echo "test123" | "$FERP" -P '\d+' | grep -q "test123"; then
361
+            pass "$name"
362
+        else
363
+            fail "$name"
364
+        fi
365
+    }
366
+
367
+    name="-P: PCRE \\w word class"
368
+    should_run "$name" && {
369
+        log_test "$name"
370
+        if echo "hello_world" | "$FERP" -P '\w+' | grep -q "hello_world"; then
371
+            pass "$name"
372
+        else
373
+            fail "$name"
374
+        fi
375
+    }
376
+
377
+    name="-P: PCRE positive lookahead (?=)"
378
+    should_run "$name" && {
379
+        log_test "$name"
380
+        if echo "foobar" | "$FERP" -P 'foo(?=bar)' | grep -q "foobar"; then
381
+            pass "$name"
382
+        else
383
+            fail "$name"
384
+        fi
385
+    }
386
+
387
+    name="-P: PCRE negative lookahead (?!)"
388
+    should_run "$name" && {
389
+        log_test "$name"
390
+        if echo "foobaz" | "$FERP" -P 'foo(?!bar)' | grep -q "foobaz"; then
391
+            pass "$name"
392
+        else
393
+            fail "$name"
394
+        fi
395
+    }
396
+
397
+    name="-P: PCRE positive lookbehind (?<=)"
398
+    should_run "$name" && {
399
+        log_test "$name"
400
+        if echo "foobar" | "$FERP" -P '(?<=foo)bar' | grep -q "foobar"; then
401
+            pass "$name"
402
+        else
403
+            fail "$name"
404
+        fi
405
+    }
406
+
407
+    name="-P: PCRE non-capturing group (?:)"
408
+    should_run "$name" && {
409
+        log_test "$name"
410
+        if echo "abcabc" | "$FERP" -P '(?:abc)+' | grep -q "abcabc"; then
411
+            pass "$name"
412
+        else
413
+            fail "$name"
414
+        fi
415
+    }
416
+}
417
+
418
+#------------------------------------------------------------------------------
419
+# MATCHING CONTROL TESTS (-i, -v, -w, -x)
420
+#------------------------------------------------------------------------------
421
+
422
+test_matching_control() {
423
+    section "Matching Control Tests (-i, -v, -w, -x)"
424
+    local name
425
+
426
+    # -i / --ignore-case
427
+    name="-i: case insensitive match (lowercase pattern)"
428
+    should_run "$name" && {
429
+        log_test "$name"
430
+        local out
431
+        out=$("$FERP" -i "hello" "$FIXTURES/simple.txt" | wc -l)
432
+        if [[ "$out" -eq 4 ]]; then  # hello, Hello, HELLO, line with hello
433
+            pass "$name"
434
+        else
435
+            fail "$name" "expected 4 lines, got $out"
436
+        fi
437
+    }
438
+
439
+    name="-i: case insensitive match (uppercase pattern)"
440
+    should_run "$name" && {
441
+        log_test "$name"
442
+        local out
443
+        out=$("$FERP" -i "HELLO" "$FIXTURES/simple.txt" | wc -l)
444
+        if [[ "$out" -eq 4 ]]; then
445
+            pass "$name"
446
+        else
447
+            fail "$name" "expected 4 lines, got $out"
448
+        fi
449
+    }
450
+
451
+    name="-i: case insensitive with -F"
452
+    should_run "$name" && {
453
+        log_test "$name"
454
+        local out
455
+        out=$("$FERP" -iF "HELLO" "$FIXTURES/simple.txt" | wc -l)
456
+        if [[ "$out" -eq 4 ]]; then
457
+            pass "$name"
458
+        else
459
+            fail "$name" "expected 4 lines, got $out"
460
+        fi
461
+    }
462
+
463
+    name="-i: case insensitive with -E"
464
+    should_run "$name" && {
465
+        log_test "$name"
466
+        local out
467
+        out=$("$FERP" -iE "hel+" "$FIXTURES/simple.txt" | wc -l)
468
+        if [[ "$out" -eq 4 ]]; then
469
+            pass "$name"
470
+        else
471
+            fail "$name" "expected 4 lines, got $out"
472
+        fi
473
+    }
474
+
475
+    name="--ignore-case: long form works"
476
+    should_run "$name" && {
477
+        log_test "$name"
478
+        local out
479
+        out=$("$FERP" --ignore-case "HELLO" "$FIXTURES/simple.txt" | wc -l)
480
+        if [[ "$out" -eq 4 ]]; then
481
+            pass "$name"
482
+        else
483
+            fail "$name"
484
+        fi
485
+    }
486
+
487
+    name="--no-ignore-case: disables case insensitivity"
488
+    should_run "$name" && {
489
+        log_test "$name"
490
+        local out
491
+        out=$("$FERP" -i --no-ignore-case "hello" "$FIXTURES/simple.txt" | wc -l)
492
+        if [[ "$out" -eq 2 ]]; then  # only lowercase hello
493
+            pass "$name"
494
+        else
495
+            fail "$name" "expected 2 lines, got $out"
496
+        fi
497
+    }
498
+
499
+    # -v / --invert-match
500
+    name="-v: invert match"
501
+    should_run "$name" && {
502
+        log_test "$name"
503
+        if assert_not_contains "hello" -v "hello" "$FIXTURES/simple.txt"; then
504
+            pass "$name"
505
+        else
506
+            fail "$name"
507
+        fi
508
+    }
509
+
510
+    name="-v: inverted results count"
511
+    should_run "$name" && {
512
+        log_test "$name"
513
+        local total matching inverted
514
+        total=$("$FERP" "" "$FIXTURES/simple.txt" | wc -l)
515
+        matching=$("$FERP" "hello" "$FIXTURES/simple.txt" | wc -l)
516
+        inverted=$("$FERP" -v "hello" "$FIXTURES/simple.txt" | wc -l)
517
+        if [[ $((matching + inverted)) -eq "$total" ]]; then
518
+            pass "$name"
519
+        else
520
+            fail "$name" "matching($matching) + inverted($inverted) != total($total)"
521
+        fi
522
+    }
523
+
524
+    name="--invert-match: long form works"
525
+    should_run "$name" && {
526
+        log_test "$name"
527
+        if assert_not_contains "hello" --invert-match "hello" "$FIXTURES/simple.txt"; then
528
+            pass "$name"
529
+        else
530
+            fail "$name"
531
+        fi
532
+    }
533
+
534
+    # -w / --word-regexp
535
+    name="-w: matches whole word only"
536
+    should_run "$name" && {
537
+        log_test "$name"
538
+        local out
539
+        out=$("$FERP" -w "test" "$FIXTURES/words.txt")
540
+        # Should match "test" but not "testing", "tested", "tester"
541
+        if [[ "$out" == "test testing tested tester" ]]; then
542
+            pass "$name"
543
+        else
544
+            fail "$name" "got: $out"
545
+        fi
546
+    }
547
+
548
+    name="-w: does not match partial word"
549
+    should_run "$name" && {
550
+        log_test "$name"
551
+        local out
552
+        out=$("$FERP" -w "foo" "$FIXTURES/words.txt" | wc -l)
553
+        # "foobar foo bar foo-bar" - should match "foo" twice (standalone and in foo-bar)
554
+        if [[ "$out" -eq 1 ]]; then
555
+            pass "$name"
556
+        else
557
+            fail "$name" "expected 1 line, got $out"
558
+        fi
559
+    }
560
+
561
+    name="-w: word with -F"
562
+    should_run "$name" && {
563
+        log_test "$name"
564
+        local out
565
+        out=$("$FERP" -wF "hello" "$FIXTURES/words.txt")
566
+        if [[ "$out" == "hello helloworld worldhello" ]]; then
567
+            pass "$name"
568
+        else
569
+            fail "$name" "got: $out"
570
+        fi
571
+    }
572
+
573
+    name="--word-regexp: long form works"
574
+    should_run "$name" && {
575
+        log_test "$name"
576
+        local out
577
+        out=$("$FERP" --word-regexp "test" "$FIXTURES/words.txt")
578
+        if [[ "$out" == "test testing tested tester" ]]; then
579
+            pass "$name"
580
+        else
581
+            fail "$name"
582
+        fi
583
+    }
584
+
585
+    # -x / --line-regexp
586
+    name="-x: matches whole line only"
587
+    should_run "$name" && {
588
+        log_test "$name"
589
+        local out
590
+        out=$("$FERP" -x "hello world" "$FIXTURES/simple.txt")
591
+        if [[ "$out" == "hello world" ]]; then
592
+            pass "$name"
593
+        else
594
+            fail "$name" "got: $out"
595
+        fi
596
+    }
597
+
598
+    name="-x: does not match partial line"
599
+    should_run "$name" && {
600
+        log_test "$name"
601
+        if run_ferp_expect 1 -x "hello" "$FIXTURES/simple.txt"; then
602
+            pass "$name"
603
+        else
604
+            fail "$name"
605
+        fi
606
+    }
607
+
608
+    name="-x: with regex"
609
+    should_run "$name" && {
610
+        log_test "$name"
611
+        local out
612
+        out=$("$FERP" -xE "hello.*" "$FIXTURES/simple.txt")
613
+        # Should match lines starting with hello
614
+        if echo "$out" | grep -q "hello world"; then
615
+            pass "$name"
616
+        else
617
+            fail "$name"
618
+        fi
619
+    }
620
+
621
+    name="--line-regexp: long form works"
622
+    should_run "$name" && {
623
+        log_test "$name"
624
+        local out
625
+        out=$("$FERP" --line-regexp "line 5" "$FIXTURES/numbers.txt")
626
+        if [[ "$out" == "line 5" ]]; then
627
+            pass "$name"
628
+        else
629
+            fail "$name"
630
+        fi
631
+    }
632
+
633
+    # Combined flags
634
+    name="-iv: case insensitive inverted"
635
+    should_run "$name" && {
636
+        log_test "$name"
637
+        local out
638
+        out=$("$FERP" -iv "HELLO" "$FIXTURES/simple.txt" | wc -l)
639
+        # Should exclude all hello variants
640
+        if [[ "$out" -eq 4 ]]; then  # 8 lines - 4 with hello
641
+            pass "$name"
642
+        else
643
+            fail "$name" "expected 4 lines, got $out"
644
+        fi
645
+    }
646
+
647
+    name="-iw: case insensitive word match"
648
+    should_run "$name" && {
649
+        log_test "$name"
650
+        local out
651
+        out=$(echo -e "TEST\nTesting\ntest" | "$FERP" -iw "test" | wc -l)
652
+        if [[ "$out" -eq 2 ]]; then  # TEST and test, not Testing
653
+            pass "$name"
654
+        else
655
+            fail "$name" "expected 2, got $out"
656
+        fi
657
+    }
658
+}
659
+
660
+#------------------------------------------------------------------------------
661
+# OUTPUT CONTROL TESTS (-c, -l, -L, -o, -q, -s)
662
+#------------------------------------------------------------------------------
663
+
664
+test_output_control() {
665
+    section "Output Control Tests (-c, -l, -L, -o, -q, -s)"
666
+    local name
667
+
668
+    # -c / --count
669
+    name="-c: count matching lines"
670
+    should_run "$name" && {
671
+        log_test "$name"
672
+        local out
673
+        out=$("$FERP" -c "hello" "$FIXTURES/simple.txt")
674
+        if [[ "$out" == "2" ]]; then
675
+            pass "$name"
676
+        else
677
+            fail "$name" "expected 2, got $out"
678
+        fi
679
+    }
680
+
681
+    name="-c: count with multiple files shows filename"
682
+    should_run "$name" && {
683
+        log_test "$name"
684
+        local out
685
+        out=$("$FERP" -c "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile2.txt")
686
+        if echo "$out" | grep -q "multifile1.txt:2" && echo "$out" | grep -q "multifile2.txt:1"; then
687
+            pass "$name"
688
+        else
689
+            fail "$name" "got: $out"
690
+        fi
691
+    }
692
+
693
+    name="-c: zero count when no matches"
694
+    should_run "$name" && {
695
+        log_test "$name"
696
+        local out
697
+        out=$("$FERP" -c "nonexistent" "$FIXTURES/simple.txt")
698
+        if [[ "$out" == "0" ]]; then
699
+            pass "$name"
700
+        else
701
+            fail "$name" "expected 0, got $out"
702
+        fi
703
+    }
704
+
705
+    name="--count: long form works"
706
+    should_run "$name" && {
707
+        log_test "$name"
708
+        local out
709
+        out=$("$FERP" --count "line" "$FIXTURES/numbers.txt")
710
+        if [[ "$out" == "10" ]]; then
711
+            pass "$name"
712
+        else
713
+            fail "$name"
714
+        fi
715
+    }
716
+
717
+    # -l / --files-with-matches
718
+    name="-l: list files with matches"
719
+    should_run "$name" && {
720
+        log_test "$name"
721
+        local out
722
+        out=$("$FERP" -l "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile2.txt" "$FIXTURES/multifile3.txt")
723
+        if echo "$out" | grep -q "multifile1.txt" && echo "$out" | grep -q "multifile2.txt" && ! echo "$out" | grep -q "multifile3.txt"; then
724
+            pass "$name"
725
+        else
726
+            fail "$name" "got: $out"
727
+        fi
728
+    }
729
+
730
+    name="-l: only prints filename once per file"
731
+    should_run "$name" && {
732
+        log_test "$name"
733
+        local out
734
+        out=$("$FERP" -l "match" "$FIXTURES/multifile1.txt" | wc -l)
735
+        if [[ "$out" -eq 1 ]]; then
736
+            pass "$name"
737
+        else
738
+            fail "$name" "expected 1 line, got $out"
739
+        fi
740
+    }
741
+
742
+    name="--files-with-matches: long form works"
743
+    should_run "$name" && {
744
+        log_test "$name"
745
+        local out
746
+        out=$("$FERP" --files-with-matches "match" "$FIXTURES/multifile1.txt")
747
+        if echo "$out" | grep -q "multifile1.txt"; then
748
+            pass "$name"
749
+        else
750
+            fail "$name"
751
+        fi
752
+    }
753
+
754
+    # -L / --files-without-match
755
+    name="-L: list files without matches"
756
+    should_run "$name" && {
757
+        log_test "$name"
758
+        local out
759
+        out=$("$FERP" -L "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile2.txt" "$FIXTURES/multifile3.txt")
760
+        if echo "$out" | grep -q "multifile3.txt" && ! echo "$out" | grep -q "multifile1.txt"; then
761
+            pass "$name"
762
+        else
763
+            fail "$name" "got: $out"
764
+        fi
765
+    }
766
+
767
+    name="--files-without-match: long form works"
768
+    should_run "$name" && {
769
+        log_test "$name"
770
+        local out
771
+        out=$("$FERP" --files-without-match "match" "$FIXTURES/multifile3.txt")
772
+        if echo "$out" | grep -q "multifile3.txt"; then
773
+            pass "$name"
774
+        else
775
+            fail "$name"
776
+        fi
777
+    }
778
+
779
+    # -o / --only-matching
780
+    name="-o: show only matching part"
781
+    should_run "$name" && {
782
+        log_test "$name"
783
+        local out
784
+        out=$("$FERP" -o "hello" "$FIXTURES/simple.txt")
785
+        # Should output "hello" for each match, not the whole line
786
+        if [[ "$out" == $'hello\nhello' ]]; then
787
+            pass "$name"
788
+        else
789
+            fail "$name" "got: $out"
790
+        fi
791
+    }
792
+
793
+    name="-o: multiple matches per line"
794
+    should_run "$name" && {
795
+        log_test "$name"
796
+        local out
797
+        out=$(echo "hello world hello" | "$FERP" -o "hello" | wc -l)
798
+        if [[ "$out" -eq 2 ]]; then
799
+            pass "$name"
800
+        else
801
+            fail "$name" "expected 2 lines, got $out"
802
+        fi
803
+    }
804
+
805
+    name="-o: with regex captures match"
806
+    should_run "$name" && {
807
+        log_test "$name"
808
+        local out
809
+        out=$(echo "test123test456" | "$FERP" -oE "[0-9]+")
810
+        if [[ "$out" == $'123\n456' ]]; then
811
+            pass "$name"
812
+        else
813
+            fail "$name" "got: $out"
814
+        fi
815
+    }
816
+
817
+    name="--only-matching: long form works"
818
+    should_run "$name" && {
819
+        log_test "$name"
820
+        local out
821
+        out=$(echo "abc123def" | "$FERP" --only-matching -E "[0-9]+")
822
+        if [[ "$out" == "123" ]]; then
823
+            pass "$name"
824
+        else
825
+            fail "$name"
826
+        fi
827
+    }
828
+
829
+    # -q / --quiet / --silent
830
+    name="-q: no output on match"
831
+    should_run "$name" && {
832
+        log_test "$name"
833
+        local out
834
+        out=$("$FERP" -q "hello" "$FIXTURES/simple.txt")
835
+        if [[ -z "$out" ]]; then
836
+            pass "$name"
837
+        else
838
+            fail "$name" "expected empty output, got: $out"
839
+        fi
840
+    }
841
+
842
+    name="-q: exit 0 on match"
843
+    should_run "$name" && {
844
+        log_test "$name"
845
+        if run_ferp_expect 0 -q "hello" "$FIXTURES/simple.txt"; then
846
+            pass "$name"
847
+        else
848
+            fail "$name"
849
+        fi
850
+    }
851
+
852
+    name="-q: exit 1 on no match"
853
+    should_run "$name" && {
854
+        log_test "$name"
855
+        if run_ferp_expect 1 -q "nonexistent" "$FIXTURES/simple.txt"; then
856
+            pass "$name"
857
+        else
858
+            fail "$name"
859
+        fi
860
+    }
861
+
862
+    name="--quiet: long form works"
863
+    should_run "$name" && {
864
+        log_test "$name"
865
+        if run_ferp_expect 0 --quiet "hello" "$FIXTURES/simple.txt"; then
866
+            pass "$name"
867
+        else
868
+            fail "$name"
869
+        fi
870
+    }
871
+
872
+    name="--silent: alias works"
873
+    should_run "$name" && {
874
+        log_test "$name"
875
+        if run_ferp_expect 0 --silent "hello" "$FIXTURES/simple.txt"; then
876
+            pass "$name"
877
+        else
878
+            fail "$name"
879
+        fi
880
+    }
881
+
882
+    # -s / --no-messages
883
+    name="-s: suppress error messages"
884
+    should_run "$name" && {
885
+        log_test "$name"
886
+        local err
887
+        err=$("$FERP" -s "test" "/nonexistent/file" 2>&1) || true
888
+        if [[ -z "$err" ]]; then
889
+            pass "$name"
890
+        else
891
+            fail "$name" "expected no output, got: $err"
892
+        fi
893
+    }
894
+
895
+    name="--no-messages: long form works"
896
+    should_run "$name" && {
897
+        log_test "$name"
898
+        local err
899
+        err=$("$FERP" --no-messages "test" "/nonexistent/file" 2>&1) || true
900
+        if [[ -z "$err" ]]; then
901
+            pass "$name"
902
+        else
903
+            fail "$name"
904
+        fi
905
+    }
906
+}
907
+
908
+#------------------------------------------------------------------------------
909
+# LINE PREFIX TESTS (-n, -b, -H, -h, -Z, -T)
910
+#------------------------------------------------------------------------------
911
+
912
+test_line_prefix() {
913
+    section "Line Prefix Tests (-n, -b, -H, -h, -Z, -T)"
914
+    local name
915
+
916
+    # -n / --line-number
917
+    name="-n: show line numbers"
918
+    should_run "$name" && {
919
+        log_test "$name"
920
+        local out
921
+        out=$("$FERP" -n "line 5" "$FIXTURES/numbers.txt")
922
+        if [[ "$out" == "5:line 5" ]]; then
923
+            pass "$name"
924
+        else
925
+            fail "$name" "got: $out"
926
+        fi
927
+    }
928
+
929
+    name="-n: line numbers start at 1"
930
+    should_run "$name" && {
931
+        log_test "$name"
932
+        local out
933
+        out=$("$FERP" -n "line 1" "$FIXTURES/numbers.txt")
934
+        if [[ "$out" == "1:line 1" ]]; then
935
+            pass "$name"
936
+        else
937
+            fail "$name" "got: $out"
938
+        fi
939
+    }
940
+
941
+    name="--line-number: long form works"
942
+    should_run "$name" && {
943
+        log_test "$name"
944
+        local out
945
+        out=$("$FERP" --line-number "line 3" "$FIXTURES/numbers.txt")
946
+        if [[ "$out" == "3:line 3" ]]; then
947
+            pass "$name"
948
+        else
949
+            fail "$name"
950
+        fi
951
+    }
952
+
953
+    # -b / --byte-offset
954
+    name="-b: show byte offset"
955
+    should_run "$name" && {
956
+        log_test "$name"
957
+        local out
958
+        out=$("$FERP" -b "line 1" "$FIXTURES/numbers.txt")
959
+        if [[ "$out" == "0:line 1" ]]; then
960
+            pass "$name"
961
+        else
962
+            fail "$name" "got: $out"
963
+        fi
964
+    }
965
+
966
+    name="-b: byte offset increases with lines"
967
+    should_run "$name" && {
968
+        log_test "$name"
969
+        local out
970
+        out=$("$FERP" -b "line 2" "$FIXTURES/numbers.txt")
971
+        # "line 1\n" is 7 bytes, so line 2 starts at 7
972
+        if [[ "$out" == "7:line 2" ]]; then
973
+            pass "$name"
974
+        else
975
+            fail "$name" "expected 7:line 2, got: $out"
976
+        fi
977
+    }
978
+
979
+    name="-nb: line number and byte offset together"
980
+    should_run "$name" && {
981
+        log_test "$name"
982
+        local out
983
+        out=$("$FERP" -nb "line 2" "$FIXTURES/numbers.txt")
984
+        if [[ "$out" == "2:7:line 2" ]]; then
985
+            pass "$name"
986
+        else
987
+            fail "$name" "got: $out"
988
+        fi
989
+    }
990
+
991
+    name="--byte-offset: long form works"
992
+    should_run "$name" && {
993
+        log_test "$name"
994
+        local out
995
+        out=$("$FERP" --byte-offset "line 1" "$FIXTURES/numbers.txt")
996
+        if [[ "$out" == "0:line 1" ]]; then
997
+            pass "$name"
998
+        else
999
+            fail "$name"
1000
+        fi
1001
+    }
1002
+
1003
+    # -H / --with-filename
1004
+    name="-H: show filename on single file"
1005
+    should_run "$name" && {
1006
+        log_test "$name"
1007
+        local out
1008
+        out=$("$FERP" -H "hello" "$FIXTURES/simple.txt" | head -1)
1009
+        if echo "$out" | grep -q "simple.txt:"; then
1010
+            pass "$name"
1011
+        else
1012
+            fail "$name" "got: $out"
1013
+        fi
1014
+    }
1015
+
1016
+    name="--with-filename: long form works"
1017
+    should_run "$name" && {
1018
+        log_test "$name"
1019
+        local out
1020
+        out=$("$FERP" --with-filename "hello" "$FIXTURES/simple.txt" | head -1)
1021
+        if echo "$out" | grep -q "simple.txt:"; then
1022
+            pass "$name"
1023
+        else
1024
+            fail "$name"
1025
+        fi
1026
+    }
1027
+
1028
+    # -h / --no-filename
1029
+    name="-h: hide filename on multiple files"
1030
+    should_run "$name" && {
1031
+        log_test "$name"
1032
+        local out
1033
+        out=$("$FERP" -h "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile2.txt" | head -1)
1034
+        if ! echo "$out" | grep -q ":"; then
1035
+            pass "$name"
1036
+        else
1037
+            fail "$name" "got: $out"
1038
+        fi
1039
+    }
1040
+
1041
+    name="--no-filename: long form works"
1042
+    should_run "$name" && {
1043
+        log_test "$name"
1044
+        local out
1045
+        out=$("$FERP" --no-filename "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile2.txt" | head -1)
1046
+        if ! echo "$out" | grep -q ":"; then
1047
+            pass "$name"
1048
+        else
1049
+            fail "$name"
1050
+        fi
1051
+    }
1052
+
1053
+    # -Z / --null (null byte after filename)
1054
+    name="-Z: null byte after filename"
1055
+    should_run "$name" && {
1056
+        log_test "$name"
1057
+        local out
1058
+        out=$("$FERP" -HZ "hello" "$FIXTURES/simple.txt" | head -1 | od -c | head -1)
1059
+        if echo "$out" | grep -q '\\0'; then
1060
+            pass "$name"
1061
+        else
1062
+            fail "$name" "expected null byte, got: $out"
1063
+        fi
1064
+    }
1065
+
1066
+    name="--null: long form works"
1067
+    should_run "$name" && {
1068
+        log_test "$name"
1069
+        local out
1070
+        out=$("$FERP" -H --null "hello" "$FIXTURES/simple.txt" | head -1 | od -c | head -1)
1071
+        if echo "$out" | grep -q '\\0'; then
1072
+            pass "$name"
1073
+        else
1074
+            fail "$name"
1075
+        fi
1076
+    }
1077
+
1078
+    # -T / --initial-tab
1079
+    name="-T: initial tab for alignment"
1080
+    should_run "$name" && {
1081
+        log_test "$name"
1082
+        local out
1083
+        out=$("$FERP" -nT "hello" "$FIXTURES/simple.txt" | head -1)
1084
+        # Should have tab between prefix and content
1085
+        if echo "$out" | grep -qP '^\d+:\t'; then
1086
+            pass "$name"
1087
+        else
1088
+            fail "$name" "got: $out"
1089
+        fi
1090
+    }
1091
+
1092
+    name="--initial-tab: long form works"
1093
+    should_run "$name" && {
1094
+        log_test "$name"
1095
+        local out
1096
+        out=$("$FERP" -n --initial-tab "hello" "$FIXTURES/simple.txt" | head -1)
1097
+        if echo "$out" | grep -qP '^\d+:\t'; then
1098
+            pass "$name"
1099
+        else
1100
+            fail "$name"
1101
+        fi
1102
+    }
1103
+}
1104
+
1105
+#------------------------------------------------------------------------------
1106
+# CONTEXT CONTROL TESTS (-A, -B, -C, -NUM)
1107
+#------------------------------------------------------------------------------
1108
+
1109
+test_context_control() {
1110
+    section "Context Control Tests (-A, -B, -C, -NUM)"
1111
+    local name
1112
+
1113
+    # -A / --after-context
1114
+    name="-A: after context lines"
1115
+    should_run "$name" && {
1116
+        log_test "$name"
1117
+        local out
1118
+        out=$("$FERP" -A2 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1119
+        if [[ "$out" -eq 3 ]]; then  # match + 2 after
1120
+            pass "$name"
1121
+        else
1122
+            fail "$name" "expected 3 lines, got $out"
1123
+        fi
1124
+    }
1125
+
1126
+    name="-A: after context content"
1127
+    should_run "$name" && {
1128
+        log_test "$name"
1129
+        local out
1130
+        out=$("$FERP" -A2 "MATCH LINE" "$FIXTURES/context.txt")
1131
+        if echo "$out" | grep -q "after 1" && echo "$out" | grep -q "after 2"; then
1132
+            pass "$name"
1133
+        else
1134
+            fail "$name" "got: $out"
1135
+        fi
1136
+    }
1137
+
1138
+    name="--after-context: long form with ="
1139
+    should_run "$name" && {
1140
+        log_test "$name"
1141
+        local out
1142
+        out=$("$FERP" --after-context=1 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1143
+        if [[ "$out" -eq 2 ]]; then
1144
+            pass "$name"
1145
+        else
1146
+            fail "$name"
1147
+        fi
1148
+    }
1149
+
1150
+    # -B / --before-context
1151
+    name="-B: before context lines"
1152
+    should_run "$name" && {
1153
+        log_test "$name"
1154
+        local out
1155
+        out=$("$FERP" -B2 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1156
+        if [[ "$out" -eq 3 ]]; then  # 2 before + match
1157
+            pass "$name"
1158
+        else
1159
+            fail "$name" "expected 3 lines, got $out"
1160
+        fi
1161
+    }
1162
+
1163
+    name="-B: before context content"
1164
+    should_run "$name" && {
1165
+        log_test "$name"
1166
+        local out
1167
+        out=$("$FERP" -B2 "MATCH LINE" "$FIXTURES/context.txt")
1168
+        if echo "$out" | grep -q "before 2" && echo "$out" | grep -q "before 3"; then
1169
+            pass "$name"
1170
+        else
1171
+            fail "$name" "got: $out"
1172
+        fi
1173
+    }
1174
+
1175
+    name="--before-context: long form with ="
1176
+    should_run "$name" && {
1177
+        log_test "$name"
1178
+        local out
1179
+        out=$("$FERP" --before-context=1 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1180
+        if [[ "$out" -eq 2 ]]; then
1181
+            pass "$name"
1182
+        else
1183
+            fail "$name"
1184
+        fi
1185
+    }
1186
+
1187
+    # -C / --context
1188
+    name="-C: both context lines"
1189
+    should_run "$name" && {
1190
+        log_test "$name"
1191
+        local out
1192
+        out=$("$FERP" -C2 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1193
+        if [[ "$out" -eq 5 ]]; then  # 2 before + match + 2 after
1194
+            pass "$name"
1195
+        else
1196
+            fail "$name" "expected 5 lines, got $out"
1197
+        fi
1198
+    }
1199
+
1200
+    name="--context: long form with ="
1201
+    should_run "$name" && {
1202
+        log_test "$name"
1203
+        local out
1204
+        out=$("$FERP" --context=1 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1205
+        if [[ "$out" -eq 3 ]]; then
1206
+            pass "$name"
1207
+        else
1208
+            fail "$name"
1209
+        fi
1210
+    }
1211
+
1212
+    # -NUM shorthand
1213
+    name="-NUM: numeric context shorthand"
1214
+    should_run "$name" && {
1215
+        log_test "$name"
1216
+        local out
1217
+        out=$("$FERP" -2 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1218
+        if [[ "$out" -eq 5 ]]; then  # Same as -C2
1219
+            pass "$name"
1220
+        else
1221
+            fail "$name" "expected 5 lines, got $out"
1222
+        fi
1223
+    }
1224
+
1225
+    name="-3: larger context"
1226
+    should_run "$name" && {
1227
+        log_test "$name"
1228
+        local out
1229
+        out=$("$FERP" -3 "MATCH LINE" "$FIXTURES/context.txt" | wc -l)
1230
+        if [[ "$out" -eq 7 ]]; then  # 3 before + match + 3 after
1231
+            pass "$name"
1232
+        else
1233
+            fail "$name" "expected 7 lines, got $out"
1234
+        fi
1235
+    }
1236
+
1237
+    # Context with multiple matches
1238
+    name="context: overlapping context groups"
1239
+    should_run "$name" && {
1240
+        log_test "$name"
1241
+        local out
1242
+        # Both MATCH and ANOTHER MATCH with context should not duplicate lines
1243
+        out=$("$FERP" -C2 "MATCH" "$FIXTURES/context.txt" | wc -l)
1244
+        # Should merge overlapping contexts appropriately
1245
+        if [[ "$out" -ge 8 ]]; then
1246
+            pass "$name"
1247
+        else
1248
+            fail "$name" "expected at least 8 lines, got $out"
1249
+        fi
1250
+    }
1251
+
1252
+    # --group-separator
1253
+    name="--group-separator: custom separator"
1254
+    should_run "$name" && {
1255
+        log_test "$name"
1256
+        local out
1257
+        out=$("$FERP" -C1 --group-separator="===" "MATCH" "$FIXTURES/context.txt")
1258
+        if echo "$out" | grep -q "==="; then
1259
+            pass "$name"
1260
+        else
1261
+            fail "$name" "got: $out"
1262
+        fi
1263
+    }
1264
+
1265
+    # --no-group-separator
1266
+    name="--no-group-separator: suppress separator"
1267
+    should_run "$name" && {
1268
+        log_test "$name"
1269
+        local out
1270
+        out=$("$FERP" -C1 --no-group-separator "MATCH" "$FIXTURES/context.txt")
1271
+        if ! echo "$out" | grep -q "^--$"; then
1272
+            pass "$name"
1273
+        else
1274
+            fail "$name" "separator should be suppressed, got: $out"
1275
+        fi
1276
+    }
1277
+
1278
+    # Context with line numbers
1279
+    name="-n with context: context lines marked differently"
1280
+    should_run "$name" && {
1281
+        log_test "$name"
1282
+        local out
1283
+        out=$("$FERP" -nB1 "MATCH LINE" "$FIXTURES/context.txt")
1284
+        # Context lines should use - separator, match lines use :
1285
+        if echo "$out" | grep -qE "^[0-9]+-" && echo "$out" | grep -qE "^[0-9]+:"; then
1286
+            pass "$name"
1287
+        else
1288
+            fail "$name" "got: $out"
1289
+        fi
1290
+    }
1291
+}
1292
+
1293
+#------------------------------------------------------------------------------
1294
+# FILE SELECTION TESTS (-r, -R, --include, --exclude)
1295
+#------------------------------------------------------------------------------
1296
+
1297
+test_file_selection() {
1298
+    section "File Selection Tests (-r, -R, --include, --exclude, -d, -D)"
1299
+    local name
1300
+
1301
+    # -r / --recursive
1302
+    name="-r: recursive search"
1303
+    should_run "$name" && {
1304
+        log_test "$name"
1305
+        local out
1306
+        out=$("$FERP" -r "match" "$FIXTURES" | wc -l)
1307
+        # Should find matches in nested files
1308
+        if [[ "$out" -ge 4 ]]; then
1309
+            pass "$name"
1310
+        else
1311
+            fail "$name" "expected at least 4 matches, got $out"
1312
+        fi
1313
+    }
1314
+
1315
+    name="-r: searches subdirectories"
1316
+    should_run "$name" && {
1317
+        log_test "$name"
1318
+        local out
1319
+        out=$("$FERP" -r "nested" "$FIXTURES")
1320
+        if echo "$out" | grep -q "subdir1" && echo "$out" | grep -q "subdir2"; then
1321
+            pass "$name"
1322
+        else
1323
+            fail "$name" "got: $out"
1324
+        fi
1325
+    }
1326
+
1327
+    name="--recursive: long form works"
1328
+    should_run "$name" && {
1329
+        log_test "$name"
1330
+        local out
1331
+        out=$("$FERP" --recursive "match" "$FIXTURES" | wc -l)
1332
+        if [[ "$out" -ge 4 ]]; then
1333
+            pass "$name"
1334
+        else
1335
+            fail "$name"
1336
+        fi
1337
+    }
1338
+
1339
+    # --include
1340
+    name="--include: filter by glob"
1341
+    should_run "$name" && {
1342
+        log_test "$name"
1343
+        local out
1344
+        out=$("$FERP" -r --include="*.txt" "match" "$FIXTURES")
1345
+        # Should only show .txt files
1346
+        if echo "$out" | grep -q ".txt:" && ! echo "$out" | grep -q ".f90:"; then
1347
+            pass "$name"
1348
+        else
1349
+            fail "$name" "got: $out"
1350
+        fi
1351
+    }
1352
+
1353
+    name="--include: *.f90 filter"
1354
+    should_run "$name" && {
1355
+        log_test "$name"
1356
+        local out
1357
+        out=$("$FERP" -r --include="*.f90" "match" "$FIXTURES")
1358
+        if echo "$out" | grep -q ".f90:"; then
1359
+            pass "$name"
1360
+        else
1361
+            fail "$name" "got: $out"
1362
+        fi
1363
+    }
1364
+
1365
+    # --exclude
1366
+    name="--exclude: skip matching files"
1367
+    should_run "$name" && {
1368
+        log_test "$name"
1369
+        local out
1370
+        out=$("$FERP" -r --exclude="*multifile*" "match" "$FIXTURES")
1371
+        if ! echo "$out" | grep -q "multifile"; then
1372
+            pass "$name"
1373
+        else
1374
+            fail "$name" "got: $out"
1375
+        fi
1376
+    }
1377
+
1378
+    # --exclude-dir
1379
+    name="--exclude-dir: skip directories"
1380
+    should_run "$name" && {
1381
+        log_test "$name"
1382
+        local out
1383
+        out=$("$FERP" -r --exclude-dir="skipme" "match" "$FIXTURES")
1384
+        if ! echo "$out" | grep -q "skipme"; then
1385
+            pass "$name"
1386
+        else
1387
+            fail "$name" "got: $out"
1388
+        fi
1389
+    }
1390
+
1391
+    name="--exclude-dir: multiple exclusions"
1392
+    should_run "$name" && {
1393
+        log_test "$name"
1394
+        local out
1395
+        out=$("$FERP" -r --exclude-dir="skipme" --exclude-dir="subdir1" "match" "$FIXTURES")
1396
+        if ! echo "$out" | grep -q "skipme" && ! echo "$out" | grep -q "subdir1"; then
1397
+            pass "$name"
1398
+        else
1399
+            fail "$name" "got: $out"
1400
+        fi
1401
+    }
1402
+
1403
+    # -d / --directories
1404
+    name="-d skip: skip directories"
1405
+    should_run "$name" && {
1406
+        log_test "$name"
1407
+        # When given a directory without -r, -d skip should skip it silently
1408
+        local exit_code=0
1409
+        "$FERP" -d skip "test" "$FIXTURES" >/dev/null 2>&1 || exit_code=$?
1410
+        if [[ "$exit_code" -eq 1 ]]; then  # No matches since dir was skipped
1411
+            pass "$name"
1412
+        else
1413
+            fail "$name" "expected exit 1, got $exit_code"
1414
+        fi
1415
+    }
1416
+
1417
+    name="--directories=recurse: same as -r"
1418
+    should_run "$name" && {
1419
+        log_test "$name"
1420
+        local out
1421
+        out=$("$FERP" --directories=recurse "nested" "$FIXTURES" | wc -l)
1422
+        if [[ "$out" -ge 2 ]]; then
1423
+            pass "$name"
1424
+        else
1425
+            fail "$name"
1426
+        fi
1427
+    }
1428
+}
1429
+
1430
+#------------------------------------------------------------------------------
1431
+# MULTI-PATTERN TESTS (-e, -f)
1432
+#------------------------------------------------------------------------------
1433
+
1434
+test_multi_pattern() {
1435
+    section "Multi-Pattern Tests (-e, -f)"
1436
+    local name
1437
+
1438
+    # -e / --regexp
1439
+    name="-e: single explicit pattern"
1440
+    should_run "$name" && {
1441
+        log_test "$name"
1442
+        local out
1443
+        out=$("$FERP" -e "hello" "$FIXTURES/simple.txt" | wc -l)
1444
+        if [[ "$out" -eq 2 ]]; then
1445
+            pass "$name"
1446
+        else
1447
+            fail "$name" "expected 2 lines, got $out"
1448
+        fi
1449
+    }
1450
+
1451
+    name="-e: multiple patterns (OR)"
1452
+    should_run "$name" && {
1453
+        log_test "$name"
1454
+        local out
1455
+        out=$("$FERP" -e "hello" -e "goodbye" "$FIXTURES/simple.txt")
1456
+        if echo "$out" | grep -q "hello" && echo "$out" | grep -q "goodbye"; then
1457
+            pass "$name"
1458
+        else
1459
+            fail "$name" "got: $out"
1460
+        fi
1461
+    }
1462
+
1463
+    name="-e: three patterns"
1464
+    should_run "$name" && {
1465
+        log_test "$name"
1466
+        local out
1467
+        out=$("$FERP" -e "hello" -e "goodbye" -e "foo" "$FIXTURES/simple.txt" | wc -l)
1468
+        if [[ "$out" -eq 4 ]]; then  # 2 hello + 1 goodbye + 1 foo
1469
+            pass "$name"
1470
+        else
1471
+            fail "$name" "expected 4 lines, got $out"
1472
+        fi
1473
+    }
1474
+
1475
+    name="--regexp=PATTERN: long form with ="
1476
+    should_run "$name" && {
1477
+        log_test "$name"
1478
+        local out
1479
+        out=$("$FERP" --regexp="hello" "$FIXTURES/simple.txt" | wc -l)
1480
+        if [[ "$out" -eq 2 ]]; then
1481
+            pass "$name"
1482
+        else
1483
+            fail "$name"
1484
+        fi
1485
+    }
1486
+
1487
+    # -f / --file
1488
+    name="-f: patterns from file"
1489
+    should_run "$name" && {
1490
+        log_test "$name"
1491
+        local out
1492
+        out=$("$FERP" -f "$FIXTURES/patterns.txt" "$FIXTURES/simple.txt")
1493
+        # patterns.txt contains: hello, world, test
1494
+        if echo "$out" | grep -q "hello" && echo "$out" | grep -q "world"; then
1495
+            pass "$name"
1496
+        else
1497
+            fail "$name" "got: $out"
1498
+        fi
1499
+    }
1500
+
1501
+    name="-f: combined with -e"
1502
+    should_run "$name" && {
1503
+        log_test "$name"
1504
+        local out
1505
+        out=$("$FERP" -f "$FIXTURES/patterns.txt" -e "foo" "$FIXTURES/simple.txt")
1506
+        if echo "$out" | grep -q "hello" && echo "$out" | grep -q "foo"; then
1507
+            pass "$name"
1508
+        else
1509
+            fail "$name" "got: $out"
1510
+        fi
1511
+    }
1512
+
1513
+    name="-f: nonexistent file error"
1514
+    should_run "$name" && {
1515
+        log_test "$name"
1516
+        if run_ferp_expect 2 -f "/nonexistent/patterns.txt" "test" "$FIXTURES/simple.txt"; then
1517
+            pass "$name"
1518
+        else
1519
+            fail "$name"
1520
+        fi
1521
+    }
1522
+}
1523
+
1524
+#------------------------------------------------------------------------------
1525
+# BINARY FILE HANDLING TESTS (-a, -I, --binary-files)
1526
+#------------------------------------------------------------------------------
1527
+
1528
+test_binary_handling() {
1529
+    section "Binary File Handling Tests (-a, -I, --binary-files)"
1530
+    local name
1531
+
1532
+    # Default binary detection
1533
+    name="binary: detected and shows message"
1534
+    should_run "$name" && {
1535
+        log_test "$name"
1536
+        local out
1537
+        out=$("$FERP" "text" "$FIXTURES/binary.bin" 2>/dev/null) || true
1538
+        if echo "$out" | grep -q "Binary file"; then
1539
+            pass "$name"
1540
+        else
1541
+            fail "$name" "got: $out"
1542
+        fi
1543
+    }
1544
+
1545
+    # -a / --text
1546
+    name="-a: treat binary as text"
1547
+    should_run "$name" && {
1548
+        log_test "$name"
1549
+        local out
1550
+        out=$("$FERP" -a "match" "$FIXTURES/binary.bin" 2>/dev/null) || true
1551
+        if echo "$out" | grep -q "match"; then
1552
+            pass "$name"
1553
+        else
1554
+            fail "$name" "got: $out"
1555
+        fi
1556
+    }
1557
+
1558
+    name="--text: long form works"
1559
+    should_run "$name" && {
1560
+        log_test "$name"
1561
+        local out
1562
+        out=$("$FERP" --text "match" "$FIXTURES/binary.bin" 2>/dev/null) || true
1563
+        if echo "$out" | grep -q "match"; then
1564
+            pass "$name"
1565
+        else
1566
+            fail "$name"
1567
+        fi
1568
+    }
1569
+
1570
+    # -I (ignore binary / without-match)
1571
+    name="-I: skip binary files"
1572
+    should_run "$name" && {
1573
+        log_test "$name"
1574
+        if run_ferp_expect 1 -I "text" "$FIXTURES/binary.bin"; then
1575
+            pass "$name"
1576
+        else
1577
+            fail "$name"
1578
+        fi
1579
+    }
1580
+
1581
+    # --binary-files
1582
+    name="--binary-files=text: same as -a"
1583
+    should_run "$name" && {
1584
+        log_test "$name"
1585
+        local out
1586
+        out=$("$FERP" --binary-files=text "match" "$FIXTURES/binary.bin" 2>/dev/null) || true
1587
+        if echo "$out" | grep -q "match"; then
1588
+            pass "$name"
1589
+        else
1590
+            fail "$name"
1591
+        fi
1592
+    }
1593
+
1594
+    name="--binary-files=without-match: same as -I"
1595
+    should_run "$name" && {
1596
+        log_test "$name"
1597
+        if run_ferp_expect 1 --binary-files=without-match "text" "$FIXTURES/binary.bin"; then
1598
+            pass "$name"
1599
+        else
1600
+            fail "$name"
1601
+        fi
1602
+    }
1603
+
1604
+    name="--binary-files=binary: show message (default)"
1605
+    should_run "$name" && {
1606
+        log_test "$name"
1607
+        local out
1608
+        out=$("$FERP" --binary-files=binary "text" "$FIXTURES/binary.bin" 2>/dev/null) || true
1609
+        if echo "$out" | grep -q "Binary file"; then
1610
+            pass "$name"
1611
+        else
1612
+            fail "$name"
1613
+        fi
1614
+    }
1615
+}
1616
+
1617
+#------------------------------------------------------------------------------
1618
+# MISCELLANEOUS TESTS (--max-count, --label, -z, --color)
1619
+#------------------------------------------------------------------------------
1620
+
1621
+test_misc_flags() {
1622
+    section "Miscellaneous Flag Tests (-m, --label, -z, --color)"
1623
+    local name
1624
+
1625
+    # -m / --max-count
1626
+    name="-m: limit match count"
1627
+    should_run "$name" && {
1628
+        log_test "$name"
1629
+        local out
1630
+        out=$("$FERP" -m2 "line" "$FIXTURES/numbers.txt" | wc -l)
1631
+        if [[ "$out" -eq 2 ]]; then
1632
+            pass "$name"
1633
+        else
1634
+            fail "$name" "expected 2 lines, got $out"
1635
+        fi
1636
+    }
1637
+
1638
+    name="-m1: stop after first match"
1639
+    should_run "$name" && {
1640
+        log_test "$name"
1641
+        local out
1642
+        out=$("$FERP" -m1 "line" "$FIXTURES/numbers.txt")
1643
+        if [[ "$out" == "line 1" ]]; then
1644
+            pass "$name"
1645
+        else
1646
+            fail "$name" "got: $out"
1647
+        fi
1648
+    }
1649
+
1650
+    name="--max-count=N: long form"
1651
+    should_run "$name" && {
1652
+        log_test "$name"
1653
+        local out
1654
+        out=$("$FERP" --max-count=3 "line" "$FIXTURES/numbers.txt" | wc -l)
1655
+        if [[ "$out" -eq 3 ]]; then
1656
+            pass "$name"
1657
+        else
1658
+            fail "$name"
1659
+        fi
1660
+    }
1661
+
1662
+    name="-m with -c: count respects limit"
1663
+    should_run "$name" && {
1664
+        log_test "$name"
1665
+        local out
1666
+        out=$("$FERP" -m5 -c "line" "$FIXTURES/numbers.txt")
1667
+        if [[ "$out" == "5" ]]; then
1668
+            pass "$name"
1669
+        else
1670
+            fail "$name" "got: $out"
1671
+        fi
1672
+    }
1673
+
1674
+    # --label
1675
+    name="--label: custom stdin label"
1676
+    should_run "$name" && {
1677
+        log_test "$name"
1678
+        local out
1679
+        out=$(echo "hello world" | "$FERP" -H --label="custom_input" "hello")
1680
+        if echo "$out" | grep -q "custom_input:"; then
1681
+            pass "$name"
1682
+        else
1683
+            fail "$name" "got: $out"
1684
+        fi
1685
+    }
1686
+
1687
+    # -z / --null-data
1688
+    name="-z: null-terminated input"
1689
+    should_run "$name" && {
1690
+        log_test "$name"
1691
+        local out
1692
+        out=$(printf 'hello\0world\0' | "$FERP" -z "hello")
1693
+        if [[ -n "$out" ]]; then
1694
+            pass "$name"
1695
+        else
1696
+            fail "$name"
1697
+        fi
1698
+    }
1699
+
1700
+    name="--null-data: long form works"
1701
+    should_run "$name" && {
1702
+        log_test "$name"
1703
+        local out
1704
+        out=$(printf 'test\0line\0' | "$FERP" --null-data "test")
1705
+        if [[ -n "$out" ]]; then
1706
+            pass "$name"
1707
+        else
1708
+            fail "$name"
1709
+        fi
1710
+    }
1711
+
1712
+    # --color
1713
+    name="--color=always: ANSI codes in output"
1714
+    should_run "$name" && {
1715
+        log_test "$name"
1716
+        local out
1717
+        out=$("$FERP" --color=always "hello" "$FIXTURES/simple.txt")
1718
+        if echo "$out" | grep -q $'\033\['; then
1719
+            pass "$name"
1720
+        else
1721
+            fail "$name" "expected ANSI escape codes"
1722
+        fi
1723
+    }
1724
+
1725
+    name="--color=never: no ANSI codes"
1726
+    should_run "$name" && {
1727
+        log_test "$name"
1728
+        local out
1729
+        out=$("$FERP" --color=never "hello" "$FIXTURES/simple.txt")
1730
+        if ! echo "$out" | grep -q $'\033\['; then
1731
+            pass "$name"
1732
+        else
1733
+            fail "$name" "expected no ANSI codes"
1734
+        fi
1735
+    }
1736
+
1737
+    name="--color=auto: no ANSI when piped"
1738
+    should_run "$name" && {
1739
+        log_test "$name"
1740
+        local out
1741
+        out=$("$FERP" --color=auto "hello" "$FIXTURES/simple.txt" | cat)
1742
+        if ! echo "$out" | grep -q $'\033\['; then
1743
+            pass "$name"
1744
+        else
1745
+            fail "$name" "expected no ANSI codes when piped"
1746
+        fi
1747
+    }
1748
+
1749
+    # --line-buffered
1750
+    name="--line-buffered: accepted"
1751
+    should_run "$name" && {
1752
+        log_test "$name"
1753
+        if "$FERP" --line-buffered "hello" "$FIXTURES/simple.txt" >/dev/null 2>&1; then
1754
+            pass "$name"
1755
+        else
1756
+            fail "$name"
1757
+        fi
1758
+    }
1759
+}
1760
+
1761
+#------------------------------------------------------------------------------
1762
+# EXIT CODE TESTS
1763
+#------------------------------------------------------------------------------
1764
+
1765
+test_exit_codes() {
1766
+    section "Exit Code Tests"
1767
+    local name
1768
+
1769
+    name="exit 0: match found"
1770
+    should_run "$name" && {
1771
+        log_test "$name"
1772
+        if run_ferp_expect 0 "hello" "$FIXTURES/simple.txt"; then
1773
+            pass "$name"
1774
+        else
1775
+            fail "$name"
1776
+        fi
1777
+    }
1778
+
1779
+    name="exit 1: no match found"
1780
+    should_run "$name" && {
1781
+        log_test "$name"
1782
+        if run_ferp_expect 1 "nonexistent_pattern_xyz" "$FIXTURES/simple.txt"; then
1783
+            pass "$name"
1784
+        else
1785
+            fail "$name"
1786
+        fi
1787
+    }
1788
+
1789
+    name="exit 2: invalid regex"
1790
+    should_run "$name" && {
1791
+        log_test "$name"
1792
+        if run_ferp_expect 2 "[invalid" "$FIXTURES/simple.txt"; then
1793
+            pass "$name"
1794
+        else
1795
+            fail "$name"
1796
+        fi
1797
+    }
1798
+
1799
+    name="exit 2: missing pattern"
1800
+    should_run "$name" && {
1801
+        log_test "$name"
1802
+        if run_ferp_expect 2; then
1803
+            pass "$name"
1804
+        else
1805
+            fail "$name"
1806
+        fi
1807
+    }
1808
+}
1809
+
1810
+#------------------------------------------------------------------------------
1811
+# EDGE CASES AND REGRESSIONS
1812
+#------------------------------------------------------------------------------
1813
+
1814
+test_edge_cases() {
1815
+    section "Edge Cases and Regressions"
1816
+    local name
1817
+
1818
+    name="empty pattern matches all lines"
1819
+    should_run "$name" && {
1820
+        log_test "$name"
1821
+        local out
1822
+        out=$("$FERP" "" "$FIXTURES/numbers.txt" | wc -l)
1823
+        if [[ "$out" -eq 10 ]]; then
1824
+            pass "$name"
1825
+        else
1826
+            fail "$name" "expected 10 lines, got $out"
1827
+        fi
1828
+    }
1829
+
1830
+    name="empty file no crash"
1831
+    should_run "$name" && {
1832
+        log_test "$name"
1833
+        local tmpfile
1834
+        tmpfile=$(mktemp)
1835
+        if run_ferp_expect 1 "test" "$tmpfile"; then
1836
+            pass "$name"
1837
+        else
1838
+            fail "$name"
1839
+        fi
1840
+        rm -f "$tmpfile"
1841
+    }
1842
+
1843
+    name="very long line handling"
1844
+    should_run "$name" && {
1845
+        log_test "$name"
1846
+        local tmpfile long_line
1847
+        tmpfile=$(mktemp)
1848
+        long_line=$(printf 'a%.0s' {1..10000})
1849
+        echo "${long_line}MATCH${long_line}" > "$tmpfile"
1850
+        if "$FERP" "MATCH" "$tmpfile" | grep -q "MATCH"; then
1851
+            pass "$name"
1852
+        else
1853
+            fail "$name"
1854
+        fi
1855
+        rm -f "$tmpfile"
1856
+    }
1857
+
1858
+    name="special regex chars in pattern"
1859
+    should_run "$name" && {
1860
+        log_test "$name"
1861
+        if "$FERP" '\$' "$FIXTURES/special_chars.txt" | grep -q "dollar"; then
1862
+            pass "$name"
1863
+        else
1864
+            fail "$name"
1865
+        fi
1866
+    }
1867
+
1868
+    name="multiple files with mixed results"
1869
+    should_run "$name" && {
1870
+        log_test "$name"
1871
+        local out
1872
+        out=$("$FERP" "match" "$FIXTURES/multifile1.txt" "$FIXTURES/multifile3.txt" "$FIXTURES/multifile2.txt")
1873
+        # Should show results from files that have matches
1874
+        if echo "$out" | grep -q "multifile1" && echo "$out" | grep -q "multifile2"; then
1875
+            pass "$name"
1876
+        else
1877
+            fail "$name" "got: $out"
1878
+        fi
1879
+    }
1880
+
1881
+    name="stdin input"
1882
+    should_run "$name" && {
1883
+        log_test "$name"
1884
+        local out
1885
+        out=$(echo "test line" | "$FERP" "test")
1886
+        if [[ "$out" == "test line" ]]; then
1887
+            pass "$name"
1888
+        else
1889
+            fail "$name" "got: $out"
1890
+        fi
1891
+    }
1892
+
1893
+    name="-- ends option parsing"
1894
+    should_run "$name" && {
1895
+        log_test "$name"
1896
+        # Pattern starting with - should work after --
1897
+        local out
1898
+        out=$(echo "-test-" | "$FERP" -- "-test-")
1899
+        if [[ "$out" == "-test-" ]]; then
1900
+            pass "$name"
1901
+        else
1902
+            fail "$name" "got: $out"
1903
+        fi
1904
+    }
1905
+
1906
+    name="combined short options -inv"
1907
+    should_run "$name" && {
1908
+        log_test "$name"
1909
+        local out
1910
+        out=$("$FERP" -inv "HELLO" "$FIXTURES/simple.txt" | wc -l)
1911
+        # Case insensitive, inverted, with line numbers
1912
+        if [[ "$out" -eq 4 ]]; then
1913
+            pass "$name"
1914
+        else
1915
+            fail "$name" "expected 4 lines, got $out"
1916
+        fi
1917
+    }
1918
+}
1919
+
1920
+#------------------------------------------------------------------------------
1921
+# Main
1922
+#------------------------------------------------------------------------------
1923
+
1924
+main() {
1925
+    echo ""
1926
+    echo -e "${BLUE}╔════════════════════════════════════════════════════════════════════════════╗${NC}"
1927
+    echo -e "${BLUE}║                     FERP Integration Test Suite                            ║${NC}"
1928
+    echo -e "${BLUE}╚════════════════════════════════════════════════════════════════════════════╝${NC}"
1929
+    echo ""
1930
+    echo "ferp binary: $FERP"
1931
+    echo "Fixtures: $FIXTURES"
1932
+    if [[ "$COMPARE_GREP" == "true" ]]; then
1933
+        echo "Mode: Comparing against GNU grep"
1934
+    fi
1935
+    if [[ -n "$FILTER" ]]; then
1936
+        echo "Filter: $FILTER"
1937
+    fi
1938
+
1939
+    # Run all test categories
1940
+    test_pattern_types
1941
+    test_matching_control
1942
+    test_output_control
1943
+    test_line_prefix
1944
+    test_context_control
1945
+    test_file_selection
1946
+    test_multi_pattern
1947
+    test_binary_handling
1948
+    test_misc_flags
1949
+    test_exit_codes
1950
+    test_edge_cases
1951
+
1952
+    # Summary
1953
+    echo ""
1954
+    echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
1955
+    echo -e "${BLUE}  Summary${NC}"
1956
+    echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
1957
+    echo ""
1958
+    echo -e "  Tests run:    ${TESTS_RUN}"
1959
+    echo -e "  ${GREEN}Passed:       ${TESTS_PASSED}${NC}"
1960
+    echo -e "  ${RED}Failed:       ${TESTS_FAILED}${NC}"
1961
+    echo -e "  ${YELLOW}Skipped:      ${TESTS_SKIPPED}${NC}"
1962
+    echo ""
1963
+
1964
+    if [[ "$TESTS_FAILED" -gt 0 ]]; then
1965
+        echo -e "${RED}Some tests failed!${NC}"
1966
+        exit 1
1967
+    else
1968
+        echo -e "${GREEN}All tests passed!${NC}"
1969
+        exit 0
1970
+    fi
1971
+}
1972
+
1973
+main "$@"