clincalc 0.2.2

Open, auditable clinical calculators: a pure scoring engine plus the `clincalc` CLI in one crate. The engine is a serde-only leaf (build with default-features = false); the default `cli` feature adds the `clincalc` binary.
Documentation
// SPDX-FileCopyrightText: 2026 Marcus Baw and Baw Medical Ltd
// SPDX-License-Identifier: AGPL-3.0-or-later

//! BASDAI - Bath Ankylosing Spondylitis Disease Activity Index.

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 = "basdai";
pub const REFERENCE: &str = "Garrett S, Jenkinson T, Kennedy LG, Whitelock H, Gaisford P, Calin A. A new approach to defining disease status in ankylosing spondylitis: the Bath Ankylosing Spondylitis Disease Activity Index. J Rheumatol. 1994;21(12):2286-2291.";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
    license: "Published clinical index - implemented from the primary literature",
    source_url: "https://pubmed.ncbi.nlm.nih.gov/7699630/",
};

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BasdaiInput {
    pub fatigue: f64,
    pub spinal_pain: f64,
    pub peripheral_joint_pain_swelling: f64,
    pub enthesitis: f64,
    pub morning_stiffness_severity: f64,
    pub morning_stiffness_duration: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BasdaiOutcome {
    pub score: f64,
    pub interpretation: String,
}

pub fn compute(i: &BasdaiInput) -> Result<BasdaiOutcome, CalcError> {
    for (name, value) in [
        ("fatigue", i.fatigue),
        ("spinal_pain", i.spinal_pain),
        (
            "peripheral_joint_pain_swelling",
            i.peripheral_joint_pain_swelling,
        ),
        ("enthesitis", i.enthesitis),
        ("morning_stiffness_severity", i.morning_stiffness_severity),
        ("morning_stiffness_duration", i.morning_stiffness_duration),
    ] {
        if !(0.0..=10.0).contains(&value) || !value.is_finite() {
            return Err(CalcError::InvalidInput(format!(
                "{name} must be a finite 0-10 score"
            )));
        }
    }
    let stiffness = (i.morning_stiffness_severity + i.morning_stiffness_duration) / 2.0;
    let score =
        (i.fatigue + i.spinal_pain + i.peripheral_joint_pain_swelling + i.enthesitis + stiffness)
            / 5.0;
    let interpretation = if score >= 4.0 {
        format!(
            "BASDAI {score:.1}/10: active disease by the commonly used >=4 threshold. Interpret alongside function, inflammation markers, imaging, and treatment context."
        )
    } else {
        format!(
            "BASDAI {score:.1}/10: below the commonly used >=4 active-disease threshold. Interpret alongside function, inflammation markers, imaging, and treatment context."
        )
    };
    Ok(BasdaiOutcome {
        score,
        interpretation,
    })
}

pub fn build_response(i: &BasdaiInput) -> Result<CalculationResponse, CalcError> {
    let o = compute(i)?;
    let mut working = Map::new();
    working.insert(
        "morning_stiffness_component".into(),
        json!(round1(
            (i.morning_stiffness_severity + i.morning_stiffness_duration) / 2.0
        )),
    );
    working.insert("active_disease_threshold".into(), json!(4.0));
    Ok(CalculationResponse {
        calculator: NAME.to_string(),
        result: json!(round1(o.score)),
        interpretation: o.interpretation,
        working,
        reference: REFERENCE.to_string(),
    })
}
fn round1(v: f64) -> f64 {
    (v * 10.0).round() / 10.0
}

pub struct Basdai;
impl Calculator for Basdai {
    fn name(&self) -> &'static str {
        NAME
    }
    fn title(&self) -> &'static str {
        "BASDAI"
    }
    fn description(&self) -> &'static str {
        "Bath Ankylosing Spondylitis Disease Activity Index (0-10)."
    }
    fn reference(&self) -> &'static str {
        REFERENCE
    }
    fn license(&self) -> CalculatorLicense {
        LICENSE
    }
    fn input_schema(&self) -> Value {
        let q = |desc: &str| json!({ "type": "number", "minimum": 0, "maximum": 10, "description": desc });
        json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "BasdaiInput", "type": "object", "additionalProperties": false, "required": ["fatigue", "spinal_pain", "peripheral_joint_pain_swelling", "enthesitis", "morning_stiffness_severity", "morning_stiffness_duration"], "properties": { "fatigue": q("Fatigue/tiredness 0-10"), "spinal_pain": q("Neck/back/hip pain 0-10"), "peripheral_joint_pain_swelling": q("Peripheral joint pain/swelling 0-10"), "enthesitis": q("Areas tender to touch/pressure 0-10"), "morning_stiffness_severity": q("Morning stiffness severity 0-10"), "morning_stiffness_duration": q("Morning stiffness duration 0-10") } })
    }
    fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
        let parsed: BasdaiInput = serde_json::from_value(input.clone())
            .map_err(|e| CalcError::InvalidInput(e.to_string()))?;
        build_response(&parsed)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn computes_stiffness_average_then_total() {
        let out = compute(&BasdaiInput {
            fatigue: 4.0,
            spinal_pain: 5.0,
            peripheral_joint_pain_swelling: 6.0,
            enthesitis: 7.0,
            morning_stiffness_severity: 8.0,
            morning_stiffness_duration: 6.0,
        })
        .unwrap();
        assert_eq!(round1(out.score), 5.8);
    }
    #[test]
    fn rejects_out_of_range() {
        assert!(
            compute(&BasdaiInput {
                fatigue: 11.0,
                spinal_pain: 0.0,
                peripheral_joint_pain_swelling: 0.0,
                enthesitis: 0.0,
                morning_stiffness_severity: 0.0,
                morning_stiffness_duration: 0.0
            })
            .is_err()
        );
    }
}