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, ÐNICITIES, "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])))
}