Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{days_in_month, parse_iso_date}; ← from dates.add-days ^1.0.0 · built alongside by fune
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<i64> {
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<i64> {
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::<i64>().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<String>| 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))
}