use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::calculator::{CalcError, Calculator};
use crate::license::CalculatorLicense;
use crate::response::CalculationResponse;
pub const NAME: &str = "findrisc";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Public-domain method - implemented from the primary literature",
source_url: "https://doi.org/10.2337/diacare.26.3.725",
};
pub const REFERENCE: &str = "Lindstrom J, Tuomilehto J. The diabetes risk score: a practical tool to predict \
type 2 diabetes risk. Diabetes Care. 2003;26(3):725-731. doi:10.2337/diacare.26.3.725";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Sex {
Male,
Female,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FamilyHistory {
None,
SecondDegree,
FirstDegree,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FindriscInput {
pub sex: Sex,
pub age: u8,
pub bmi: f64,
pub waist_cm: f64,
pub physically_active: bool,
pub daily_vegetables_fruit: bool,
pub antihypertensive_medication: bool,
pub high_blood_glucose_history: bool,
pub family_history: FamilyHistory,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FindriscOutcome {
pub score: u8,
pub risk_band: &'static str,
pub ten_year_risk: &'static str,
pub interpretation: String,
}
pub fn compute(input: &FindriscInput) -> Result<FindriscOutcome, CalcError> {
if input.bmi <= 0.0 || !input.bmi.is_finite() {
return Err(CalcError::InvalidInput("bmi must be positive".into()));
}
if input.waist_cm <= 0.0 || !input.waist_cm.is_finite() {
return Err(CalcError::InvalidInput("waist_cm must be positive".into()));
}
let age_pts: u8 = if input.age < 45 {
0
} else if input.age < 55 {
2
} else if input.age < 65 {
3
} else {
4
};
let bmi_pts: u8 = if input.bmi < 25.0 {
0
} else if input.bmi <= 30.0 {
1
} else {
3
};
let waist_pts: u8 = match input.sex {
Sex::Male => {
if input.waist_cm < 94.0 {
0
} else if input.waist_cm <= 102.0 {
3
} else {
4
}
}
Sex::Female => {
if input.waist_cm < 80.0 {
0
} else if input.waist_cm <= 88.0 {
3
} else {
4
}
}
};
let activity_pts: u8 = if input.physically_active { 0 } else { 2 };
let diet_pts: u8 = if input.daily_vegetables_fruit { 0 } else { 1 };
let antihyp_pts: u8 = if input.antihypertensive_medication {
2
} else {
0
};
let glucose_pts: u8 = if input.high_blood_glucose_history {
5
} else {
0
};
let family_pts: u8 = match input.family_history {
FamilyHistory::None => 0,
FamilyHistory::SecondDegree => 3,
FamilyHistory::FirstDegree => 5,
};
let score = age_pts
+ bmi_pts
+ waist_pts
+ activity_pts
+ diet_pts
+ antihyp_pts
+ glucose_pts
+ family_pts;
let (risk_band, ten_year_risk) = match score {
0..=7 => ("Low", "approximately 1 in 100"),
8..=11 => ("Slightly elevated", "approximately 1 in 25"),
12..=14 => ("Moderate", "approximately 1 in 6"),
15..=20 => ("High", "approximately 1 in 3"),
_ => ("Very high", "approximately 1 in 2"),
};
let interpretation = format!(
"FINDRISC score {score}/26 - {risk_band} risk of developing type 2 diabetes \
within 10 years ({ten_year_risk}). Lifestyle interventions (weight loss, increased \
physical activity, dietary change) can substantially reduce risk in moderate and above categories."
);
Ok(FindriscOutcome {
score,
risk_band,
ten_year_risk,
interpretation,
})
}
pub fn build_response(input: &FindriscInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert("findrisc_score".into(), json!(o.score));
working.insert("risk_band".into(), json!(o.risk_band));
working.insert("ten_year_risk".into(), json!(o.ten_year_risk));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.score),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Findrisc;
impl Calculator for Findrisc {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"FINDRISC (Finnish Diabetes Risk Score)"
}
fn description(&self) -> &'static str {
"Predicts 10-year risk of type 2 diabetes using 8 lifestyle and clinical items (score 0-26)."
}
fn reference(&self) -> &'static str {
REFERENCE
}
fn license(&self) -> CalculatorLicense {
LICENSE
}
fn input_schema(&self) -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FindriscInput",
"type": "object",
"additionalProperties": false,
"required": ["sex", "age", "bmi", "waist_cm", "physically_active",
"daily_vegetables_fruit", "antihypertensive_medication",
"high_blood_glucose_history", "family_history"],
"properties": {
"sex": {
"type": "string",
"enum": ["male", "female"],
"description": "Sex (used for sex-specific waist circumference thresholds)"
},
"age": {
"type": "integer",
"minimum": 18,
"maximum": 120,
"description": "Age in years (<45=0, 45-54=2, 55-64=3, ≥65=4 points)"
},
"bmi": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Body mass index in kg/m² (<25=0, 25-30=1, >30=3 points)"
},
"waist_cm": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Waist circumference in cm (sex-specific thresholds apply)"
},
"physically_active": {
"type": "boolean",
"description": "At least 30 minutes of moderate physical activity daily (true=0, false=2 points)"
},
"daily_vegetables_fruit": {
"type": "boolean",
"description": "Eats vegetables, fruit, or berries every day (true=0, false=1 point)"
},
"antihypertensive_medication": {
"type": "boolean",
"description": "Currently taking antihypertensive medication (false=0, true=2 points)"
},
"high_blood_glucose_history": {
"type": "boolean",
"description": "History of high blood glucose (including during pregnancy) (false=0, true=5 points)"
},
"family_history": {
"type": "string",
"enum": ["none", "second_degree", "first_degree"],
"description": "Family history of diabetes: none=0, second_degree=3, first_degree=5 points"
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: FindriscInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> FindriscInput {
FindriscInput {
sex: Sex::Male,
age: 40,
bmi: 22.0,
waist_cm: 85.0,
physically_active: true,
daily_vegetables_fruit: true,
antihypertensive_medication: false,
high_blood_glucose_history: false,
family_history: FamilyHistory::None,
}
}
#[test]
fn low_risk_young_healthy() {
let o = compute(&base()).unwrap();
assert_eq!(o.score, 0);
assert_eq!(o.risk_band, "Low");
}
#[test]
fn high_risk_scenario() {
let o = compute(&FindriscInput {
sex: Sex::Male,
age: 67,
bmi: 32.0,
waist_cm: 110.0,
physically_active: false,
daily_vegetables_fruit: false,
antihypertensive_medication: true,
high_blood_glucose_history: true,
family_history: FamilyHistory::None,
})
.unwrap();
assert_eq!(o.score, 21);
assert_eq!(o.risk_band, "Very high");
}
#[test]
fn female_waist_thresholds() {
let o = compute(&FindriscInput {
sex: Sex::Female,
waist_cm: 82.0,
..base()
})
.unwrap();
assert_eq!(o.score, 3);
}
#[test]
fn family_history_first_degree() {
let o = compute(&FindriscInput {
family_history: FamilyHistory::FirstDegree,
..base()
})
.unwrap();
assert_eq!(o.score, 5);
}
#[test]
fn dynamic_calculate_matches_typed() {
let b = base();
let value = json!({
"sex": "male",
"age": 40,
"bmi": 22.0,
"waist_cm": 85.0,
"physically_active": true,
"daily_vegetables_fruit": true,
"antihypertensive_medication": false,
"high_blood_glucose_history": false,
"family_history": "none"
});
let dynamic = Findrisc.calculate(&value).unwrap();
let typed = build_response(&b).unwrap();
assert_eq!(dynamic, typed);
}
}