use indexmap::IndexMap;
use lemma::{DateTimeValue, Engine, EvaluateDataValue, ResourceLimits, SourceType};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn api_schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas/api.v1.json")
}
fn load_api_schema() -> serde_json::Value {
let path = api_schema_path();
assert!(
path.is_file(),
"engine/schemas/api.v1.json must exist (emit via `cargo run -p xtask -- schema`)"
);
let text = std::fs::read_to_string(&path).expect("read api.v1.json");
serde_json::from_str(&text).expect("api.v1.json must be JSON")
}
fn sample_documents() -> Vec<(&'static str, serde_json::Value)> {
let mut engine = Engine::new();
engine
.load([(
SourceType::Path(Arc::new(PathBuf::from("sample.lemma"))),
r#"
spec sample 2024-01-01
meta title: "t"
data amount: number -> suggest 1
data money: measure
-> unit eur: 1
-> suggest 10 eur
rule ok: amount
"#
.to_string(),
)])
.expect("load");
let now = DateTimeValue::now();
let show = engine.show(None, "sample", Some(&now)).expect("show");
let response = engine
.evaluate(
None,
"sample",
Some(&now),
IndexMap::from([("amount".into(), "2".into())]),
None,
true,
)
.expect("evaluate");
let specs = engine.specs();
let limits = ResourceLimits {
max_source_size_bytes: 50,
..ResourceLimits::default()
};
let err = Engine::with_limits(limits)
.load([(SourceType::Volatile, "spec t\ndata x: 1\n".repeat(20))])
.expect_err("limit");
let errors: Vec<_> = err
.errors
.iter()
.map(lemma::__test_support::current_binding_error_json)
.collect();
let install = lemma::RepositoryInstallResult {
source: "repo @iso/countries\n\nspec alpha2\ndata code: text\n".to_string(),
id: "@iso/countries".to_string(),
};
vec![
("show", lemma::api::show_document(&engine, &show)),
(
"response",
serde_json::to_value(lemma::api::Response::from(&response)).expect("response"),
),
("specs", serde_json::to_value(&specs).expect("specs")),
("errors", serde_json::Value::Array(errors)),
("install", serde_json::to_value(&install).expect("install")),
]
}
#[test]
fn api_schema_file_exists_and_is_object() {
let schema = load_api_schema();
assert!(schema.is_object(), "api.v1.json root must be an object");
assert_eq!(
schema["$id"],
"https://github.com/lemma/lemma/blob/main/engine/schemas/api.v1.json"
);
}
#[test]
fn real_show_run_list_and_error_documents_validate_against_schema() {
for (name, document) in sample_documents() {
assert!(
document.is_object() || document.is_array(),
"{name} document must be object or array"
);
validate_against_api_schema(&document)
.unwrap_or_else(|e| panic!("{name} must validate against api.v1.json: {e}"));
}
}
fn assert_is_2020_12_schema(name: &str, document: &serde_json::Value) {
jsonschema::draft202012::meta::validate(document)
.unwrap_or_else(|e| panic!("{name} must be a Draft 2020-12 JSON Schema: {e}"));
jsonschema::draft202012::new(document)
.unwrap_or_else(|e| panic!("{name} must compile as a JSON Schema: {e}"));
}
#[test]
fn show_documents_are_2020_12_schemas() {
let show = sample_documents()
.into_iter()
.find(|(name, _)| *name == "show")
.expect("sample show")
.1;
assert_is_2020_12_schema("sample show", &show);
let engine = list_engine();
for (name, document) in list_documents(&engine) {
if name.ends_with("show") {
assert_is_2020_12_schema(&name, &document);
}
}
}
fn validate_against_api_schema(document: &serde_json::Value) -> Result<(), String> {
let schema = load_api_schema();
let validator = jsonschema::validator_for(&schema).map_err(|e| e.to_string())?;
validator.validate(document).map_err(|e| e.to_string())
}
#[test]
fn every_decimal_string_field_has_explicit_format_marker() {
let schema = load_api_schema();
let text = serde_json::to_string(&schema).expect("schema text");
assert!(
text.contains("\"format\"")
&& (text.contains("decimal") || text.contains("lemma-decimal")),
"decimal-string fields must carry an explicit format marker for Java BigDecimal / TS string mapping"
);
}
#[test]
fn checked_in_schema_matches_generated() {
let status = Command::new("cargo")
.args(["run", "-p", "xtask", "--quiet", "--", "schema", "--check"])
.current_dir(workspace_root())
.status()
.expect("spawn xtask schema --check");
assert!(
status.success(),
"checked-in api.v1.json must match `cargo run -p xtask -- schema` output"
);
}
const LIST_SOURCE: &str = r#"
spec menu_item
data drink: text
data price: measure
-> unit eur: 1.00
-> unit cent: 0.01
spec coffee_order
uses menu: menu_item list
-> key drink
-> with drink: "espresso", price: 2.50 eur
-> with drink: "latte", price: 350 cent
data ordered: menu.drink
-> with "latte"
data fares: menu.price list
-> with 89 eur, 120 eur
rule selected:
menu -> find ordered
rule selected_price:
selected.price
rule pricey:
menu -> where price > 3 eur
rule by_price:
menu -> sort price desc -> top 1
rule high_fares:
fares -> where >= 100 eur
rule mid_fares:
fares -> where in 80 eur...100 eur
rule fare_total:
fares -> sum
rule extended:
menu -> with drink: "mocha", price: 4 eur
rule any_cheap:
menu -> any price < 3 eur
spec part
data money: measure
-> unit eur: 1.00
uses components: part list
data own_cost: money
rule unit_cost:
own_cost + (components -> sum components.unit_cost)
unless components -> count is 0 then own_cost
rule first_child:
components -> first
"#;
fn list_engine() -> Engine {
let mut engine = Engine::new();
engine
.load([(
SourceType::Path(Arc::new(PathBuf::from("lists.lemma"))),
LIST_SOURCE.to_string(),
)])
.expect("load");
engine
}
fn list_documents(engine: &Engine) -> Vec<(String, serde_json::Value)> {
let now = DateTimeValue::now();
let mut documents = Vec::new();
let runs: [(&str, IndexMap<String, EvaluateDataValue>); 2] = [
("coffee_order", IndexMap::new()),
(
"part",
IndexMap::from([
("own_cost".to_string(), EvaluateDataValue::from("1 eur")),
(
"components".to_string(),
EvaluateDataValue::List(vec![EvaluateDataValue::Map(IndexMap::from([
("own_cost".to_string(), EvaluateDataValue::from("2 eur")),
(
"components".to_string(),
EvaluateDataValue::List(vec![EvaluateDataValue::Map(IndexMap::from(
[("own_cost".to_string(), EvaluateDataValue::from("3 eur"))],
))]),
),
]))]),
),
]),
),
];
for (spec, data) in runs {
let show = engine.show(None, spec, Some(&now)).expect("show");
documents.push((
format!("{spec} show"),
lemma::api::show_document(engine, &show),
));
let response = engine
.evaluate(None, spec, Some(&now), data, None, true)
.expect("evaluate");
documents.push((
format!("{spec} response"),
serde_json::to_value(lemma::api::Response::from(&response)).expect("response json"),
));
}
documents
}
fn errors_against_def(document: &serde_json::Value, def: &str) -> Vec<String> {
let schema = load_api_schema();
let rooted = serde_json::json!({
"$schema": schema["$schema"],
"$defs": schema["$defs"],
"$ref": format!("#/$defs/{def}"),
});
let validator = jsonschema::validator_for(&rooted).expect("api.v1.json compiles");
validator
.iter_errors(document)
.map(|error| format!("{}: {error}", error.instance_path()))
.collect()
}
#[test]
fn list_show_and_run_documents_validate_against_schema() {
let engine = list_engine();
for (name, document) in list_documents(&engine) {
let def = if name.ends_with("show") {
"Show"
} else {
"Response"
};
let errors = errors_against_def(&document, def);
assert!(
errors.is_empty(),
"{name} must validate against api.v1.json:\n{}",
errors.join("\n")
);
validate_against_api_schema(&document)
.unwrap_or_else(|e| panic!("{name} must validate against api.v1.json: {e}"));
}
}
#[test]
fn list_fixtures_match_engine_output_and_schema() {
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let source = std::fs::read_to_string(fixtures.join("coffee_order_lists.lemma"))
.expect("read coffee_order_lists.lemma");
let mut engine = Engine::new();
engine
.load([(
SourceType::Path(Arc::new(PathBuf::from("coffee_order_lists.lemma"))),
source,
)])
.expect("load");
let effective = <DateTimeValue as std::str::FromStr>::from_str("2026-01-01").expect("date");
let show = lemma::api::show_document(
&engine,
&engine
.show(None, "coffee_order", Some(&effective))
.expect("show"),
);
let response = serde_json::to_value(lemma::api::Response::from(
&engine
.evaluate(
None,
"coffee_order",
Some(&effective),
IndexMap::new(),
None,
false,
)
.expect("evaluate"),
))
.expect("response json");
for (file, def, actual) in [
("show_list.json", "Show", show),
("response_list.json", "Response", response),
] {
let text = std::fs::read_to_string(fixtures.join("api").join(file))
.unwrap_or_else(|e| panic!("read {file}: {e}"));
let fixture: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{file} JSON: {e}"));
assert_eq!(fixture, actual, "{file} must equal engine output");
let errors = errors_against_def(&fixture, def);
assert!(errors.is_empty(), "{file}:\n{}", errors.join("\n"));
}
}
#[test]
fn list_documents_carry_list_type_rows_and_structured_commands() {
let engine = list_engine();
let documents: HashMap<String, serde_json::Value> =
list_documents(&engine).into_iter().collect();
let show = &documents["coffee_order show"];
assert_eq!(
show["properties"]["menu"]["x-lemma-type"]["key"],
serde_json::json!({"fields": ["drink"]})
);
assert_eq!(
show["properties"]["menu"]["x-lemma-type"]["element"]["kind"],
"spec"
);
assert_eq!(
show["properties"]["fares"]["x-lemma-type"]["key"],
"numbered"
);
let high = &show["x-lemma-rules"]["high_fares"]["branches"][0]["result"];
assert_eq!(high["command"], "where");
assert_eq!(high["args"][0]["type"], "comparison");
assert_eq!(high["args"][0]["left"]["type"], "row_value");
let by_price = &show["x-lemma-rules"]["by_price"]["branches"][0]["result"];
assert_eq!(by_price["command"], "top");
assert_eq!(by_price["list"]["command"], "sort");
assert_eq!(by_price["list"]["direction"], "desc");
let response = &documents["coffee_order response"];
let selected = &response["results"]["selected"]["row"];
assert_eq!(selected["instance"], "menu.2");
let index = selected["index"].as_u64().expect("row index") as usize;
assert_eq!(
response["rows"][index]["data"]["drink"]["result"]["text"],
"latte"
);
assert_eq!(
response["results"]["high_fares"]["list"][0]["result"],
"120 eur"
);
}