lemma-engine 0.10.2

A pure, declarative language for business rules.
Documentation
//! Worked examples from engine/documentation/design/lists.md, verbatim.

use crate::lists::{assert_contains, data, evaluate, lit, load_ok, load_planning_err, value};

const COFFEE_ORDER: &str = r#"
spec menu_item

data drink: text
  -> options "espresso", "latte"

data price: measure
  -> unit eur: 1.00
  -> decimals 2


spec coffee_order

uses menu: menu_item list
  -> key drink
  -> with drink: "espresso", price: 2.50 eur
  -> with drink: "latte",    price: 3.50 eur

data ordered:  menu.drink
data quantity: number


rule selected_drink:
  menu -> find ordered

rule total:
  selected_drink.price * quantity

rule cheapest:
  menu -> min price
"#;

const PARCEL: &str = r#"
spec rate

data weight: measure
  -> unit kilogram: 1.00

data money: measure
  -> unit eur: 1.00
  -> decimals 2

data band:  weight range
data price: money


spec parcel_shipping

uses standard_rates: rate list
  -> key band
  -> with band: 0 kilogram...2 kilogram,     price: 4.95 eur
  -> with band: 2 kilogram...10 kilogram,    price: 6.95 eur
  -> with band: 10 kilogram...23 kilogram,   price: 13.95 eur

uses express_rates: rate list
  -> key band
  -> with band: 0 kilogram...2 kilogram,     price: 9.95 eur
  -> with band: 2 kilogram...10 kilogram,    price: 12.95 eur
  -> with band: 10 kilogram...23 kilogram,   price: 19.95 eur

data parcel_weight: standard_rates.weight
data express:       boolean


rule rates:
  standard_rates
  unless express then express_rates

rule shipping_cost:
  (rates -> find parcel_weight).price
"#;

const FREIGHT: &str = r#"
spec freight_rate

data weight: measure
  -> unit kilogram: 1.00

data volume: measure
  -> unit liter: 1.00

data money: measure
  -> unit eur: 1.00
  -> decimals 2

data band:        weight range
data volume_band: volume range
data price:       money


spec freight

uses rates: freight_rate list
  -> key band, volume_band
  -> with band: 0 kilogram...5 kilogram,    volume_band: 0 liter...2 liter,    price: 6.95 eur
  -> with band: 0 kilogram...5 kilogram,    volume_band: 2 liter...20 liter,   price: 9.95 eur
  -> with band: 5 kilogram...20 kilogram,   volume_band: 0 liter...20 liter,   price: 14.95 eur

data parcel_weight: rates.weight
data parcel_volume: rates.volume


rule shipping_cost:
  (rates -> find parcel_weight, parcel_volume).price
"#;

const PAYROLL: &str = r#"
spec employee

data name: text
data salary: measure
  -> unit eur: 1.00
  -> decimals 2


rule bonus:
  salary * 10%


spec payroll

uses staff: employee list
  -> with name: "Ada",   salary: 9000 eur
  -> with name: "Grace", salary: 9500 eur


rule total_bonus:
  staff -> sum bonus

rule top_bonus:
  (staff -> sort salary desc -> first).bonus
"#;

const INCOME_TAX: &str = r#"
spec tax_bracket

uses finance

data income: finance.money
data band:   finance.money range
data rate:   ratio


rule taxable:
  band as eur
  unless income < upper band  then income - lower band
  unless income <= lower band then 0 eur

rule tax:
  taxable * rate


spec income_tax

uses finance

data income: finance.money

uses brackets: tax_bracket list
  -> with band: 0 eur...38000 eur,       rate: 36%,  income: income
  -> with band: 38000 eur...76000 eur,   rate: 37%,  income: income


rule tax:
  brackets -> sum tax
"#;

const FINANCE: &str = r#"
spec finance
data money: measure
  -> unit eur: 1.00
"#;

#[test]
fn coffee_order() {
    let engine = load_ok(COFFEE_ORDER);
    let response = evaluate(
        &engine,
        "coffee_order",
        data(&[("ordered", lit("espresso")), ("quantity", lit("2"))]),
    );
    assert_eq!(value(&response, "total"), "5.00 eur");
    assert_eq!(value(&response, "cheapest"), "2.50 eur");
}

#[test]
fn parcel_shipping() {
    let engine = load_ok(PARCEL);
    let standard = evaluate(
        &engine,
        "parcel_shipping",
        data(&[
            ("parcel_weight", lit("1 kilogram")),
            ("express", crate::lists::boolean(false)),
        ]),
    );
    assert_eq!(value(&standard, "shipping_cost"), "4.95 eur");
    let express = evaluate(
        &engine,
        "parcel_shipping",
        data(&[
            ("parcel_weight", lit("1 kilogram")),
            ("express", crate::lists::boolean(true)),
        ]),
    );
    assert_eq!(value(&express, "shipping_cost"), "9.95 eur");
}

#[test]
fn dropping_key_from_express_rates_is_planning_error() {
    let err = load_planning_err(
        r#"
spec rate
data band: text
data price: number
spec parcel_shipping
uses standard_rates: rate list
  -> key band
  -> with band: "light", price: 1
uses express_rates: rate list
  -> with band: "light", price: 2
data express: boolean
rule rates:
  standard_rates
  unless express then express_rates
"#,
    );
    assert_contains(&err, "band");
}

#[test]
fn freight_find_4kg_1l_is_6_95() {
    let engine = load_ok(FREIGHT);
    let response = evaluate(
        &engine,
        "freight",
        data(&[
            ("parcel_weight", lit("4 kilogram")),
            ("parcel_volume", lit("1 liter")),
        ]),
    );
    assert_eq!(value(&response, "shipping_cost"), "6.95 eur");
}

#[test]
fn payroll() {
    let engine = load_ok(PAYROLL);
    let response = evaluate(&engine, "payroll", data(&[]));
    assert_eq!(value(&response, "total_bonus"), "1850.00 eur");
    assert_eq!(value(&response, "top_bonus"), "950.00 eur");
}

#[test]
fn income_tax() {
    let engine = crate::lists::load_files(&[("finance.lemma", FINANCE), ("tax.lemma", INCOME_TAX)]);
    let response = evaluate(&engine, "income_tax", data(&[("income", lit("40000 eur"))]));
    assert_eq!(value(&response, "tax"), "14420 eur");
}