#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
clap::ValueEnum,
serde::Serialize,
serde::Deserialize,
)]
pub enum Mode {
#[default]
Flip,
Typing,
Fuzzy,
Choice,
#[value(name = "line")]
LineByLine,
Explain,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Typed {
pub ch: char,
pub correct: bool,
}
#[derive(Clone, Debug)]
pub struct TypingValidator {
expected: Vec<char>,
typed: Vec<Typed>,
hints_used: usize,
typos: usize,
hint_revealed: usize,
}
pub const HINT_CHARS: usize = 2;
impl TypingValidator {
pub fn new(expected: &str) -> Self {
Self {
expected: expected.chars().collect(),
typed: Vec::new(),
hints_used: 0,
typos: 0,
hint_revealed: 0,
}
}
pub fn expected(&self) -> String {
self.expected.iter().collect()
}
pub fn typed(&self) -> &[Typed] {
&self.typed
}
pub fn type_char(&mut self, ch: char) -> bool {
self.hint_revealed = 0;
if self.typed.len() >= self.expected.len() {
return false;
}
let correct = self.expected[self.typed.len()] == ch;
if !correct {
self.typos += 1;
}
self.typed.push(Typed { ch, correct });
correct
}
pub fn backspace(&mut self) -> bool {
self.hint_revealed = 0;
self.typed.pop().is_some()
}
pub fn hint(&mut self) -> String {
self.hints_used += 1;
if let Some(first_bad) = self.typed.iter().position(|t| !t.correct) {
self.typed.truncate(first_bad);
}
let remaining = self.expected.len() - self.typed.len();
self.hint_revealed = (self.hint_revealed + HINT_CHARS).min(remaining);
self.expected
.iter()
.skip(self.typed.len())
.take(self.hint_revealed)
.collect()
}
pub fn set_expected(&mut self, expected: &str) {
self.expected = expected.chars().collect();
for (i, t) in self.typed.iter_mut().enumerate() {
t.correct = self.expected.get(i) == Some(&t.ch);
}
}
pub fn remaining(&self) -> String {
self.expected.iter().skip(self.typed.len()).collect()
}
pub fn is_complete(&self) -> bool {
self.typed.len() == self.expected.len() && self.typed.iter().all(|t| t.correct)
}
pub fn passed(&self) -> bool {
self.is_complete() && self.hints_used == 0
}
pub fn hints_used(&self) -> usize {
self.hints_used
}
pub fn typos(&self) -> usize {
self.typos
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FuzzyResult {
pub input: String,
pub expected: String,
pub distance: usize,
pub passed: bool,
}
pub fn grade_lines_unordered(
inputs: &[String],
expected: &[String],
max_typos: usize,
) -> Vec<FuzzyResult> {
let mut claimed = vec![false; expected.len()];
let mut results = Vec::with_capacity(inputs.len());
for input in inputs {
let best = expected
.iter()
.enumerate()
.filter(|(i, _)| !claimed[*i])
.map(|(i, exp)| (i, grade_fuzzy(input, exp, max_typos)))
.min_by_key(|(_, r)| r.distance);
match best {
Some((i, r)) => {
claimed[i] = true;
results.push(r);
}
None => results.push(grade_fuzzy(input, "", max_typos)),
}
}
results
}
pub fn best_prefix_match(typed: &str, candidates: &[&str]) -> Option<usize> {
(0..candidates.len()).min_by_key(|&i| {
let cand = candidates[i];
let shared = typed
.chars()
.zip(cand.chars())
.take_while(|(a, b)| a == b)
.count();
(std::cmp::Reverse(shared), cand.chars().count(), i)
})
}
pub fn grade_fuzzy(input: &str, expected: &str, max_typos: usize) -> FuzzyResult {
let input = input.trim();
let distance = strsim::levenshtein(input, expected);
FuzzyResult {
input: input.to_string(),
expected: expected.to_string(),
distance,
passed: distance <= max_typos,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typing_correct_line_passes() {
let mut v = TypingValidator::new("hello");
for c in "hello".chars() {
assert!(v.type_char(c));
}
assert!(v.is_complete());
assert!(v.passed());
assert_eq!(0, v.typos());
}
#[test]
fn typing_incomplete_line_is_not_complete() {
let mut v = TypingValidator::new("hello");
v.type_char('h');
assert!(!v.is_complete());
assert_eq!("ello", v.remaining());
}
#[test]
fn typing_wrong_char_blocks_completion_until_fixed() {
let mut v = TypingValidator::new("hello");
assert!(v.type_char('h'));
assert!(!v.type_char('3'));
v.type_char('l');
v.type_char('l');
v.type_char('o');
assert!(!v.is_complete());
for _ in 0..4 {
v.backspace();
}
for c in "ello".chars() {
assert!(v.type_char(c));
}
assert!(v.is_complete());
assert!(v.passed()); assert_eq!(1, v.typos());
}
#[test]
fn typing_input_beyond_length_ignored() {
let mut v = TypingValidator::new("hi");
v.type_char('h');
v.type_char('i');
assert!(!v.type_char('!'));
assert!(v.is_complete());
assert_eq!(2, v.typed().len());
}
#[test]
fn backspace_on_empty_returns_false() {
let mut v = TypingValidator::new("hi");
assert!(!v.backspace());
}
#[test]
fn hint_reveals_next_chars_and_fails_the_line() {
let mut v = TypingValidator::new("hello");
v.type_char('h');
assert_eq!("el", v.hint());
for c in "ello".chars() {
v.type_char(c);
}
assert!(v.is_complete());
assert!(!v.passed());
assert_eq!(1, v.hints_used());
}
#[test]
fn repeated_hints_reveal_more_until_full() {
let mut v = TypingValidator::new("hello");
v.type_char('h');
assert_eq!("el", v.hint()); assert_eq!("ello", v.hint()); assert_eq!("ello", v.hint()); v.type_char('e');
assert_eq!("ll", v.hint());
}
#[test]
fn hint_clears_incorrect_tail_first() {
let mut v = TypingValidator::new("hello");
v.type_char('h');
v.type_char('3'); v.type_char('l');
assert_eq!("el", v.hint());
assert_eq!(1, v.typed().len());
assert_eq!("ello", v.remaining());
}
#[test]
fn hint_at_end_is_empty() {
let mut v = TypingValidator::new("hi");
v.type_char('h');
v.type_char('i');
assert_eq!("", v.hint());
}
#[test]
fn unicode_is_handled_per_char() {
let mut v = TypingValidator::new("héllö");
for c in "héllö".chars() {
assert!(v.type_char(c));
}
assert!(v.passed());
}
#[test]
fn fuzzy_exact_match_passes_with_zero_tolerance() {
let r = grade_fuzzy("hello", "hello", 0);
assert!(r.passed);
assert_eq!(0, r.distance);
}
#[test]
fn fuzzy_within_tolerance_passes() {
let r = grade_fuzzy("helo", "hello", 2);
assert!(r.passed);
assert_eq!(1, r.distance);
let r = grade_fuzzy("hxllo wxrld", "hello world", 2);
assert!(r.passed);
assert_eq!(2, r.distance);
}
#[test]
fn fuzzy_beyond_tolerance_fails() {
let r = grade_fuzzy("hxlxo wxrld", "hello world", 2);
assert!(!r.passed);
assert_eq!(3, r.distance);
}
#[test]
fn fuzzy_input_is_trimmed() {
let r = grade_fuzzy(" hello ", "hello", 0);
assert!(r.passed);
}
fn lines(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn unordered_lines_pass_in_any_order() {
let expected = lines(&["red", "green", "blue"]);
let inputs = lines(&["blue", "red", "green"]);
let results = grade_lines_unordered(&inputs, &expected, 0);
assert!(results.iter().all(|r| r.passed));
assert_eq!("blue", results[0].expected);
assert_eq!("red", results[1].expected);
assert_eq!("green", results[2].expected);
}
#[test]
fn unordered_one_wrong_line_maps_to_its_nearest_expected() {
let expected = lines(&["red", "green", "blue"]);
let inputs = lines(&["blue", "gren", "red"]);
let results = grade_lines_unordered(&inputs, &expected, 0);
assert!(results[0].passed); assert!(!results[1].passed); assert_eq!("green", results[1].expected);
assert!(results[2].passed); }
#[test]
fn unordered_does_not_claim_one_expected_twice() {
let expected = lines(&["aa", "ab"]);
let inputs = lines(&["ab", "aa"]);
let results = grade_lines_unordered(&inputs, &expected, 0);
let mut matched: Vec<&str> = results.iter().map(|r| r.expected.as_str()).collect();
matched.sort_unstable();
assert_eq!(vec!["aa", "ab"], matched);
assert!(results.iter().all(|r| r.passed));
}
#[test]
fn best_prefix_match_prefers_longest_shared_prefix() {
let cands = ["green", "grape", "blue"];
assert_eq!(Some(0), best_prefix_match("gre", &cands));
assert_eq!(Some(1), best_prefix_match("gra", &cands));
assert_eq!(Some(2), best_prefix_match("b", &cands));
assert_eq!(Some(2), best_prefix_match("x", &cands));
assert_eq!(None, best_prefix_match("x", &[]));
}
#[test]
fn set_expected_re_evaluates_typed_chars() {
let mut v = TypingValidator::new("cat");
for c in "car".chars() {
v.type_char(c);
}
assert!(!v.is_complete()); v.set_expected("car");
assert!(v.is_complete());
assert!(v.passed());
}
}