clincalc 0.2.1

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

//! Fillable input templates derived from a calculator's JSON Schema.
//!
//! `clincalc <name>` (with no `--input`) prints one of these: a JSON object
//! with every input key present and a placeholder value describing the expected
//! type, allowed values, and meaning. A human or an LLM fills in the values and
//! passes the object straight back via `--input`, so the template's shape is by
//! construction the shape of valid input.
//!
//! The template is generated by walking the schema, so it can never drift from
//! the real contract returned by [`Calculator::input_schema`](crate::Calculator::input_schema).

use serde_json::{Map, Value};

/// Build a fillable input template from a JSON Schema object.
///
/// Each property becomes a key whose placeholder describes the expected value.
/// Nested object properties recurse; every other property becomes a hint string
/// like `"<boolean> Fever in the last 24 hours"` or `"<one of: a|b|c> ..."`.
///
/// If the schema declares top-level `oneOf` alternatives (each with its own
/// `required` array), the template emits only the **first** alternative's
/// required fields - so the printed object always represents one valid input
/// path rather than a confusing union of mutually-exclusive fields. The other
/// alternatives are still discoverable via `clincalc <name> --schema`.
pub fn template_from_schema(schema: &Value) -> Value {
    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
        return Value::Object(Map::new());
    };

    // If the schema is a `oneOf` of alternative required-field groups, restrict
    // the template to the first alternative so it represents one usable path.
    let allowed_keys: Option<std::collections::BTreeSet<String>> = schema
        .get("oneOf")
        .and_then(Value::as_array)
        .and_then(|alts| alts.first())
        .and_then(|alt| alt.get("required"))
        .and_then(Value::as_array)
        .map(|req| {
            req.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        });

    let mut out = Map::new();
    for (key, prop) in props {
        if let Some(allowed) = &allowed_keys
            && !allowed.contains(key)
        {
            continue;
        }
        out.insert(key.clone(), placeholder(prop));
    }
    Value::Object(out)
}

fn placeholder(prop: &Value) -> Value {
    // Nested object: recurse so the template mirrors the structure.
    if prop.get("type").and_then(Value::as_str) == Some("object") {
        return template_from_schema(prop);
    }
    let desc = prop
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or("");
    let kind = type_hint(prop);
    let text = if desc.is_empty() {
        format!("<{kind}>")
    } else {
        format!("<{kind}> {desc}")
    };
    Value::String(text)
}

fn type_hint(prop: &Value) -> String {
    // Enumerated values take priority - they are the most useful hint. For
    // string enums the variants are unambiguous; for integer / number enums
    // we lead with the JSON type so the reader knows to write `1` not `"1"`.
    if let Some(values) = prop.get("enum").and_then(Value::as_array) {
        let opts: Vec<String> = values.iter().map(render_scalar).collect();
        let joined = opts.join("|");
        return match prop.get("type").and_then(Value::as_str) {
            Some("integer") => format!("integer, one of: {joined}"),
            Some("number") => format!("number, one of: {joined}"),
            _ => format!("one of: {joined}"),
        };
    }
    match prop.get("type").and_then(Value::as_str) {
        Some("boolean") => "boolean".to_string(),
        Some("integer") => with_range("integer", prop),
        Some("number") => with_range("number", prop),
        Some("string") => "string".to_string(),
        Some("array") => array_hint(prop),
        Some(other) => other.to_string(),
        None => "value".to_string(),
    }
}

fn with_range(base: &str, prop: &Value) -> String {
    let min = prop.get("minimum").and_then(Value::as_i64);
    let max = prop.get("maximum").and_then(Value::as_i64);
    match (min, max) {
        (Some(lo), Some(hi)) => format!("{base} {lo}-{hi}"),
        (Some(lo), None) => format!("{base} >= {lo}"),
        (None, Some(hi)) => format!("{base} <= {hi}"),
        (None, None) => base.to_string(),
    }
}

fn array_hint(prop: &Value) -> String {
    let item_kind = prop
        .get("items")
        .map(type_hint)
        .unwrap_or_else(|| "value".to_string());
    let min = prop.get("minItems").and_then(Value::as_u64);
    let max = prop.get("maxItems").and_then(Value::as_u64);
    match (min, max) {
        (Some(n), Some(m)) if n == m => format!("array of {item_kind} ({n} items)"),
        _ => format!("array of {item_kind}"),
    }
}

fn render_scalar(v: &Value) -> String {
    match v {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

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

    #[test]
    fn booleans_become_typed_hints() {
        let schema = json!({
            "type": "object",
            "properties": {
                "fever": { "type": "boolean", "description": "Fever in the last 24 hours" }
            }
        });
        let t = template_from_schema(&schema);
        assert_eq!(t["fever"], json!("<boolean> Fever in the last 24 hours"));
    }

    #[test]
    fn enums_list_their_options() {
        let schema = json!({
            "type": "object",
            "properties": {
                "smoking": { "type": "string", "enum": ["non", "ex", "current"], "description": "Smoking status" }
            }
        });
        let t = template_from_schema(&schema);
        assert_eq!(
            t["smoking"],
            json!("<one of: non|ex|current> Smoking status")
        );
    }

    #[test]
    fn arrays_report_item_type_and_count() {
        let schema = json!({
            "type": "object",
            "properties": {
                "responses": {
                    "type": "array",
                    "items": { "type": "integer", "minimum": 0, "maximum": 4 },
                    "minItems": 18, "maxItems": 18,
                    "description": "18 frequency responses"
                }
            }
        });
        let t = template_from_schema(&schema);
        assert_eq!(
            t["responses"],
            json!("<array of integer 0-4 (18 items)> 18 frequency responses")
        );
    }

    #[test]
    fn no_properties_yields_empty_object() {
        assert_eq!(template_from_schema(&json!({"type": "object"})), json!({}));
    }

    #[test]
    fn integer_enums_lead_with_the_json_type() {
        // Regression: previously rendered as `<one of: 1|2|3|4>`, which a
        // reader would naturally fill in as a quoted string and break.
        let schema = json!({
            "type": "object",
            "properties": {
                "killip_class": {
                    "type": "integer",
                    "enum": [1, 2, 3, 4],
                    "description": "Killip class on admission (1-4)"
                }
            }
        });
        let t = template_from_schema(&schema);
        assert_eq!(
            t["killip_class"],
            json!("<integer, one of: 1|2|3|4> Killip class on admission (1-4)")
        );
    }

    #[test]
    fn number_enums_lead_with_the_json_type() {
        let schema = json!({
            "type": "object",
            "properties": {
                "dose": { "type": "number", "enum": [0.5, 1.0, 2.0] }
            }
        });
        let t = template_from_schema(&schema);
        assert_eq!(t["dose"], json!("<number, one of: 0.5|1.0|2.0>"));
    }

    #[test]
    fn one_of_alternatives_emit_only_the_first_alternative() {
        // Regression: the uACR schema declares `acr+acr_unit` OR
        // `albumin+creatinine` via top-level `oneOf`. The template used to
        // emit all four fields, which fails the "exactly one" check.
        let schema = json!({
            "type": "object",
            "properties": {
                "acr": { "type": "number" },
                "acr_unit": { "type": "string", "enum": ["mg/mmol", "mg/g"] },
                "albumin": { "type": "number" },
                "creatinine": { "type": "number" }
            },
            "oneOf": [
                { "required": ["acr", "acr_unit"] },
                { "required": ["albumin", "creatinine"] }
            ]
        });
        let t = template_from_schema(&schema);
        let obj = t.as_object().unwrap();
        assert!(obj.contains_key("acr"));
        assert!(obj.contains_key("acr_unit"));
        assert!(!obj.contains_key("albumin"));
        assert!(!obj.contains_key("creatinine"));
    }
}