use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaseLock {
None,
Upper,
Lower,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MaskPosition {
Editable {
class: MaskClass,
required: bool,
case: CaseLock,
},
Fixed(char),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaskClass {
Digit,
Letter,
Alphanumeric,
Any,
HexDigit,
}
impl MaskClass {
pub fn accepts(self, c: char) -> bool {
match self {
Self::Digit => c.is_ascii_digit(),
Self::Letter => c.is_ascii_alphabetic(),
Self::Alphanumeric => c.is_ascii_alphanumeric(),
Self::Any => !c.is_control(),
Self::HexDigit => c.is_ascii_hexdigit(),
}
}
}
impl MaskPosition {
pub fn is_editable(&self) -> bool {
matches!(self, Self::Editable { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputMask {
positions: Vec<MaskPosition>,
}
impl InputMask {
pub fn parse(mask: &str) -> Result<Self, MaskError> {
let mut positions = Vec::with_capacity(mask.len());
let mut chars = mask.chars().peekable();
let mut case = CaseLock::None;
while let Some(c) = chars.next() {
match c {
'\\' => {
let Some(next) = chars.next() else {
return Err(MaskError::TrailingBackslash);
};
positions.push(MaskPosition::Fixed(next));
}
'>' => case = CaseLock::Upper,
'<' => case = CaseLock::Lower,
'!' => case = CaseLock::None,
'9' => positions.push(MaskPosition::Editable {
class: MaskClass::Digit,
required: true,
case,
}),
'0' => positions.push(MaskPosition::Editable {
class: MaskClass::Digit,
required: false,
case,
}),
'A' => positions.push(MaskPosition::Editable {
class: MaskClass::Letter,
required: true,
case,
}),
'a' => positions.push(MaskPosition::Editable {
class: MaskClass::Letter,
required: false,
case,
}),
'N' => positions.push(MaskPosition::Editable {
class: MaskClass::Alphanumeric,
required: true,
case,
}),
'n' => positions.push(MaskPosition::Editable {
class: MaskClass::Alphanumeric,
required: false,
case,
}),
'X' => positions.push(MaskPosition::Editable {
class: MaskClass::Any,
required: true,
case,
}),
'x' => positions.push(MaskPosition::Editable {
class: MaskClass::Any,
required: false,
case,
}),
'H' => positions.push(MaskPosition::Editable {
class: MaskClass::HexDigit,
required: true,
case,
}),
'h' => positions.push(MaskPosition::Editable {
class: MaskClass::HexDigit,
required: false,
case,
}),
other => positions.push(MaskPosition::Fixed(other)),
}
}
Ok(Self { positions })
}
pub fn len(&self) -> usize {
self.positions.len()
}
pub fn is_empty(&self) -> bool {
self.positions.is_empty()
}
pub fn positions(&self) -> impl Iterator<Item = &MaskPosition> {
self.positions.iter()
}
pub fn get(&self, i: usize) -> Option<&MaskPosition> {
self.positions.get(i)
}
pub fn empty_template(&self, placeholder_char: char) -> String {
let mut s = String::with_capacity(self.positions.len());
for pos in &self.positions {
match pos {
MaskPosition::Editable { .. } => s.push(placeholder_char),
MaskPosition::Fixed(c) => s.push(*c),
}
}
s
}
pub fn format(&self, raw: &str, placeholder_char: char) -> FormattedMask {
let mut buf = String::with_capacity(self.positions.len());
let mut raw_iter = raw.chars().peekable();
let mut last_filled_index: Option<usize> = None;
for (i, pos) in self.positions.iter().enumerate() {
match pos {
MaskPosition::Fixed(sep) => {
let _ = write!(buf, "{}", sep);
if raw_iter.peek() == Some(sep) {
raw_iter.next();
}
}
MaskPosition::Editable { class, case, .. } => {
let mut filled = false;
while let Some(&c) = raw_iter.peek() {
raw_iter.next();
if class.accepts(c) {
let cased = match case {
CaseLock::None => c,
CaseLock::Upper => c.to_ascii_uppercase(),
CaseLock::Lower => c.to_ascii_lowercase(),
};
buf.push(cased);
last_filled_index = Some(i);
filled = true;
break;
}
}
if !filled {
buf.push(placeholder_char);
}
}
}
}
FormattedMask {
full: buf,
last_filled_index,
mask_len: self.positions.len(),
}
}
pub fn strip_trailing_placeholders(&self, formatted: &FormattedMask) -> String {
let Some(last) = formatted.last_filled_index else {
return String::new();
};
formatted.full.chars().take(last + 1).collect()
}
pub fn accepts_at(&self, pos_index: usize, c: char) -> bool {
match self.positions.get(pos_index) {
Some(MaskPosition::Editable { class, .. }) => class.accepts(c),
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormattedMask {
pub full: String,
pub last_filled_index: Option<usize>,
pub mask_len: usize,
}
impl FormattedMask {
pub fn is_complete(&self, mask: &InputMask) -> bool {
let last_editable = mask
.positions()
.enumerate()
.filter(|(_, p)| p.is_editable())
.map(|(i, _)| i)
.last();
match (self.last_filled_index, last_editable) {
(Some(filled), Some(target)) => filled == target,
(None, None) => true,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MaskError {
#[error("trailing `\\` in mask string")]
TrailingBackslash,
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> InputMask {
InputMask::parse(s).unwrap()
}
#[test]
fn parse_date_mask() {
let m = parse("99/99/9999");
assert_eq!(m.len(), 10);
assert!(matches!(
m.get(0),
Some(MaskPosition::Editable {
class: MaskClass::Digit,
required: true,
..
})
));
assert!(matches!(m.get(2), Some(MaskPosition::Fixed('/'))));
assert!(matches!(m.get(5), Some(MaskPosition::Fixed('/'))));
}
#[test]
fn parse_phone_mask() {
let m = parse("(999) 999-9999");
let editable_count = m.positions().filter(|p| p.is_editable()).count();
assert_eq!(editable_count, 10);
}
#[test]
fn parse_uppercase_letters() {
let m = parse(">AA");
match m.get(0) {
Some(MaskPosition::Editable {
class: MaskClass::Letter,
case: CaseLock::Upper,
..
}) => {}
other => panic!("expected uppercase letter at 0, got {other:?}"),
}
}
#[test]
fn parse_escape() {
let m = parse(r"\99");
assert!(matches!(m.get(0), Some(MaskPosition::Fixed('9'))));
assert!(matches!(
m.get(1),
Some(MaskPosition::Editable {
class: MaskClass::Digit,
..
})
));
}
#[test]
fn parse_trailing_backslash_errors() {
assert_eq!(InputMask::parse(r"99\"), Err(MaskError::TrailingBackslash));
}
#[test]
fn empty_template_is_underscore_for_editable() {
let m = parse("99/99/9999");
assert_eq!(m.empty_template('_'), "__/__/____");
assert_eq!(m.empty_template('·'), "··/··/····");
}
#[test]
fn format_partial_fills_then_placeholders() {
let m = parse("99/99/9999");
let f = m.format("1", '_');
assert_eq!(f.full, "1_/__/____");
assert_eq!(f.last_filled_index, Some(0));
let f = m.format("12", '_');
assert_eq!(f.full, "12/__/____");
assert_eq!(f.last_filled_index, Some(1));
let f = m.format("123", '_');
assert_eq!(f.full, "12/3_/____");
assert_eq!(f.last_filled_index, Some(3));
}
#[test]
fn format_consumes_user_typed_separators() {
let m = parse("99/99/9999");
let f = m.format("12/30/2026", '_');
assert_eq!(f.full, "12/30/2026");
assert!(f.is_complete(&m));
}
#[test]
fn format_drops_chars_that_dont_fit_class() {
let m = parse("99/99/9999");
let f = m.format("abc12def30ghi2026", '_');
assert_eq!(f.full, "12/30/2026");
}
#[test]
fn format_uppercase_lock_applies() {
let m = parse(">AA");
let f = m.format("us", '_');
assert_eq!(f.full, "US");
}
#[test]
fn format_complete_for_full_input() {
let m = parse("99/99/9999");
let f = m.format("12302026", '_');
assert_eq!(f.full, "12/30/2026");
assert!(f.is_complete(&m));
}
#[test]
fn format_empty_input_all_placeholders() {
let m = parse("99/99/9999");
let f = m.format("", '_');
assert_eq!(f.full, "__/__/____");
assert_eq!(f.last_filled_index, None);
assert!(!f.is_complete(&m));
}
#[test]
fn strip_trailing_placeholders_truncates_template() {
let m = parse("99/99/9999");
let f = m.format("12", '_');
assert_eq!(m.strip_trailing_placeholders(&f), "12");
let f = m.format("123", '_');
assert_eq!(m.strip_trailing_placeholders(&f), "12/3");
let f = m.format("12302026", '_');
assert_eq!(m.strip_trailing_placeholders(&f), "12/30/2026");
}
#[test]
fn accepts_at_position() {
let m = parse("99/99/9999");
assert!(m.accepts_at(0, '5')); assert!(!m.accepts_at(0, 'a')); assert!(!m.accepts_at(2, '/')); assert!(!m.accepts_at(99, '5')); }
}