use super::funejson::Value; use super::dates_add_days::{days_in_month, parse_iso_date}; const MONTH_NAMES: [&str; 12] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const PARTS: [&str; 3] = ["day", "month", "year"]; // ASCII whitespace only, so all three languages trim the same characters // (str::trim would also take Unicode spaces). fn trim_ascii(value: &str) -> &str { value.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c' | '\x0b')) } // ASCII digits only: "٢" is not a day in any of the three languages. fn is_digits(value: &str) -> bool { !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) } // A day or month number: one or two digits. fn small_number(value: &str, largest: i64) -> Option { if !is_digits(value) || value.len() > 2 { return None; } let n: i64 = value.parse().ok()?; if (1..=largest).contains(&n) { Some(n) } else { None } } // GOV.UK asks services to accept month names in full or short ("jan"), in // any case; "sept" is common enough to accept too. fn month_number(value: &str) -> Option { if let Some(n) = small_number(value, 12) { return Some(n); } let lower = value.to_ascii_lowercase(); if lower == "sept" { return Some(9); } for (i, full) in MONTH_NAMES.iter().enumerate() { let name = full.to_ascii_lowercase(); if lower == name || lower == name[..3] { return Some(i as i64 + 1); } } None } // "Date of birth" starts a sentence; after "Enter" it is "date of birth". // A label that starts with an acronym ("UK arrival date") is left alone. fn lower_first(label: &str) -> String { let bytes = label.as_bytes(); let upper = |i: usize| i < bytes.len() && bytes[i].is_ascii_uppercase(); if upper(0) && !upper(1) { let mut out = String::with_capacity(label.len()); out.push(bytes[0].to_ascii_lowercase() as char); out.push_str(&label[1..]); out } else { label.to_string() } } fn long_date(iso: &str) -> String { let d = parse_iso_date(iso); format!("{} {} {}", d.day, MONTH_NAMES[(d.month - 1) as usize], d.year) } fn invalid(message: String, fields: &[&str]) -> DatePartsCheck { DatePartsCheck { valid: false, date: None, message: Some(message), fields: fields.iter().map(|f| f.to_string()).collect(), } } /// A date typed into three boxes, checked the way the GOV.UK Design System's /// date input asks: missing or incomplete information first, then a date that /// cannot be real, then the service's own rules, each with GOV.UK's wording. /// /// # Panics /// Panics on an empty label, a timing rule without today, a malformed rule /// date, or notBefore after notAfter. pub fn validate_date_parts(parts: &DateParts, label: &str, rules: &DateRules) -> DatePartsCheck { let name = trim_ascii(label); if name.is_empty() { panic!("a date needs a label, such as \"Date of birth\", to write its error messages"); } // Rules are the caller's, so a bad one fails loudly whatever was typed. let timing = rules.timing.as_deref().unwrap_or("any"); if timing != "any" && rules.today.is_none() { panic!("rules.today is needed to check that a date is {}", timing.replace('-', " ")); } for bound in [&rules.today, &rules.not_before, &rules.not_after].into_iter().flatten() { parse_iso_date(bound); } if let (Some(before), Some(after)) = (&rules.not_before, &rules.not_after) { if before > after { panic!("rules.notBefore ({}) must not be after rules.notAfter ({})", before, after); } } let typed = [trim_ascii(&parts.day), trim_ascii(&parts.month), trim_ascii(&parts.year)]; let year = typed[2]; let missing: Vec<&str> = (0..3).filter(|&i| typed[i].is_empty()).map(|i| PARTS[i]).collect(); if missing.len() == 3 { return invalid(format!("Enter {}", lower_first(name)), &PARTS); } if !missing.is_empty() { let what = if missing.len() == 1 { missing[0].to_string() } else { format!("{} and {}", missing[0], missing[1]) }; return invalid(format!("{} must include a {}", name, what), &missing); } if is_digits(year) && year.len() != 4 { return invalid("Year must include 4 numbers".to_string(), &["year"]); } let d = small_number(typed[0], 31); let m = month_number(typed[1]); let y = if is_digits(year) { year.parse::().ok().filter(|&n| n >= 1) } else { None }; let found = [d, m, y]; let wrong: Vec<&str> = (0..3).filter(|&i| found[i].is_none()).map(|i| PARTS[i]).collect(); if !wrong.is_empty() { let fields: &[&str] = if wrong.len() == 1 { &wrong } else { &PARTS }; return invalid(format!("{} must be a real date", name), fields); } let (d, m, y) = (d.unwrap(), m.unwrap(), y.unwrap()); // 31 April or 29 February 2023: the day is what does not exist. if d > days_in_month(y, m) { return invalid(format!("{} must be a real date", name), &["day"]); } let iso = format!("{}-{:02}-{:02}", year, m, d); let iso = iso.as_str(); let today = rules.today.as_deref().unwrap_or(""); if timing == "past" && iso >= today { return invalid(format!("{} must be in the past", name), &PARTS); } if timing == "past-or-today" && iso > today { return invalid(format!("{} must be today or in the past", name), &PARTS); } if timing == "future" && iso <= today { return invalid(format!("{} must be in the future", name), &PARTS); } if timing == "future-or-today" && iso < today { return invalid(format!("{} must be today or in the future", name), &PARTS); } match (rules.not_before.as_deref(), rules.not_after.as_deref()) { (Some(before), Some(after)) => { if iso < before || iso > after { return invalid( format!("{} must be between {} and {}", name, long_date(before), long_date(after)), &PARTS, ); } } (Some(before), None) if iso < before => { return invalid(format!("{} must be the same as or after {}", name, long_date(before)), &PARTS); } (None, Some(after)) if iso > after => { return invalid(format!("{} must be the same as or before {}", name, long_date(after)), &PARTS); } _ => {} } DatePartsCheck { valid: true, date: Some(iso.to_string()), message: None, fields: Vec::new() } } pub fn date_parts_check_to_value(check: &DatePartsCheck) -> Value { let text = |v: &Option| v.as_deref().map(Value::str).unwrap_or(Value::Null); Value::obj(vec![ ("valid", Value::Bool(check.valid)), ("date", text(&check.date)), ("message", text(&check.message)), ("fields", Value::Arr(check.fields.iter().map(|f| Value::str(f)).collect())), ]) } pub fn fune_vector(args: &[Value]) -> Value { let text = |v: &Value, key: &str| match v.get(key) { Value::Str(s) => s.clone(), _ => panic!("parts.{} must be a string", key), }; let optional = |v: &Value, key: &str| match v.get(key) { Value::Str(s) => Some(s.clone()), _ => None, }; let parts = DateParts { day: text(&args[0], "day"), month: text(&args[0], "month"), year: text(&args[0], "year") }; let rules = DateRules { today: optional(&args[2], "today"), timing: optional(&args[2], "timing"), not_before: optional(&args[2], "notBefore"), not_after: optional(&args[2], "notAfter"), }; date_parts_check_to_value(&validate_date_parts(&parts, args[1].as_str(), &rules)) }