use crate::constants::{AMBIGUOUS, DIGITS, LOWERCASE, SPECIALS, UPPERCASE};
use crate::error::VaultKeyError;
use crate::options::PasswordOptions;
use anyhow::Result;
use rand::{seq::SliceRandom, Rng};
#[derive(Debug)]
pub struct PasswordBuilder {
options: PasswordOptions,
}
impl Default for PasswordBuilder {
fn default() -> Self {
Self {
options: PasswordOptions {
length: 12,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 1,
min_specials: 1,
avoid_ambiguous: false,
},
}
}
}
impl PasswordBuilder {
#[must_use]
pub const fn length(mut self, len: usize) -> Self {
self.options.length = len;
self
}
#[must_use]
pub const fn with_uppercase(mut self, include: bool) -> Self {
self.options.include_uppercase = include;
self
}
#[must_use]
pub const fn with_lowercase(mut self, include: bool) -> Self {
self.options.include_lowercase = include;
self
}
#[must_use]
pub const fn with_digits(mut self, include: bool) -> Self {
self.options.include_digits = include;
self
}
#[must_use]
pub const fn with_specials(mut self, include: bool) -> Self {
self.options.include_specials = include;
self
}
#[must_use]
pub const fn min_digits(mut self, min: usize) -> Self {
self.options.min_digits = min;
self
}
#[must_use]
pub const fn min_specials(mut self, min: usize) -> Self {
self.options.min_specials = min;
self
}
#[must_use]
pub const fn avoid_ambiguous(mut self, avoid: bool) -> Self {
self.options.avoid_ambiguous = avoid;
self
}
pub fn build(self) -> Result<String> {
generate_password(&self.options)
}
}
fn generate_password(options: &PasswordOptions) -> Result<String> {
let mut rng = rand::rng();
let mut pool = String::new();
if options.include_uppercase {
pool.push_str(&UPPERCASE);
}
if options.include_lowercase {
pool.push_str(&LOWERCASE);
}
if options.include_digits {
pool.push_str(&DIGITS);
}
if options.include_specials {
pool.push_str(&SPECIALS);
}
if options.avoid_ambiguous {
pool = pool.chars().filter(|c| !AMBIGUOUS.contains(*c)).collect();
}
if options.length < 5 {
return Err(VaultKeyError::PasswordTooShort.into());
}
if pool.is_empty() {
return Err(VaultKeyError::NoCharacterTypesSelected.into());
}
let mut password = String::with_capacity(options.length);
let available_length = options.length;
let min_digits = options.min_digits.min(if options.include_digits {
available_length
} else {
0
});
let min_specials = options.min_specials.min(if options.include_specials {
available_length.saturating_sub(min_digits)
} else {
0
});
let filter_ambiguous = |chars: &str| -> Vec<char> {
if options.avoid_ambiguous {
chars.chars().filter(|c| !AMBIGUOUS.contains(*c)).collect()
} else {
chars.chars().collect()
}
};
let digits_chars: Vec<char> = filter_ambiguous(&DIGITS);
let special_chars: Vec<char> = filter_ambiguous(&SPECIALS);
if options.include_digits && min_digits > 0 && !digits_chars.is_empty() {
for _ in 0..min_digits {
let idx = rng.random_range(0..digits_chars.len());
password.push(digits_chars[idx]);
}
}
if options.include_specials && min_specials > 0 && !special_chars.is_empty() {
for _ in 0..min_specials {
let idx = rng.random_range(0..special_chars.len());
password.push(special_chars[idx]);
}
}
while password.len() < options.length {
let idx = rng.random_range(0..pool.len());
password.push(pool.chars().nth(idx).unwrap());
}
let mut password_chars: Vec<char> = password.chars().collect();
password_chars.shuffle(&mut rng);
Ok(password_chars.iter().collect::<String>())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_matches_requested_length() {
let options = PasswordOptions {
length: 16,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 1,
min_specials: 1,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
assert_eq!(password.len(), 16);
}
#[test]
fn password_contains_only_uppercase_when_specified() {
let options = PasswordOptions {
length: 10,
include_uppercase: true,
include_lowercase: false,
include_digits: false,
include_specials: false,
min_digits: 0,
min_specials: 0,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
assert!(password.chars().all(|c| UPPERCASE.contains(c)));
}
#[test]
fn password_contains_only_lowercase_when_specified() {
let options = PasswordOptions {
length: 10,
include_uppercase: false,
include_lowercase: true,
include_digits: false,
include_specials: false,
min_digits: 0,
min_specials: 0,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
assert!(password.chars().all(|c| LOWERCASE.contains(c)));
}
#[test]
fn password_contains_minimum_required_digits() {
let options = PasswordOptions {
length: 20,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 5,
min_specials: 2,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
let digit_count = password.chars().filter(|c| DIGITS.contains(*c)).count();
assert!(digit_count >= 5);
}
#[test]
fn password_contains_minimum_required_specials() {
let options = PasswordOptions {
length: 20,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 2,
min_specials: 7,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
let special_count = password.chars().filter(|c| SPECIALS.contains(*c)).count();
assert!(special_count >= 7);
}
#[test]
fn password_excludes_ambiguous_characters_when_specified() {
let options = PasswordOptions {
length: 100,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: false,
min_digits: 10,
min_specials: 0,
avoid_ambiguous: true,
};
let password = generate_password(&options).unwrap();
assert!(!password.chars().any(|c| AMBIGUOUS.contains(c)));
}
#[test]
fn zero_length_returns_empty_string() {
let options = PasswordOptions {
length: 0,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 0,
min_specials: 0,
avoid_ambiguous: false,
};
let result = generate_password(&options);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Password length must be at least 5"
);
}
#[test]
fn handles_large_password_lengths() {
let options = PasswordOptions {
length: 1000,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 100,
min_specials: 100,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
assert_eq!(password.len(), 1000);
}
#[test]
fn handles_minimum_requirements_exceeding_length() {
let options = PasswordOptions {
length: 5,
include_uppercase: true,
include_lowercase: true,
include_digits: true,
include_specials: true,
min_digits: 3,
min_specials: 4,
avoid_ambiguous: false,
};
let password = generate_password(&options).unwrap();
assert_eq!(password.len(), 5);
}
}