Functional Weave
Code in Rust

health.qrisk-inputs-validate@1.0.0

impl/rust.rs

8,084 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::validation_uk_postcode::validate_uk_postcode;  ← from validation.uk-postcode ^2.0.0 · built alongside by fune

const SEXES: [&str; 2] = ["female", "male"];
const ETHNICITIES: [&str; 9] = [
    "white-or-not-stated",
    "indian",
    "pakistani",
    "bangladeshi",
    "chinese",
    "other-asian",
    "black-caribbean",
    "black-african",
    "other",
];
const SMOKING: [&str; 5] = ["non-smoker", "ex-smoker", "light", "moderate", "heavy"];
const DIABETES: [&str; 3] = ["none", "type-1", "type-2"];

fn error(field: &str, reason: &str, message: &str) -> QriskInputError {
    QriskInputError {
        field: field.to_string(),
        reason: reason.to_string(),
        message: message.to_string(),
    }
}

fn choice(errors: &mut Vec<QriskInputError>, field: &str, value: &Option<String>, allowed: &[&str], label: &str) {
    let message = format!("{}: enter one of {}", label, allowed.join(", "));
    match value {
        None => errors.push(error(field, "required", &message)),
        Some(v) if !allowed.contains(&v.as_str()) => errors.push(error(field, "unknown-value", &message)),
        _ => {}
    }
}

/// Check a QRISK3 input set against the calculator's published input
/// definitions (qrisk.org form and its validation). It reports every problem,
/// in field order, and never computes a risk.
pub fn validate_qrisk_inputs(inputs: &QriskInputs) -> QriskInputsValidation {
    let mut errors: Vec<QriskInputError> = Vec::new();

    let age_message = "Age: enter an integer between 25 and 84";
    match inputs.age {
        None => errors.push(error("age", "required", age_message)),
        Some(a) if !(25..=84).contains(&a) => errors.push(error("age", "out-of-range", age_message)),
        _ => {}
    }
    choice(&mut errors, "sex", &inputs.sex, &SEXES, "Sex");
    choice(&mut errors, "ethnicity", &inputs.ethnicity, &ETHNICITIES, "Ethnicity");
    // Blank means unknown, as on the calculator ("leave blank if unknown").
    if let Some(p) = &inputs.postcode {
        if !p.trim().is_empty() && !validate_uk_postcode(p).valid {
            errors.push(error(
                "postcode",
                "bad-postcode",
                "Postcode: the postcode is not recognised. Please re-enter or leave blank.",
            ));
        }
    }
    choice(&mut errors, "smoking", &inputs.smoking, &SMOKING, "Smoking status");
    choice(&mut errors, "diabetes", &inputs.diabetes, &DIABETES, "Diabetes status");

    if let Some(r) = inputs.cholesterol_hdl_ratio {
        if !(1.0..=11.0).contains(&r) {
            errors.push(error(
                "cholesterolHdlRatio",
                "out-of-range",
                "Cholesterol/HDL ratio: either leave blank or enter a number between 1.0 and 11.0.",
            ));
        }
    }
    if let Some(s) = inputs.systolic_bp {
        if !(70..=210).contains(&s) {
            errors.push(error(
                "systolicBp",
                "out-of-range",
                "Systolic blood pressure: either leave blank or enter an integer between 70 and 210",
            ));
        }
    }
    if let Some(sd) = inputs.systolic_bp_sd {
        if !(0.0..=40.0).contains(&sd) {
            errors.push(error(
                "systolicBpSd",
                "out-of-range",
                "Standard deviation of SBP: either leave blank or enter a value between 0.0 and 40.0",
            ));
        }
    }
    let pair = "You have only entered one of height and weight: please enter both or leave both blank.";
    if inputs.height_cm.is_none() && inputs.weight_kg.is_some() {
        errors.push(error("heightCm", "incomplete-pair", pair));
    }
    if let Some(h) = inputs.height_cm {
        if !(140..=210).contains(&h) {
            errors.push(error("heightCm", "out-of-range", "Height: enter an integer between 140 and 210"));
        }
    }
    if inputs.weight_kg.is_none() && inputs.height_cm.is_some() {
        errors.push(error("weightKg", "incomplete-pair", pair));
    }
    if let Some(w) = inputs.weight_kg {
        if !(40..=180).contains(&w) {
            errors.push(error("weightKg", "out-of-range", "Weight: enter an integer between 40 and 180"));
        }
    }

    if inputs.existing_cvd {
        errors.push(error(
            "existingCvd",
            "not-applicable",
            "QRISK3 is only valid for people without a diagnosis of coronary heart disease (including angina or heart attack) or stroke/transient ischaemic attack.",
        ));
    }
    if inputs.on_statins {
        errors.push(error("onStatins", "not-applicable", "QRISK3 is only valid for people who are not on statins."));
    }
    QriskInputsValidation {
        valid: errors.is_empty(),
        errors,
    }
}

pub fn qrisk_inputs_validation_to_value(result: &QriskInputsValidation) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        (
            "errors",
            Value::Arr(
                result
                    .errors
                    .iter()
                    .map(|e| {
                        Value::obj(vec![
                            ("field", Value::str(&e.field)),
                            ("reason", Value::str(&e.reason)),
                            ("message", Value::str(&e.message)),
                        ])
                    })
                    .collect(),
            ),
        ),
    ])
}

/// The adapter refuses a value the typed struct cannot hold, with the wording
/// TypeScript and Python use, instead of letting a conversion change it.
fn whole_or_none(v: &Value, name: &str) -> Option<i64> {
    match v {
        Value::Null => None,
        Value::Int(i) => Some(*i),
        Value::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(*f as i64),
        _ => panic!("{} must be a whole number or null, received {:?}", name, v),
    }
}

fn number_or_none(v: &Value, name: &str) -> Option<f64> {
    match v {
        Value::Null => None,
        Value::Int(_) | Value::Float(_) => Some(v.as_f64()),
        _ => panic!("{} must be a number or null, received {:?}", name, v),
    }
}

fn text_or_none(v: &Value, name: &str) -> Option<String> {
    match v {
        Value::Null => None,
        Value::Str(s) => Some(s.clone()),
        _ => panic!("{} must be text or null, received {:?}", name, v),
    }
}

pub fn qrisk_inputs_from_value(v: &Value) -> QriskInputs {
    QriskInputs {
        age: whole_or_none(v.get("age"), "age"),
        sex: text_or_none(v.get("sex"), "sex"),
        ethnicity: text_or_none(v.get("ethnicity"), "ethnicity"),
        postcode: text_or_none(v.get("postcode"), "postcode"),
        smoking: text_or_none(v.get("smoking"), "smoking"),
        diabetes: text_or_none(v.get("diabetes"), "diabetes"),
        family_history_cvd: v.get("familyHistoryCvd").as_bool(),
        chronic_kidney_disease: v.get("chronicKidneyDisease").as_bool(),
        atrial_fibrillation: v.get("atrialFibrillation").as_bool(),
        blood_pressure_treatment: v.get("bloodPressureTreatment").as_bool(),
        migraine: v.get("migraine").as_bool(),
        rheumatoid_arthritis: v.get("rheumatoidArthritis").as_bool(),
        systemic_lupus: v.get("systemicLupus").as_bool(),
        severe_mental_illness: v.get("severeMentalIllness").as_bool(),
        atypical_antipsychotic: v.get("atypicalAntipsychotic").as_bool(),
        corticosteroids: v.get("corticosteroids").as_bool(),
        erectile_dysfunction: v.get("erectileDysfunction").as_bool(),
        cholesterol_hdl_ratio: number_or_none(v.get("cholesterolHdlRatio"), "cholesterolHdlRatio"),
        systolic_bp: whole_or_none(v.get("systolicBp"), "systolicBp"),
        systolic_bp_sd: number_or_none(v.get("systolicBpSd"), "systolicBpSd"),
        height_cm: whole_or_none(v.get("heightCm"), "heightCm"),
        weight_kg: whole_or_none(v.get("weightKg"), "weightKg"),
        existing_cvd: v.get("existingCvd").as_bool(),
        on_statins: v.get("onStatins").as_bool(),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    qrisk_inputs_validation_to_value(&validate_qrisk_inputs(&qrisk_inputs_from_value(&args[0])))
}