Skip to main content

lemma_openapi/
lib.rs

1//! OpenAPI 3.1 specification generator for Lemma specs.
2//!
3//! Takes a Lemma `Engine` and produces a complete OpenAPI specification as JSON.
4//! Used by both `lemma server` (CLI) and LemmaBase.com for consistent API docs.
5//!
6//! ## Temporal versioning
7//!
8//! Lemma specs can have multiple temporal versions (e.g. `spec pricing 2024-01-01`
9//! and `spec pricing 2025-01-01`) with potentially different interfaces (data, rules,
10//! types). The OpenAPI spec must reflect the interface active at a specific point in
11//! time. Use [`generate_openapi_effective`] with an explicit `DateTimeValue` to get the
12//! spec for a given instant. [`generate_openapi`] is a convenience wrapper that uses
13//! the current time.
14//!
15//! For Scalar multi-spec rendering, [`temporal_api_sources`] returns the list of
16//! temporal version boundaries so the Scalar UI can offer a source selector.
17
18use lemma::parsing::ast::DateTimeValue;
19use lemma::{Engine, LemmaType, TypeSpecification};
20use serde_json::{json, Map, Value};
21
22/// Query slug for the default temporal view (request-time instant). OpenAPI URLs use no `?effective=`.
23pub const NOW_SLUG: &str = "now";
24
25/// A single Scalar API reference source entry.
26///
27/// Each temporal version boundary gets its own source so Scalar renders a
28/// version switcher in the UI.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct ApiSource {
31    pub title: String,
32    pub slug: String,
33    pub url: String,
34}
35
36/// Compute the list of Scalar multi-source entries for temporal versioning.
37///
38/// Returns one [`ApiSource`] per distinct temporal version boundary across all
39/// loaded specs, plus one **now** source (slug [`NOW_SLUG`]) that uses no `effective`
40/// query (evaluation instant = request time). That entry is first (Scalar default),
41/// then boundaries in descending chronological order (newest first).
42///
43/// If there are no temporal version boundaries (all specs are unversioned),
44/// returns a single **now** entry.
45pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
46    let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
47        std::collections::BTreeSet::new();
48
49    let all_specs = engine.list_specs();
50    let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
51    for spec in &all_specs {
52        if seen_names.insert(spec.name.clone()) {
53            for s in all_specs.iter().filter(|s| s.name == spec.name) {
54                if let Some(af) = s.effective_from() {
55                    all_boundaries.insert(af.clone());
56                }
57            }
58        }
59    }
60
61    if all_boundaries.is_empty() {
62        return vec![ApiSource {
63            title: "Now".to_string(),
64            slug: NOW_SLUG.to_string(),
65            url: "/openapi.json".to_string(),
66        }];
67    }
68
69    let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
70
71    sources.push(ApiSource {
72        title: "Now".to_string(),
73        slug: NOW_SLUG.to_string(),
74        url: "/openapi.json".to_string(),
75    });
76
77    for boundary in all_boundaries.iter().rev() {
78        let label = boundary.to_string();
79        sources.push(ApiSource {
80            title: format!("Effective {}", label),
81            slug: label.clone(),
82            url: format!("/openapi.json?effective={}", label),
83        });
84    }
85
86    sources
87}
88
89/// Generate a complete OpenAPI 3.1 specification using the current time.
90///
91/// Convenience wrapper around [`generate_openapi_effective`]. The spec reflects
92/// only the specs and interfaces active at `DateTimeValue::now()`.
93pub fn generate_openapi(engine: &Engine, explanations_enabled: bool) -> Value {
94    generate_openapi_effective(engine, explanations_enabled, &DateTimeValue::now())
95}
96
97/// Generate a complete OpenAPI 3.1 specification for a specific point in time.
98///
99/// The specification includes:
100/// - `GET /` — list loaded specs (name, data/rule counts)
101/// - Spec endpoints (`/{spec_name}`) with `?rules=` query parameter
102/// - GET (schema: `spec_set_id`, `effective_from`, `data`, `rules`, `meta`, `versions`) and
103///   POST (evaluate: envelope `spec`, `effective`, `result`) with `Accept-Datetime` header
104/// - `x-effective-from` / `x-effective-to` vendor extensions on each spec PathItem
105///   exposing the half-open `[effective_from, effective_to)` range of the version
106///   resolved at the document's effective instant (both `null` when unbounded)
107///
108/// CLI `lemma server` also exposes shell routes (`/openapi.json`, `/health`, `/docs`) and
109/// legacy schema routes (`/schema/{spec_name}`, `/schema/{spec_name}/{rules}`); both are
110/// intentionally omitted from the generated document. The legacy `/schema/*` routes
111/// predate the spec envelope returned by `GET /{spec_name}` and are kept for backward
112/// compatibility only; use `GET /{spec_name}` instead. `GET /` (list loaded specs) is
113/// included alongside `GET|POST /{spec_name}`.
114///
115/// When `explanations_enabled` is true, the spec adds the `x-explanations` header parameter
116/// to evaluation endpoints and describes the optional `explanation` field on rule results.
117pub fn generate_openapi_effective(
118    engine: &Engine,
119    explanations_enabled: bool,
120    effective: &DateTimeValue,
121) -> Value {
122    let mut paths = Map::new();
123    let mut components_schemas = Map::new();
124
125    components_schemas.insert(
126        "LemmaRuleResult".to_string(),
127        build_rule_result_schema(explanations_enabled),
128    );
129
130    let active_specs = engine.list_specs_effective(effective);
131    let unique_spec_names: Vec<String> = active_specs.iter().map(|s| s.name.clone()).collect();
132
133    paths.insert(
134        "/".to_string(),
135        index_path_item(&unique_spec_names, engine, effective),
136    );
137
138    for spec_name in &unique_spec_names {
139        if let Ok(plan) = engine.get_plan(spec_name, Some(effective)) {
140            let schema = plan.schema();
141            let data = collect_input_data_from_schema(&schema);
142            let rule_names: Vec<String> = schema.rules.keys().cloned().collect();
143
144            let spec_set = engine
145                .get_spec_set(spec_name)
146                .expect("BUG: spec in list_specs_effective but spec set missing from engine");
147            let active_spec = active_specs
148                .iter()
149                .find(|s| s.name == *spec_name)
150                .expect("BUG: active_specs was produced by this engine for this name");
151            let (spec_effective_from, spec_effective_to) = spec_set.effective_range(active_spec);
152
153            let safe_name = spec_name.replace('/', "_");
154            let get_response_schema_name = format!("{}_get_response", safe_name);
155            components_schemas.insert(
156                get_response_schema_name.clone(),
157                build_get_schema_response(),
158            );
159
160            let evaluate_response_schema_name = format!("{}_evaluate_response", safe_name);
161            components_schemas.insert(
162                evaluate_response_schema_name.clone(),
163                build_evaluate_response_schema(&schema, &rule_names),
164            );
165
166            let post_body_schema_name = format!("{}_request", safe_name);
167            components_schemas.insert(
168                post_body_schema_name.clone(),
169                build_post_request_schema(&data),
170            );
171
172            let path = format!("/{}", spec_name);
173            paths.insert(
174                path,
175                build_spec_path_item(
176                    spec_name,
177                    &get_response_schema_name,
178                    &evaluate_response_schema_name,
179                    &post_body_schema_name,
180                    &rule_names,
181                    explanations_enabled,
182                    (spec_effective_from.as_ref(), spec_effective_to.as_ref()),
183                ),
184            );
185        }
186    }
187
188    let mut tags = vec![json!({
189        "name": "Specs",
190        "description": "Simple API to retrieve the list of Lemma specs"
191    })];
192    for spec_name in &unique_spec_names {
193        let safe_tag = spec_name.replace('/', "_");
194        tags.push(json!({
195            "name": safe_tag,
196            "x-displayName": spec_name,
197            "description": format!("GET schema or POST evaluate for spec '{}'. Use ?rules= to scope.", spec_name)
198        }));
199    }
200
201    let spec_tags: Vec<Value> = unique_spec_names
202        .iter()
203        .map(|n| Value::String(n.replace('/', "_")))
204        .collect();
205
206    let tag_groups = vec![
207        json!({ "name": "Overview", "tags": ["Specs"] }),
208        json!({ "name": "Specs", "tags": spec_tags }),
209    ];
210
211    let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
212
213    json!({
214        "openapi": "3.1.0",
215        "info": {
216            "title": "Lemma API",
217            "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.",
218            "version": version_label
219        },
220        "tags": tags,
221        "x-tagGroups": tag_groups,
222        "paths": Value::Object(paths),
223        "components": {
224            "schemas": Value::Object(components_schemas)
225        }
226    })
227}
228
229/// Information about a single input data for OpenAPI generation.
230struct InputData {
231    /// The data name as it appears in the API (e.g. "quantity", "is_member").
232    name: String,
233    /// The resolved Lemma type for this data.
234    lemma_type: LemmaType,
235    /// The data's literal value if defined in the spec (e.g. `data quantity: 10`).
236    /// None for type-only data (e.g. `data quantity: number`).
237    default_value: Option<lemma::LiteralValue>,
238}
239
240/// Collect all local input data from a pre-built schema.
241///
242/// Only includes data local to the spec (no dot-separated cross-spec
243/// paths like `calc.price`). Already sorted alphabetically by `schema()`.
244fn collect_input_data_from_schema(schema: &lemma::SpecSchema) -> Vec<InputData> {
245    schema
246        .data
247        .iter()
248        .filter(|(name, _)| !name.contains('.'))
249        .map(|(name, entry)| InputData {
250            name: name.clone(),
251            lemma_type: entry.lemma_type.clone(),
252            default_value: entry.default.clone(),
253        })
254        .collect()
255}
256
257// ---------------------------------------------------------------------------
258// Index path (list specs)
259// ---------------------------------------------------------------------------
260
261fn index_path_item(spec_names: &[String], engine: &Engine, effective: &DateTimeValue) -> Value {
262    let spec_items: Vec<Value> = spec_names
263        .iter()
264        .map(|name| match engine.schema(name, Some(effective)) {
265            Ok(s) => {
266                let data_count = s.data.keys().filter(|n| !n.contains('.')).count();
267                let rules_count = s.rules.len();
268                json!({
269                    "name": name,
270                    "data": data_count,
271                    "rules": rules_count
272                })
273            }
274            Err(e) => json!({
275                "name": name,
276                "schema_error": true,
277                "message": e.to_string()
278            }),
279        })
280        .collect();
281
282    json!({
283        "get": {
284            "operationId": "list",
285            "summary": "List all available specs",
286            "tags": ["Specs"],
287            "responses": {
288                "200": {
289                    "description": "List of loaded Lemma specs",
290                    "content": {
291                        "application/json": {
292                            "schema": {
293                                "type": "array",
294                                "items": {
295                                    "type": "object",
296                                    "properties": {
297                                        "name": { "type": "string" },
298                                        "data": { "type": "integer" },
299                                        "rules": { "type": "integer" },
300                                        "schema_error": { "type": "boolean" },
301                                        "message": { "type": "string" }
302                                    },
303                                    "required": ["name"]
304                                }
305                            },
306                            "example": spec_items
307                        }
308                    }
309                }
310            }
311        }
312    })
313}
314
315// ---------------------------------------------------------------------------
316// Shared response schemas
317// ---------------------------------------------------------------------------
318
319fn error_response_schema() -> Value {
320    json!({
321        "description": "Evaluation error",
322        "content": {
323            "application/json": {
324                "schema": {
325                    "type": "object",
326                    "properties": {
327                        "error": { "type": "string" }
328                    },
329                    "required": ["error"]
330                }
331            }
332        }
333    })
334}
335
336fn not_found_response_schema() -> Value {
337    json!({
338        "description": "Spec not found",
339        "content": {
340            "application/json": {
341                "schema": {
342                    "type": "object",
343                    "properties": {
344                        "error": { "type": "string" }
345                    },
346                    "required": ["error"]
347                }
348            }
349        }
350    })
351}
352
353fn memento_spec_response_headers() -> Value {
354    json!({
355        "Memento-Datetime": {
356            "description": "RFC 7089: datetime of the resolved spec version (absent for unversioned specs)",
357            "schema": { "type": "string" }
358        },
359        "Vary": {
360            "description": "Indicates negotiation on Accept-Datetime",
361            "schema": { "type": "string", "example": "Accept-Datetime" }
362        }
363    })
364}
365
366/// GET `/{spec}` body: matches [cli::server::GetSpecResponse].
367fn build_get_schema_response() -> Value {
368    json!({
369        "type": "object",
370        "required": ["spec_set_id", "data", "rules", "meta", "versions"],
371        "properties": {
372            "spec_set_id": {
373                "type": "string",
374                "description": "Spec set identifier (path segments, e.g. org/product/pricing)"
375            },
376            "effective_from": {
377                "type": ["string", "null"],
378                "description": "Effective-from of the resolved temporal version, if any"
379            },
380            "data": {
381                "type": "object",
382                "description": "Input data names mapped to type metadata and optional defaults",
383                "additionalProperties": true
384            },
385            "rules": {
386                "type": "object",
387                "description": "Rule names mapped to result types (scoped by ?rules= when provided)",
388                "additionalProperties": true
389            },
390            "meta": {
391                "type": "object",
392                "description": "Spec metadata key/value pairs",
393                "additionalProperties": true
394            },
395            "versions": {
396                "type": "array",
397                "description": "All loaded temporal versions for this spec name, each with a half-open [effective_from, effective_to) range",
398                "items": {
399                    "type": "object",
400                    "required": ["effective_from", "effective_to"],
401                    "properties": {
402                        "effective_from": {
403                            "type": ["string", "null"],
404                            "description": "Start of validity for this version; null when unbounded (no earlier version exists)"
405                        },
406                        "effective_to": {
407                            "type": ["string", "null"],
408                            "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"
409                        }
410                    }
411                }
412            }
413        }
414    })
415}
416
417/// Single rule output: matches [cli::response::RuleResultJson].
418fn build_rule_result_schema(explanations_enabled: bool) -> Value {
419    let mut explanation = json!({
420        "type": "object",
421        "description": "Structured explanation tree when explanations are enabled"
422    });
423    if explanations_enabled {
424        explanation["description"] = Value::String(
425            "Structured explanation tree (present when x-explanations is sent and server uses --explanations)"
426                .to_string(),
427        );
428    }
429
430    json!({
431        "type": "object",
432        "required": ["vetoed", "rule_type"],
433        "properties": {
434            "value": {
435                "description": "Native JSON value when not vetoed (boolean, number, string, array, object)"
436            },
437            "unit": {
438                "type": "string",
439                "description": "Unit for scale/duration results (e.g. currency code, hours)"
440            },
441            "display": {
442                "type": "string",
443                "description": "Human-readable formatted value"
444            },
445            "vetoed": { "type": "boolean" },
446            "veto_reason": { "type": "string" },
447            "rule_type": {
448                "type": "string",
449                "description": "Result type name (e.g. number, boolean, money)"
450            },
451            "explanation": explanation
452        }
453    })
454}
455
456/// POST evaluate body: matches [cli::response::EvaluationEnvelope].
457fn build_evaluate_response_schema(schema: &lemma::SpecSchema, rule_names: &[String]) -> Value {
458    let mut result_props = Map::new();
459    for rule_name in rule_names {
460        if schema.rules.contains_key(rule_name) {
461            result_props.insert(
462                rule_name.clone(),
463                json!({
464                    "$ref": "#/components/schemas/LemmaRuleResult"
465                }),
466            );
467        }
468    }
469
470    json!({
471        "type": "object",
472        "required": ["spec", "effective", "result"],
473        "properties": {
474            "spec": {
475                "type": "string",
476                "description": "Spec set id that was evaluated"
477            },
478            "effective": {
479                "type": "string",
480                "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
481            },
482            "result": {
483                "type": "object",
484                "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
485                "properties": Value::Object(result_props)
486            }
487        }
488    })
489}
490
491// ---------------------------------------------------------------------------
492// Spec path items
493// ---------------------------------------------------------------------------
494
495fn x_explanations_header_parameter() -> Value {
496    json!({
497        "name": "x-explanations",
498        "in": "header",
499        "required": false,
500        "description": "Set to request explanation objects in the response (server must be started with --explanations)",
501        "schema": { "type": "string", "default": "true" }
502    })
503}
504
505fn accept_datetime_header_parameter() -> Value {
506    json!({
507        "name": "Accept-Datetime",
508        "in": "header",
509        "required": false,
510        "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
511        "schema": { "type": "string", "format": "date-time" },
512        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
513    })
514}
515
516/// Build the PathItem for `/{spec_name}` (GET schema + POST evaluate).
517///
518/// `effective_range` is the half-open `[effective_from, effective_to)`
519/// validity range of the temporal version resolved at the OpenAPI document's
520/// effective instant. Both bounds are emitted as the `x-effective-from` /
521/// `x-effective-to` vendor extensions on the PathItem so tooling can render
522/// the active version's window without having to inspect the `versions`
523/// array. `None` in either position (unbounded start for the first row,
524/// unbounded end for the latest row) is serialised as JSON `null`.
525fn build_spec_path_item(
526    spec_name: &str,
527    get_response_schema_name: &str,
528    evaluate_response_schema_name: &str,
529    post_body_schema_name: &str,
530    rule_names: &[String],
531    explanations_enabled: bool,
532    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
533) -> Value {
534    let (effective_from, effective_to) = effective_range;
535
536    let get_schema_ref = json!({
537        "$ref": format!("#/components/schemas/{}", get_response_schema_name)
538    });
539    let evaluate_schema_ref = json!({
540        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
541    });
542    let body_ref = json!({
543        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
544    });
545
546    let tag = spec_name.replace('/', "_");
547
548    let rules_example = if rule_names.is_empty() {
549        String::new()
550    } else {
551        rule_names.join(",")
552    };
553
554    let rules_param = json!({
555        "name": "rules",
556        "in": "query",
557        "required": false,
558        "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
559        "schema": { "type": "string" },
560        "example": rules_example
561    });
562
563    let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
564    get_parameters.push(accept_datetime_header_parameter());
565    if explanations_enabled {
566        get_parameters.push(x_explanations_header_parameter());
567    }
568
569    let get_summary = "Schema of resolved version (spec, data, rules, meta, versions)".to_string();
570    let post_summary = "Evaluate".to_string();
571    let get_operation_id = format!("get_{}", spec_name);
572    let post_operation_id = format!("post_{}", spec_name);
573
574    let mut post_parameters: Vec<Value> = vec![rules_param];
575    post_parameters.push(accept_datetime_header_parameter());
576    if explanations_enabled {
577        post_parameters.push(x_explanations_header_parameter());
578    }
579
580    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
581        match dt {
582            Some(d) => Value::String(d.to_string()),
583            None => Value::Null,
584        }
585    };
586
587    json!({
588        "x-effective-from": datetime_or_null(effective_from),
589        "x-effective-to": datetime_or_null(effective_to),
590        "get": {
591            "operationId": get_operation_id,
592            "summary": get_summary,
593            "tags": [tag],
594            "parameters": get_parameters,
595            "responses": {
596                "200": {
597                    "description": "Schema of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
598                    "headers": memento_spec_response_headers(),
599                    "content": {
600                        "application/json": {
601                            "schema": get_schema_ref
602                        }
603                    }
604                },
605                "400": error_response_schema(),
606                "404": not_found_response_schema()
607            }
608        },
609        "post": {
610            "operationId": post_operation_id,
611            "summary": post_summary,
612            "tags": [tag],
613            "parameters": post_parameters,
614            "requestBody": {
615                "required": true,
616                "content": {
617                    "application/x-www-form-urlencoded": {
618                        "schema": body_ref
619                    }
620                }
621            },
622            "responses": {
623                "200": {
624                    "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
625                    "headers": memento_spec_response_headers(),
626                    "content": {
627                        "application/json": {
628                            "schema": evaluate_schema_ref
629                        }
630                    }
631                },
632                "400": error_response_schema(),
633                "404": not_found_response_schema()
634            }
635        }
636    })
637}
638
639// ---------------------------------------------------------------------------
640// Help and default from Lemma types
641// ---------------------------------------------------------------------------
642
643/// Extract the type's help text for use as description. Always has a value for non-Veto types.
644fn type_help(lemma_type: &LemmaType) -> String {
645    match &lemma_type.specifications {
646        TypeSpecification::Boolean { help, .. } => help.clone(),
647        TypeSpecification::Scale { help, .. } => help.clone(),
648        TypeSpecification::Number { help, .. } => help.clone(),
649        TypeSpecification::Ratio { help, .. } => help.clone(),
650        TypeSpecification::Text { help, .. } => help.clone(),
651        TypeSpecification::Date { help, .. } => help.clone(),
652        TypeSpecification::Time { help, .. } => help.clone(),
653        TypeSpecification::Duration { help, .. } => help.clone(),
654        TypeSpecification::Veto { .. } => String::new(),
655        TypeSpecification::Undetermined => unreachable!(
656            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
657        ),
658    }
659}
660
661// ---------------------------------------------------------------------------
662// POST request body schema generation (form-encoded — all string values)
663// ---------------------------------------------------------------------------
664
665fn build_post_request_schema(data: &[InputData]) -> Value {
666    let mut properties = Map::new();
667    let mut required = Vec::new();
668
669    for data in data {
670        properties.insert(
671            data.name.clone(),
672            build_post_property_schema(&data.lemma_type, data.default_value.as_ref()),
673        );
674        if data.default_value.is_none() {
675            required.push(Value::String(data.name.clone()));
676        }
677    }
678
679    let mut schema = json!({
680        "type": "object",
681        "properties": Value::Object(properties)
682    });
683    if !required.is_empty() {
684        schema["required"] = Value::Array(required);
685    }
686    schema
687}
688
689fn build_post_property_schema(
690    lemma_type: &LemmaType,
691    data_value: Option<&lemma::LiteralValue>,
692) -> Value {
693    let mut schema = build_post_type_schema(lemma_type);
694
695    let help = type_help(lemma_type);
696    if !help.is_empty() {
697        schema["description"] = Value::String(help);
698    }
699
700    if let Some(v) = data_value {
701        schema["default"] = Value::String(v.display_value());
702    }
703
704    schema
705}
706
707fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
708    match &lemma_type.specifications {
709        TypeSpecification::Text { options, .. } => {
710            let mut schema = json!({ "type": "string" });
711            if !options.is_empty() {
712                schema["enum"] =
713                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
714            }
715            schema
716        }
717        TypeSpecification::Boolean { .. } => {
718            json!({ "type": "string", "enum": ["true", "false"] })
719        }
720        _ => json!({ "type": "string" }),
721    }
722}
723
724// ---------------------------------------------------------------------------
725// Helpers
726// ---------------------------------------------------------------------------
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use lemma::parsing::ast::DateTimeValue;
732    use lemma::SourceType;
733
734    fn create_engine_with_code(code: &str) -> Engine {
735        let mut engine = Engine::new();
736        engine
737            .load(code, SourceType::Labeled("test.lemma"))
738            .expect("failed to parse lemma code");
739        engine
740    }
741
742    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
743        let mut engine = Engine::new();
744        for (name, code) in files {
745            engine
746                .load(code, SourceType::Labeled(name))
747                .expect("failed to parse lemma code");
748        }
749        engine
750    }
751
752    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
753        DateTimeValue {
754            year,
755            month,
756            day,
757            hour: 0,
758            minute: 0,
759            second: 0,
760            microsecond: 0,
761            timezone: None,
762        }
763    }
764
765    fn has_param(params: &Value, name: &str) -> bool {
766        params
767            .as_array()
768            .map(|a| a.iter().any(|p| p["name"] == name))
769            .unwrap_or(false)
770    }
771
772    // =======================================================================
773    // Basic spec structure (pre-existing, adapted)
774    // =======================================================================
775
776    #[test]
777    fn test_generate_openapi_x_tag_groups() {
778        let engine = create_engine_with_code(
779            "spec pricing
780            data quantity: 10
781            rule total: quantity * 2",
782        );
783        let spec = generate_openapi(&engine, false);
784
785        let groups = spec["x-tagGroups"]
786            .as_array()
787            .expect("x-tagGroups should be array");
788        assert_eq!(groups.len(), 2);
789        assert_eq!(groups[0]["name"], "Overview");
790        assert_eq!(groups[0]["tags"], json!(["Specs"]));
791        assert_eq!(groups[1]["name"], "Specs");
792        assert_eq!(groups[1]["tags"], json!(["pricing"]));
793    }
794
795    #[test]
796    fn test_spec_path_has_get_and_post() {
797        let engine = create_engine_with_code(
798            "spec pricing
799            data quantity: 10
800            rule total: quantity * 2",
801        );
802        let spec = generate_openapi(&engine, false);
803
804        assert!(
805            spec["paths"]["/pricing"].is_object(),
806            "single spec path /pricing"
807        );
808        assert!(spec["paths"]["/pricing"]["get"].is_object());
809        assert!(spec["paths"]["/pricing"]["post"].is_object());
810
811        assert_eq!(
812            spec["paths"]["/pricing"]["get"]["operationId"],
813            "get_pricing"
814        );
815        assert_eq!(
816            spec["paths"]["/pricing"]["post"]["operationId"],
817            "post_pricing"
818        );
819        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
820
821        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
822            .as_array()
823            .expect("parameters array");
824        let param_names: Vec<&str> = get_params
825            .iter()
826            .map(|p| p["name"].as_str().unwrap())
827            .collect();
828        assert!(
829            param_names.contains(&"rules"),
830            "GET must have rules query param"
831        );
832        assert!(
833            param_names.contains(&"Accept-Datetime"),
834            "GET must have Accept-Datetime header"
835        );
836
837        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
838            ["application/json"]["schema"]["$ref"]
839            .as_str()
840            .unwrap();
841        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
842            ["application/json"]["schema"]["$ref"]
843            .as_str()
844            .unwrap();
845        assert_eq!(get_ref, "#/components/schemas/pricing_get_response");
846        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
847        assert_ne!(get_ref, post_ref);
848
849        let get_schema = &spec["components"]["schemas"]["pricing_get_response"];
850        assert!(get_schema["properties"]["spec_set_id"]["type"] == "string");
851        assert!(get_schema["properties"]["versions"].is_object());
852
853        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
854        assert!(h200["headers"]["Memento-Datetime"].is_object());
855        assert!(h200["headers"]["Vary"].is_object());
856    }
857
858    /// The generated OpenAPI document describes the public spec surface only.
859    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) and legacy
860    /// schema routes (`/schema/{spec_name}` and `/schema/{spec_name}/{rules}`)
861    /// are intentionally omitted; consumers must not rely on them for code
862    /// generation or contract inspection.
863    #[test]
864    fn test_openapi_omits_shell_and_legacy_schema_routes() {
865        let engine = create_engine_with_code(
866            "spec pricing
867            data quantity: 10
868            rule total: quantity * 2",
869        );
870        let spec = generate_openapi(&engine, false);
871
872        let paths = spec["paths"].as_object().expect("paths object");
873        assert!(paths.contains_key("/"));
874        assert_eq!(paths["/"]["get"]["operationId"], "list");
875        assert!(!paths.contains_key("/openapi.json"));
876        assert!(!paths.contains_key("/health"));
877        assert!(!paths.contains_key("/docs"));
878        assert!(!paths.contains_key("/schema/pricing"));
879        assert!(!paths.contains_key("/schema/pricing/{rules}"));
880        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
881    }
882
883    #[test]
884    fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
885        let engine = create_engine_with_code(
886            "spec pricing
887            data quantity: 10
888            rule total: quantity * 2",
889        );
890        let spec = generate_openapi(&engine, true);
891
892        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
893        assert!(has_param(get_params, "x-explanations"));
894
895        let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
896        assert!(rule_result["properties"]["explanation"].is_object());
897        assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
898        assert!(rule_result["properties"]["rule_type"]["type"] == "string");
899
900        let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
901        assert!(evaluate["required"]
902            .as_array()
903            .unwrap()
904            .contains(&json!("spec")));
905        assert!(evaluate["required"]
906            .as_array()
907            .unwrap()
908            .contains(&json!("effective")));
909        assert!(evaluate["required"]
910            .as_array()
911            .unwrap()
912            .contains(&json!("result")));
913        let total_ref = evaluate["properties"]["result"]["properties"]["total"]["$ref"]
914            .as_str()
915            .unwrap();
916        assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
917    }
918
919    #[test]
920    fn test_generate_openapi_multiple_specs() {
921        let engine = create_engine_with_files(vec![
922            (
923                "pricing.lemma",
924                "spec pricing
925                data quantity: 10
926                rule total: quantity * 2",
927            ),
928            (
929                "shipping.lemma",
930                "spec shipping
931                data weight: 5
932                rule cost: weight * 3",
933            ),
934        ]);
935        let spec = generate_openapi(&engine, false);
936
937        assert!(spec["paths"]["/pricing"].is_object());
938        assert!(spec["paths"]["/shipping"].is_object());
939    }
940
941    #[test]
942    fn test_nested_spec_path_schema_refs_are_valid() {
943        let engine = create_engine_with_code(
944            "spec a/b/c
945        data x: number
946        rule result: x",
947        );
948        let spec = generate_openapi(&engine, false);
949
950        assert!(spec["paths"]["/a/b/c"]["post"].is_object());
951        let body_ref = spec["paths"]["/a/b/c"]["post"]["requestBody"]["content"]
952            ["application/x-www-form-urlencoded"]["schema"]["$ref"]
953            .as_str()
954            .unwrap();
955        assert_eq!(body_ref, "#/components/schemas/a_b_c_request");
956        assert!(spec["components"]["schemas"]["a_b_c_request"].is_object());
957        assert!(spec["components"]["schemas"]["a_b_c_request"]["properties"]["x"].is_object());
958    }
959
960    // =======================================================================
961    // generate_openapi_effective with explicit timestamp
962    // =======================================================================
963
964    #[test]
965    fn test_generate_openapi_effective_reflects_specific_time() {
966        let engine = create_engine_with_code(
967            "spec pricing
968            data quantity: 10
969            rule total: quantity * 2",
970        );
971        let effective = date(2025, 6, 15);
972        let spec = generate_openapi_effective(&engine, false, &effective);
973
974        assert_eq!(spec["openapi"], "3.1.0");
975        let version = spec["info"]["version"].as_str().unwrap();
976        assert!(
977            version.contains("2025-06-15"),
978            "version string should contain the effective date, got: {}",
979            version
980        );
981    }
982
983    #[test]
984    fn test_effective_shows_correct_temporal_version_interface() {
985        let engine = create_engine_with_files(vec![(
986            "policy.lemma",
987            r#"
988spec policy
989data base: 100
990rule discount: 10
991
992spec policy 2025-06-01
993data base: 200
994data premium: boolean
995rule discount: 20
996rule surcharge:
997  5
998  unless premium then 10
999"#,
1000        )]);
1001
1002        let before = date(2025, 3, 1);
1003        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1004
1005        assert!(spec_v1["paths"]["/policy"].is_object());
1006        let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1007        let v1_result = &v1_evaluate["properties"]["result"]["properties"];
1008        assert_eq!(
1009            v1_result["discount"]["$ref"].as_str(),
1010            Some("#/components/schemas/LemmaRuleResult"),
1011            "v1 should have discount rule"
1012        );
1013        assert!(
1014            v1_result["surcharge"].is_null(),
1015            "v1 must NOT have surcharge rule"
1016        );
1017        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1018        assert!(
1019            v1_request["properties"]["premium"].is_null(),
1020            "v1 must NOT have premium data"
1021        );
1022
1023        let after = date(2025, 8, 1);
1024        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1025
1026        let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1027        let v2_result = &v2_evaluate["properties"]["result"]["properties"];
1028        assert!(
1029            v2_result["discount"]["$ref"].is_string(),
1030            "v2 should have discount rule"
1031        );
1032        assert!(
1033            v2_result["surcharge"]["$ref"].is_string(),
1034            "v2 should have surcharge rule"
1035        );
1036        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1037        assert!(
1038            v2_request["properties"]["premium"].is_object(),
1039            "v2 should have premium data"
1040        );
1041    }
1042
1043    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1044    /// describing the half-open `[effective_from, effective_to)` validity
1045    /// range of the version resolved at the document's effective instant.
1046    ///
1047    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1048    /// - Latest row: `x-effective-to` = `null` (no successor).
1049    /// - Unversioned spec (no declared `effective_from`): both extensions are
1050    ///   `null`.
1051    #[test]
1052    fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1053        let engine = create_engine_with_files(vec![(
1054            "policy.lemma",
1055            r#"
1056spec policy 2025-01-01
1057data base: 10
1058rule total: base
1059
1060spec policy 2026-01-01
1061data base: 99
1062rule total: base
1063"#,
1064        )]);
1065
1066        let at_earlier = date(2025, 6, 1);
1067        let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1068        let earlier_path = &earlier_doc["paths"]["/policy"];
1069        assert_eq!(
1070            earlier_path["x-effective-from"].as_str(),
1071            Some("2025-01-01"),
1072            "earlier version effective_from on PathItem"
1073        );
1074        assert_eq!(
1075            earlier_path["x-effective-to"].as_str(),
1076            Some("2026-01-01"),
1077            "earlier version effective_to equals next version's effective_from"
1078        );
1079
1080        let at_latest = date(2026, 6, 1);
1081        let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1082        let latest_path = &latest_doc["paths"]["/policy"];
1083        assert_eq!(
1084            latest_path["x-effective-from"].as_str(),
1085            Some("2026-01-01"),
1086            "latest version effective_from on PathItem"
1087        );
1088        assert!(
1089            latest_path["x-effective-to"].is_null(),
1090            "latest version has no successor; x-effective-to must be null: {latest_path}"
1091        );
1092    }
1093
1094    /// Unversioned specs (no declared `effective_from`) have both extensions
1095    /// serialised as JSON `null`, not omitted.
1096    #[test]
1097    fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1098        let engine = create_engine_with_code(
1099            "spec pricing
1100            data quantity: 10
1101            rule total: quantity * 2",
1102        );
1103        let document = generate_openapi(&engine, false);
1104        let path_item = &document["paths"]["/pricing"];
1105        assert!(
1106            path_item["x-effective-from"].is_null(),
1107            "unversioned spec: x-effective-from must be null: {path_item}"
1108        );
1109        assert!(
1110            path_item["x-effective-to"].is_null(),
1111            "unversioned spec: x-effective-to must be null: {path_item}"
1112        );
1113    }
1114
1115    // =======================================================================
1116    // temporal_api_sources
1117    // =======================================================================
1118
1119    #[test]
1120    fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1121        let engine = create_engine_with_files(vec![(
1122            "policy.lemma",
1123            r#"
1124spec policy
1125data base: 100
1126rule discount: 10
1127
1128spec policy 2025-06-01
1129data base: 200
1130rule discount: 20
1131"#,
1132        )]);
1133
1134        let sources = temporal_api_sources(&engine);
1135
1136        assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1137
1138        assert_eq!(sources[0].title, "Now");
1139        assert_eq!(sources[0].slug, NOW_SLUG);
1140        assert_eq!(sources[0].url, "/openapi.json");
1141
1142        assert_eq!(sources[1].title, "Effective 2025-06-01");
1143        assert_eq!(sources[1].slug, "2025-06-01");
1144        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1145    }
1146
1147    #[test]
1148    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1149        let engine = create_engine_with_files(vec![
1150            (
1151                "policy.lemma",
1152                r#"
1153spec policy
1154data base: 100
1155rule discount: 10
1156
1157spec policy 2025-06-01
1158data base: 200
1159rule discount: 20
1160"#,
1161            ),
1162            (
1163                "rates.lemma",
1164                r#"
1165spec rates
1166data rate: 5
1167rule total: rate * 2
1168
1169spec rates 2025-03-01
1170data rate: 7
1171rule total: rate * 2
1172
1173spec rates 2025-06-01
1174data rate: 9
1175rule total: rate * 2
1176"#,
1177            ),
1178        ]);
1179
1180        let sources = temporal_api_sources(&engine);
1181
1182        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1183        assert!(
1184            slugs.contains(&"2025-03-01"),
1185            "should contain rates boundary"
1186        );
1187        assert!(
1188            slugs.contains(&"2025-06-01"),
1189            "should contain shared boundary"
1190        );
1191        assert!(slugs.contains(&NOW_SLUG), "should contain now");
1192        assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1193    }
1194
1195    #[test]
1196    fn test_temporal_sources_ordered_chronologically() {
1197        let engine = create_engine_with_files(vec![(
1198            "policy.lemma",
1199            r#"
1200spec policy
1201data base: 100
1202rule discount: 10
1203
1204spec policy 2024-01-01
1205data base: 50
1206rule discount: 5
1207
1208spec policy 2025-06-01
1209data base: 200
1210rule discount: 20
1211"#,
1212        )]);
1213
1214        let sources = temporal_api_sources(&engine);
1215        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1216        assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1217    }
1218
1219    // =======================================================================
1220    // Type-specific parameter tests
1221    // =======================================================================
1222
1223    #[test]
1224    fn test_post_schema_text_with_options_has_enum() {
1225        let engine = create_engine_with_code(
1226            "spec test
1227            data product: text -> option \"A\" -> option \"B\"
1228            rule result: product",
1229        );
1230        let spec = generate_openapi(&engine, false);
1231
1232        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1233        assert!(product_prop["enum"].is_array());
1234        let enums = product_prop["enum"].as_array().unwrap();
1235        assert_eq!(enums.len(), 2);
1236        assert_eq!(enums[0], "A");
1237        assert_eq!(enums[1], "B");
1238    }
1239
1240    #[test]
1241    fn test_post_schema_boolean_is_string_with_enum() {
1242        let engine = create_engine_with_code(
1243            "spec test
1244            data is_active: boolean
1245            rule result: is_active",
1246        );
1247        let spec = generate_openapi(&engine, false);
1248
1249        let schema = &spec["components"]["schemas"]["test_request"];
1250        let is_active = &schema["properties"]["is_active"];
1251        assert_eq!(is_active["type"], "string");
1252        assert_eq!(is_active["enum"], json!(["true", "false"]));
1253    }
1254
1255    #[test]
1256    fn test_post_schema_number_is_string() {
1257        let engine = create_engine_with_code(
1258            "spec test
1259            data quantity: number
1260            rule result: quantity",
1261        );
1262        let spec = generate_openapi(&engine, false);
1263
1264        let schema = &spec["components"]["schemas"]["test_request"];
1265        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1266    }
1267
1268    #[test]
1269    fn test_data_with_default_is_not_required() {
1270        let engine = create_engine_with_code(
1271            "spec test
1272            data quantity: 10
1273            data name: text
1274            rule result: quantity
1275            rule label: name",
1276        );
1277        let spec = generate_openapi(&engine, false);
1278
1279        let schema = &spec["components"]["schemas"]["test_request"];
1280        let required = schema["required"]
1281            .as_array()
1282            .expect("required should be array");
1283
1284        assert!(required.contains(&Value::String("name".to_string())));
1285        assert!(!required.contains(&Value::String("quantity".to_string())));
1286    }
1287
1288    #[test]
1289    fn test_help_and_default_in_openapi() {
1290        let engine = create_engine_with_code(
1291            r#"spec test
1292data quantity: number -> help "Number of items to order" -> default 10
1293data active: boolean -> help "Whether the feature is enabled" -> default true
1294rule result:
1295  quantity
1296  unless active then 0
1297"#,
1298        );
1299        let spec = generate_openapi(&engine, false);
1300
1301        let req_schema = &spec["components"]["schemas"]["test_request"];
1302        assert!(req_schema["properties"]["quantity"]["description"]
1303            .as_str()
1304            .unwrap()
1305            .contains("Number of items to order"));
1306        assert_eq!(
1307            req_schema["properties"]["quantity"]["default"]
1308                .as_str()
1309                .unwrap(),
1310            "10"
1311        );
1312        assert!(req_schema["properties"]["active"]["description"]
1313            .as_str()
1314            .unwrap()
1315            .contains("Whether the feature is enabled"));
1316        assert_eq!(
1317            req_schema["properties"]["active"]["default"]
1318                .as_str()
1319                .unwrap(),
1320            "true"
1321        );
1322    }
1323}