lemma-engine 0.9.7

A pure, declarative language for business rules.
Documentation
use std::collections::HashMap;

use serde_json::Value;

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

pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
    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(map_engine_error)?;
    let rule_names = optional_rule_names(args)?;
    let data_values = parse_data(args)?;
    let now = resolve_effective(args)?;
    let rules = if rule_names.is_empty() {
        None
    } else {
        Some(rule_names.as_slice())
    };
    let response = engine
        .run(None, &spec_name, Some(&now), data_values, rules, true)
        .map_err(map_engine_error)?;

    let show_for_missing = if response
        .results
        .values()
        .any(|result| result.awaits_missing_data())
    {
        Some(
            engine
                .show(None, &spec_name, Some(&now))
                .unwrap_or_else(|error| {
                    panic!("BUG: show must succeed after evaluate for '{spec_set_id}': {error}")
                }),
        )
    } else {
        None
    };

    let mut output = String::new();
    output.push_str(&format!("spec: {spec_set_id}\n"));
    output.push_str(&format!("effective: {now}\n"));
    output.push('\n');

    for result in response.results.values() {
        output.push_str(&format!("{}: ", result.rule.name));
        if result.vetoed {
            if let Some(reason) = result.veto_reason.as_deref() {
                output.push_str(reason);
            }
        } else {
            let display = result.display().unwrap_or_else(|| {
                panic!(
                    "BUG: rule '{}' evaluated without display after evaluation",
                    result.rule.name
                )
            });
            output.push_str(display);
            if let Some(value) = &result.value {
                if let Some(measure) = &value.measure {
                    append_unit_map(&mut output, measure);
                } else if let Some(ratio) = &value.ratio {
                    append_unit_map(&mut output, ratio);
                }
            }
        }
        output.push('\n');

        if result.awaits_missing_data() {
            let show = show_for_missing
                .as_ref()
                .expect("BUG: any awaiting rule requires show_for_missing after evaluate");
            output.push_str("missing_data:\n");
            for name in result.missing_data() {
                let entry = show.data.get(name).unwrap_or_else(|| {
                    panic!("BUG: missing_data key {name:?} must exist in show.data after evaluate")
                });
                let type_name = entry.lemma_type.specifications.to_string();
                let help = entry.lemma_type.specifications.help();
                if help.is_empty() {
                    output.push_str(&format!("  {name}: {type_name}\n"));
                } else {
                    output.push_str(&format!("  {name}: {type_name} — {help}\n"));
                }
            }
        }

        if let Some(explanation) = &result.explanation {
            let steps = format_explanation(explanation);
            if !steps.is_empty() {
                output.push_str("\nReasoning:\n");
                output.push_str(&steps);
                output.push('\n');
            }
        }
    }

    Ok(output)
}

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 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(map_engine_error)?;
    let now = resolve_effective(args)?;
    let show = engine
        .show(None, &spec_name, Some(&now))
        .map_err(map_engine_error)?;
    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)?;
    if let Some(repository) = optional_nonempty_string(args, "repository")? {
        return engine
            .source(Some(repository), None, None)
            .map_err(map_engine_error);
    }
    let spec_set_id = required_string(args, "spec")
        .map_err(|_| ToolError::invalid_arguments("Missing 'spec' or 'repository' field"))?;
    let spec_name = parse_spec_set_id(spec_set_id).map_err(map_engine_error)?;
    let now = resolve_effective(args)?;
    engine
        .source(None, Some(&spec_name), Some(&now))
        .map_err(map_engine_error)
}

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();
    let mut text = String::from(
        "Parsed and planned. Syntax is valid; this does not verify the policy is correct.",
    );
    const MAX_RECOMMENDATIONS: usize = 20;
    if !recommendations.is_empty() {
        text.push_str("\n\nRecommendations:");
        for (i, rec) in recommendations.iter().take(MAX_RECOMMENDATIONS).enumerate() {
            text.push_str(&format!("\n{}. {}", i + 1, rec));
        }
        let omitted = recommendations.len().saturating_sub(MAX_RECOMMENDATIONS);
        if omitted > 0 {
            text.push_str(&format!("\n… and {omitted} more"));
        }
    }
    Ok(text)
}

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 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 optional_rule_names(args: &Value) -> Result<Vec<String>, ToolError> {
    match args.get("rule") {
        None | Some(Value::Null) => Ok(Vec::new()),
        Some(Value::String(rule)) => {
            let trimmed = rule.trim();
            if trimmed.is_empty() {
                Ok(Vec::new())
            } else {
                Ok(vec![trimmed.to_string()])
            }
        }
        Some(_) => Err(ToolError::invalid_arguments("'rule' must be a string")),
    }
}

fn parse_data(args: &Value) -> Result<HashMap<String, String>, ToolError> {
    match args.get("data") {
        None | Some(Value::Null) => Ok(HashMap::new()),
        Some(Value::Array(entries)) => {
            let mut data = HashMap::new();
            for (i, entry) in entries.iter().enumerate() {
                let Some(raw) = entry.as_str() else {
                    return Err(ToolError::invalid_arguments(format!(
                        "data[{i}] must be a string 'name=value'"
                    )));
                };
                let Some((name, value)) = raw.split_once('=') else {
                    return Err(ToolError::invalid_arguments(format!(
                        "data[{i}] must be 'name=value', got '{raw}'"
                    )));
                };
                data.insert(name.to_string(), value.to_string());
            }
            Ok(data)
        }
        Some(_) => Err(ToolError::invalid_arguments(
            "'data' must be an array of 'name=value' strings",
        )),
    }
}

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

fn append_unit_map(output: &mut String, map: &std::collections::BTreeMap<String, String>) {
    let parts: Vec<String> = map
        .iter()
        .map(|(unit, magnitude)| format!("{unit} {magnitude}"))
        .collect();
    output.push_str(" (");
    output.push_str(&parts.join(", "));
    output.push(')');
}