From 71742dfb54d9fddd5c7db2a8806979e7aeb44a33 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 15:56:17 +0100 Subject: [PATCH 1/8] Update CI workflow to specify stable Rust toolchain - Added configuration to use the stable Rust toolchain in the CI workflow for all jobs. - Ensured consistent environment setup across different CI jobs. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7761b4..b63c977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable - name: Install cargo-tarpaulin run: cargo install cargo-tarpaulin - name: Run coverage @@ -53,6 +55,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable - name: Build release run: cargo build --release --verbose @@ -62,6 +66,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable - name: Check package run: cargo package --allow-dirty - name: Check publish From fc8d85cf4205c72171989d02708e35369d0e81ba Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 16:06:07 +0100 Subject: [PATCH 2/8] Update CI workflow to consistently use stable Rust toolchain - Changed the Rust toolchain specification from `master` to `stable` across all CI jobs. - Ensured that the `cargo publish` command allows dirty packages for better flexibility during checks. --- .github/workflows/ci.yml | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63c977..98f6756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable + - uses: dtolnay/rust-toolchain@stable - name: Install cargo-tarpaulin run: cargo install cargo-tarpaulin - name: Run coverage @@ -54,9 +52,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable + - uses: dtolnay/rust-toolchain@stable - name: Build release run: cargo build --release --verbose @@ -65,13 +61,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable + - uses: dtolnay/rust-toolchain@stable - name: Check package run: cargo package --allow-dirty - name: Check publish - run: cargo publish --dry-run + run: cargo publish --dry-run --allow-dirty release: name: Release @@ -99,9 +93,8 @@ jobs: asset_name: rpg-windows-x86_64.exe steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@stable with: - toolchain: stable targets: ${{ matrix.target }} - name: Build release binary run: cargo build --release --target ${{ matrix.target }} From 8eece57353a98970f64f5699b09a30f749a64869 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 16:12:04 +0100 Subject: [PATCH 3/8] Refactor CI workflow for improved code coverage and publish checks - Consolidated code coverage steps into a single job with conditional execution based on OS and Rust version. - Streamlined publish check steps to only run on Ubuntu, allowing for dirty packages during checks. - Enhanced overall clarity and maintainability of the CI configuration. --- .github/workflows/ci.yml | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f6756..904f2d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,18 +26,13 @@ jobs: run: cargo clippy -- -D warnings - name: Check formatting run: cargo fmt -- --check - - coverage: - name: Code Coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin - - name: Run coverage - run: cargo tarpaulin --out Xml --output-dir coverage + - name: Code coverage + if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + run: | + cargo install cargo-tarpaulin + cargo tarpaulin --out Xml --output-dir coverage - name: Upload to codecov + if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' uses: codecov/codecov-action@v4 with: files: ./coverage/cobertura.xml @@ -55,16 +50,11 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Build release run: cargo build --release --verbose - - publish-check: - name: Publish Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - name: Check package + if: matrix.os == 'ubuntu-latest' run: cargo package --allow-dirty - name: Check publish + if: matrix.os == 'ubuntu-latest' run: cargo publish --dry-run --allow-dirty release: From 548074304a45498644d55f03840f76917383f2c3 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 16:17:36 +0100 Subject: [PATCH 4/8] Fix integration tests to use CARGO_BIN_EXE_rpg --- tests/integration_test.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 7cc4775..08f2c0e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2,7 +2,7 @@ use std::process::Command; #[test] fn test_basic_generation() { - let output = Command::new("./target/release/rpg") + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) .args(&["3", "--quiet"]) .output() .expect("Failed to execute command"); @@ -21,7 +21,7 @@ fn test_basic_generation() { #[test] fn test_length_option() { - let output = Command::new("./target/release/rpg") + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) .args(&["1", "--length", "20", "--quiet"]) .output() .expect("Failed to execute command"); @@ -55,12 +55,12 @@ fn test_length_option() { #[test] fn test_seed_reproducibility() { - let output1 = Command::new("./target/release/rpg") + let output1 = Command::new(env!("CARGO_BIN_EXE_rpg")) .args(&["1", "--seed", "12345", "--quiet"]) .output() .expect("Failed to execute command"); - let output2 = Command::new("./target/release/rpg") + let output2 = Command::new(env!("CARGO_BIN_EXE_rpg")) .args(&["1", "--seed", "12345", "--quiet"]) .output() .expect("Failed to execute command"); From 62609c39a5caf8e2e1c7cbb273bdf4750e4ef7a2 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 16:21:55 +0100 Subject: [PATCH 5/8] Add clippy and rustfmt components to test toolchain --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 904f2d1..c26966d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} + components: clippy, rustfmt - name: Run tests run: cargo test --verbose - name: Run clippy From afa4a8adddee0d1f83a81ea1b95cf36f8317b414 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Thu, 15 Jan 2026 16:38:47 +0100 Subject: [PATCH 6/8] Refine CI workflow to simplify Rust toolchain usage and coverage checks - Updated the CI configuration to consistently use the stable Rust toolchain. - Removed unnecessary conditions for code coverage and upload steps, allowing them to run on Ubuntu regardless of Rust version. - Enhanced clarity and maintainability of the CI setup. --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c26966d..3b2c51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable, beta] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ matrix.rust }} components: clippy, rustfmt - name: Run tests run: cargo test --verbose @@ -28,12 +26,12 @@ jobs: - name: Check formatting run: cargo fmt -- --check - name: Code coverage - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + if: matrix.os == 'ubuntu-latest' run: | cargo install cargo-tarpaulin cargo tarpaulin --out Xml --output-dir coverage - name: Upload to codecov - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: files: ./coverage/cobertura.xml From 6929f5829a3b45db4ff8b60e8217ee184c66d18a Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Fri, 16 Jan 2026 12:18:03 +0100 Subject: [PATCH 7/8] Enhance CI workflow and add comprehensive tests for character parsing - Updated the CI configuration to include tests for character exclusion and inclusion logic. - Modified the `cargo tarpaulin` command to include tests for better coverage reporting. - Added new tests for handling invalid character ranges and ensuring correct password generation based on specified patterns and character sets. - Introduced a new coverage report file for better tracking of test coverage metrics. --- .github/workflows/ci.yml | 2 +- coverage-test/cobertura.xml | 1 + src/lib.rs | 272 ++++++++++++++++++++++++++++++ tests/integration_test.rs | 323 ++++++++++++++++++++++++++++++++++++ 4 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 coverage-test/cobertura.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b2c51f..70d40fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | cargo install cargo-tarpaulin - cargo tarpaulin --out Xml --output-dir coverage + cargo tarpaulin --tests --out Xml --output-dir coverage - name: Upload to codecov if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 diff --git a/coverage-test/cobertura.xml b/coverage-test/cobertura.xml new file mode 100644 index 0000000..3ab9380 --- /dev/null +++ b/coverage-test/cobertura.xml @@ -0,0 +1 @@ +/Users/jmachen/code/rpg \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 15bf6f4..305a1b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1218,4 +1218,276 @@ mod tests { print_columns(passwords.clone(), 1, false); print_columns(passwords, 3, false); } + + #[test] + fn test_parse_exclude_chars_non_printable_start() { + // Test range with start character < 32 (non-printable) + // This should skip the range logic and treat as individual chars + let result = parse_exclude_chars(vec!["\x1f-9".to_string()]); + // Should succeed but treat as individual characters, not a range + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should contain the characters from the string, not a range expansion + assert!(chars.len() >= 2); // At least \x1f, -, and 9 + } + + #[test] + fn test_parse_exclude_chars_non_printable_end() { + // Test range with end character >= 127 (non-printable) + // This should skip the range logic + let result = parse_exclude_chars(vec!["a-\x7f".to_string()]); + // Should succeed but treat as individual characters + assert!(result.is_ok()); + // Should not expand as a range + let chars = result.unwrap(); + // The range logic should be skipped due to end >= 127 + // So it should treat as individual characters + assert!(chars.contains(&'a')); + } + + #[test] + fn test_parse_exclude_chars_duplicate_handling() { + // Test that duplicates are properly handled + let result = parse_exclude_chars(vec!["a".to_string(), "a".to_string(), "b".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should only contain one 'a' and one 'b' + assert_eq!(chars.iter().filter(|&&c| c == 'a').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'b').count(), 1); + } + + #[test] + fn test_parse_exclude_chars_duplicate_in_range_and_individual() { + // Test that characters in ranges are not duplicated when also specified individually + let result = parse_exclude_chars(vec!["a-c".to_string(), "b".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should contain a, b, c each once + assert_eq!(chars.iter().filter(|&&c| c == 'a').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'b').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'c').count(), 1); + } + + #[test] + fn test_parse_exclude_chars_boundary_conditions() { + // Test range exactly at printable ASCII boundaries + // Space (32) to ~ (126) should work + let result = parse_exclude_chars(vec![" -~".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should expand to all printable ASCII + assert!(chars.contains(&' ')); + assert!(chars.contains(&'~')); + } + + #[test] + fn test_build_char_set_all_types_disabled_lowercase_available() { + // Test with all character types disabled, but lowercase still available + let args = create_test_args(true, true, true, vec![]); + let char_set = build_char_set(&args).unwrap(); + // Should only contain lowercase letters + assert!(!char_set.is_empty()); + assert!(char_set.contains(&b'a')); + assert!(char_set.contains(&b'z')); + assert!(!char_set.contains(&b'A')); + assert!(!char_set.contains(&b'0')); + assert!(!char_set.contains(&b'!')); + } + + #[test] + fn test_build_char_set_include_chars_empty_after_exclude() { + // Test include_chars where exclude_chars removes all characters + let mut args = create_test_args(false, false, false, vec!['a', 'b', 'c']); + args.include_chars = Some(vec!['a', 'b', 'c']); + let result = build_char_set(&args); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + PasswordError::EmptyCharacterSet + )); + } + + #[test] + fn test_build_char_set_include_chars_with_exclusions_partial() { + // Test include_chars with exclude_chars that removes some but not all + let mut args = create_test_args(false, false, false, vec!['a']); + args.include_chars = Some(vec!['a', 'b', 'c', 'd', 'e']); + let char_set = build_char_set(&args).unwrap(); + assert_eq!(char_set.len(), 4); // b, c, d, e + assert!(!char_set.contains(&b'a')); + assert!(char_set.contains(&b'b')); + assert!(char_set.contains(&b'c')); + assert!(char_set.contains(&b'd')); + assert!(char_set.contains(&b'e')); + } + + #[test] + fn test_password_error_display_all_variants() { + // Ensure all error variants are tested for Display implementation + let err = PasswordError::InvalidLength; + let msg = err.to_string(); + assert!(msg.contains("Password length must be greater than 0")); + + let err = PasswordError::InvalidLengthTooLong; + let msg = err.to_string(); + assert!(msg.contains("exceeds maximum of 10,000")); + + let err = PasswordError::InvalidCount; + let msg = err.to_string(); + assert!(msg.contains("Password count must be greater than 0")); + + let err = PasswordError::EmptyCharacterSet; + let msg = err.to_string(); + assert!(msg.contains("All characters have been excluded")); + assert!(msg.contains("Hint")); + + let err = PasswordError::AllTypesDisabled; + let msg = err.to_string(); + assert!(msg.contains("All character types are disabled")); + assert!(msg.contains("Hint")); + } + + #[test] + fn test_password_error_source() { + // Test that PasswordError implements std::error::Error + let err = PasswordError::InvalidLength; + // Should be able to use as Error trait object + let _err_ref: &dyn std::error::Error = &err; + } + + #[test] + fn test_generate_password_with_minimums_exceeding_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; + // Request 5 minimums but length is only 4 + // Minimums take precedence, so password will be length 5 + let mut rng = StdRng::seed_from_u64(1001); + let password = generate_password_with_minimums(&char_set, 4, Some(5), None, None, &mut rng); + + // Should generate a password with at least 5 capitals (minimum takes precedence) + assert!(password.len() >= 5); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + assert!(capitals >= 5); // Minimum requirement is met + } + + #[test] + fn test_generate_password_with_minimums_sum_exceeds_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![ + b'a', b'b', b'c', b'A', b'B', b'C', b'0', b'1', b'2', b'!', b'@', b'#', + ]; + // Request min_capitals=3, min_numerals=3, min_symbols=3, but length=6 + // Minimums take precedence, so password will be at least length 9 + let mut rng = StdRng::seed_from_u64(1002); + let password = generate_password_with_minimums( + &char_set, + 6, + Some(3), + Some(3), + Some(3), + &mut rng, + ); + + // Password length should be at least 9 (sum of minimums) + // May be more if minimums are applied then filled up to length + assert!(password.len() >= 9); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); + + // Should meet all minimum requirements + assert!(capitals >= 3); + assert!(numerals >= 3); + assert!(symbols >= 3); + } + + #[test] + fn test_generate_password_with_minimums_exact_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![ + b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@', + ]; + // Request min_capitals=2, min_numerals=2, length=4 + let mut rng = StdRng::seed_from_u64(1003); + let password = generate_password_with_minimums(&char_set, 4, Some(2), Some(2), None, &mut rng); + + assert_eq!(password.len(), 4); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + + assert!(capitals >= 2); + assert!(numerals >= 2); + } + + #[test] + fn test_generate_password_from_pattern_all_same_type() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'c', b'A', b'B', b'C', b'0', b'1', b'2']; + let pattern = vec![ + PatternChar::Lowercase, + PatternChar::Lowercase, + PatternChar::Lowercase, + ]; + + let mut rng = StdRng::seed_from_u64(2001); + let password = generate_password_from_pattern(&char_set, &pattern, &mut rng); + + assert_eq!(password.len(), 3); + for c in password.chars() { + assert!(c.is_ascii_lowercase()); + } + } + + #[test] + fn test_generate_password_from_pattern_very_long() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; + // Create a pattern of length 100 + let pattern: Vec = (0..100) + .map(|i| match i % 4 { + 0 => PatternChar::Lowercase, + 1 => PatternChar::Uppercase, + 2 => PatternChar::Numeric, + _ => PatternChar::Symbol, + }) + .collect(); + + let mut rng = StdRng::seed_from_u64(2002); + let password = generate_password_from_pattern(&char_set, &pattern, &mut rng); + + assert_eq!(password.len(), 100); + } + + #[test] + fn test_print_columns_very_long_passwords() { + let passwords = vec![ + "a".repeat(100), + "b".repeat(50), + "c".repeat(150), + ]; + // Test width calculation with very long passwords + print_columns(passwords.clone(), 1, false); + print_columns(passwords.clone(), 2, false); + print_columns(passwords, 3, true); + } + + #[test] + fn test_print_columns_single_password_multi_column() { + // Test single password with multiple columns (should still print it) + let passwords = vec!["single".to_string()]; + print_columns(passwords, 5, false); + } + + #[test] + fn test_validate_args_all_types_disabled_lowercase_available() { + // Test validate_args when all types are disabled but lowercase available + let args = create_test_args(true, true, true, vec![]); + let result = validate_args(&args); + assert!(result.is_ok()); // Should be valid since lowercase is still available + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 08f2c0e..489fcca 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -92,3 +92,326 @@ fn test_seed_reproducibility() { assert_eq!(pass1, pass2); } + +#[test] +fn test_cli_invalid_exclude_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--exclude-chars", "z-a", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid range"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Invalid range") || stderr.contains("Error parsing exclude characters")); +} + +#[test] +fn test_cli_invalid_include_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--include-chars", "z-a", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid range"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Invalid range") || stderr.contains("Error parsing include characters")); +} + +#[test] +fn test_cli_invalid_pattern() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLX", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid pattern"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Invalid pattern character") || stderr.contains("Error parsing pattern")); +} + +#[test] +fn test_cli_invalid_length_zero() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--length", "0", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with length 0"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Password length must be greater than 0")); +} + +#[test] +fn test_cli_invalid_length_too_long() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--length", "10001", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with length > 10000"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("exceeds maximum of 10,000")); +} + +#[test] +fn test_cli_json_output() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["2", "--length", "10", "--format", "json", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should be valid JSON + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + assert!(json.get("passwords").is_some()); + assert!(json.get("count").is_some()); + assert!(json.get("length").is_some()); + assert!(json.get("entropy_bits").is_some()); + + let passwords = json.get("passwords").unwrap().as_array().unwrap(); + assert_eq!(passwords.len(), 2); + assert_eq!(passwords[0].as_str().unwrap().len(), 10); +} + +#[test] +fn test_cli_table_output() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["6", "--table", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + + // Should have passwords in table format (may be on multiple lines in columns) + // With 6 passwords and 2 columns, we'd expect at least 3 lines + assert!(lines.len() >= 3, "Expected at least 3 lines in table format, got {}", lines.len()); + // Verify passwords are present by checking non-empty content + assert!(!stdout.trim().is_empty(), "Output should contain passwords"); +} + +#[test] +fn test_cli_table_with_header() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["6", "--table"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should contain header when not in quiet mode + assert!(stdout.contains("Printing")); +} + +#[test] +fn test_cli_quiet_mode() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["3", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should not contain banner or header in quiet mode + assert!(!stdout.contains("RPG v")); + assert!(!stdout.contains("Printing")); + + // Should only have passwords + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 3); +} + +#[test] +fn test_cli_pattern_generation() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLLNNNSSS", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 9); + // Verify pattern was followed (can't predict exact chars but can verify types) + let chars: Vec = password.chars().collect(); + assert!(chars[0].is_ascii_lowercase()); + assert!(chars[1].is_ascii_lowercase()); + assert!(chars[2].is_ascii_lowercase()); + assert!(chars[3].is_ascii_digit()); + assert!(chars[4].is_ascii_digit()); + assert!(chars[5].is_ascii_digit()); + assert!(!chars[6].is_alphanumeric()); + assert!(!chars[7].is_alphanumeric()); + assert!(!chars[8].is_alphanumeric()); +} + +#[test] +fn test_cli_pattern_case_insensitive() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "lllununss", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 9); +} + +#[test] +fn test_cli_minimum_requirements() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--length", "10", "--min-capitals", "2", "--min-numerals", "2", "--min-symbols", "2", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 10); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); + + assert!(capitals >= 2); + assert!(numerals >= 2); + assert!(symbols >= 2); +} + +#[test] +fn test_cli_seed_reproducibility_with_options() { + let output1 = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--seed", "999", "--length", "20", "--min-capitals", "3", "--quiet"]) + .output() + .expect("Failed to execute command"); + + let output2 = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--seed", "999", "--length", "20", "--min-capitals", "3", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output1.status.success()); + assert!(output2.status.success()); + + let stdout1 = String::from_utf8(output1.stdout).unwrap(); + let stdout2 = String::from_utf8(output2.stdout).unwrap(); + let pass1: Vec<&str> = stdout1 + .lines() + .filter(|l| !l.is_empty()) + .collect(); + let pass2: Vec<&str> = stdout2 + .lines() + .filter(|l| !l.is_empty()) + .collect(); + + assert_eq!(pass1, pass2); +} + +#[test] +fn test_cli_include_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--include-chars", "a,b,c,1,2,3", "--length", "10", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 10); + // All characters should be from the include set + for c in password.chars() { + assert!(matches!(c, 'a' | 'b' | 'c' | '1' | '2' | '3')); + } +} + +#[test] +fn test_cli_include_chars_with_exclude() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--include-chars", "a,b,c", "--exclude-chars", "a", "--length", "10", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 10); + // Should only contain 'b' or 'c' + for c in password.chars() { + assert!(matches!(c, 'b' | 'c')); + } +} + +#[test] +fn test_cli_include_chars_with_range() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--include-chars", "a-z,0-9", "--length", "10", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + assert_eq!(password.len(), 10); + // All characters should be lowercase or digit + for c in password.chars() { + assert!(c.is_ascii_lowercase() || c.is_ascii_digit()); + } +} + +#[test] +fn test_cli_character_type_combinations() { + // Test with capitals off + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--capitals-off", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + // Should not contain uppercase letters + assert!(!password.chars().any(|c| c.is_ascii_uppercase())); +} + +#[test] +fn test_cli_pattern_overrides_length() { + // Pattern length should override --length option + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLL", "--length", "20", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + // Should be length 3 (pattern length), not 20 + assert_eq!(password.len(), 3); +} + +#[test] +fn test_cli_exclude_chars_with_range() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--exclude-chars", "a-z", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); + + // Should not contain lowercase letters + assert!(!password.chars().any(|c| c.is_ascii_lowercase())); +} From c6eeafc11b41dd25e2fcc0571e0ab765c9671c11 Mon Sep 17 00:00:00 2001 From: Jonathan Machen Date: Fri, 16 Jan 2026 14:02:09 +0100 Subject: [PATCH 8/8] Refactor test cases for improved readability and consistency - Cleaned up whitespace and formatting in test cases for password generation and CLI output. - Consolidated argument passing in test commands for better clarity. - Enhanced assertions for error messages in CLI tests to improve maintainability. --- src/lib.rs | 33 +++---- tests/integration_test.rs | 187 +++++++++++++++++++++++++++++--------- 2 files changed, 155 insertions(+), 65 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 305a1b9..a738655 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1364,7 +1364,7 @@ mod tests { // Minimums take precedence, so password will be length 5 let mut rng = StdRng::seed_from_u64(1001); let password = generate_password_with_minimums(&char_set, 4, Some(5), None, None, &mut rng); - + // Should generate a password with at least 5 capitals (minimum takes precedence) assert!(password.len() >= 5); let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); @@ -1381,22 +1381,16 @@ mod tests { // Request min_capitals=3, min_numerals=3, min_symbols=3, but length=6 // Minimums take precedence, so password will be at least length 9 let mut rng = StdRng::seed_from_u64(1002); - let password = generate_password_with_minimums( - &char_set, - 6, - Some(3), - Some(3), - Some(3), - &mut rng, - ); - + let password = + generate_password_with_minimums(&char_set, 6, Some(3), Some(3), Some(3), &mut rng); + // Password length should be at least 9 (sum of minimums) // May be more if minimums are applied then filled up to length assert!(password.len() >= 9); let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); - + // Should meet all minimum requirements assert!(capitals >= 3); assert!(numerals >= 3); @@ -1407,17 +1401,16 @@ mod tests { fn test_generate_password_with_minimums_exact_length() { use rand::{SeedableRng, rngs::StdRng}; - let char_set = vec![ - b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@', - ]; + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; // Request min_capitals=2, min_numerals=2, length=4 let mut rng = StdRng::seed_from_u64(1003); - let password = generate_password_with_minimums(&char_set, 4, Some(2), Some(2), None, &mut rng); - + let password = + generate_password_with_minimums(&char_set, 4, Some(2), Some(2), None, &mut rng); + assert_eq!(password.len(), 4); let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); - + assert!(capitals >= 2); assert!(numerals >= 2); } @@ -1465,11 +1458,7 @@ mod tests { #[test] fn test_print_columns_very_long_passwords() { - let passwords = vec![ - "a".repeat(100), - "b".repeat(50), - "c".repeat(150), - ]; + let passwords = vec!["a".repeat(100), "b".repeat(50), "c".repeat(150)]; // Test width calculation with very long passwords print_columns(passwords.clone(), 1, false); print_columns(passwords.clone(), 2, false); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 489fcca..511ab8c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -102,7 +102,9 @@ fn test_cli_invalid_exclude_chars() { assert!(!output.status.success(), "Should fail with invalid range"); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Invalid range") || stderr.contains("Error parsing exclude characters")); + assert!( + stderr.contains("Invalid range") || stderr.contains("Error parsing exclude characters") + ); } #[test] @@ -114,7 +116,9 @@ fn test_cli_invalid_include_chars() { assert!(!output.status.success(), "Should fail with invalid range"); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Invalid range") || stderr.contains("Error parsing include characters")); + assert!( + stderr.contains("Invalid range") || stderr.contains("Error parsing include characters") + ); } #[test] @@ -126,7 +130,9 @@ fn test_cli_invalid_pattern() { assert!(!output.status.success(), "Should fail with invalid pattern"); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Invalid pattern character") || stderr.contains("Error parsing pattern")); + assert!( + stderr.contains("Invalid pattern character") || stderr.contains("Error parsing pattern") + ); } #[test] @@ -162,14 +168,14 @@ fn test_cli_json_output() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - + // Should be valid JSON let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); assert!(json.get("passwords").is_some()); assert!(json.get("count").is_some()); assert!(json.get("length").is_some()); assert!(json.get("entropy_bits").is_some()); - + let passwords = json.get("passwords").unwrap().as_array().unwrap(); assert_eq!(passwords.len(), 2); assert_eq!(passwords[0].as_str().unwrap().len(), 10); @@ -185,10 +191,14 @@ fn test_cli_table_output() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); - + // Should have passwords in table format (may be on multiple lines in columns) // With 6 passwords and 2 columns, we'd expect at least 3 lines - assert!(lines.len() >= 3, "Expected at least 3 lines in table format, got {}", lines.len()); + assert!( + lines.len() >= 3, + "Expected at least 3 lines in table format, got {}", + lines.len() + ); // Verify passwords are present by checking non-empty content assert!(!stdout.trim().is_empty(), "Output should contain passwords"); } @@ -202,7 +212,7 @@ fn test_cli_table_with_header() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - + // Should contain header when not in quiet mode assert!(stdout.contains("Printing")); } @@ -216,11 +226,11 @@ fn test_cli_quiet_mode() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - + // Should not contain banner or header in quiet mode assert!(!stdout.contains("RPG v")); assert!(!stdout.contains("Printing")); - + // Should only have passwords let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(lines.len(), 3); @@ -235,8 +245,13 @@ fn test_cli_pattern_generation() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 9); // Verify pattern was followed (can't predict exact chars but can verify types) let chars: Vec = password.chars().collect(); @@ -260,27 +275,48 @@ fn test_cli_pattern_case_insensitive() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 9); } #[test] fn test_cli_minimum_requirements() { let output = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--length", "10", "--min-capitals", "2", "--min-numerals", "2", "--min-symbols", "2", "--quiet"]) + .args(&[ + "1", + "--length", + "10", + "--min-capitals", + "2", + "--min-numerals", + "2", + "--min-symbols", + "2", + "--quiet", + ]) .output() .expect("Failed to execute command"); assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 10); let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); - + assert!(capitals >= 2); assert!(numerals >= 2); assert!(symbols >= 2); @@ -289,12 +325,30 @@ fn test_cli_minimum_requirements() { #[test] fn test_cli_seed_reproducibility_with_options() { let output1 = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--seed", "999", "--length", "20", "--min-capitals", "3", "--quiet"]) + .args(&[ + "1", + "--seed", + "999", + "--length", + "20", + "--min-capitals", + "3", + "--quiet", + ]) .output() .expect("Failed to execute command"); let output2 = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--seed", "999", "--length", "20", "--min-capitals", "3", "--quiet"]) + .args(&[ + "1", + "--seed", + "999", + "--length", + "20", + "--min-capitals", + "3", + "--quiet", + ]) .output() .expect("Failed to execute command"); @@ -303,14 +357,8 @@ fn test_cli_seed_reproducibility_with_options() { let stdout1 = String::from_utf8(output1.stdout).unwrap(); let stdout2 = String::from_utf8(output2.stdout).unwrap(); - let pass1: Vec<&str> = stdout1 - .lines() - .filter(|l| !l.is_empty()) - .collect(); - let pass2: Vec<&str> = stdout2 - .lines() - .filter(|l| !l.is_empty()) - .collect(); + let pass1: Vec<&str> = stdout1.lines().filter(|l| !l.is_empty()).collect(); + let pass2: Vec<&str> = stdout2.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(pass1, pass2); } @@ -318,14 +366,26 @@ fn test_cli_seed_reproducibility_with_options() { #[test] fn test_cli_include_chars() { let output = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--include-chars", "a,b,c,1,2,3", "--length", "10", "--quiet"]) + .args(&[ + "1", + "--include-chars", + "a,b,c,1,2,3", + "--length", + "10", + "--quiet", + ]) .output() .expect("Failed to execute command"); assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 10); // All characters should be from the include set for c in password.chars() { @@ -336,14 +396,28 @@ fn test_cli_include_chars() { #[test] fn test_cli_include_chars_with_exclude() { let output = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--include-chars", "a,b,c", "--exclude-chars", "a", "--length", "10", "--quiet"]) + .args(&[ + "1", + "--include-chars", + "a,b,c", + "--exclude-chars", + "a", + "--length", + "10", + "--quiet", + ]) .output() .expect("Failed to execute command"); assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 10); // Should only contain 'b' or 'c' for c in password.chars() { @@ -354,14 +428,26 @@ fn test_cli_include_chars_with_exclude() { #[test] fn test_cli_include_chars_with_range() { let output = Command::new(env!("CARGO_BIN_EXE_rpg")) - .args(&["1", "--include-chars", "a-z,0-9", "--length", "10", "--quiet"]) + .args(&[ + "1", + "--include-chars", + "a-z,0-9", + "--length", + "10", + "--quiet", + ]) .output() .expect("Failed to execute command"); assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + assert_eq!(password.len(), 10); // All characters should be lowercase or digit for c in password.chars() { @@ -379,8 +465,13 @@ fn test_cli_character_type_combinations() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + // Should not contain uppercase letters assert!(!password.chars().any(|c| c.is_ascii_uppercase())); } @@ -395,8 +486,13 @@ fn test_cli_pattern_overrides_length() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + // Should be length 3 (pattern length), not 20 assert_eq!(password.len(), 3); } @@ -410,8 +506,13 @@ fn test_cli_exclude_chars_with_range() { assert!(output.status.success(), "Command failed: {:?}", output); let stdout = String::from_utf8(output.stdout).unwrap(); - let password = stdout.lines().filter(|l| !l.is_empty()).next().unwrap().trim(); - + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + // Should not contain lowercase letters assert!(!password.chars().any(|c| c.is_ascii_lowercase())); }