use super::funejson::Value; use super::validation_uk_postcode::validate_uk_postcode; 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, field: &str, value: &Option, 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 = 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 { 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 { 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 { 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]))) }