cleansh 0.1.7

Sanitize your terminal output. One tool. One purpose.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
// tests/sanitize_shell_integration_tests.rs

// This is an integration test, so we import from the crate root
use anyhow::Result;

// Only import what's directly used in this test file
// These imports correctly point to the re-exports from `cleansh::test_exposed` as defined in cleansh/src/lib.rs
use cleansh::test_exposed::config::RedactionRule; 
use cleansh::test_exposed::sanitizer::{compile_rules, sanitize_content};
// Corrected import path for RedactionMatch as it's re-exported from cleansh_core::redaction_match
use cleansh::test_exposed::redaction_match::RedactionMatch;
use std::collections::HashMap; // Needed for aggregation in tests

// This block ensures that logging (e.g., from pii_debug! macro) is set up for tests.
// It initializes env_logger exactly once per test run.
#[allow(unused_imports)] // Allow unused for clarity, as it's not always directly called
#[cfg(test)]
mod test_setup {
    use std::sync::Once;
    static INIT: Once = Once::new();

    pub fn setup_logger() {
        INIT.call_once(|| {
            env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug"))
                .is_test(true)
                .try_init()
                .ok(); // Ignore error if logger already initialized
        });
    }
}

// Helper to create a basic rule for testing
fn create_test_rule(
    name: &str,
    pattern: &str,
    replace: &str,
    opt_in: bool,
    description: Option<&str>,
    multiline: bool,
    dot_matches_new_line: bool,
    programmatic_validation: bool, // Added for programmatic validation flag
) -> RedactionRule {
    RedactionRule {
        name: name.to_string(),
        pattern: pattern.to_string(),
        replace_with: replace.to_string(),
        description: description.map(|s| s.to_string()),
        multiline,
        dot_matches_new_line,
        opt_in,
        programmatic_validation,
        // Removed `use_fancy_regex` and `rule_type` as they are no longer fields
    }
}

// Helper struct for test assertions, mimicking RedactionSummaryItem
#[derive(Debug, PartialEq, Eq)]
struct TestRedactionSummaryItem {
    rule_name: String,
    occurrences: usize,
    original_texts: Vec<String>,
    sanitized_texts: Vec<String>,
}

// Helper function to aggregate RedactionMatch into TestRedactionSummaryItem for assertions
fn aggregate_matches_for_test(matches: &[RedactionMatch]) -> Vec<TestRedactionSummaryItem> {
    let mut summary_map: HashMap<String, TestRedactionSummaryItem> = HashMap::new();

    for m in matches {
        let item = summary_map.entry(m.rule_name.clone()).or_insert_with(|| TestRedactionSummaryItem {
            rule_name: m.rule_name.clone(),
            occurrences: 0,
            original_texts: Vec::new(),
            sanitized_texts: Vec::new(),
        });
        item.occurrences += 1;
        // Only add unique original and sanitized strings
        if !item.original_texts.contains(&m.original_string) {
            item.original_texts.push(m.original_string.clone());
        }
        if !item.sanitized_texts.contains(&m.sanitized_string) {
            item.sanitized_texts.push(m.sanitized_string.clone());
        }
    }

    // Sort original_texts and sanitized_texts within each summary item for consistent output
    for item in summary_map.values_mut() {
        item.original_texts.sort();
        item.sanitized_texts.sort();
    }

    let mut summary: Vec<TestRedactionSummaryItem> = summary_map.into_values().collect();
    // Sort the overall summary by rule name for deterministic output/tests
    summary.sort_by(|a, b| a.rule_name.cmp(&b.rule_name));

    summary
}


#[test]
fn test_compile_rules_basic() -> Result<()> {
    test_setup::setup_logger(); // Initialize logger for this test
    let rules_vec = vec![ // Directly pass Vec<RedactionRule>
        create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false),
        create_test_rule("ip", r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", false, None, false, false, false),
    ];
    let compiled = compile_rules(rules_vec, &[], &[]).unwrap();
    assert_eq!(compiled.rules.len(), 2); // Access .rules field
    Ok(())
}

#[test]
fn test_compile_rules_opt_in_not_enabled() -> Result<()> {
    test_setup::setup_logger();
    let rules_vec = vec![ // Directly pass Vec<RedactionRule>
        create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false),
        create_test_rule("aws_key", "AKIA[A-Z0-9]{16}", "[AWS_KEY]", true, None, false, false, false), // Opt-in
    ];
    let compiled = compile_rules(rules_vec, &[], &[]).unwrap(); // Not enabled
    assert_eq!(compiled.rules.len(), 1);
    assert_eq!(compiled.rules[0].name, "email");
    Ok(())
}

#[test]
fn test_compile_rules_opt_in_missing_returns_empty() -> Result<()> {
    test_setup::setup_logger();
    let rules_vec = vec![
        create_test_rule("secret_key", r"secret_\w+", "[REDACTED]", true, None, false, false, false),
    ];
    let compiled = compile_rules(rules_vec, &[], &[])?;
    assert_eq!(compiled.rules.len(), 0);
    Ok(())
}


#[test]
fn test_compile_rules_opt_in_enabled() -> Result<()> {
    test_setup::setup_logger();
    let rules_vec = vec![ // Directly pass Vec<RedactionRule>
        create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false),
        create_test_rule("aws_key", "AKIA[A-Z0-9]{16}", "[AWS_KEY]", true, None, false, false, false), // Opt-in
    ];
    let compiled = compile_rules(
        rules_vec,
        &["aws_key".to_string()], // Enable aws_key
        &[],
    )
    .unwrap();
    assert_eq!(compiled.rules.len(), 2);
    assert!(compiled.rules.iter().any(|r| r.name == "aws_key"));
    Ok(())
}

#[test]
fn test_compile_rules_disabled() -> Result<()> {
    test_setup::setup_logger();
    let rules_vec = vec![ // Directly pass Vec<RedactionRule>
        create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false),
        create_test_rule("aws_key", "AKIA[A-Z0-9]{16}", "[AWS_KEY]", true, None, false, false, false), // Opt-in
    ];
    let compiled = compile_rules(
        rules_vec,
        &["aws_key".to_string()],
        &["email".to_string()], // Disable email
    )
    .unwrap();
    assert_eq!(compiled.rules.len(), 1);
    assert_eq!(compiled.rules[0].name, "aws_key");
    Ok(())
}

#[test]
fn test_compile_rules_opt_in_and_disabled_conflict() -> Result<()> {
    test_setup::setup_logger();
    let rules_vec = vec![ // Directly pass Vec<RedactionRule>
        create_test_rule("sensitive_data", "sensitive_text", "[REDACTED]", true, None, false, false, false),
    ];
    let compiled = compile_rules(
        rules_vec,
        &["sensitive_data".to_string()],
        &["sensitive_data".to_string()],
    )
    .unwrap();
    assert_eq!(compiled.rules.len(), 0);
    Ok(())
}

#[test]
fn test_overlapping_rules_priority() -> Result<()> {
    test_setup::setup_logger();
    let rule_email = create_test_rule("email", r"(\w+)@example\.com", "[EMAIL]", false, None, false, false, false);
    let rule_generic = create_test_rule("example_match", r"example\.com", "[DOMAIN]", false, None, false, false, false);
    // Order matters here when compiling, assuming the `compile_rules` or `sanitize_content` logic
    // applies the first matching rule, or the "longest match".
    // If the email regex matches the entire string, it will likely take precedence.
    let compiled = compile_rules(vec![rule_email, rule_generic], &[], &[])?;

    let input = "user@example.com";
    let (sanitized, all_matches) = sanitize_content(&input, &compiled); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    // Updated assertion: If "email" rule (which is a complete match) applies first/greedily,
    // the output will be "[EMAIL]". The summary should reflect only one redaction.
    assert_eq!(sanitized, "[EMAIL]");
    assert_eq!(summary.len(), 1); // Only one rule should fire if it's a full replacement

    // Additionally, assert the details of the single redaction for clarity
    assert_eq!(summary[0].rule_name, "email");
    assert_eq!(summary[0].occurrences, 1); // Only one occurrence for this rule
    assert_eq!(summary[0].original_texts, vec!["user@example.com"]);
    assert_eq!(summary[0].sanitized_texts, vec!["[EMAIL]"]);

    Ok(())
}


#[test]
fn test_sanitize_content_basic() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL_REDACTED]", false, None, false, false, false);
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules to create CompiledRules struct

    let input = "My email is test@example.com.";
    let (output, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(output, "My email is [EMAIL_REDACTED].");
    assert_eq!(summary.len(), 1);
    assert_eq!(summary[0].rule_name, "email");
    assert_eq!(summary[0].occurrences, 1);
    assert_eq!(summary[0].original_texts, vec!["test@example.com"]);
    assert_eq!(summary[0].sanitized_texts, vec!["[EMAIL_REDACTED]"]);
    Ok(())
}

#[test]
fn test_sanitize_content_multiple_matches_same_rule() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL_REDACTED]", false, None, false, false, false);
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    let input = "test1@example.com and test2@example.com.";
    let (output, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(
        output,
        "[EMAIL_REDACTED] and [EMAIL_REDACTED]."
    );
    assert_eq!(summary.len(), 1);
    assert_eq!(summary[0].rule_name, "email");
    assert_eq!(summary[0].occurrences, 2);
    // Sort for consistent assertion, as HashMap iteration order is not guaranteed (summary is sorted now)
    let mut expected_original_texts = vec!["test1@example.com".to_string(), "test2@example.com".to_string()];
    expected_original_texts.sort(); // Ensure local sort as well for comparison
    assert_eq!(summary[0].original_texts, expected_original_texts);
    // Ensure sanitized_texts also contains the correct single entry since the replacement is constant
    assert_eq!(summary[0].sanitized_texts, vec!["[EMAIL_REDACTED]".to_string()]);
    Ok(())
}

#[test]
fn test_sanitize_content_multiple_rules() -> Result<()> {
    test_setup::setup_logger();
    let email_rule = create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false);
    let ip_rule = create_test_rule("ipv4_address", r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IPV4]", false, None, false, false, false);

    let compiled_rules = compile_rules(vec![email_rule, ip_rule], &[], &[]).unwrap(); // Use compile_rules

    let input = "Email: a@b.com, IP: 192.168.1.1.";
    let (output, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(output, "Email: [EMAIL], IP: [IPV4].");
    assert_eq!(summary.len(), 2);
    // summary is already sorted by rule name in aggregate_matches_for_test

    assert_eq!(summary[0].rule_name, "email");
    assert_eq!(summary[0].occurrences, 1);
    assert_eq!(summary[0].original_texts, vec!["a@b.com"]);
    assert_eq!(summary[0].sanitized_texts, vec!["[EMAIL]"]);

    assert_eq!(summary[1].rule_name, "ipv4_address");
    assert_eq!(summary[1].occurrences, 1);
    assert_eq!(summary[1].original_texts, vec!["192.168.1.1"]);
    assert_eq!(summary[1].sanitized_texts, vec!["[IPV4]"]);
    Ok(())
}

#[test]
fn test_sanitize_content_with_ansi_escapes() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule("email", r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[EMAIL]", false, None, false, false, false);
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    let input = "Hello \x1b[31mtest@example.com\x1b[0m world.";
    let (output, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(output, "Hello [EMAIL] world.");
    assert_eq!(summary.len(), 1);
    assert_eq!(summary[0].rule_name, "email");
    assert_eq!(summary[0].occurrences, 1);
    assert_eq!(summary[0].original_texts, vec!["test@example.com"]);
    assert_eq!(summary[0].sanitized_texts, vec!["[EMAIL]"]);
    Ok(())
}

// Tests for programmatic validation

#[test]
fn test_us_ssn_programmatic_validation_valid() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b", // Pattern with capturing groups
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Valid SSN - should be redacted
    let text_valid = "My SSN is 123-45-6789. Another is 789-12-3456.";
    let (sanitized_valid, all_matches) = sanitize_content(text_valid, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_valid, "My SSN is [US_SSN_REDACTED]. Another is [US_SSN_REDACTED].");
    assert_eq!(summary.len(), 1);
    assert_eq!(summary[0].rule_name, "us_ssn");
    assert_eq!(summary[0].occurrences, 2);
    // Sort for consistent assertion
    let mut expected_original_texts = vec!["123-45-6789".to_string(), "789-12-3456".to_string()];
    expected_original_texts.sort();
    assert_eq!(summary[0].original_texts, expected_original_texts);
    Ok(())
}

#[test]
fn test_us_ssn_programmatic_validation_invalid_area_000() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b",
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid SSN (000 area) - should NOT be redacted programmatically, meaning no RedactionMatch is generated
    let text_invalid_area_000 = "Invalid SSN: 000-12-3456.";
    let (sanitized_invalid_area_000, all_matches) = sanitize_content(text_invalid_area_000, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_invalid_area_000, "Invalid SSN: 000-12-3456.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid SSN.");
    Ok(())
}

#[test]
fn test_us_ssn_programmatic_validation_invalid_area_666() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b",
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid SSN (666 area) - should NOT be redacted programmatically, meaning no RedactionMatch is generated
    let text_invalid_area_666 = "Another invalid: 666-78-9012.";
    let (sanitized_invalid_area_666, all_matches) = sanitize_content(text_invalid_area_666, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_invalid_area_666, "Another invalid: 666-78-9012.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid SSN.");
    Ok(())
}

#[test]
fn test_us_ssn_programmatic_validation_invalid_area_9xx() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b",
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid SSN (9XX area) - should NOT be redacted programmatically, meaning no RedactionMatch is generated
    let text_invalid_area_9xx = "Area 9: 900-11-2222.";
    let (sanitized_invalid_area_9xx, all_matches) = sanitize_content(text_invalid_area_9xx, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_invalid_area_9xx, "Area 9: 900-11-2222.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid SSN.");
    Ok(())
}

#[test]
fn test_us_ssn_programmatic_validation_invalid_group_00() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b",
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid SSN (00 group) - should NOT be redacted programmatically, meaning no RedactionMatch is generated
    let text_invalid_group_00 = "Group 00: 123-00-4567.";
    let (sanitized_invalid_group_00, all_matches) = sanitize_content(text_invalid_group_00, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_invalid_group_00, "Group 00: 123-00-4567.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid SSN.");
    Ok(())
}

#[test]
fn test_us_ssn_programmatic_validation_invalid_serial_0000() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "us_ssn",
        r"\b(\d{3})-(\d{2})-(\d{4})\b",
        "[US_SSN_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid SSN (0000 serial) - should NOT be redacted programmatically, meaning no RedactionMatch is generated
    let text_invalid_serial_0000 = "Serial 0000: 123-45-0000.";
    let (sanitized_invalid_serial_0000, all_matches) = sanitize_content(text_invalid_serial_0000, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized_invalid_serial_0000, "Serial 0000: 123-45-0000.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid SSN.");
    Ok(())
}

#[test]
fn test_uk_nino_programmatic_validation_valid() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "uk_nino",
        r"\b([A-CEGHJ-NPR-TW-Z]{2})\s?(\d{2})\s?(\d{2})\s?(\d{2})\s?([A-D])\b",
        "[UK_NINO_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Corrected input: Use a genuinely valid NINO with spaces
    let input = "Valid NINO: AB123456A. Valid Spaced NINO: AA 12 34 56 B.";
    let (sanitized, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    assert_eq!(sanitized, "Valid NINO: [UK_NINO_REDACTED]. Valid Spaced NINO: [UK_NINO_REDACTED].");
    assert_eq!(summary.len(), 1);
    assert_eq!(summary[0].rule_name, "uk_nino");
    assert_eq!(summary[0].occurrences, 2);
    // Sort for consistent assertion
    let mut expected_original_texts = vec!["AB123456A".to_string(), "AA 12 34 56 B".to_string()];
    expected_original_texts.sort();
    assert_eq!(summary[0].original_texts, expected_original_texts);
    Ok(())
}

#[test]
fn test_uk_nino_programmatic_validation_invalid_prefix() -> Result<()> {
    test_setup::setup_logger();
    let rule = create_test_rule(
        "uk_nino",
        r"\b([A-CEGHJ-NPR-TW-Z]{2})\s?(\d{2})\s?(\d{2})\s?(\d{2})\s?([A-D])\b",
        "[UK_NINO_REDACTED]",
        false, None, false, false,
        true, // Enable programmatic validation
    );
    let compiled_rules = compile_rules(vec![rule], &[], &[]).unwrap(); // Use compile_rules

    // Invalid prefixes: BG, GB, NK, KN, TN, NT, ZZ, and those starting with D, F, I, Q, U, V, O
    let input = "Invalid BG: BG123456A. Invalid GB: GB123456B. Invalid ZZ: ZZ123456C. Invalid DF: DF123456A. Invalid QV: QV123456B.";
    let (sanitized, all_matches) = sanitize_content(input, &compiled_rules); // Renamed summary to all_matches
    let summary = aggregate_matches_for_test(&all_matches); // Aggregate for assertion

    // These should NOT be redacted due to programmatic validation, meaning no RedactionMatch is generated
    assert_eq!(sanitized, "Invalid BG: BG123456A. Invalid GB: GB123456B. Invalid ZZ: ZZ123456C. Invalid DF: DF123456A. Invalid QV: QV123456B.");
    assert!(summary.is_empty(), "No redactions should have occurred for invalid NINO prefixes."); // No redactions should have occurred
    Ok(())
}

#[test]
fn test_compile_rules_invalid_regex_fails_fast() {
    test_setup::setup_logger();
    let rules_vec = vec![
        create_test_rule("valid_rule", "abc", "[REDACTED]", false, None, false, false, false),
        create_test_rule("invalid_rule", "[", "[ERROR]", false, None, false, false, false), // Invalid regex
    ];
    let result = compile_rules(rules_vec, &[], &[]);
    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_msg = err.to_string();
    eprintln!("Actual error message:\n{}", err_msg); // Added for debugging

    assert!(err_msg.contains("Failed to compile 1 rule(s)"));
    assert!(err_msg.contains("invalid_rule"));
    // Relaxed assertions as per ChatGPT's suggestion
    assert!(err_msg.contains("failed to compile regex pattern"));
    assert!(err_msg.contains("regex parse error"));
    assert!(err_msg.contains("unclosed character class"));
}

#[test]
fn test_compile_rules_pattern_too_long_fails_fast() {
    test_setup::setup_logger();
    use cleansh::test_exposed::config::MAX_PATTERN_LENGTH; // Moved import here
    let long_pattern = "a".repeat(MAX_PATTERN_LENGTH + 1);
    let rules_vec = vec![
        create_test_rule("valid_rule", "abc", "[REDACTED]", false, None, false, false, false),
        create_test_rule("long_pattern_rule", &long_pattern, "[TOO_LONG]", false, None, false, false, false),
    ];
    let result = compile_rules(rules_vec, &[], &[]);
    assert!(result.is_err());
    let err = result.unwrap_err();
    let err_msg = err.to_string();
    assert!(err_msg.contains("Failed to compile 1 rule(s)"));
    assert!(err_msg.contains("long_pattern_rule"));
    assert!(err_msg.contains(&format!("pattern length ({}) exceeds maximum allowed ({})", MAX_PATTERN_LENGTH + 1, MAX_PATTERN_LENGTH)));
}