Skip to main content

lemma_openapi/
lib.rs

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