A password form that says only "invalid" leaves the user guessing. Let's build one that explains which checks failed. The result will be a Rust value a caller can inspect, rather than a message printed inside the validator.
This optional project brings together borrowing, enums, structs, iterators, and
Result. Try it after the iterators chapter. There are four steps:
Each editor runs on its own. Later steps supply the helpers you've already practiced, so you can focus on the new task instead of copying earlier answers. Read the contract above each editor, try the tests, and open its hints if you get stuck.
This is a programming exercise, not a password-security tool. The scores and the label
Strongdescribe our made-up rules; they do not measure how hard a password is to guess. Use invented inputs only: Run sends the code, including its test strings, to the Rust Playground.
The files get longer here because each editor includes its own support code and tests. Open in Web Editor opens the file on github.dev if you'd like more room. For local execution and
rust-analyzer, clone the repo and runcargo test --example 19_password_validator. The unfinished steps will fail until you implement them.
Start with four small questions the validator will need to ask. Does the input contain an uppercase letter, a lowercase letter, a digit, or one of our chosen special characters? Keeping these checks separate lets you test each rule before combining them.
Implement has_uppercase, has_lowercase, has_digit, and has_special. Each
takes a borrowed &str and returns whether at least one character belongs to
its class:
| Function | Characters That Count |
|---|---|
has_uppercase | A through Z |
has_lowercase | a through z |
has_digit | 0 through 9 |
has_special | Exactly !@#$%^&* |
An empty input satisfies none of the checks. A matching character can appear
anywhere, not just at the start. Other characters are allowed in the input, but
don't satisfy these rules: É is not an ASCII uppercase letter, and ? is not
in our special-character set. Don't trim or change the input.
For example, "café7?" contains lowercase ASCII letters and an ASCII digit, but
neither uppercase ASCII letters nor a special character from our set.
Useful from the Standard Library
str::charsvisits Unicode scalar values without changing the string.Iterator::anyanswers whether any item satisfies a predicate.char::is_ascii_uppercase,is_ascii_lowercase, andis_ascii_digitcheck the ASCII classes.str::containscan check whether a string contains a particularchar.
Ask whether any character matches each rule; you don't need to count matches or allocate a new string. Check the difference between the ASCII predicates and their Unicode counterparts. For the special rule, treat the allowed characters as a small set, not as a substring that must appear in full.
/// Checks for an ASCII uppercase letter without trimming or normalizing.
fn has_uppercase(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_uppercase())
}
/// Checks for an ASCII lowercase letter without trimming or normalizing.
fn has_lowercase(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_lowercase())
}
/// Checks for an ASCII digit without trimming or normalizing.
fn has_digit(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_digit())
}
/// Checks for one of exactly `!@#$%^&*`.
fn has_special(password: &str) -> bool {
password.chars().any(|c| "!@#$%^&*".contains(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uppercase_accepts_ascii_including_a_late_match() {
for password in ["A", "Z", "é١!?lowercaseZ", " A "] {
assert!(has_uppercase(password));
}
}
#[test]
fn uppercase_rejects_empty_other_classes_and_unicode() {
for password in ["", "lowercase012!", "É", "é", "١", "?_-."] {
assert!(!has_uppercase(password));
}
}
#[test]
fn lowercase_accepts_ascii_including_a_late_match() {
for password in ["a", "z", "É١!?ABCz", " a "] {
assert!(has_lowercase(password));
}
}
#[test]
fn lowercase_rejects_empty_other_classes_and_unicode() {
for password in ["", "UPPERCASE012!", "É", "é", "١", "?_-."] {
assert!(!has_lowercase(password));
}
}
#[test]
fn digit_accepts_ascii_including_a_late_match() {
for password in ["0", "9", "Éé١!?Letters9", " 0 "] {
assert!(has_digit(password));
}
}
#[test]
fn digit_rejects_empty_other_classes_and_unicode() {
for password in ["", "Letters!", "É", "é", "١", "?_-."] {
assert!(!has_digit(password));
}
}
#[test]
fn special_accepts_every_allowed_symbol_including_a_late_match() {
for symbol in "!@#$%^&*".chars() {
assert!(has_special(&symbol.to_string()));
assert!(has_special(&format!("Éé١?Letters09{symbol}")));
}
}
#[test]
fn special_rejects_empty_other_classes_and_other_punctuation() {
for password in [
"",
"Letters09",
"É",
"é",
"١",
"?_-.,:;/\\()[]{}+=~`|<>\"'",
" \n\t",
] {
assert!(!has_special(password));
}
}
}
The checks tell us what is present, but the caller will need a summary.
PasswordReport groups a numeric score, a list of feedback messages, and a
PasswordStrength label. Using an enum for the label means callers can match on
known cases instead of comparing strings such as "strong" and hoping nobody
misspells one. The report deliberately does not keep a copy of the password.
Implement two methods:
PasswordStrength::from_score(score) returns Weak below 30, Medium from
30 through 69, and Strong from 70 upward. It must handle every u8 value,
even though our validator will produce scores no higher than 100.PasswordReport::is_strong(&self) answers whether the report's stored label
is Strong. It borrows the report, so a caller can ask this question and
still read the feedback afterward.This step doesn't calculate a password's score yet. The tests supply scores and reports directly so you can check the boundaries independently of the character rules.
Useful from the Standard Library
- A
matchmust cover every variant or value it can receive. You can use ranges or conditions to classify the score; choose whichever makes the boundaries clearest to you.- The supplied
PartialEqderive allows twoPasswordStrengthvalues to be compared.
Separate the two jobs. from_score chooses a label from a number; is_strong
reads a label from an existing report. Walk through the values immediately below
and at each boundary before you run the tests. If you use range patterns, ..=
includes its upper endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
enum PasswordStrength {
Weak,
Medium,
Strong,
}
impl PasswordStrength {
/// Classifies any u8: 0..=29 is Weak, 30..=69 is Medium, 70..=255 is
/// Strong.
const fn from_score(score: u8) -> Self {
match score {
0..=29 => Self::Weak,
30..=69 => Self::Medium,
_ => Self::Strong,
}
}
}
#[derive(Debug, Clone)]
struct PasswordReport {
score: u8,
feedback: Vec<String>,
strength: PasswordStrength,
}
impl PasswordReport {
/// Checks the stored strength label; does not recompute it from the score.
fn is_strong(&self) -> bool {
self.strength == PasswordStrength::Strong
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_weak_boundaries() {
assert_eq!(PasswordStrength::from_score(0), PasswordStrength::Weak);
assert_eq!(PasswordStrength::from_score(29), PasswordStrength::Weak);
}
#[test]
fn classifies_medium_boundaries() {
assert_eq!(PasswordStrength::from_score(30), PasswordStrength::Medium);
assert_eq!(PasswordStrength::from_score(69), PasswordStrength::Medium);
}
#[test]
fn classifies_strong_through_the_full_u8_range() {
for score in [70, 100, 255] {
assert_eq!(
PasswordStrength::from_score(score),
PasswordStrength::Strong
);
}
}
#[test]
fn weak_report_is_not_strong() {
let report = PasswordReport {
score: 29,
feedback: vec!["Add an uppercase ASCII letter.".to_string()],
strength: PasswordStrength::Weak,
};
assert!(!report.is_strong());
}
#[test]
fn medium_report_is_not_strong() {
let report = PasswordReport {
score: 69,
feedback: Vec::new(),
strength: PasswordStrength::Medium,
};
assert!(!report.is_strong());
}
#[test]
fn strong_report_is_strong() {
let report = PasswordReport {
score: 70,
feedback: Vec::new(),
strength: PasswordStrength::Strong,
};
assert!(report.is_strong());
}
#[test]
fn stored_label_is_authoritative_even_if_a_report_is_manually_inconsistent() {
// This method reads the label, not the score. Only the validator
// guarantees consistency; a manually constructed report need not have
// that invariant.
for (score, strength, expected) in [
(100, PasswordStrength::Weak, false),
(100, PasswordStrength::Medium, false),
(0, PasswordStrength::Strong, true),
] {
let report = PasswordReport {
score,
feedback: Vec::new(),
strength,
};
assert_eq!(report.is_strong(), expected);
}
}
}
Now put the pieces together in PasswordValidator::validate(password). A caller
should get both a summary and every missing base requirement from a single call.
Unlike a parser that stops at its first error, this function must keep checking
after it finds a problem.
The character helpers and report methods are already implemented in this editor.
Your task is only the body of validate. Borrow the input, apply the following
scoring rules, and return a PasswordReport.
| Rule | Points | Feedback When Missing |
|---|---|---|
| At least 8 characters | 20 | Use at least 8 characters. |
| An uppercase ASCII letter | 15 | Add an uppercase ASCII letter. |
| A lowercase ASCII letter | 15 | Add a lowercase ASCII letter. |
| An ASCII digit | 15 | Add an ASCII digit. |
A character from !@#$%^&* | 15 | Add one of !@#$%^&*. |
| At least 12 characters | 10 more | None |
| At least 16 characters | 10 more | None |
Here, "characters" means Unicode scalar values, not bytes or visible symbols. Keep whitespace and punctuation as they are; they count toward length even when they don't satisfy a character-class rule. The length rewards accumulate, so an input of at least 16 characters earns all 40 length points. Each character class earns its points once, regardless of how many matching characters appear.
Use the exact feedback strings in the table, in table order, and only for failed
base rules. The two extra length rewards do not create complaints. Use
PasswordStrength::from_score for the label rather than writing the
classification rules again.
For example, "Rust1234" has eight characters and three of the four classes.
Its report has a score of 65, the label Medium, and one feedback message:
"Add one of !@#$%^&*.". An empty input scores zero and receives all five
base-rule messages.
The fixed rules make the tests precise. Once they pass, you can experiment with another policy, but change its tests too.
Useful from the Standard Library
str::charswithIterator::countcounts scalar values;str::lencounts bytes.Vec::pushadds a message while preserving the order of the checks.str::to_stringcreates an owned message for the report'sVec<String>.
Keep a score and an initially empty feedback vector. For each base rule, either award its points or add its message, then continue to the next rule. Check the two length bonuses independently: reaching 16 characters must not skip the reward for reaching 12. Classify the finished score with the supplied method and construct the report last.
If only the non-ASCII tests fail, check how you measure length. If a long input
fails, check whether you converted its length to u8 before comparing it with
the thresholds.
#[derive(Debug, Clone, PartialEq, Eq)]
enum PasswordStrength {
Weak,
Medium,
Strong,
}
impl PasswordStrength {
/// Classifies any u8: 0..=29 is Weak, 30..=69 is Medium, 70..=255 is
/// Strong.
const fn from_score(score: u8) -> Self {
match score {
0..=29 => Self::Weak,
30..=69 => Self::Medium,
_ => Self::Strong,
}
}
}
#[derive(Debug, Clone)]
struct PasswordReport {
score: u8,
feedback: Vec<String>,
strength: PasswordStrength,
}
impl PasswordReport {
/// Checks the stored strength label; does not recompute it from the score.
fn is_strong(&self) -> bool {
self.strength == PasswordStrength::Strong
}
}
fn has_uppercase(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_uppercase())
}
fn has_lowercase(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_lowercase())
}
fn has_digit(password: &str) -> bool {
password.chars().any(|c| c.is_ascii_digit())
}
fn has_special(password: &str) -> bool {
password.chars().any(|c| "!@#$%^&*".contains(c))
}
struct PasswordValidator {}
impl PasswordValidator {
/// Applies a toy scoring scheme, not a real-world password security
/// assessment.
///
/// Count Unicode scalar values without trimming or normalization. Lengths
/// of at least 8, 12, and 16 earn 20, 10, and 10 cumulative points. Each
/// ASCII character class earns 15 points. Report only failed base rules, in
/// order: length >= 8, uppercase, lowercase, digit, special (`!@#$%^&*`).
/// Never store the input in the report.
fn validate(password: &str) -> PasswordReport {
let length = password.chars().count();
let mut score = 0;
let mut feedback = Vec::new();
if length >= 8 {
score += 20;
} else {
feedback.push("Use at least 8 characters.".to_string());
}
if length >= 12 {
score += 10;
}
if length >= 16 {
score += 10;
}
for (present, message) in [
(has_uppercase(password), "Add an uppercase ASCII letter."),
(has_lowercase(password), "Add a lowercase ASCII letter."),
(has_digit(password), "Add an ASCII digit."),
(has_special(password), "Add one of !@#$%^&*."),
] {
if present {
score += 15;
} else {
feedback.push(message.to_string());
}
}
PasswordReport {
score,
feedback,
strength: PasswordStrength::from_score(score),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_report(password: &str, score: u8, feedback: &[&str], strength: PasswordStrength) {
let report = PasswordValidator::validate(password);
assert_eq!(report.score, score);
assert_eq!(report.feedback, feedback);
assert_eq!(report.strength, strength);
assert_eq!(report.strength, PasswordStrength::from_score(report.score));
assert_eq!(report.is_strong(), strength == PasswordStrength::Strong);
}
#[test]
fn scores_exact_length_boundaries_with_all_classes_held_constant() {
for (length, score) in [(7, 60), (8, 80), (11, 80), (12, 90), (15, 90), (16, 100)] {
let password = format!("Aa1!{}", "?".repeat(length - 4));
let feedback: &[&str] = if length < 8 {
&["Use at least 8 characters."]
} else {
&[]
};
let strength = if length < 8 {
PasswordStrength::Medium
} else {
PasswordStrength::Strong
};
assert_report(&password, score, feedback, strength);
}
}
#[test]
fn reports_only_missing_base_length() {
assert_report(
"Aa1!",
60,
&["Use at least 8 characters."],
PasswordStrength::Medium,
);
}
#[test]
fn reports_only_missing_uppercase() {
assert_report(
"aa1!????",
65,
&["Add an uppercase ASCII letter."],
PasswordStrength::Medium,
);
}
#[test]
fn reports_only_missing_lowercase() {
assert_report(
"AA1!????",
65,
&["Add a lowercase ASCII letter."],
PasswordStrength::Medium,
);
}
#[test]
fn reports_only_missing_digit() {
assert_report(
"Aaa!????",
65,
&["Add an ASCII digit."],
PasswordStrength::Medium,
);
}
#[test]
fn reports_only_missing_special() {
assert_report(
"Aa11?_-.",
65,
&["Add one of !@#$%^&*."],
PasswordStrength::Medium,
);
}
#[test]
fn empty_input_has_zero_score_and_all_five_messages_in_order() {
assert_report(
"",
0,
&[
"Use at least 8 characters.",
"Add an uppercase ASCII letter.",
"Add a lowercase ASCII letter.",
"Add an ASCII digit.",
"Add one of !@#$%^&*.",
],
PasswordStrength::Weak,
);
}
#[test]
fn multiple_missing_rules_keep_base_rule_order() {
assert_report(
"a",
15,
&[
"Use at least 8 characters.",
"Add an uppercase ASCII letter.",
"Add an ASCII digit.",
"Add one of !@#$%^&*.",
],
PasswordStrength::Weak,
);
}
#[test]
fn counts_unicode_scalars_not_bytes_at_every_length_boundary() {
for (length, score) in [(7, 60), (8, 80), (11, 80), (12, 90), (15, 90), (16, 100)] {
let password = format!("Aa1!{}", "é".repeat(length - 4));
let feedback: &[&str] = if length < 8 {
&["Use at least 8 characters."]
} else {
&[]
};
let strength = if length < 8 {
PasswordStrength::Medium
} else {
PasswordStrength::Strong
};
assert_report(&password, score, feedback, strength);
}
}
#[test]
fn unicode_letters_and_arabic_digits_contribute_only_to_length() {
for (length, score, strength) in [
(8, 20, PasswordStrength::Weak),
(12, 30, PasswordStrength::Medium),
(16, 40, PasswordStrength::Medium),
] {
let password: String = "Éé١".chars().cycle().take(length).collect();
assert_report(
&password,
score,
&[
"Add an uppercase ASCII letter.",
"Add a lowercase ASCII letter.",
"Add an ASCII digit.",
"Add one of !@#$%^&*.",
],
strength,
);
}
}
#[test]
fn every_allowed_special_symbol_earns_points() {
for special in "!@#$%^&*".chars() {
let password = format!("Aa1{special}????");
assert_report(&password, 80, &[], PasswordStrength::Strong);
}
}
#[test]
fn whitespace_is_not_trimmed() {
assert_report(" Aa1! \t\n", 80, &[], PasswordStrength::Strong);
}
#[test]
fn combining_marks_are_separate_scalars_without_normalization() {
assert_report("Aa1!e\u{301}??", 80, &[], PasswordStrength::Strong);
}
#[test]
fn long_inputs_do_not_truncate_the_count_or_repeat_class_points() {
for length in [255, 256, 257, 300, 512] {
let password = format!("Aa1!{}", "é".repeat(length - 4));
assert_report(&password, 100, &[], PasswordStrength::Strong);
}
assert_report(&"Aa1!".repeat(100), 100, &[], PasswordStrength::Strong);
}
#[test]
fn borrowed_input_remains_unchanged_and_reusable() {
let password = String::from("Aa1!????");
let report = PasswordValidator::validate(&password);
assert_eq!(password, "Aa1!????");
assert_eq!(report.score, 80);
assert_report(&password, 80, &[], PasswordStrength::Strong);
}
}
So far you've checked strings supplied by a caller. Now work in the other direction: construct sample strings with known properties, so you can try different lengths without typing every input by hand. This is test-data generation, not random password generation.
Implement PasswordGenerator::generate_example_password(length):
Err("Need at least 4 characters."). Four
distinct character classes cannot fit into fewer than four positions.!@#$%^&*.Any string meeting the contract is acceptable. It can be predictable and
identical across calls; the tests do not require randomness or a particular
order. The Result makes the impossible request visible to the caller instead
of silently returning a shorter string or panicking.
Don't call this function to create real passwords. Its job is to produce fixtures for tests, and a fixture can satisfy every formatting rule while being trivial to guess.
Useful from the Standard Library
String::newcreates an empty output buffer.String::pushappends one character.OkandErrlet the caller distinguish a generated string from an unsupported length.
Deal with an impossible length before constructing the string. Guaranteeing each class appears is easier than generating arbitrary characters and hoping all four classes turn up. Once those requirements are satisfied, extend the string to the requested length using allowed characters. Since every allowed character is ASCII, its byte length and scalar count are the same. No clock, random-number generator, or external crate is needed.
struct PasswordGenerator {}
impl PasswordGenerator {
/// Generates deterministic test data ONLY, never real secrets.
///
/// Reject lengths below 4 with `"Need at least 4 characters."`. Otherwise,
/// return exactly `length` ASCII characters drawn from uppercase letters,
/// lowercase letters, digits, and `!@#$%^&*`, including at least one of
/// each. No randomness or clock is needed; this is not a secure password
/// generator.
fn generate_example_password(length: usize) -> Result<String, &'static str> {
if length < 4 {
return Err("Need at least 4 characters.");
}
Ok("Aa1!".chars().cycle().take(length).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_example_contract(length: usize) {
let password = PasswordGenerator::generate_example_password(length)
.expect("lengths of at least four must succeed");
assert_eq!(password.len(), length);
assert_eq!(password.chars().count(), length);
assert!(password.is_ascii());
assert!(password.chars().all(|c| {
c.is_ascii_uppercase()
|| c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| "!@#$%^&*".contains(c)
}));
assert!(password.chars().any(|c| c.is_ascii_uppercase()));
assert!(password.chars().any(|c| c.is_ascii_lowercase()));
assert!(password.chars().any(|c| c.is_ascii_digit()));
assert!(password.chars().any(|c| "!@#$%^&*".contains(c)));
}
#[test]
fn rejects_every_length_below_four() {
for length in 0..4 {
assert_eq!(
PasswordGenerator::generate_example_password(length),
Err("Need at least 4 characters.")
);
}
}
#[test]
fn minimum_length_includes_all_four_classes() {
assert_example_contract(4);
}
#[test]
fn handles_odd_even_and_longer_lengths() {
for length in [5, 8, 12, 16, 33] {
assert_example_contract(length);
}
}
}
You started with small borrowed-string checks, gave their summary a type, and combined the rules into a report. The generator reversed the problem: it constructed an input with known properties and rejected requests it couldn't satisfy.
What We Learned
- Small predicates let you test each rule separately from the scoring policy.
- A report can own its feedback without owning or retaining the input password.
- An enum gives callers a fixed set of labels to match on.
- Collecting all missing requirements is a different error-handling choice from returning after the first failure.
- Precise boundary tests catch mistakes that one "weak" and one "strong" example would miss.
- Passing a set of formatting checks does not establish security.
If you want to take the project further, add a PasswordPolicy struct with a
configurable minimum length. Keep the feedback and score consistent with that
setting, and write boundary tests before changing the validator. Another useful
experiment is to replace feedback strings with an enum of missing requirements,
leaving human-readable wording to a separate display function.
Extra practice to explore at your own pace. These chapters do not count toward course progress.