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

//! MRC Dyspnoea Scale - a five-point grade of breathlessness.
//!
//! The Medical Research Council (MRC) breathlessness scale (Fletcher, 1959)
//! grades the disability that breathlessness causes during day-to-day
//! activities, on a single ordinal scale from grade 1 (breathless only on
//! strenuous exertion) to grade 5 (too breathless to leave the house). It is
//! the version in routine UK use, recommended by NICE (NG115) and the British
//! Thoracic Society for COPD assessment.
//!
//! This is the classic MRC scale (grades 1-5). A modified form (mMRC) renumbers
//! the same descriptors 0-4; mMRC grade = MRC grade - 1. The two are otherwise
//! identical in wording, so a grade quoted without a scale is ambiguous - this
//! calculator takes the classic 1-5 grade.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};

use crate::calculator::{CalcError, Calculator};
use crate::license::CalculatorLicense;
use crate::response::CalculationResponse;

/// Machine name.
pub const NAME: &str = "mrc_dyspnoea";

/// Distribution licence: the MRC Dyspnoea Scale is published by the Medical
/// Research Council and made freely available for use, asking only that the
/// source is acknowledged.
pub const LICENSE: CalculatorLicense = CalculatorLicense {
    license: "Free to use - published by the Medical Research Council; reproduction permitted with acknowledgement of the source",
    source_url: "https://www.ukri.org/councils/mrc/facilities-and-resources/find-an-mrc-facility-or-resource/mrc-dyspnoea-scale/",
};

/// Primary citation.
pub const REFERENCE: &str = "Fletcher CM, Elmes PC, Fairbairn AS, Wood CH. The significance of respiratory symptoms and \
the diagnosis of chronic bronchitis in a working population. Br Med J. 1959;2(5147):257-266. \
doi:10.1136/bmj.2.5147.257";

/// Lowest valid MRC grade.
pub const MIN_GRADE: u8 = 1;

/// Highest valid MRC grade.
pub const MAX_GRADE: u8 = 5;

/// Input: a single MRC breathlessness grade (1-5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MrcDyspnoeaInput {
    /// The MRC Dyspnoea Scale grade, an integer from 1 (least) to 5 (most).
    pub grade: u8,
}

/// The descriptor for a given grade.
fn descriptor(grade: u8) -> &'static str {
    match grade {
        1 => "Not troubled by breathlessness except on strenuous exercise",
        2 => "Short of breath when hurrying on the level or walking up a slight hill",
        3 => {
            "Walks slower than contemporaries on the level because of breathlessness, or has to stop for breath when walking at own pace"
        }
        4 => "Stops for breath after walking about 100 metres or after a few minutes on the level",
        5 => "Too breathless to leave the house, or breathless when dressing or undressing",
        _ => unreachable!("grade is validated to 1-5 before this is called"),
    }
}

/// The computed outcome.
#[derive(Debug, Clone, PartialEq)]
pub struct MrcDyspnoeaOutcome {
    /// The MRC grade (echoed back, 1-5).
    pub grade: u8,
    /// The grade's breathlessness descriptor.
    pub descriptor: &'static str,
    /// Clinical interpretation.
    pub interpretation: String,
}

/// Pure scoring: validate the grade and attach its descriptor.
pub fn compute(input: &MrcDyspnoeaInput) -> Result<MrcDyspnoeaOutcome, CalcError> {
    if input.grade < MIN_GRADE || input.grade > MAX_GRADE {
        return Err(CalcError::InvalidInput(format!(
            "grade must be an integer from {MIN_GRADE} to {MAX_GRADE}, got {}",
            input.grade
        )));
    }

    let descriptor = descriptor(input.grade);
    let interpretation = format!(
        "MRC Dyspnoea grade {} of 5: {}. The MRC scale grades the disability breathlessness causes, \
not lung function; it does not by itself diagnose or stage a disease. This is the classic MRC scale \
(1-5); the modified mMRC scale numbers the same descriptors 0-4 (mMRC = MRC grade - 1), so confirm \
which scale is meant when a grade is recorded.",
        input.grade, descriptor
    );

    Ok(MrcDyspnoeaOutcome {
        grade: input.grade,
        descriptor,
        interpretation,
    })
}

/// Build the dispatchable [`CalculationResponse`] from typed inputs.
pub fn build_response(input: &MrcDyspnoeaInput) -> Result<CalculationResponse, CalcError> {
    let o = compute(input)?;

    let mut working = Map::new();
    working.insert("grade".into(), json!(o.grade));
    working.insert("descriptor".into(), json!(o.descriptor));

    Ok(CalculationResponse {
        calculator: NAME.to_string(),
        result: json!(o.grade),
        interpretation: o.interpretation,
        working,
        reference: REFERENCE.to_string(),
    })
}

/// Unit struct implementing the dynamic [`Calculator`] surface.
pub struct MrcDyspnoea;

impl Calculator for MrcDyspnoea {
    fn name(&self) -> &'static str {
        NAME
    }

    fn title(&self) -> &'static str {
        "MRC Dyspnoea Scale"
    }

    fn description(&self) -> &'static str {
        "Grades breathlessness-related disability on the classic MRC 1-5 scale (Fletcher 1959; NICE/BTS UK usage)."
    }

    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": "MrcDyspnoeaInput",
            "type": "object",
            "additionalProperties": false,
            "required": ["grade"],
            "properties": {
                "grade": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5,
                    "description": "MRC Dyspnoea grade (classic 1-5 scale). 1 = not troubled by breathlessness except on strenuous exercise; 2 = short of breath when hurrying on the level or walking up a slight hill; 3 = walks slower than contemporaries on the level because of breathlessness, or stops for breath when walking at own pace; 4 = stops for breath after walking about 100 metres or after a few minutes on the level; 5 = too breathless to leave the house, or breathless when dressing or undressing.",
                    "definition": {
                        "concept": "MRC Dyspnoea Scale grade",
                        "statement": "A single ordinal grade (1-5) describing how much breathlessness limits everyday activity, from breathless only on strenuous exertion (1) to too breathless to leave the house (5).",
                        "caveats": "This is the classic MRC scale (1-5). The modified mMRC scale numbers the same descriptors 0-4, where mMRC grade = MRC grade - 1; the two are easily confused, so confirm which scale a recorded grade refers to.",
                        "excludes": [
                            "Do NOT pass an mMRC (0-4) grade here: 0 is out of range, and grades 1-4 would each be read one step too severe relative to the classic scale"
                        ],
                        "source": {
                            "citation": "Fletcher CM et al. Br Med J. 1959;2(5147):257-266.",
                            "url": "https://www.ukri.org/councils/mrc/facilities-and-resources/find-an-mrc-facility-or-resource/mrc-dyspnoea-scale/"
                        },
                        "status": "draft"
                    }
                }
            }
        })
    }

    fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
        let parsed: MrcDyspnoeaInput = serde_json::from_value(input.clone())
            .map_err(|e| CalcError::InvalidInput(e.to_string()))?;
        build_response(&parsed)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn input(grade: u8) -> MrcDyspnoeaInput {
        MrcDyspnoeaInput { grade }
    }

    #[test]
    fn grade_1_interpretation() {
        let o = compute(&input(1)).unwrap();
        assert_eq!(o.grade, 1);
        assert_eq!(
            o.descriptor,
            "Not troubled by breathlessness except on strenuous exercise"
        );
    }

    #[test]
    fn grade_2_interpretation() {
        let o = compute(&input(2)).unwrap();
        assert_eq!(o.grade, 2);
        assert_eq!(
            o.descriptor,
            "Short of breath when hurrying on the level or walking up a slight hill"
        );
    }

    #[test]
    fn grade_3_interpretation() {
        let o = compute(&input(3)).unwrap();
        assert_eq!(o.grade, 3);
        assert!(o.descriptor.contains("Walks slower than contemporaries"));
    }

    #[test]
    fn grade_4_interpretation() {
        let o = compute(&input(4)).unwrap();
        assert_eq!(o.grade, 4);
        assert!(o.descriptor.contains("100 metres"));
    }

    #[test]
    fn grade_5_interpretation() {
        let o = compute(&input(5)).unwrap();
        assert_eq!(o.grade, 5);
        assert!(o.descriptor.contains("Too breathless to leave the house"));
    }

    #[test]
    fn rejects_out_of_range() {
        // 0 is the mMRC floor, not valid on the classic scale.
        assert!(compute(&input(0)).is_err());
        assert!(compute(&input(6)).is_err());
        assert!(compute(&input(100)).is_err());
    }

    #[test]
    fn result_echoes_grade() {
        let r = build_response(&input(3)).unwrap();
        assert_eq!(r.result, json!(3));
        assert_eq!(r.calculator, NAME);
    }

    #[test]
    fn dynamic_calculate_matches_typed() {
        let value = json!({ "grade": 4 });
        let dynamic = MrcDyspnoea.calculate(&value).unwrap();
        let typed = build_response(&input(4)).unwrap();
        assert_eq!(dynamic, typed);
    }

    #[test]
    fn dynamic_rejects_out_of_range() {
        assert!(MrcDyspnoea.calculate(&json!({ "grade": 0 })).is_err());
        assert!(MrcDyspnoea.calculate(&json!({ "grade": 9 })).is_err());
    }

    #[test]
    fn schema_constrains_grade_and_flags_mmrc() {
        let schema = MrcDyspnoea.input_schema();
        let grade = &schema["properties"]["grade"];
        assert_eq!(grade["minimum"], json!(1));
        assert_eq!(grade["maximum"], json!(5));
        let def = &grade["definition"];
        assert!(def["excludes"][0].as_str().unwrap().contains("mMRC"));
    }
}