Functional Weave
Code in TypeScript

form.date-parts@1.0.1

impl/rust.rs

7,848 bytes · the Rust implementation · view raw

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))
}