lemma-engine 0.9.8

A pure, declarative language for business rules.
Documentation
use serde_json::Value;

use crate::documentation::{GuideTopic, EVALUATE_GUIDE};
use crate::engine::{resolve_effective as resolve_effective_datetime, Engine};
use crate::mcp::error::ToolError;
use crate::parse_run_data_object;
use crate::parsing::ast::DateTimeValue;
use crate::parsing::source::SourceType;
use crate::resolve_run_rules;
use crate::spec_set_id::parse_spec_set_id;

/// Evaluate a spec. Always explains (`Engine::run(..., true)`). No `explain` arg.
pub fn run(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    require_object(args)?;
    reject_explain_arg(args)?;
    if args.get("rule").is_some() {
        return Err(ToolError::invalid_arguments(
            "Unknown field 'rule'. Use 'rules' (string or string array).",
        ));
    }

    let spec_set_id = required_string(args, "spec")?;
    if spec_set_id.is_empty() {
        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
    }
    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
    let repository = optional_nonempty_string(args, "repository")?;
    let now = resolve_effective(args)?;
    let data_values =
        parse_run_data_object(&args.get("data").cloned()).map_err(ToolError::invalid_arguments)?;
    let rule_names =
        resolve_run_rules(&args.get("rules").cloned()).map_err(ToolError::invalid_arguments)?;
    let rules = rule_names.as_deref();

    let response = engine
        .run(repository, &spec_name, Some(&now), data_values, rules, true)
        .map_err(engine_error_to_diagnostics)?;

    Ok(serde_json::to_string_pretty(&response)
        .unwrap_or_else(|error| panic!("BUG: Response must serialize: {error}")))
}

/// Deprecated alias of [`run`]. Same args and Response JSON.
pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    run(engine, args)
}

pub fn list(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    require_object(args)?;
    let list = engine.list();
    Ok(serde_json::to_string_pretty(&list)
        .unwrap_or_else(|error| panic!("BUG: engine list must serialize: {error}")))
}

pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    let repository = optional_nonempty_string(args, "repository")?;
    let spec_set_id = required_string(args, "spec")?;
    if spec_set_id.is_empty() {
        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
    }
    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
    let now = resolve_effective(args)?;
    let show = engine
        .show(repository, &spec_name, Some(&now))
        .map_err(engine_error_to_diagnostics)?;
    Ok(serde_json::to_string_pretty(&show)
        .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")))
}

pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    require_object(args)?;
    let repository = optional_nonempty_string(args, "repository")?;
    let spec = optional_nonempty_string(args, "spec")?;
    match (repository, spec) {
        (Some(repo), None) => engine
            .source(Some(repo), None, None)
            .map_err(engine_error_to_diagnostics),
        (repo, Some(spec_set_id)) => {
            let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
            let now = resolve_effective(args)?;
            engine
                .source(repo, Some(&spec_name), Some(&now))
                .map_err(engine_error_to_diagnostics)
        }
        (None, None) => Err(ToolError::invalid_arguments(
            "Missing 'spec' or 'repository' field",
        )),
    }
}

pub fn check(args: &Value) -> Result<String, ToolError> {
    let sources_value = args
        .get("sources")
        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
    let sources_arr = sources_value
        .as_array()
        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
    if sources_arr.is_empty() {
        return Err(ToolError::invalid_arguments(
            "'sources' must be a non-empty array of [label, code] pairs",
        ));
    }

    let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
    for (i, entry) in sources_arr.iter().enumerate() {
        let pair = entry.as_array().ok_or_else(|| {
            ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
        })?;
        if pair.len() != 2 {
            return Err(ToolError::invalid_arguments(format!(
                "sources[{i}] must have exactly 2 elements [label, code]"
            )));
        }
        let label = pair[0].as_str().ok_or_else(|| {
            ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
        })?;
        let code = pair[1].as_str().ok_or_else(|| {
            ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
        })?;
        let source_type =
            SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
        sources.push((source_type, code.to_string()));
    }

    let mut engine = Engine::new();
    if let Err(load_err) = engine.load(sources) {
        return Err(ToolError::diagnostics(&load_err.errors));
    }

    let recommendations = engine.quality();
    Ok(serde_json::to_string_pretty(&recommendations)
        .unwrap_or_else(|error| panic!("BUG: quality recommendations must serialize: {error}")))
}

pub fn guide(args: &Value) -> Result<String, ToolError> {
    require_object(args)?;
    match args.get("topic") {
        None => Ok(EVALUATE_GUIDE.to_string()),
        Some(value) => {
            let topic_name = value
                .as_str()
                .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
            let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
                ToolError::invalid_arguments(format!(
                    "Unknown guide topic '{topic_name}'. Valid: {}",
                    GuideTopic::VALID_LIST
                ))
            })?;
            Ok(topic.section_text().to_string())
        }
    }
}

fn engine_error_to_diagnostics(error: crate::Error) -> ToolError {
    ToolError::diagnostics(std::slice::from_ref(&error))
}

fn reject_explain_arg(args: &Value) -> Result<(), ToolError> {
    if args.get("explain").is_some() {
        return Err(ToolError::invalid_arguments(
            "MCP run always includes explanations; do not pass 'explain'",
        ));
    }
    Ok(())
}

fn require_object(args: &Value) -> Result<(), ToolError> {
    if args.is_object() || args.is_null() {
        Ok(())
    } else {
        Err(ToolError::invalid_arguments("arguments must be an object"))
    }
}

fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
    match args.get(field) {
        Some(Value::String(value)) => Ok(value.trim()),
        Some(_) => Err(ToolError::invalid_arguments(format!(
            "'{field}' must be a string"
        ))),
        None => Err(ToolError::invalid_arguments(format!(
            "Missing '{field}' field"
        ))),
    }
}

fn optional_nonempty_string<'a>(
    args: &'a Value,
    field: &str,
) -> Result<Option<&'a str>, ToolError> {
    match args.get(field) {
        None => Ok(None),
        Some(Value::Null) => Ok(None),
        Some(Value::String(value)) => {
            let trimmed = value.trim();
            if trimmed.is_empty() {
                Ok(None)
            } else {
                Ok(Some(trimmed))
            }
        }
        Some(_) => Err(ToolError::invalid_arguments(format!(
            "'{field}' must be a string"
        ))),
    }
}

fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
    match args.get("effective") {
        None | Some(Value::Null) => {
            resolve_effective_datetime(None).map_err(engine_error_to_diagnostics)
        }
        Some(Value::String(raw)) => {
            resolve_effective_datetime(Some(raw)).map_err(engine_error_to_diagnostics)
        }
        Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use std::sync::Arc;

    fn load_pricing() -> Engine {
        let mut engine = Engine::new();
        engine
            .load([(
                SourceType::Path(Arc::new(PathBuf::from("pricing.lemma"))),
                "spec pricing\ndata quantity: number\nrule total: quantity * 10\n".to_string(),
            )])
            .expect("load");
        engine
    }

    #[test]
    fn run_returns_response_json_with_explanation() {
        let engine = load_pricing();
        let text = run(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "rules": "total",
                "data": { "quantity": 3 }
            }),
        )
        .expect("run");
        let value: Value = serde_json::from_str(&text).expect("Response JSON");
        assert_eq!(value["results"]["total"]["display"], "30");
        assert!(value["results"]["total"]["explanation"].is_object());
    }

    #[test]
    fn run_rejects_explain_arg() {
        let engine = load_pricing();
        let err = run(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "explain": false
            }),
        )
        .expect_err("explain forbidden");
        assert!(matches!(err, ToolError::InvalidArguments(_)));
    }

    #[test]
    fn run_rejects_legacy_rule_field() {
        let engine = load_pricing();
        let err = run(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "rule": "total"
            }),
        )
        .expect_err("rule forbidden");
        assert!(matches!(err, ToolError::InvalidArguments(_)));
    }

    #[test]
    fn run_rules_array() {
        let engine = load_pricing();
        let text = run(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "rules": ["total"],
                "data": { "quantity": 2 }
            }),
        )
        .expect("run");
        let value: Value = serde_json::from_str(&text).expect("Response JSON");
        assert_eq!(value["results"]["total"]["display"], "20");
    }

    #[test]
    fn show_accepts_repository() {
        let engine = Engine::new();
        let text = show(
            &engine,
            &serde_json::json!({
                "repository": "lemma",
                "spec": "units"
            }),
        )
        .expect("show lemma units");
        let value: Value = serde_json::from_str(&text).expect("Show JSON");
        assert_eq!(value["spec"], "units");
    }

    #[test]
    fn missing_spec_is_diagnostics() {
        let engine = Engine::new();
        let err =
            run(&engine, &serde_json::json!({ "spec": "nonexistent" })).expect_err("missing spec");
        match err {
            ToolError::Diagnostics(text) => {
                let value: Value = serde_json::from_str(&text).expect("EngineError JSON");
                assert!(value.is_array());
                assert!(!value.as_array().expect("array").is_empty());
            }
            other => panic!("expected Diagnostics, got {other}"),
        }
    }

    #[test]
    fn evaluate_aliases_run() {
        let engine = load_pricing();
        let a = run(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "data": { "quantity": 1 }
            }),
        )
        .expect("run");
        let b = evaluate(
            &engine,
            &serde_json::json!({
                "spec": "pricing",
                "data": { "quantity": 1 }
            }),
        )
        .expect("evaluate");
        let va: Value = serde_json::from_str(&a).expect("run JSON");
        let vb: Value = serde_json::from_str(&b).expect("evaluate JSON");
        assert_eq!(
            va["results"]["total"]["display"],
            vb["results"]["total"]["display"]
        );
        assert_eq!(
            va["results"]["total"]["explanation"]["body"],
            vb["results"]["total"]["explanation"]["body"]
        );
    }

    #[test]
    fn check_success_is_quality_json() {
        let text = check(&serde_json::json!({
            "sources": [["ok.lemma", "spec ok\nrule r: 1\n"]]
        }))
        .expect("check");
        let value: Value = serde_json::from_str(&text).expect("quality JSON");
        assert!(value.is_array());
    }
}