Skip to main content

coding_agent_search/pages/
password.rs

1//! Password strength validation and visual feedback.
2//!
3//! Provides real-time password strength validation with consistent behavior
4//! between CLI (Rust) and browser (JavaScript) implementations.
5//!
6//! # Strength Levels
7//!
8//! | Level | Entropy | Requirements |
9//! |-------|---------|--------------|
10//! | Weak | <20 bits | Missing multiple requirements |
11//! | Fair | 20-40 bits | Missing some requirements |
12//! | Good | 40-60 bits | Most requirements met |
13//! | Strong | ≥60 bits | All requirements met, 12+ chars |
14
15use console::{Term, style};
16use std::io::Write;
17
18/// Password strength levels with associated colors.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PasswordStrength {
21    Weak,
22    Fair,
23    Good,
24    Strong,
25}
26
27#[derive(Debug, Clone, Copy)]
28struct PasswordStrengthVisuals {
29    color: &'static str,
30    label: &'static str,
31    bar: &'static str,
32    percent: u8,
33}
34
35impl PasswordStrength {
36    fn visuals(self) -> PasswordStrengthVisuals {
37        match self {
38            Self::Weak => PasswordStrengthVisuals {
39                color: "red",
40                label: "Weak",
41                bar: "[█░░░]",
42                percent: 25,
43            },
44            Self::Fair => PasswordStrengthVisuals {
45                color: "yellow",
46                label: "Fair",
47                bar: "[██░░]",
48                percent: 50,
49            },
50            Self::Good => PasswordStrengthVisuals {
51                color: "blue",
52                label: "Good",
53                bar: "[███░]",
54                percent: 75,
55            },
56            Self::Strong => PasswordStrengthVisuals {
57                color: "green",
58                label: "Strong",
59                bar: "[████]",
60                percent: 100,
61            },
62        }
63    }
64
65    /// Get the ANSI color name for this strength level.
66    pub fn color(&self) -> &'static str {
67        self.visuals().color
68    }
69
70    /// Get a human-readable label.
71    pub fn label(&self) -> &'static str {
72        self.visuals().label
73    }
74
75    /// Get the progress bar representation (4 segments).
76    pub fn bar(&self) -> &'static str {
77        self.visuals().bar
78    }
79
80    /// Get the percentage (0-100) for progress bar width.
81    pub fn percent(&self) -> u8 {
82        self.visuals().percent
83    }
84}
85
86impl std::fmt::Display for PasswordStrength {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}", self.label())
89    }
90}
91
92/// Result of password validation.
93#[derive(Debug, Clone)]
94pub struct PasswordValidation {
95    /// Overall strength level.
96    pub strength: PasswordStrength,
97    /// Computed entropy score (0-7 based on criteria).
98    pub score: u8,
99    /// Entropy in bits.
100    pub entropy_bits: f64,
101    /// List of improvement suggestions.
102    pub suggestions: Vec<&'static str>,
103    /// Individual requirement checks.
104    pub checks: PasswordChecks,
105}
106
107/// Individual password requirement checks.
108#[derive(Debug, Clone, Copy)]
109pub struct PasswordChecks {
110    pub has_lowercase: bool,
111    pub has_uppercase: bool,
112    pub has_digit: bool,
113    pub has_special: bool,
114    pub length: usize,
115    pub meets_min_length: bool,
116}
117
118/// Validate a password and return strength assessment with suggestions.
119///
120/// # Algorithm
121///
122/// 1. Check for presence of lowercase, uppercase, digits, and special characters
123/// 2. Compute length score: 0 (0-7), 1 (8-11), 2 (12-15), 3 (16+)
124/// 3. Sum all criteria to get score (0-7)
125/// 4. Map score to strength level
126///
127/// # Example
128///
129/// ```
130/// use coding_agent_search::pages::password::{PasswordStrength, validate_password};
131///
132/// let result = validate_password("MySecureP@ssw0rd!");
133/// assert_eq!(result.strength, PasswordStrength::Strong);
134/// assert!(result.suggestions.is_empty());
135/// ```
136pub fn validate_password(password: &str) -> PasswordValidation {
137    let length = password.chars().count();
138    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
139    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
140    let has_digit = password.chars().any(|c| c.is_ascii_digit());
141    let has_special = password.chars().any(|c| !c.is_alphanumeric());
142
143    // Length scoring (0-3 points)
144    let length_score: u8 = match length {
145        0..=7 => 0,
146        8..=11 => 1,
147        12..=15 => 2,
148        _ => 3,
149    };
150
151    // Total score (0-7)
152    let score =
153        length_score + has_upper as u8 + has_lower as u8 + has_digit as u8 + has_special as u8;
154
155    // Collect improvement suggestions
156    let mut suggestions = Vec::new();
157    if length < 12 {
158        suggestions.push("Use at least 12 characters");
159    }
160    if !has_upper {
161        suggestions.push("Add uppercase letters");
162    }
163    if !has_lower {
164        suggestions.push("Add lowercase letters");
165    }
166    if !has_digit {
167        suggestions.push("Add numbers");
168    }
169    if !has_special {
170        suggestions.push("Add special characters (!@#$%^&*)");
171    }
172
173    // Map score to strength
174    let strength = match score {
175        0..=2 => PasswordStrength::Weak,
176        3..=4 => PasswordStrength::Fair,
177        5..=6 => PasswordStrength::Good,
178        _ => PasswordStrength::Strong,
179    };
180
181    // Calculate entropy bits for compatibility with confirmation.rs
182    let entropy_bits = estimate_entropy(password);
183
184    PasswordValidation {
185        strength,
186        score,
187        entropy_bits,
188        suggestions,
189        checks: PasswordChecks {
190            has_lowercase: has_lower,
191            has_uppercase: has_upper,
192            has_digit,
193            has_special,
194            length,
195            meets_min_length: length >= 12,
196        },
197    }
198}
199
200/// Calculate password entropy using character class analysis.
201///
202/// This mirrors the algorithm in `confirmation.rs::estimate_password_entropy`
203/// for consistency.
204fn estimate_entropy(password: &str) -> f64 {
205    if password.is_empty() {
206        return 0.0;
207    }
208
209    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
210    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
211    let has_digit = password.chars().any(|c| c.is_ascii_digit());
212    let has_special = password.chars().any(|c| !c.is_alphanumeric());
213
214    let mut pool_size = 0u32;
215    if has_lower {
216        pool_size += 26;
217    }
218    if has_upper {
219        pool_size += 26;
220    }
221    if has_digit {
222        pool_size += 10;
223    }
224    if has_special {
225        pool_size += 32;
226    }
227
228    if pool_size == 0 {
229        pool_size = 26; // Assume lowercase if nothing else
230    }
231
232    let bits_per_char = (pool_size as f64).log2();
233    let length = password.chars().count() as f64;
234
235    bits_per_char * length
236}
237
238/// Display password strength in the terminal with colored progress bar.
239///
240/// Clears the current line and writes:
241/// ```text
242/// Strength: [████] Strong
243///   • Add special characters (!@#$%^&*)
244/// ```
245pub fn display_strength(term: &mut Term, validation: &PasswordValidation) -> std::io::Result<()> {
246    let strength = &validation.strength;
247
248    // Choose color based on strength
249    let colored_bar = match strength {
250        PasswordStrength::Weak => style(strength.bar()).red(),
251        PasswordStrength::Fair => style(strength.bar()).yellow(),
252        PasswordStrength::Good => style(strength.bar()).blue(),
253        PasswordStrength::Strong => style(strength.bar()).green(),
254    };
255
256    let colored_label = match strength {
257        PasswordStrength::Weak => style(strength.label()).red().bold(),
258        PasswordStrength::Fair => style(strength.label()).yellow().bold(),
259        PasswordStrength::Good => style(strength.label()).blue().bold(),
260        PasswordStrength::Strong => style(strength.label()).green().bold(),
261    };
262
263    // Clear line and write strength indicator
264    term.clear_line()?;
265    write!(term, "Strength: {} {}", colored_bar, colored_label)?;
266
267    // Show suggestions if any
268    if !validation.suggestions.is_empty() {
269        writeln!(term)?;
270        for suggestion in &validation.suggestions {
271            writeln!(term, "  {} {}", style("•").dim(), style(suggestion).dim())?;
272        }
273    }
274
275    term.flush()?;
276    Ok(())
277}
278
279/// Format password strength as a simple inline indicator.
280///
281/// Returns a string like "[████] Strong" with ANSI colors.
282pub fn format_strength_inline(validation: &PasswordValidation) -> String {
283    let strength = &validation.strength;
284
285    let bar = match strength {
286        PasswordStrength::Weak => style(strength.bar()).red(),
287        PasswordStrength::Fair => style(strength.bar()).yellow(),
288        PasswordStrength::Good => style(strength.bar()).blue(),
289        PasswordStrength::Strong => style(strength.bar()).green(),
290    };
291
292    let label = match strength {
293        PasswordStrength::Weak => style(strength.label()).red().bold(),
294        PasswordStrength::Fair => style(strength.label()).yellow().bold(),
295        PasswordStrength::Good => style(strength.label()).blue().bold(),
296        PasswordStrength::Strong => style(strength.label()).green().bold(),
297    };
298
299    format!("{} {}", bar, label)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_empty_password() {
308        let result = validate_password("");
309        assert_eq!(result.strength, PasswordStrength::Weak);
310        assert!(!result.suggestions.is_empty());
311    }
312
313    #[test]
314    fn test_weak_password() {
315        let result = validate_password("password");
316        assert_eq!(result.strength, PasswordStrength::Weak);
317        assert!(result.suggestions.contains(&"Add uppercase letters"));
318        assert!(result.suggestions.contains(&"Add numbers"));
319        assert!(
320            result
321                .suggestions
322                .contains(&"Add special characters (!@#$%^&*)")
323        );
324    }
325
326    #[test]
327    fn test_fair_password() {
328        let result = validate_password("Password1");
329        assert_eq!(result.strength, PasswordStrength::Fair);
330    }
331
332    #[test]
333    fn test_good_password() {
334        let result = validate_password("Password1!");
335        assert_eq!(result.strength, PasswordStrength::Good);
336    }
337
338    #[test]
339    fn test_strong_password() {
340        let result = validate_password("MySecureP@ssw0rd!");
341        assert_eq!(result.strength, PasswordStrength::Strong);
342        assert!(result.suggestions.is_empty());
343    }
344
345    #[test]
346    fn test_long_lowercase_only() {
347        // Long but only lowercase - should be fair due to length
348        let result = validate_password("averylongpasswordwithnothingelse");
349        assert!(matches!(
350            result.strength,
351            PasswordStrength::Fair | PasswordStrength::Good
352        ));
353    }
354
355    #[test]
356    fn test_strength_bar_rendering() {
357        let cases = [
358            (PasswordStrength::Weak, "[█░░░]"),
359            (PasswordStrength::Fair, "[██░░]"),
360            (PasswordStrength::Good, "[███░]"),
361            (PasswordStrength::Strong, "[████]"),
362        ];
363
364        for (strength, expected_bar) in cases {
365            assert_eq!(strength.bar(), expected_bar, "{strength:?}");
366        }
367    }
368
369    #[test]
370    fn test_strength_color_and_label() {
371        let cases = [
372            (PasswordStrength::Weak, "red", "Weak"),
373            (PasswordStrength::Fair, "yellow", "Fair"),
374            (PasswordStrength::Good, "blue", "Good"),
375            (PasswordStrength::Strong, "green", "Strong"),
376        ];
377
378        for (strength, expected_color, expected_label) in cases {
379            assert_eq!(strength.color(), expected_color, "{strength:?}");
380            assert_eq!(strength.label(), expected_label, "{strength:?}");
381            assert_eq!(strength.to_string(), expected_label, "{strength:?}");
382        }
383    }
384
385    #[test]
386    fn test_strength_percent() {
387        let cases = [
388            (PasswordStrength::Weak, 25),
389            (PasswordStrength::Fair, 50),
390            (PasswordStrength::Good, 75),
391            (PasswordStrength::Strong, 100),
392        ];
393
394        for (strength, expected_percent) in cases {
395            assert_eq!(strength.percent(), expected_percent, "{strength:?}");
396        }
397    }
398
399    #[test]
400    fn test_checks_populated() {
401        let result = validate_password("Test123!");
402        assert!(result.checks.has_lowercase);
403        assert!(result.checks.has_uppercase);
404        assert!(result.checks.has_digit);
405        assert!(result.checks.has_special);
406        assert_eq!(result.checks.length, 8);
407        assert!(!result.checks.meets_min_length);
408    }
409
410    #[test]
411    fn test_entropy_calculation() {
412        // All character classes: pool_size = 26+26+10+32 = 94
413        // log2(94) ≈ 6.55 bits per char
414        // 16 chars → ~105 bits
415        let result = validate_password("MySecureP@ssw0rd");
416        assert!(result.entropy_bits > 80.0);
417    }
418
419    #[test]
420    fn test_unicode_password() {
421        // Unicode characters should be handled (treated as special)
422        let result = validate_password("Pässwörd123!");
423        assert!(result.checks.has_special); // ä and ö are special
424        assert!(result.checks.has_uppercase);
425        assert!(result.checks.has_digit);
426    }
427}