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 = "asrs";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "(c) WHO / New York University / President and Fellows of Harvard College - free to use with citation, no permission required",
source_url: "https://www.hcp.med.harvard.edu/ncs/asrs.php",
};
pub const REFERENCE: &str =
"Kessler RC et al. (2005). Psychol Med. 35(2):245-56. doi:10.1017/S0033291704002892";
pub const ITEM_COUNT: usize = 18;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AsrsInput {
pub responses: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsrsOutcome {
pub part_a_positive: u8,
pub part_a_total: u16,
pub part_b_total: u16,
pub total: u16,
pub screen_positive: bool,
pub part_a_item_positive: [bool; 6],
pub interpretation: String,
}
fn part_a_item_positive(index: usize, score: u8) -> bool {
if index < 3 { score >= 2 } else { score >= 3 }
}
fn interpret(part_a_positive: u8) -> String {
if part_a_positive >= 4 {
format!(
"Positive screen: {part_a_positive}/6 Part A items meet the frequency threshold. \
These symptoms are highly consistent with adult ADHD. A formal diagnostic \
assessment by a qualified clinician is recommended. This result is not a \
diagnosis of ADHD."
)
} else {
format!(
"Negative screen: {part_a_positive}/6 Part A items meet the frequency threshold \
(4 required for a positive screen). Reported symptoms are less consistent with \
adult ADHD, though clinical judgement should always be applied. If clinical \
concern persists, further assessment is warranted."
)
}
}
pub fn compute(input: &AsrsInput) -> Result<AsrsOutcome, CalcError> {
if input.responses.len() != ITEM_COUNT {
return Err(CalcError::InvalidInput(format!(
"expected {ITEM_COUNT} responses, got {}",
input.responses.len()
)));
}
for (i, &v) in input.responses.iter().enumerate() {
if v > 4 {
return Err(CalcError::InvalidInput(format!(
"response {} = {v} is out of range 0–4",
i + 1
)));
}
}
let mut part_a_item_positive_arr = [false; 6];
let mut part_a_positive = 0u8;
let mut part_a_total = 0u16;
for (i, &v) in input.responses.iter().take(6).enumerate() {
part_a_total += v as u16;
let pos = part_a_item_positive(i, v);
part_a_item_positive_arr[i] = pos;
if pos {
part_a_positive += 1;
}
}
let part_b_total: u16 = input.responses[6..ITEM_COUNT]
.iter()
.map(|&v| v as u16)
.sum();
let total = part_a_total + part_b_total;
let screen_positive = part_a_positive >= 4;
Ok(AsrsOutcome {
part_a_positive,
part_a_total,
part_b_total,
total,
screen_positive,
part_a_item_positive: part_a_item_positive_arr,
interpretation: interpret(part_a_positive),
})
}
pub fn build_response(input: &AsrsInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert(
"part_a_screen_result".into(),
json!(if o.screen_positive {
"POSITIVE"
} else {
"NEGATIVE"
}),
);
working.insert(
"part_a_positive_item_count".into(),
json!(o.part_a_positive),
);
working.insert("part_a_total_score".into(), json!(o.part_a_total));
working.insert("part_b_total_score".into(), json!(o.part_b_total));
working.insert("total_score".into(), json!(o.total));
working.insert("answers".into(), json!(input.responses));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.part_a_positive),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Asrs;
impl Calculator for Asrs {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"ASRS-v1.1 Adult ADHD Screener"
}
fn description(&self) -> &'static str {
"18-item WHO-validated screener for adult ADHD; Part A (items 1–6) is the validated screen."
}
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": "AsrsInput",
"type": "object",
"additionalProperties": false,
"required": ["responses"],
"properties": {
"responses": {
"type": "array",
"description": "18 frequency responses (Q1–Q18), each 0=Never, 1=Rarely, 2=Sometimes, 3=Often, 4=Very Often",
"items": { "type": "integer", "minimum": 0, "maximum": 4 },
"minItems": 18,
"maxItems": 18
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: AsrsInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn responses(v: [u8; 18]) -> AsrsInput {
AsrsInput {
responses: v.to_vec(),
}
}
#[test]
fn all_zero_is_negative() {
let o = compute(&responses([0; 18])).unwrap();
assert_eq!(o.part_a_positive, 0);
assert_eq!(o.total, 0);
assert!(!o.screen_positive);
}
#[test]
fn item_thresholds_differ_between_q1_3_and_q4_6() {
let mut v = [0u8; 18];
v[0] = 2;
v[1] = 2;
v[2] = 2; v[3] = 2;
v[4] = 2;
v[5] = 2; let o = compute(&responses(v)).unwrap();
assert_eq!(o.part_a_positive, 3);
assert!(!o.screen_positive);
}
#[test]
fn four_positives_is_a_positive_screen() {
let mut v = [0u8; 18];
v[0] = 2;
v[1] = 2;
v[2] = 2;
v[3] = 3; let o = compute(&responses(v)).unwrap();
assert_eq!(o.part_a_positive, 4);
assert!(o.screen_positive);
}
#[test]
fn totals_split_part_a_and_b() {
let o = compute(&responses([4; 18])).unwrap();
assert_eq!(o.part_a_total, 24);
assert_eq!(o.part_b_total, 48);
assert_eq!(o.total, 72);
assert_eq!(o.part_a_positive, 6);
assert!(o.screen_positive);
}
#[test]
fn wrong_length_is_rejected() {
assert!(
compute(&AsrsInput {
responses: vec![0; 17]
})
.is_err()
);
assert!(
compute(&AsrsInput {
responses: vec![0; 19]
})
.is_err()
);
}
#[test]
fn out_of_range_is_rejected() {
let mut v = vec![0u8; 18];
v[5] = 5;
assert!(compute(&AsrsInput { responses: v }).is_err());
}
#[test]
fn dynamic_calculate_matches_typed() {
let arr = [2, 2, 2, 3, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
let dynamic = Asrs.calculate(&json!({ "responses": arr })).unwrap();
let typed = build_response(&responses(arr)).unwrap();
assert_eq!(dynamic, typed);
assert_eq!(dynamic.working["part_a_screen_result"], json!("POSITIVE"));
}
}