use console::{Term, style};
use std::io::Write;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordStrength {
Weak,
Fair,
Good,
Strong,
}
#[derive(Debug, Clone, Copy)]
struct PasswordStrengthVisuals {
color: &'static str,
label: &'static str,
bar: &'static str,
percent: u8,
}
impl PasswordStrength {
fn visuals(self) -> PasswordStrengthVisuals {
match self {
Self::Weak => PasswordStrengthVisuals {
color: "red",
label: "Weak",
bar: "[█░░░]",
percent: 25,
},
Self::Fair => PasswordStrengthVisuals {
color: "yellow",
label: "Fair",
bar: "[██░░]",
percent: 50,
},
Self::Good => PasswordStrengthVisuals {
color: "blue",
label: "Good",
bar: "[███░]",
percent: 75,
},
Self::Strong => PasswordStrengthVisuals {
color: "green",
label: "Strong",
bar: "[████]",
percent: 100,
},
}
}
pub fn color(&self) -> &'static str {
self.visuals().color
}
pub fn label(&self) -> &'static str {
self.visuals().label
}
pub fn bar(&self) -> &'static str {
self.visuals().bar
}
pub fn percent(&self) -> u8 {
self.visuals().percent
}
}
impl std::fmt::Display for PasswordStrength {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
#[derive(Debug, Clone)]
pub struct PasswordValidation {
pub strength: PasswordStrength,
pub score: u8,
pub entropy_bits: f64,
pub suggestions: Vec<&'static str>,
pub checks: PasswordChecks,
}
#[derive(Debug, Clone, Copy)]
pub struct PasswordChecks {
pub has_lowercase: bool,
pub has_uppercase: bool,
pub has_digit: bool,
pub has_special: bool,
pub length: usize,
pub meets_min_length: bool,
}
pub fn validate_password(password: &str) -> PasswordValidation {
let length = password.chars().count();
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_alphanumeric());
let length_score: u8 = match length {
0..=7 => 0,
8..=11 => 1,
12..=15 => 2,
_ => 3,
};
let score =
length_score + has_upper as u8 + has_lower as u8 + has_digit as u8 + has_special as u8;
let mut suggestions = Vec::new();
if length < 12 {
suggestions.push("Use at least 12 characters");
}
if !has_upper {
suggestions.push("Add uppercase letters");
}
if !has_lower {
suggestions.push("Add lowercase letters");
}
if !has_digit {
suggestions.push("Add numbers");
}
if !has_special {
suggestions.push("Add special characters (!@#$%^&*)");
}
let strength = match score {
0..=2 => PasswordStrength::Weak,
3..=4 => PasswordStrength::Fair,
5..=6 => PasswordStrength::Good,
_ => PasswordStrength::Strong,
};
let entropy_bits = estimate_entropy(password);
PasswordValidation {
strength,
score,
entropy_bits,
suggestions,
checks: PasswordChecks {
has_lowercase: has_lower,
has_uppercase: has_upper,
has_digit,
has_special,
length,
meets_min_length: length >= 12,
},
}
}
fn estimate_entropy(password: &str) -> f64 {
if password.is_empty() {
return 0.0;
}
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_alphanumeric());
let mut pool_size = 0u32;
if has_lower {
pool_size += 26;
}
if has_upper {
pool_size += 26;
}
if has_digit {
pool_size += 10;
}
if has_special {
pool_size += 32;
}
if pool_size == 0 {
pool_size = 26; }
let bits_per_char = (pool_size as f64).log2();
let length = password.chars().count() as f64;
bits_per_char * length
}
pub fn display_strength(term: &mut Term, validation: &PasswordValidation) -> std::io::Result<()> {
let strength = &validation.strength;
let colored_bar = match strength {
PasswordStrength::Weak => style(strength.bar()).red(),
PasswordStrength::Fair => style(strength.bar()).yellow(),
PasswordStrength::Good => style(strength.bar()).blue(),
PasswordStrength::Strong => style(strength.bar()).green(),
};
let colored_label = match strength {
PasswordStrength::Weak => style(strength.label()).red().bold(),
PasswordStrength::Fair => style(strength.label()).yellow().bold(),
PasswordStrength::Good => style(strength.label()).blue().bold(),
PasswordStrength::Strong => style(strength.label()).green().bold(),
};
term.clear_line()?;
write!(term, "Strength: {} {}", colored_bar, colored_label)?;
if !validation.suggestions.is_empty() {
writeln!(term)?;
for suggestion in &validation.suggestions {
writeln!(term, " {} {}", style("•").dim(), style(suggestion).dim())?;
}
}
term.flush()?;
Ok(())
}
pub fn format_strength_inline(validation: &PasswordValidation) -> String {
let strength = &validation.strength;
let bar = match strength {
PasswordStrength::Weak => style(strength.bar()).red(),
PasswordStrength::Fair => style(strength.bar()).yellow(),
PasswordStrength::Good => style(strength.bar()).blue(),
PasswordStrength::Strong => style(strength.bar()).green(),
};
let label = match strength {
PasswordStrength::Weak => style(strength.label()).red().bold(),
PasswordStrength::Fair => style(strength.label()).yellow().bold(),
PasswordStrength::Good => style(strength.label()).blue().bold(),
PasswordStrength::Strong => style(strength.label()).green().bold(),
};
format!("{} {}", bar, label)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_password() {
let result = validate_password("");
assert_eq!(result.strength, PasswordStrength::Weak);
assert!(!result.suggestions.is_empty());
}
#[test]
fn test_weak_password() {
let result = validate_password("password");
assert_eq!(result.strength, PasswordStrength::Weak);
assert!(result.suggestions.contains(&"Add uppercase letters"));
assert!(result.suggestions.contains(&"Add numbers"));
assert!(
result
.suggestions
.contains(&"Add special characters (!@#$%^&*)")
);
}
#[test]
fn test_fair_password() {
let result = validate_password("Password1");
assert_eq!(result.strength, PasswordStrength::Fair);
}
#[test]
fn test_good_password() {
let result = validate_password("Password1!");
assert_eq!(result.strength, PasswordStrength::Good);
}
#[test]
fn test_strong_password() {
let result = validate_password("MySecureP@ssw0rd!");
assert_eq!(result.strength, PasswordStrength::Strong);
assert!(result.suggestions.is_empty());
}
#[test]
fn test_long_lowercase_only() {
let result = validate_password("averylongpasswordwithnothingelse");
assert!(matches!(
result.strength,
PasswordStrength::Fair | PasswordStrength::Good
));
}
#[test]
fn test_strength_bar_rendering() {
let cases = [
(PasswordStrength::Weak, "[█░░░]"),
(PasswordStrength::Fair, "[██░░]"),
(PasswordStrength::Good, "[███░]"),
(PasswordStrength::Strong, "[████]"),
];
for (strength, expected_bar) in cases {
assert_eq!(strength.bar(), expected_bar, "{strength:?}");
}
}
#[test]
fn test_strength_color_and_label() {
let cases = [
(PasswordStrength::Weak, "red", "Weak"),
(PasswordStrength::Fair, "yellow", "Fair"),
(PasswordStrength::Good, "blue", "Good"),
(PasswordStrength::Strong, "green", "Strong"),
];
for (strength, expected_color, expected_label) in cases {
assert_eq!(strength.color(), expected_color, "{strength:?}");
assert_eq!(strength.label(), expected_label, "{strength:?}");
assert_eq!(strength.to_string(), expected_label, "{strength:?}");
}
}
#[test]
fn test_strength_percent() {
let cases = [
(PasswordStrength::Weak, 25),
(PasswordStrength::Fair, 50),
(PasswordStrength::Good, 75),
(PasswordStrength::Strong, 100),
];
for (strength, expected_percent) in cases {
assert_eq!(strength.percent(), expected_percent, "{strength:?}");
}
}
#[test]
fn test_checks_populated() {
let result = validate_password("Test123!");
assert!(result.checks.has_lowercase);
assert!(result.checks.has_uppercase);
assert!(result.checks.has_digit);
assert!(result.checks.has_special);
assert_eq!(result.checks.length, 8);
assert!(!result.checks.meets_min_length);
}
#[test]
fn test_entropy_calculation() {
let result = validate_password("MySecureP@ssw0rd");
assert!(result.entropy_bits > 80.0);
}
#[test]
fn test_unicode_password() {
let result = validate_password("Pässwörd123!");
assert!(result.checks.has_special); assert!(result.checks.has_uppercase);
assert!(result.checks.has_digit);
}
}