Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 0 additions & 105 deletions .github/workflows/mutation-testing.yml

This file was deleted.

13 changes: 2 additions & 11 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,5 @@ jobs:
restore-keys: |
${{ runner.os }}-cargo-

- name: Build
run: cargo build --verbose

- name: Run tests
run: cargo test --verbose

- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings

- name: Check formatting
run: cargo fmt --check
- name: Run CI script
run: ./ci.sh
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ Thumbs.db
# Temporary files
*.tmp
*.temp
/.local-notes/
/.local-notes/
/mutants.out/
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,16 @@ cargo test --release
cargo test unit:: # Run only unit tests
```

### Git Hooks

The project ships a tracked pre-commit hook in `githooks/pre-commit` that mirrors the CI steps (build, test, clippy) defined in `.github/workflows/rust.yml`. Enable it once per machine by running:

```bash
git config core.hooksPath githooks
```

After enabling the hook, every `git commit` will sequentially run `cargo build --verbose`, `cargo test --verbose`, and `cargo clippy --all-targets --all-features -- -D warnings` and stop the commit if any step fails.

### Code Coverage

Code coverage is tracked using `cargo-llvm-cov`. Reports are automatically generated on every PR and push to main.
Expand Down Expand Up @@ -644,15 +654,17 @@ open tarpaulin-report.html

Mutation testing complements code coverage by validating test quality. It systematically introduces small bugs (mutations) into the source code and verifies that tests detect them. While coverage shows *what code is executed*, mutation testing reveals *whether tests actually validate behavior*.

Mutation testing is run locally on demand; there is no GitHub Actions workflow for it.

```bash
# Install cargo-mutants
cargo install cargo-mutants

# Run mutation testing (5-15 minutes)
cargo mutants --all
cargo mutants

# Generate HTML report
cargo mutants --all --html
cargo mutants --html
open mutants-out/html/index.html
```

Expand All @@ -665,7 +677,6 @@ open mutants-out/html/index.html
**Target metrics:**
- Mutation score >85% indicates strong test quality
- Surviving mutations highlight areas needing stronger test coverage
- Weekly runs via GitHub Actions with reports uploaded as artifacts

**Configuration:**
Mutation testing is configured via `mutants.toml` in the project root, which excludes test code, FFI bindings, and CLI entry points from mutation.
Expand Down
16 changes: 16 additions & 0 deletions ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

# Always run from the repo root so cargo commands behave consistently.
cd "$(git rev-parse --show-toplevel)"

run_phase() {
printf '\n== %s ==\n' "$1"
shift
"$@"
}

run_phase "cargo build" cargo build --verbose
run_phase "cargo test" cargo test --verbose
run_phase "cargo clippy" cargo clippy --all-targets --all-features -- -D warnings
run_phase "cargo fmt" cargo fmt --check
4 changes: 4 additions & 0 deletions githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

exec "$(git rev-parse --show-toplevel)/ci.sh"
7 changes: 4 additions & 3 deletions mutants.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# cargo-mutants configuration for redactor project
#
# Mutation testing validates test quality by making systematic changes
# to source code and verifying tests catch them. This config excludes
# files that shouldn't be mutated (test code, FFI, CLI covered by integration tests).
# to source code and verifying tests catch them. This configuration is
# intended for local, on-demand runs (not CI), and excludes files that
# shouldn't be mutated (test code, FFI, CLI covered by integration tests).

[mutants]
# Timeout per mutation test run (seconds)
timeout = 60

# Number of parallel workers for mutation testing
# Adjust based on CI runner capacity
# Adjust based on local machine capacity
workers = 4

# Exclude test files from mutation
Expand Down
35 changes: 34 additions & 1 deletion src/domain/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ impl VerizonAccountMatcher {

fn pattern_14_with_context() -> &'static Regex {
static PATTERN: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:account|acct)(?:\s*(?:number|num|no|#))?\s*:?\s*(\d{14})")
Regex::new(r"(?i)(?:account|acct)(?:\s*(?:number|num|no|#))?\s*:?\s*(\d{14})\b")
.expect("Valid regex")
});
&PATTERN
Expand Down Expand Up @@ -216,4 +216,37 @@ mod tests {
let account = VerizonAccountMatcher::find_account_number(text);
assert_eq!(account, None);
}

#[test]
fn test_generic_account_length_bounds() {
let too_short = "Account: 123456789";
let too_long = "Account: 1234567890123456";
let ten_digits = "Account: 1234567890";
let fifteen_digits = "Account: 123456789012345";

assert_eq!(VerizonAccountMatcher::find_account_number(too_short), None);
assert_eq!(VerizonAccountMatcher::find_account_number(too_long), None);
assert_eq!(
VerizonAccountMatcher::find_account_number(ten_digits),
Some("1234567890".to_string())
);
assert_eq!(
VerizonAccountMatcher::find_account_number(fifteen_digits),
Some("123456789012345".to_string())
);
}

#[test]
fn test_prefers_14_digit_account() {
let text = "Account: 123456789012 and Account: 12345678901234";
let account = VerizonAccountMatcher::find_account_number(text);
assert_eq!(account, Some("12345678901234".to_string()));
}

#[test]
fn test_short_account_variant_has_no_splits() {
let matcher = VerizonAccountMatcher::new();
let variants = matcher.generate_variants("123456789");
assert_eq!(variants, vec!["123456789".to_string()]);
}
}
9 changes: 2 additions & 7 deletions tests/cli_unit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,13 +308,9 @@ mod extract_command_tests {

mod integration_workflow_tests {
use super::*;
use std::sync::Mutex;

static MUPDF_LOCK: Mutex<()> = Mutex::new(());

#[test]
fn test_complete_redaction_workflow() -> Result<()> {
let _guard = MUPDF_LOCK.lock().unwrap();
let temp_dir = TempDir::new()?;
let input = temp_dir.path().join("input.pdf");
let output = temp_dir.path().join("output.pdf");
Expand All @@ -334,7 +330,7 @@ mod integration_workflow_tests {

// Execute redaction
let service = RedactionService::with_secure_strategy();
let result = service.redact(&input, &output, &targets)?;
let result = with_mupdf_lock(|| service.redact(&input, &output, &targets))?;

// Verify results
assert!(result.has_redactions());
Expand All @@ -345,7 +341,6 @@ mod integration_workflow_tests {

#[test]
fn test_workflow_with_multiple_targets() -> Result<()> {
let _guard = MUPDF_LOCK.lock().unwrap();
let temp_dir = TempDir::new()?;
let input = temp_dir.path().join("input.pdf");
let output = temp_dir.path().join("output.pdf");
Expand All @@ -362,7 +357,7 @@ mod integration_workflow_tests {
];

let service = RedactionService::with_secure_strategy();
let result = service.redact(&input, &output, &targets)?;
let result = with_mupdf_lock(|| service.redact(&input, &output, &targets))?;

assert!(result.has_redactions());
// At least one pattern should match (phone and/or CONFIDENTIAL)
Expand Down
14 changes: 14 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,18 @@

pub mod fixtures;

use std::sync::Mutex;

pub use fixtures::*;

#[allow(dead_code)]
static MUPDF_LOCK: Mutex<()> = Mutex::new(());

#[allow(dead_code)]
pub fn with_mupdf_lock<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = MUPDF_LOCK.lock().expect("MuPDF lock poisoned");
f()
}
Loading