1use lemma::{DateTimeValue, Engine, LemmaType, ListedSpec, TypeSpecification};
19use serde_json::{json, Map, Value};
20
21pub const NOW_SLUG: &str = "now";
23
24#[derive(Debug, Clone, serde::Serialize)]
29pub struct ApiSource {
30 pub title: String,
31 pub slug: String,
32 pub url: String,
33}
34
35pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
45 let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
46 std::collections::BTreeSet::new();
47
48 for repo in engine.list() {
49 for ls in &repo.specs {
50 if let Some(af) = &ls.effective_from {
51 all_boundaries.insert(af.clone());
52 }
53 }
54 }
55
56 if all_boundaries.is_empty() {
57 return vec![ApiSource {
58 title: "Now".to_string(),
59 slug: NOW_SLUG.to_string(),
60 url: "/openapi.json".to_string(),
61 }];
62 }
63
64 let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
65
66 sources.push(ApiSource {
67 title: "Now".to_string(),
68 slug: NOW_SLUG.to_string(),
69 url: "/openapi.json".to_string(),
70 });
71
72 for boundary in all_boundaries.iter().rev() {
73 let label = boundary.to_string();
74 sources.push(ApiSource {
75 title: format!("Effective {}", label),
76 slug: label.clone(),
77 url: format!("/openapi.json?effective={}", label),
78 });
79 }
80
81 sources
82}
83
84pub fn generate_openapi(engine: &Engine, explanations_enabled: bool) -> Value {
89 generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
90}
91
92pub fn generate_openapi_effective(
109 engine: &Engine,
110 explanations_enabled: bool,
111 effective: &DateTimeValue,
112) -> Value {
113 let mut paths = Map::new();
114 let mut components_schemas = Map::new();
115
116 components_schemas.insert(
117 "LemmaRuleResult".to_string(),
118 build_rule_result_schema(explanations_enabled),
119 );
120
121 let repositories = engine.list();
122 let workspace = repositories
123 .iter()
124 .find(|r| r.repository.is_none())
125 .map(|r| r.specs.as_slice())
126 .expect("BUG: workspace repository must exist in list()");
127
128 let is_active = |ls: &ListedSpec| -> bool {
129 let after_start = match &ls.effective_from {
130 None => true,
131 Some(from) => effective >= from,
132 };
133 let before_end = match &ls.effective_to {
134 None => true,
135 Some(to) => effective < to,
136 };
137 after_start && before_end
138 };
139
140 let active_specs: Vec<&ListedSpec> = workspace.iter().filter(|ls| is_active(ls)).collect();
141
142 let unique_spec_names: std::collections::BTreeSet<&str> =
143 active_specs.iter().map(|ls| ls.name.as_str()).collect();
144 let unique_spec_names: Vec<String> = unique_spec_names.into_iter().map(String::from).collect();
145
146 paths.insert("/".to_string(), index_path_item(engine));
147
148 for ls in &active_specs {
149 let spec_name = &ls.name;
150 if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
151 let artifacts = build_spec_openapi_artifacts(
152 spec_name,
153 &show,
154 (ls.effective_from.as_ref(), ls.effective_to.as_ref()),
155 explanations_enabled,
156 );
157 paths.insert(format!("/{spec_name}"), artifacts.path_item);
158 for (name, schema_value) in artifacts.component_schemas {
159 components_schemas.insert(name, schema_value);
160 }
161 }
162 }
163
164 let mut tags = vec![json!({
165 "name": "Specs",
166 "description": "Simple API to retrieve the list of Lemma specs"
167 })];
168 for spec_name in &unique_spec_names {
169 let safe_tag = spec_name.replace('.', "_");
170 tags.push(json!({
171 "name": safe_tag,
172 "x-displayName": spec_name,
173 "description": format!("GET show or POST evaluate for spec '{}'. Use ?rules= on POST to limit evaluated rules.", spec_name)
174 }));
175 }
176
177 let spec_tags: Vec<Value> = unique_spec_names
178 .iter()
179 .map(|n| Value::String(n.replace('.', "_")))
180 .collect();
181
182 let tag_groups = vec![
183 json!({ "name": "Overview", "tags": ["Specs"] }),
184 json!({ "name": "Specs", "tags": spec_tags }),
185 ];
186
187 let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
188
189 json!({
190 "openapi": "3.1.0",
191 "info": {
192 "title": "Lemma API",
193 "description": "Lemma is a declarative language for expressing business logic — pricing rules, tax calculations, eligibility criteria, contracts, and policies. Learn more at [LemmaBase.com](https://lemmabase.com).\n\n**Temporal resolution.** `GET /{spec}` describes **version boundaries**: each entry in `versions` carries the half-open `[effective_from, effective_to)` validity range of a temporal version. `POST /{spec}` treats the request's effective instant (from the `Accept-Datetime` header, or the evaluation envelope's `effective` field) as the **evaluation instant** used to pick the active version and compute the result.",
194 "version": version_label
195 },
196 "tags": tags,
197 "x-tagGroups": tag_groups,
198 "paths": Value::Object(paths),
199 "components": {
200 "schemas": Value::Object(components_schemas)
201 }
202 })
203}
204
205struct InputData {
207 name: String,
209 lemma_type: LemmaType,
211 prefilled: Option<lemma::LiteralValue>,
213 suggestion: Option<lemma::LiteralValue>,
215}
216
217fn collect_input_data_from_show(show: &lemma::Show) -> Vec<InputData> {
222 show.data
223 .iter()
224 .filter(|(name, _)| !name.contains('.'))
225 .map(|(name, entry)| InputData {
226 name: name.clone(),
227 lemma_type: entry.lemma_type.clone(),
228 prefilled: entry.prefilled.clone(),
229 suggestion: entry.suggestion.clone(),
230 })
231 .collect()
232}
233
234fn index_path_item(engine: &Engine) -> Value {
239 let list = engine.list();
240 let example = serde_json::to_value(&list).expect("BUG: Engine::list must serialize");
241
242 json!({
243 "get": {
244 "operationId": "list",
245 "summary": "List loaded repositories and specs",
246 "tags": ["Specs"],
247 "responses": {
248 "200": {
249 "description": "Same JSON as Engine.list() (metadata only: name, effective_from, effective_to per spec row)",
250 "content": {
251 "application/json": {
252 "schema": {
253 "type": "array",
254 "items": {
255 "type": "object",
256 "properties": {
257 "repository": { "type": ["string", "null"] },
258 "specs": {
259 "type": "array",
260 "items": {
261 "type": "object",
262 "properties": {
263 "name": { "type": "string" },
264 "effective_from": { "type": ["object", "null"] },
265 "effective_to": { "type": ["object", "null"] }
266 },
267 "required": ["name"]
268 }
269 }
270 },
271 "required": ["specs"]
272 }
273 },
274 "example": example
275 }
276 }
277 }
278 }
279 }
280 })
281}
282
283fn error_response_schema() -> Value {
288 json!({
289 "description": "Evaluation error",
290 "content": {
291 "application/json": {
292 "schema": {
293 "type": "object",
294 "properties": {
295 "error": { "type": "string" }
296 },
297 "required": ["error"]
298 }
299 }
300 }
301 })
302}
303
304fn not_found_response_schema() -> Value {
305 json!({
306 "description": "Spec not found",
307 "content": {
308 "application/json": {
309 "schema": {
310 "type": "object",
311 "properties": {
312 "error": { "type": "string" }
313 },
314 "required": ["error"]
315 }
316 }
317 }
318 })
319}
320
321fn memento_spec_response_headers() -> Value {
322 json!({
323 "Memento-Datetime": {
324 "description": "RFC 7089: datetime of the resolved spec version (absent for unversioned specs)",
325 "schema": { "type": "string" }
326 },
327 "Vary": {
328 "description": "Indicates negotiation on Accept-Datetime",
329 "schema": { "type": "string", "example": "Accept-Datetime" }
330 }
331 })
332}
333
334fn build_get_show_response() -> Value {
336 json!({
337 "type": "object",
338 "required": ["spec_set_id", "spec", "data", "rules", "meta", "start_line"],
339 "properties": {
340 "spec_set_id": {
341 "type": "string",
342 "description": "Spec set identifier (path segments, e.g. org/product/pricing)"
343 },
344 "spec": {
345 "type": "string",
346 "description": "Resolved spec name"
347 },
348 "commentary": {
349 "type": ["string", "null"],
350 "description": "Optional commentary from the spec source"
351 },
352 "effective_from": {
353 "description": "Effective-from of the resolved temporal version, if any"
354 },
355 "effective_to": {
356 "description": "Exclusive effective-to of the resolved temporal version, if any"
357 },
358 "start_line": {
359 "type": "integer",
360 "description": "1-based line number of the spec declaration in source"
361 },
362 "source_type": {
363 "description": "How this spec was loaded (path, inline, registry, etc.)"
364 },
365 "data": {
366 "type": "object",
367 "description": "Data used by the spec's rules, mapped to type metadata, optional prefilled, and optional suggestion",
368 "additionalProperties": true
369 },
370 "rules": {
371 "type": "object",
372 "description": "Local rule names mapped to result types (full planning-time interface)",
373 "additionalProperties": true
374 },
375 "meta": {
376 "type": "object",
377 "description": "Spec metadata key/value pairs",
378 "additionalProperties": true
379 },
380 "versions": {
381 "type": "array",
382 "description": "All loaded temporal versions for this spec name, each with a half-open [effective_from, effective_to) range",
383 "items": {
384 "type": "object",
385 "required": ["effective_from", "effective_to"],
386 "properties": {
387 "effective_from": {
388 "type": ["string", "null"],
389 "description": "Start of validity for this version; null when unbounded (no earlier version exists)"
390 },
391 "effective_to": {
392 "type": ["string", "null"],
393 "description": "Exclusive end of validity (same instant as the next version's effective_from); null when this is the latest version and has no successor"
394 }
395 }
396 }
397 }
398 }
399 })
400}
401
402fn build_rule_result_schema(explanations_enabled: bool) -> Value {
404 let mut explanation = json!({
405 "type": "object",
406 "description": "Structured explanation tree when explanations are enabled"
407 });
408 if explanations_enabled {
409 explanation["description"] = Value::String(
410 "Structured explanation tree (present when x-explanations is sent and server uses --explanations)"
411 .to_string(),
412 );
413 }
414
415 json!({
416 "type": "object",
417 "required": ["vetoed", "rule_type"],
418 "properties": {
419 "vetoed": { "type": "boolean" },
420 "display": {
421 "type": "string",
422 "description": "Human-readable formatted value when not vetoed"
423 },
424 "veto_reason": { "type": "string" },
425 "rule_type": {
426 "type": "string",
427 "description": "Result type name (e.g. number, boolean, money)"
428 },
429 "measure": {
430 "type": "object",
431 "additionalProperties": { "type": "string" },
432 "description": "Named measure rule: unit name to magnitude string"
433 },
434 "ratio": {
435 "type": "object",
436 "additionalProperties": { "type": "string" },
437 "description": "Named ratio rule: unit name to magnitude string"
438 },
439 "number": { "type": "string" },
440 "boolean": { "type": "boolean" },
441 "text": { "type": "string" },
442 "date": { "type": "object" },
443 "time": { "type": "object" },
444 "calendar": {
445 "type": "object",
446 "properties": {
447 "value": { "type": "string" },
448 "unit": { "type": "string" }
449 }
450 },
451 "range": { "type": "object" },
452 "missing_data": {
453 "type": "array",
454 "items": { "type": "string" },
455 "description": "Input keys still unbound for this rule after overlay-aware pruning (same keys as Show.data)"
456 },
457 "explanation": explanation
458 }
459 })
460}
461
462fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
464 let mut result_props = Map::new();
465 for rule_name in rule_names {
466 if show.rules.contains_key(rule_name) {
467 result_props.insert(
468 rule_name.clone(),
469 json!({
470 "$ref": "#/components/schemas/LemmaRuleResult"
471 }),
472 );
473 }
474 }
475
476 json!({
477 "type": "object",
478 "required": ["spec", "effective", "results"],
479 "properties": {
480 "spec": {
481 "type": "string",
482 "description": "Spec set id that was evaluated"
483 },
484 "effective": {
485 "type": "string",
486 "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
487 },
488 "results": {
489 "type": "object",
490 "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
491 "properties": Value::Object(result_props)
492 }
493 }
494 })
495}
496
497struct SpecOpenApiArtifacts {
502 path_item: Value,
503 component_schemas: Map<String, Value>,
504}
505
506fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
507 let safe_name = spec_name.replace('.', "_");
508 (
509 format!("{safe_name}_get_show"),
510 format!("{safe_name}_evaluate_response"),
511 format!("{safe_name}_request"),
512 format!("{safe_name}_form_request"),
513 )
514}
515
516fn build_spec_openapi_artifacts(
526 spec_name: &str,
527 show: &lemma::Show,
528 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
529 explanations_enabled: bool,
530) -> SpecOpenApiArtifacts {
531 let data = collect_input_data_from_show(show);
532 let rule_names: Vec<String> = show.rules.keys().cloned().collect();
533 let (
534 get_show_component_name,
535 evaluate_response_schema_name,
536 post_body_schema_name,
537 post_form_body_schema_name,
538 ) = spec_component_show_names(spec_name);
539
540 let mut component_schemas = Map::new();
541 component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
542 component_schemas.insert(
543 evaluate_response_schema_name.clone(),
544 build_evaluate_response_schema(show, &rule_names),
545 );
546 component_schemas.insert(
547 post_body_schema_name.clone(),
548 build_post_request_schema(&data),
549 );
550 component_schemas.insert(
551 post_form_body_schema_name.clone(),
552 build_post_form_request_schema(&data),
553 );
554
555 let path_item = build_spec_path_item_with_show_refs(
556 spec_name,
557 (
558 &get_show_component_name,
559 &evaluate_response_schema_name,
560 &post_body_schema_name,
561 &post_form_body_schema_name,
562 ),
563 &rule_names,
564 explanations_enabled,
565 effective_range,
566 );
567
568 SpecOpenApiArtifacts {
569 path_item,
570 component_schemas,
571 }
572}
573
574fn x_explanations_header_parameter() -> Value {
575 json!({
576 "name": "x-explanations",
577 "in": "header",
578 "required": false,
579 "description": "Set to request explanation objects in the response (server must be started with --explanations)",
580 "schema": { "type": "string", "default": "true" }
581 })
582}
583
584fn accept_datetime_header_parameter() -> Value {
585 json!({
586 "name": "Accept-Datetime",
587 "in": "header",
588 "required": false,
589 "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
590 "schema": { "type": "string", "format": "date-time" },
591 "example": "Sat, 01 Jan 2025 00:00:00 GMT"
592 })
593}
594
595fn build_spec_path_item_with_show_refs(
597 spec_name: &str,
598 component_names: (&str, &str, &str, &str),
599 rule_names: &[String],
600 explanations_enabled: bool,
601 effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
602) -> Value {
603 let (
604 get_show_component_name,
605 evaluate_response_schema_name,
606 post_body_schema_name,
607 post_form_body_schema_name,
608 ) = component_names;
609 let (effective_from, effective_to) = effective_range;
610
611 let get_show_ref = json!({
612 "$ref": format!("#/components/schemas/{}", get_show_component_name)
613 });
614 let evaluate_schema_ref = json!({
615 "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
616 });
617 let body_ref = json!({
618 "$ref": format!("#/components/schemas/{}", post_body_schema_name)
619 });
620 let form_body_ref = json!({
621 "$ref": format!("#/components/schemas/{}", post_form_body_schema_name)
622 });
623
624 let tag = spec_name.replace('.', "_");
625
626 let rules_example = if rule_names.is_empty() {
627 String::new()
628 } else {
629 rule_names.join(",")
630 };
631
632 let rules_param = json!({
633 "name": "rules",
634 "in": "query",
635 "required": false,
636 "description": "Comma-separated list of rule names to evaluate; omit for all.",
637 "schema": { "type": "string" },
638 "example": rules_example
639 });
640
641 let mut get_parameters: Vec<Value> = Vec::new();
642 get_parameters.push(accept_datetime_header_parameter());
643 if explanations_enabled {
644 get_parameters.push(x_explanations_header_parameter());
645 }
646
647 let get_summary = "Show of resolved version (spec, data, rules, meta, versions)".to_string();
648 let post_summary = "Evaluate".to_string();
649 let get_operation_id = format!("get_{}", spec_name);
650 let post_operation_id = format!("post_{}", spec_name);
651
652 let mut post_parameters: Vec<Value> = vec![rules_param];
653 post_parameters.push(accept_datetime_header_parameter());
654 if explanations_enabled {
655 post_parameters.push(x_explanations_header_parameter());
656 }
657
658 let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
659 match dt {
660 Some(d) => Value::String(d.to_string()),
661 None => Value::Null,
662 }
663 };
664
665 json!({
666 "x-effective-from": datetime_or_null(effective_from),
667 "x-effective-to": datetime_or_null(effective_to),
668 "get": {
669 "operationId": get_operation_id,
670 "summary": get_summary,
671 "tags": [tag],
672 "parameters": get_parameters,
673 "responses": {
674 "200": {
675 "description": "Show of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
676 "headers": memento_spec_response_headers(),
677 "content": {
678 "application/json": {
679 "schema": get_show_ref
680 }
681 }
682 },
683 "400": error_response_schema(),
684 "404": not_found_response_schema()
685 }
686 },
687 "post": {
688 "operationId": post_operation_id,
689 "summary": post_summary,
690 "tags": [tag],
691 "parameters": post_parameters,
692 "requestBody": {
693 "required": true,
694 "content": {
695 "application/x-www-form-urlencoded": {
697 "schema": form_body_ref
698 },
699 "application/json": {
700 "schema": body_ref
701 }
702 }
703 },
704 "responses": {
705 "200": {
706 "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
707 "headers": memento_spec_response_headers(),
708 "content": {
709 "application/json": {
710 "schema": evaluate_schema_ref
711 }
712 }
713 },
714 "400": error_response_schema(),
715 "404": not_found_response_schema()
716 }
717 }
718 })
719}
720
721fn type_help(lemma_type: &LemmaType) -> String {
727 match &lemma_type.specifications {
728 TypeSpecification::Boolean { help, .. } => help.clone(),
729 TypeSpecification::Measure { help, .. } => help.clone(),
730 TypeSpecification::MeasureRange { help, .. } => help.clone(),
731 TypeSpecification::Number { help, .. } => help.clone(),
732 TypeSpecification::NumberRange { help, .. } => help.clone(),
733 TypeSpecification::Ratio { help, .. } => help.clone(),
734 TypeSpecification::RatioRange { help, .. } => help.clone(),
735 TypeSpecification::Text { help, .. } => help.clone(),
736 TypeSpecification::Date { help, .. } => help.clone(),
737 TypeSpecification::DateRange { help, .. } => help.clone(),
738 TypeSpecification::TimeRange { help, .. } => help.clone(),
739 TypeSpecification::Time { help, .. } => help.clone(),
740 TypeSpecification::Veto { .. } => String::new(),
741 TypeSpecification::Undetermined => unreachable!(
742 "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
743 ),
744 }
745}
746
747fn build_post_request_schema(data: &[InputData]) -> Value {
752 let mut properties = Map::new();
753 let mut required = Vec::new();
754
755 for data in data {
756 let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
757 properties.insert(
758 data.name.clone(),
759 build_post_property_schema(&data.lemma_type, default_for_docs),
760 );
761 if data.prefilled.is_none() {
762 required.push(Value::String(data.name.clone()));
763 }
764 }
765
766 let mut schema = json!({
767 "type": "object",
768 "properties": Value::Object(properties)
769 });
770 if !required.is_empty() {
771 schema["required"] = Value::Array(required);
772 }
773 schema
774}
775
776fn build_post_property_schema(
777 lemma_type: &LemmaType,
778 data_value: Option<&lemma::LiteralValue>,
779) -> Value {
780 let mut schema = build_post_type_schema(lemma_type);
781
782 let help = type_help(lemma_type);
783 if !help.is_empty() {
784 schema["description"] = Value::String(help);
785 }
786
787 if let Some(v) = data_value {
788 schema["default"] = Value::String(v.display_value());
789 }
790
791 schema
792}
793
794fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
795 match &lemma_type.specifications {
796 TypeSpecification::Text { options, .. } => {
797 let mut schema = json!({ "type": "string" });
798 if !options.is_empty() {
799 schema["enum"] =
800 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
801 }
802 schema
803 }
804 TypeSpecification::Boolean { .. } => {
805 json!({ "type": "boolean" })
806 }
807 _ => json!({ "type": "string" }),
808 }
809}
810
811fn build_post_form_request_schema(data: &[InputData]) -> Value {
812 let mut properties = Map::new();
813 let mut required = Vec::new();
814
815 for data in data {
816 let default_for_docs = data.prefilled.as_ref().or(data.suggestion.as_ref());
817 properties.insert(
818 data.name.clone(),
819 build_post_form_property_schema(&data.lemma_type, default_for_docs),
820 );
821 if data.prefilled.is_none() {
822 required.push(Value::String(data.name.clone()));
823 }
824 }
825
826 let mut schema = json!({
827 "type": "object",
828 "properties": Value::Object(properties)
829 });
830 if !required.is_empty() {
831 schema["required"] = Value::Array(required);
832 }
833 schema
834}
835
836fn build_post_form_property_schema(
837 lemma_type: &LemmaType,
838 data_value: Option<&lemma::LiteralValue>,
839) -> Value {
840 let mut schema = build_post_form_type_schema(lemma_type);
841
842 let help = type_help(lemma_type);
843 if !help.is_empty() {
844 schema["description"] = Value::String(help);
845 }
846
847 if let Some(v) = data_value {
848 schema["default"] = Value::String(v.display_value());
849 }
850
851 schema
852}
853
854fn build_post_form_type_schema(lemma_type: &LemmaType) -> Value {
855 match &lemma_type.specifications {
856 TypeSpecification::Text { options, .. } => {
857 let mut schema = json!({ "type": "string" });
858 if !options.is_empty() {
859 schema["enum"] =
860 Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
861 }
862 schema
863 }
864 TypeSpecification::Boolean { .. } => {
865 json!({ "type": "string", "enum": ["true", "false"] })
866 }
867 _ => json!({ "type": "string" }),
868 }
869}
870
871#[cfg(test)]
876mod tests {
877 use super::*;
878 use lemma::{DateGranularity, DateTimeValue, SourceType};
879
880 fn create_engine_with_code(code: &str) -> Engine {
881 let mut engine = Engine::new();
882 engine
883 .load([(
884 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
885 code.to_string(),
886 )])
887 .expect("failed to parse lemma code");
888 engine
889 }
890
891 fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
892 let mut engine = Engine::new();
893 for (name, code) in files {
894 engine
895 .load([(
896 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
897 code.to_string(),
898 )])
899 .expect("failed to parse lemma code");
900 }
901 engine
902 }
903
904 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
905 DateTimeValue {
906 year,
907 month,
908 day,
909 hour: 0,
910 minute: 0,
911 second: 0,
912 microsecond: 0,
913 timezone: None,
914 granularity: DateGranularity::Full,
915 }
916 }
917
918 fn has_param(params: &Value, name: &str) -> bool {
919 params
920 .as_array()
921 .map(|a| a.iter().any(|p| p["name"] == name))
922 .unwrap_or(false)
923 }
924
925 #[test]
930 fn test_generate_openapi_x_tag_groups() {
931 let engine = create_engine_with_code(
932 "spec pricing
933 data quantity: 10
934 rule total: quantity * 2",
935 );
936 let spec = generate_openapi(&engine, false);
937
938 let groups = spec["x-tagGroups"]
939 .as_array()
940 .expect("x-tagGroups should be array");
941 assert_eq!(groups.len(), 2);
942 assert_eq!(groups[0]["name"], "Overview");
943 assert_eq!(groups[0]["tags"], json!(["Specs"]));
944 assert_eq!(groups[1]["name"], "Specs");
945 assert_eq!(groups[1]["tags"], json!(["pricing"]));
946 }
947
948 #[test]
949 fn test_spec_path_has_get_and_post() {
950 let engine = create_engine_with_code(
951 "spec pricing
952 data quantity: 10
953 rule total: quantity * 2",
954 );
955 let spec = generate_openapi(&engine, false);
956
957 assert!(
958 spec["paths"]["/pricing"].is_object(),
959 "single spec path /pricing"
960 );
961 assert!(spec["paths"]["/pricing"]["get"].is_object());
962 assert!(spec["paths"]["/pricing"]["post"].is_object());
963
964 assert_eq!(
965 spec["paths"]["/pricing"]["get"]["operationId"],
966 "get_pricing"
967 );
968 assert_eq!(
969 spec["paths"]["/pricing"]["post"]["operationId"],
970 "post_pricing"
971 );
972 assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
973
974 let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
975 .as_array()
976 .expect("parameters array");
977 let param_names: Vec<&str> = get_params
978 .iter()
979 .map(|p| p["name"].as_str().unwrap())
980 .collect();
981 assert!(
982 !param_names.contains(&"rules"),
983 "GET must not have rules query param (show is full interface)"
984 );
985 let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
986 .as_array()
987 .expect("post parameters array");
988 let post_param_names: Vec<&str> = post_params
989 .iter()
990 .map(|p| p["name"].as_str().unwrap())
991 .collect();
992 assert!(
993 post_param_names.contains(&"rules"),
994 "POST must have rules query param"
995 );
996 assert!(
997 param_names.contains(&"Accept-Datetime"),
998 "GET must have Accept-Datetime header"
999 );
1000
1001 let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1002 ["application/json"]["schema"]["$ref"]
1003 .as_str()
1004 .unwrap();
1005 let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1006 ["application/json"]["schema"]["$ref"]
1007 .as_str()
1008 .unwrap();
1009 assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1010 assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1011 assert_ne!(get_ref, post_ref);
1012
1013 let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1014 assert!(get_show["properties"]["spec_set_id"]["type"] == "string");
1015 assert!(get_show["properties"]["versions"].is_object());
1016 assert!(get_show["properties"]["start_line"]["type"] == "integer");
1017
1018 let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1019 assert!(h200["headers"]["Memento-Datetime"].is_object());
1020 assert!(h200["headers"]["Vary"].is_object());
1021 }
1022
1023 #[test]
1024 fn suggestion_only_field_is_required_with_json_schema_default() {
1025 let engine = create_engine_with_code(
1026 r#"
1027spec age_check
1028data age: number -> suggest 18
1029rule adult: age >= 18
1030"#,
1031 );
1032 let spec = generate_openapi(&engine, false);
1033 let body = &spec["components"]["schemas"]["age_check_request"];
1034 let required = body["required"]
1035 .as_array()
1036 .expect("required array")
1037 .iter()
1038 .map(|v| v.as_str().unwrap())
1039 .collect::<Vec<_>>();
1040 assert!(
1041 required.contains(&"age"),
1042 "suggest-only fields must stay required, got {required:?}"
1043 );
1044 assert_eq!(body["properties"]["age"]["default"], "18");
1045 }
1046
1047 #[test]
1052 fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1053 let engine = create_engine_with_code(
1054 "spec pricing
1055 data quantity: 10
1056 rule total: quantity * 2",
1057 );
1058 let spec = generate_openapi(&engine, false);
1059
1060 let paths = spec["paths"].as_object().expect("paths object");
1061 assert!(paths.contains_key("/"));
1062 assert_eq!(paths["/"]["get"]["operationId"], "list");
1063 assert!(!paths.contains_key("/openapi.json"));
1064 assert!(!paths.contains_key("/health"));
1065 assert!(!paths.contains_key("/docs"));
1066 assert!(!paths.contains_key("/schema/pricing"));
1067 assert!(!paths.contains_key("/schema/pricing/{rules}"));
1068 assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1069 }
1070
1071 #[test]
1072 fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
1073 let engine = create_engine_with_code(
1074 "spec pricing
1075 data quantity: 10
1076 rule total: quantity * 2",
1077 );
1078 let spec = generate_openapi(&engine, true);
1079
1080 let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1081 assert!(has_param(get_params, "x-explanations"));
1082
1083 let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
1084 assert!(rule_result["properties"]["explanation"].is_object());
1085 assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1086 assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1087
1088 let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1089 assert!(evaluate["required"]
1090 .as_array()
1091 .unwrap()
1092 .contains(&json!("spec")));
1093 assert!(evaluate["required"]
1094 .as_array()
1095 .unwrap()
1096 .contains(&json!("effective")));
1097 assert!(evaluate["required"]
1098 .as_array()
1099 .unwrap()
1100 .contains(&json!("results")));
1101 let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1102 .as_str()
1103 .unwrap();
1104 assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
1105 }
1106
1107 #[test]
1108 fn test_generate_openapi_multiple_specs() {
1109 let engine = create_engine_with_files(vec![
1110 (
1111 "pricing.lemma",
1112 "spec pricing
1113 data quantity: 10
1114 rule total: quantity * 2",
1115 ),
1116 (
1117 "shipping.lemma",
1118 "spec shipping
1119 data weight: 5
1120 rule cost: weight * 3",
1121 ),
1122 ]);
1123 let spec = generate_openapi(&engine, false);
1124
1125 assert!(spec["paths"]["/pricing"].is_object());
1126 assert!(spec["paths"]["/shipping"].is_object());
1127 }
1128
1129 #[test]
1130 fn test_nested_spec_path_schema_refs_are_valid() {
1131 let engine = create_engine_with_code(
1132 "spec bc
1133 data x: number
1134 rule result: x",
1135 );
1136 let spec = generate_openapi(&engine, false);
1137
1138 assert!(spec["paths"]["/bc"]["post"].is_object());
1139 let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1140 let content_keys: Vec<&str> = post_content
1141 .as_object()
1142 .expect("requestBody.content object")
1143 .keys()
1144 .map(|k| k.as_str())
1145 .collect();
1146 assert_eq!(
1147 content_keys.first().copied(),
1148 Some("application/x-www-form-urlencoded"),
1149 "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1150 );
1151 let body_ref = post_content["application/json"]["schema"]["$ref"]
1152 .as_str()
1153 .unwrap();
1154 let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1155 .as_str()
1156 .unwrap();
1157 assert_eq!(body_ref, "#/components/schemas/bc_request");
1158 assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1159 assert!(spec["components"]["schemas"]["bc_request"].is_object());
1160 assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1161 assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1162 assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1163 }
1164
1165 #[test]
1170 fn test_generate_openapi_effective_reflects_specific_time() {
1171 let engine = create_engine_with_code(
1172 "spec pricing
1173 data quantity: 10
1174 rule total: quantity * 2",
1175 );
1176 let effective = date(2025, 6, 15);
1177 let spec = generate_openapi_effective(&engine, false, &effective);
1178
1179 assert_eq!(spec["openapi"], "3.1.0");
1180 let version = spec["info"]["version"].as_str().unwrap();
1181 assert!(
1182 version.contains("2025-06-15"),
1183 "version string should contain the effective date, got: {}",
1184 version
1185 );
1186 }
1187
1188 #[test]
1189 fn test_effective_shows_correct_temporal_version_interface() {
1190 let engine = create_engine_with_files(vec![(
1191 "policy.lemma",
1192 r#"
1193spec policy
1194data base: 100
1195rule discount: 10
1196
1197spec policy 2025-06-01
1198data base: 200
1199data premium: boolean
1200rule discount: 20
1201rule surcharge:
1202 5
1203 unless premium then 10
1204"#,
1205 )]);
1206
1207 let before = date(2025, 3, 1);
1208 let spec_v1 = generate_openapi_effective(&engine, false, &before);
1209
1210 assert!(spec_v1["paths"]["/policy"].is_object());
1211 let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1212 let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1213 assert_eq!(
1214 v1_result["discount"]["$ref"].as_str(),
1215 Some("#/components/schemas/LemmaRuleResult"),
1216 "v1 should have discount rule"
1217 );
1218 assert!(
1219 v1_result["surcharge"].is_null(),
1220 "v1 must NOT have surcharge rule"
1221 );
1222 let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1223 assert!(
1224 v1_request["properties"]["premium"].is_null(),
1225 "v1 must NOT have premium data"
1226 );
1227
1228 let after = date(2025, 8, 1);
1229 let spec_v2 = generate_openapi_effective(&engine, false, &after);
1230
1231 let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1232 let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1233 assert!(
1234 v2_result["discount"]["$ref"].is_string(),
1235 "v2 should have discount rule"
1236 );
1237 assert!(
1238 v2_result["surcharge"]["$ref"].is_string(),
1239 "v2 should have surcharge rule"
1240 );
1241 let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1242 assert!(
1243 v2_request["properties"]["premium"].is_object(),
1244 "v2 should have premium data"
1245 );
1246 }
1247
1248 #[test]
1257 fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1258 let engine = create_engine_with_files(vec![(
1259 "policy.lemma",
1260 r#"
1261spec policy 2025-01-01
1262data base: 10
1263rule total: base
1264
1265spec policy 2026-01-01
1266data base: 99
1267rule total: base
1268"#,
1269 )]);
1270
1271 let at_earlier = date(2025, 6, 1);
1272 let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1273 let earlier_path = &earlier_doc["paths"]["/policy"];
1274 assert_eq!(
1275 earlier_path["x-effective-from"].as_str(),
1276 Some("2025-01-01"),
1277 "earlier version effective_from on PathItem"
1278 );
1279 assert_eq!(
1280 earlier_path["x-effective-to"].as_str(),
1281 Some("2026-01-01"),
1282 "earlier version effective_to equals next version's effective_from"
1283 );
1284
1285 let at_latest = date(2026, 6, 1);
1286 let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1287 let latest_path = &latest_doc["paths"]["/policy"];
1288 assert_eq!(
1289 latest_path["x-effective-from"].as_str(),
1290 Some("2026-01-01"),
1291 "latest version effective_from on PathItem"
1292 );
1293 assert!(
1294 latest_path["x-effective-to"].is_null(),
1295 "latest version has no successor; x-effective-to must be null: {latest_path}"
1296 );
1297 }
1298
1299 #[test]
1302 fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1303 let engine = create_engine_with_code(
1304 "spec pricing
1305 data quantity: 10
1306 rule total: quantity * 2",
1307 );
1308 let document = generate_openapi(&engine, false);
1309 let path_item = &document["paths"]["/pricing"];
1310 assert!(
1311 path_item["x-effective-from"].is_null(),
1312 "unversioned spec: x-effective-from must be null: {path_item}"
1313 );
1314 assert!(
1315 path_item["x-effective-to"].is_null(),
1316 "unversioned spec: x-effective-to must be null: {path_item}"
1317 );
1318 }
1319
1320 #[test]
1325 fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1326 let engine = create_engine_with_files(vec![(
1327 "policy.lemma",
1328 r#"
1329spec policy
1330data base: 100
1331rule discount: 10
1332
1333spec policy 2025-06-01
1334data base: 200
1335rule discount: 20
1336"#,
1337 )]);
1338
1339 let sources = temporal_api_sources(&engine);
1340
1341 assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1342
1343 assert_eq!(sources[0].title, "Now");
1344 assert_eq!(sources[0].slug, NOW_SLUG);
1345 assert_eq!(sources[0].url, "/openapi.json");
1346
1347 assert_eq!(sources[1].title, "Effective 2025-06-01");
1348 assert_eq!(sources[1].slug, "2025-06-01");
1349 assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1350 }
1351
1352 #[test]
1353 fn test_temporal_sources_multiple_specs_merged_boundaries() {
1354 let engine = create_engine_with_files(vec![
1355 (
1356 "policy.lemma",
1357 r#"
1358spec policy
1359data base: 100
1360rule discount: 10
1361
1362spec policy 2025-06-01
1363data base: 200
1364rule discount: 20
1365"#,
1366 ),
1367 (
1368 "rates.lemma",
1369 r#"
1370spec rates
1371data rate: 5
1372rule total: rate * 2
1373
1374spec rates 2025-03-01
1375data rate: 7
1376rule total: rate * 2
1377
1378spec rates 2025-06-01
1379data rate: 9
1380rule total: rate * 2
1381"#,
1382 ),
1383 ]);
1384
1385 let sources = temporal_api_sources(&engine);
1386
1387 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1388 assert!(
1389 slugs.contains(&"2025-03-01"),
1390 "should contain rates boundary"
1391 );
1392 assert!(
1393 slugs.contains(&"2025-06-01"),
1394 "should contain shared boundary"
1395 );
1396 assert!(slugs.contains(&NOW_SLUG), "should contain now");
1397 assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1398 }
1399
1400 #[test]
1401 fn test_temporal_sources_ordered_chronologically() {
1402 let engine = create_engine_with_files(vec![(
1403 "policy.lemma",
1404 r#"
1405spec policy
1406data base: 100
1407rule discount: 10
1408
1409spec policy 2024-01-01
1410data base: 50
1411rule discount: 5
1412
1413spec policy 2025-06-01
1414data base: 200
1415rule discount: 20
1416"#,
1417 )]);
1418
1419 let sources = temporal_api_sources(&engine);
1420 let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1421 assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1422 }
1423
1424 #[test]
1429 fn test_post_schema_text_with_options_has_enum() {
1430 let engine = create_engine_with_code(
1431 "spec test
1432 data product: text -> option \"A\" -> option \"B\"
1433 rule result: product",
1434 );
1435 let spec = generate_openapi(&engine, false);
1436
1437 let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1438 assert!(product_prop["enum"].is_array());
1439 let enums = product_prop["enum"].as_array().unwrap();
1440 assert_eq!(enums.len(), 2);
1441 assert_eq!(enums[0], "A");
1442 assert_eq!(enums[1], "B");
1443 }
1444
1445 #[test]
1446 fn test_post_schema_boolean_is_json_boolean() {
1447 let engine = create_engine_with_code(
1448 "spec test
1449 data is_active: boolean
1450 rule result: is_active",
1451 );
1452 let spec = generate_openapi(&engine, false);
1453
1454 let schema = &spec["components"]["schemas"]["test_request"];
1455 let is_active = &schema["properties"]["is_active"];
1456 assert_eq!(is_active["type"], "boolean");
1457
1458 let form_schema = &spec["components"]["schemas"]["test_form_request"];
1459 let form_is_active = &form_schema["properties"]["is_active"];
1460 assert_eq!(form_is_active["type"], "string");
1461 assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1462 }
1463
1464 #[test]
1465 fn test_post_schema_number_is_string() {
1466 let engine = create_engine_with_code(
1467 "spec test
1468 data quantity: number
1469 rule result: quantity",
1470 );
1471 let spec = generate_openapi(&engine, false);
1472
1473 let schema = &spec["components"]["schemas"]["test_request"];
1474 assert_eq!(schema["properties"]["quantity"]["type"], "string");
1475 }
1476
1477 #[test]
1478 fn test_data_with_default_is_not_required() {
1479 let engine = create_engine_with_code(
1480 "spec test
1481 data quantity: 10
1482 data name: text
1483 rule result: quantity
1484 rule label: name",
1485 );
1486 let spec = generate_openapi(&engine, false);
1487
1488 let schema = &spec["components"]["schemas"]["test_request"];
1489 let required = schema["required"]
1490 .as_array()
1491 .expect("required should be array");
1492
1493 assert!(required.contains(&Value::String("name".to_string())));
1494 assert!(!required.contains(&Value::String("quantity".to_string())));
1495 }
1496
1497 #[test]
1498 fn test_help_and_default_in_openapi() {
1499 let engine = create_engine_with_code(
1500 r#"spec test
1501data quantity: number -> help "Number of items to order" -> suggest 10
1502data active: boolean -> help "Whether the feature is enabled" -> suggest true
1503rule result:
1504 quantity
1505 unless active then 0
1506"#,
1507 );
1508 let spec = generate_openapi(&engine, false);
1509
1510 let req_schema = &spec["components"]["schemas"]["test_request"];
1511 assert!(req_schema["properties"]["quantity"]["description"]
1512 .as_str()
1513 .unwrap()
1514 .contains("Number of items to order"));
1515 assert_eq!(
1516 req_schema["properties"]["quantity"]["default"]
1517 .as_str()
1518 .unwrap(),
1519 "10"
1520 );
1521 assert!(req_schema["properties"]["active"]["description"]
1522 .as_str()
1523 .unwrap()
1524 .contains("Whether the feature is enabled"));
1525 assert_eq!(
1526 req_schema["properties"]["active"]["default"]
1527 .as_str()
1528 .unwrap(),
1529 "true"
1530 );
1531 let required = req_schema["required"]
1532 .as_array()
1533 .expect("required array")
1534 .iter()
1535 .map(|v| v.as_str().unwrap())
1536 .collect::<Vec<_>>();
1537 assert!(required.contains(&"quantity"));
1538 assert!(required.contains(&"active"));
1539 }
1540}