zen-engine 2.0.1

Business rules engine
Documentation
use serde_json::json;
use zen_engine::policy::{Cursor, CursorTarget, NlExpression, PolicyWorkspace, ScopeRequest};
use zen_expression::nl::{EditHint, NlTokenKind};

fn tier_dictionary() -> serde_json::Value {
    json!({
        "id": "dict1",
        "type": "dictionary",
        "props": { "data": {
            "name": "customerTier",
            "entries": [
                { "id": "e1", "value": "VIP", "label": "Very important" },
                { "id": "e2", "value": "STD", "label": "Standard" }
            ]
        }}
    })
}

fn table_with_output(column_type: &str, cells: &[&str]) -> serde_json::Value {
    let rules: Vec<serde_json::Value> = cells
        .iter()
        .enumerate()
        .map(|(i, cell)| json!({ "_id": format!("row{i}"), "in1": if i == 0 { "" } else { "> 10" }, "out1": cell }))
        .collect();
    json!({
        "id": "dt1",
        "type": "decisionTable",
        "props": { "data": {
            "hitPolicy": "first",
            "inputs": [ { "id": "in1", "name": "Age", "field": "customer.age" } ],
            "outputs": [ { "id": "out1", "name": "Tier", "field": "customer.tier", "type": column_type } ],
            "rules": rules
        }}
    })
}

fn workspace_with(blocks: Vec<serde_json::Value>) -> PolicyWorkspace {
    let mut ws = PolicyWorkspace::new();
    ws.set_policy(
        "main",
        serde_json::from_value(json!({ "blocks": blocks })).unwrap(),
    );
    ws
}

fn cell_diagnostics(ws: &PolicyWorkspace) -> Vec<String> {
    ws.diagnostics("main")
        .iter()
        .map(|d| format!("{d:?}"))
        .collect()
}

#[test]
fn number_column_rejects_string_cells() {
    let ws = workspace_with(vec![table_with_output("number", &["42", "'high'"])]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        diagnostics
            .iter()
            .any(|d| d.contains("must be `number`") && d.contains("row1")),
        "got: {diagnostics:?}"
    );
    assert!(
        !diagnostics.iter().any(|d| d.contains("row0")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn typed_column_accepts_matching_cells() {
    let ws = workspace_with(vec![table_with_output(
        "number",
        &["42", "customer.age * 2"],
    )]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        !diagnostics.iter().any(|d| d.contains("TypeMismatch")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn dictionary_column_checks_membership_of_literals() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier", &["'VIP'", "'GOLD'"]),
    ]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        diagnostics
            .iter()
            .any(|d| d.contains("must be `customerTier`") && d.contains("row1")),
        "got: {diagnostics:?}"
    );
    assert!(
        !diagnostics.iter().any(|d| d.contains("row0")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn dictionary_array_column_accepts_and_checks_lists() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier[]", &["['VIP', 'STD']", "['VIP', 'GOLD']"]),
    ]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        diagnostics
            .iter()
            .any(|d| d.contains("must be `customerTier[]`") && d.contains("row1")),
        "got: {diagnostics:?}"
    );
    assert!(
        !diagnostics.iter().any(|d| d.contains("row0")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn unknown_dictionary_type_is_diagnosed_on_head() {
    let ws = workspace_with(vec![table_with_output("goldTier", &["'VIP'"])]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        diagnostics
            .iter()
            .any(|d| d.contains("unknown output type 'goldTier'")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn malformed_type_annotation_is_diagnosed() {
    let ws = workspace_with(vec![table_with_output("customer tier", &["'VIP'"])]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        diagnostics
            .iter()
            .any(|d| d.contains("invalid output type 'customer tier'")),
        "got: {diagnostics:?}"
    );
}

#[test]
fn declared_type_narrows_output_schema() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier", &["'VIP'", "'STD'"]),
    ]);
    let outputs = ws.outputs(&ScopeRequest {
        policy_path: "main".into(),
        goals: Vec::new(),
    });
    let customer = outputs
        .iter()
        .find(|o| o.path.as_ref() == "customer")
        .unwrap_or_else(|| panic!("output present, got: {outputs:?}"));
    let printed = format!("{:?}", customer.resolved_type);
    assert!(
        printed.contains("Enum(Some(\"customerTier\"), [\"VIP\", \"STD\"])"),
        "expected narrowed enum type, got: {printed}"
    );
}

fn output_cell<'a>(results: &'a [NlExpression], row: &str) -> &'a NlExpression {
    results
        .iter()
        .find(|e| {
            matches!(
                &e.target,
                CursorTarget::DecisionTableCell { row: r, col }
                    if r.as_ref() == row && col.as_ref() == "out1"
            )
        })
        .expect("output cell projected")
}

#[test]
fn nl_output_cell_gets_enum_select_with_labels() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier", &["'VIP'"]),
    ]);
    let results = ws.nl("main");
    let cell = output_cell(&results, "row0");

    let token = &cell.result.tokens[0];
    assert!(matches!(token.token, NlTokenKind::Str { .. }));
    let EditHint::Select { options } = token.hint.clone().expect("select hint") else {
        panic!("expected select hint, got {:?}", token.hint);
    };
    let options = &cell.result.enums[options as usize];
    assert_eq!(options[0].label, "Very important");
    assert_eq!(options[0].source.as_deref(), Some("\"VIP\""));

    let subject_options = cell.result.subject_options.as_ref().expect("options");
    assert_eq!(subject_options.len(), 2);
}

#[test]
fn nl_array_output_cell_gets_multiselect() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier[]", &["['VIP', 'STD']"]),
    ]);
    let results = ws.nl("main");
    let cell = output_cell(&results, "row0");

    let token = &cell.result.tokens[0];
    assert!(matches!(token.token, NlTokenKind::EnumList { .. }));
    assert!(matches!(token.hint, Some(EditHint::MultiSelect { .. })));
}

#[test]
fn nl_tokenize_live_output_cell_uses_declared_type() {
    let ws = workspace_with(vec![
        tier_dictionary(),
        table_with_output("customerTier", &["'VIP'"]),
    ]);
    let result = ws
        .nl_tokenize(
            &Cursor {
                policy_path: "main".into(),
                block_id: "dt1".into(),
                pos: 0,
                target: CursorTarget::DecisionTableCell {
                    row: "row0".into(),
                    col: "out1".into(),
                },
            },
            "'STD'",
        )
        .expect("tokenized");

    let token = &result.tokens[0];
    assert!(matches!(token.hint, Some(EditHint::Select { .. })));
    let subject_options = result.subject_options.as_ref().expect("options");
    assert_eq!(subject_options[1].label, "Standard");
}

#[test]
fn untyped_columns_keep_inferred_behavior() {
    let ws = workspace_with(vec![table_with_output("", &["'a'", "'b'"])]);
    let diagnostics = cell_diagnostics(&ws);
    assert!(
        !diagnostics.iter().any(|d| d.contains("TypeMismatch")),
        "got: {diagnostics:?}"
    );

    let results = ws.nl("main");
    let cell = output_cell(&results, "row0");
    assert!(cell.result.tokens[0].hint.is_none());
    assert!(cell.result.subject_options.is_none());
}

fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value {
    json!({ "id": id, "type": "expression", "props": { "data": { "key": key, "value": value } } })
}

#[test]
fn any_fallback_does_not_cascade_past_the_root_error() {
    let ws = workspace_with(vec![
        expression_block("e1", "frac", "map(123 as p, p * 2)"),
        expression_block("e2", "l0", "map(frac as p, p)"),
        expression_block("e3", "l1", "map(l0 as p, p)"),
    ]);
    let diagnostics = cell_diagnostics(&ws);

    let cascade: Vec<_> = diagnostics
        .iter()
        .filter(|d| d.contains("which is `any`"))
        .collect();
    assert!(cascade.is_empty(), "{cascade:?}");

    let root: Vec<_> = diagnostics
        .iter()
        .filter(|d| d.contains("iterable") && d.contains("Error"))
        .collect();
    assert_eq!(root.len(), 1, "{diagnostics:?}");
}

#[test]
fn direct_any_write_is_reported_once_and_poisons_downstream() {
    let ws = workspace_with(vec![
        expression_block("e1", "bag", "[]"),
        expression_block("e2", "copy", "bag"),
        expression_block("e3", "twice", "map(copy as p, p)"),
    ]);
    let diagnostics = cell_diagnostics(&ws);

    let any_errors: Vec<_> = diagnostics
        .iter()
        .filter(|d| d.contains("which is `any`"))
        .collect();
    assert_eq!(any_errors.len(), 1, "{diagnostics:?}");
    assert!(any_errors[0].contains("'bag'"), "{any_errors:?}");
}