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": "Native JSON value when not vetoed (boolean, number, string, array, object)"
445            },
446            "unit": {
447                "type": "string",
448                "description": "Unit for scale/duration results (e.g. currency code, hours)"
449            },
450            "display": {
451                "type": "string",
452                "description": "Human-readable formatted value"
453            },
454            "vetoed": { "type": "boolean" },
455            "veto_reason": { "type": "string" },
456            "rule_type": {
457                "type": "string",
458                "description": "Result type name (e.g. number, boolean, money)"
459            },
460            "explanation": explanation
461        }
462    })
463}
464
465/// POST evaluate body: matches [cli::response::EvaluationEnvelope].
466fn build_evaluate_response_schema(schema: &lemma::SpecSchema, rule_names: &[String]) -> Value {
467    let mut result_props = Map::new();
468    for rule_name in rule_names {
469        if schema.rules.contains_key(rule_name) {
470            result_props.insert(
471                rule_name.clone(),
472                json!({
473                    "$ref": "#/components/schemas/LemmaRuleResult"
474                }),
475            );
476        }
477    }
478
479    json!({
480        "type": "object",
481        "required": ["spec", "effective", "result"],
482        "properties": {
483            "spec": {
484                "type": "string",
485                "description": "Spec set id that was evaluated"
486            },
487            "effective": {
488                "type": "string",
489                "description": "Evaluation instant used for temporal resolution (matches request instant unless overridden)"
490            },
491            "result": {
492                "type": "object",
493                "description": "Rule names to evaluation results (definition order in response; keys match ?rules= filter when set)",
494                "properties": Value::Object(result_props)
495            }
496        }
497    })
498}
499
500// ---------------------------------------------------------------------------
501// Spec path items
502// ---------------------------------------------------------------------------
503
504fn x_explanations_header_parameter() -> Value {
505    json!({
506        "name": "x-explanations",
507        "in": "header",
508        "required": false,
509        "description": "Set to request explanation objects in the response (server must be started with --explanations)",
510        "schema": { "type": "string", "default": "true" }
511    })
512}
513
514fn accept_datetime_header_parameter() -> Value {
515    json!({
516        "name": "Accept-Datetime",
517        "in": "header",
518        "required": false,
519        "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit to evaluate at the request instant (now).",
520        "schema": { "type": "string", "format": "date-time" },
521        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
522    })
523}
524
525/// Build the PathItem for `/{spec_name}` (GET schema + POST evaluate).
526///
527/// `effective_range` is the half-open `[effective_from, effective_to)`
528/// validity range of the temporal version resolved at the OpenAPI document's
529/// effective instant. Both bounds are emitted as the `x-effective-from` /
530/// `x-effective-to` vendor extensions on the PathItem so tooling can render
531/// the active version's window without having to inspect the `versions`
532/// array. `None` in either position (unbounded start for the first row,
533/// unbounded end for the latest row) is serialised as JSON `null`.
534fn build_spec_path_item(
535    spec_name: &str,
536    get_response_schema_name: &str,
537    evaluate_response_schema_name: &str,
538    post_body_schema_name: &str,
539    rule_names: &[String],
540    explanations_enabled: bool,
541    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
542) -> Value {
543    let (effective_from, effective_to) = effective_range;
544
545    let get_schema_ref = json!({
546        "$ref": format!("#/components/schemas/{}", get_response_schema_name)
547    });
548    let evaluate_schema_ref = json!({
549        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
550    });
551    let body_ref = json!({
552        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
553    });
554
555    let tag = spec_name.replace('.', "_");
556
557    let rules_example = if rule_names.is_empty() {
558        String::new()
559    } else {
560        rule_names.join(",")
561    };
562
563    let rules_param = json!({
564        "name": "rules",
565        "in": "query",
566        "required": false,
567        "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
568        "schema": { "type": "string" },
569        "example": rules_example
570    });
571
572    let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
573    get_parameters.push(accept_datetime_header_parameter());
574    if explanations_enabled {
575        get_parameters.push(x_explanations_header_parameter());
576    }
577
578    let get_summary = "Schema of resolved version (spec, data, rules, meta, versions)".to_string();
579    let post_summary = "Evaluate".to_string();
580    let get_operation_id = format!("get_{}", spec_name);
581    let post_operation_id = format!("post_{}", spec_name);
582
583    let mut post_parameters: Vec<Value> = vec![rules_param];
584    post_parameters.push(accept_datetime_header_parameter());
585    if explanations_enabled {
586        post_parameters.push(x_explanations_header_parameter());
587    }
588
589    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
590        match dt {
591            Some(d) => Value::String(d.to_string()),
592            None => Value::Null,
593        }
594    };
595
596    json!({
597        "x-effective-from": datetime_or_null(effective_from),
598        "x-effective-to": datetime_or_null(effective_to),
599        "get": {
600            "operationId": get_operation_id,
601            "summary": get_summary,
602            "tags": [tag],
603            "parameters": get_parameters,
604            "responses": {
605                "200": {
606                    "description": "Schema of resolved version (spec_set_id, effective_from, data, rules, meta, versions).",
607                    "headers": memento_spec_response_headers(),
608                    "content": {
609                        "application/json": {
610                            "schema": get_schema_ref
611                        }
612                    }
613                },
614                "400": error_response_schema(),
615                "404": not_found_response_schema()
616            }
617        },
618        "post": {
619            "operationId": post_operation_id,
620            "summary": post_summary,
621            "tags": [tag],
622            "parameters": post_parameters,
623            "requestBody": {
624                "required": true,
625                "content": {
626                    "application/x-www-form-urlencoded": {
627                        "schema": body_ref
628                    }
629                }
630            },
631            "responses": {
632                "200": {
633                    "description": "Evaluation envelope: spec, effective, result (per-rule RuleResultJson).",
634                    "headers": memento_spec_response_headers(),
635                    "content": {
636                        "application/json": {
637                            "schema": evaluate_schema_ref
638                        }
639                    }
640                },
641                "400": error_response_schema(),
642                "404": not_found_response_schema()
643            }
644        }
645    })
646}
647
648// ---------------------------------------------------------------------------
649// Help and default from Lemma types
650// ---------------------------------------------------------------------------
651
652/// Extract the type's help text for use as description. Always has a value for non-Veto types.
653fn type_help(lemma_type: &LemmaType) -> String {
654    match &lemma_type.specifications {
655        TypeSpecification::Boolean { help, .. } => help.clone(),
656        TypeSpecification::Scale { help, .. } => help.clone(),
657        TypeSpecification::Number { help, .. } => help.clone(),
658        TypeSpecification::Ratio { help, .. } => help.clone(),
659        TypeSpecification::Text { help, .. } => help.clone(),
660        TypeSpecification::Date { help, .. } => help.clone(),
661        TypeSpecification::Time { help, .. } => help.clone(),
662        TypeSpecification::Duration { help, .. } => help.clone(),
663        TypeSpecification::Veto { .. } => String::new(),
664        TypeSpecification::Undetermined => unreachable!(
665            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
666        ),
667    }
668}
669
670// ---------------------------------------------------------------------------
671// POST request body schema generation (form-encoded — all string values)
672// ---------------------------------------------------------------------------
673
674fn build_post_request_schema(data: &[InputData]) -> Value {
675    let mut properties = Map::new();
676    let mut required = Vec::new();
677
678    for data in data {
679        let default_for_docs = data
680            .bound_value
681            .as_ref()
682            .or(data.suggestion_default.as_ref());
683        properties.insert(
684            data.name.clone(),
685            build_post_property_schema(&data.lemma_type, default_for_docs),
686        );
687        if data.bound_value.is_none() && data.suggestion_default.is_none() {
688            required.push(Value::String(data.name.clone()));
689        }
690    }
691
692    let mut schema = json!({
693        "type": "object",
694        "properties": Value::Object(properties)
695    });
696    if !required.is_empty() {
697        schema["required"] = Value::Array(required);
698    }
699    schema
700}
701
702fn build_post_property_schema(
703    lemma_type: &LemmaType,
704    data_value: Option<&lemma::LiteralValue>,
705) -> Value {
706    let mut schema = build_post_type_schema(lemma_type);
707
708    let help = type_help(lemma_type);
709    if !help.is_empty() {
710        schema["description"] = Value::String(help);
711    }
712
713    if let Some(v) = data_value {
714        schema["default"] = Value::String(v.display_value());
715    }
716
717    schema
718}
719
720fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
721    match &lemma_type.specifications {
722        TypeSpecification::Text { options, .. } => {
723            let mut schema = json!({ "type": "string" });
724            if !options.is_empty() {
725                schema["enum"] =
726                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
727            }
728            schema
729        }
730        TypeSpecification::Boolean { .. } => {
731            json!({ "type": "string", "enum": ["true", "false"] })
732        }
733        _ => json!({ "type": "string" }),
734    }
735}
736
737// ---------------------------------------------------------------------------
738// Helpers
739// ---------------------------------------------------------------------------
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use lemma::parsing::ast::DateTimeValue;
745    use lemma::SourceType;
746
747    fn create_engine_with_code(code: &str) -> Engine {
748        let mut engine = Engine::new();
749        engine
750            .load(
751                code,
752                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
753            )
754            .expect("failed to parse lemma code");
755        engine
756    }
757
758    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
759        let mut engine = Engine::new();
760        for (name, code) in files {
761            engine
762                .load(
763                    code,
764                    SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
765                )
766                .expect("failed to parse lemma code");
767        }
768        engine
769    }
770
771    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
772        DateTimeValue {
773            year,
774            month,
775            day,
776            hour: 0,
777            minute: 0,
778            second: 0,
779            microsecond: 0,
780            timezone: None,
781        }
782    }
783
784    fn has_param(params: &Value, name: &str) -> bool {
785        params
786            .as_array()
787            .map(|a| a.iter().any(|p| p["name"] == name))
788            .unwrap_or(false)
789    }
790
791    // =======================================================================
792    // Basic spec structure (pre-existing, adapted)
793    // =======================================================================
794
795    #[test]
796    fn test_generate_openapi_x_tag_groups() {
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        let groups = spec["x-tagGroups"]
805            .as_array()
806            .expect("x-tagGroups should be array");
807        assert_eq!(groups.len(), 2);
808        assert_eq!(groups[0]["name"], "Overview");
809        assert_eq!(groups[0]["tags"], json!(["Specs"]));
810        assert_eq!(groups[1]["name"], "Specs");
811        assert_eq!(groups[1]["tags"], json!(["pricing"]));
812    }
813
814    #[test]
815    fn test_spec_path_has_get_and_post() {
816        let engine = create_engine_with_code(
817            "spec pricing
818            data quantity: 10
819            rule total: quantity * 2",
820        );
821        let spec = generate_openapi(&engine, false);
822
823        assert!(
824            spec["paths"]["/pricing"].is_object(),
825            "single spec path /pricing"
826        );
827        assert!(spec["paths"]["/pricing"]["get"].is_object());
828        assert!(spec["paths"]["/pricing"]["post"].is_object());
829
830        assert_eq!(
831            spec["paths"]["/pricing"]["get"]["operationId"],
832            "get_pricing"
833        );
834        assert_eq!(
835            spec["paths"]["/pricing"]["post"]["operationId"],
836            "post_pricing"
837        );
838        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
839
840        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
841            .as_array()
842            .expect("parameters array");
843        let param_names: Vec<&str> = get_params
844            .iter()
845            .map(|p| p["name"].as_str().unwrap())
846            .collect();
847        assert!(
848            param_names.contains(&"rules"),
849            "GET must have rules query param"
850        );
851        assert!(
852            param_names.contains(&"Accept-Datetime"),
853            "GET must have Accept-Datetime header"
854        );
855
856        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
857            ["application/json"]["schema"]["$ref"]
858            .as_str()
859            .unwrap();
860        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
861            ["application/json"]["schema"]["$ref"]
862            .as_str()
863            .unwrap();
864        assert_eq!(get_ref, "#/components/schemas/pricing_get_response");
865        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
866        assert_ne!(get_ref, post_ref);
867
868        let get_schema = &spec["components"]["schemas"]["pricing_get_response"];
869        assert!(get_schema["properties"]["spec_set_id"]["type"] == "string");
870        assert!(get_schema["properties"]["versions"].is_object());
871
872        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
873        assert!(h200["headers"]["Memento-Datetime"].is_object());
874        assert!(h200["headers"]["Vary"].is_object());
875    }
876
877    /// The generated OpenAPI document describes the public spec surface only.
878    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
879    /// intentionally omitted; consumers must not rely on them for code
880    /// generation or contract inspection.
881    #[test]
882    fn test_openapi_omits_shell_and_legacy_schema_routes() {
883        let engine = create_engine_with_code(
884            "spec pricing
885            data quantity: 10
886            rule total: quantity * 2",
887        );
888        let spec = generate_openapi(&engine, false);
889
890        let paths = spec["paths"].as_object().expect("paths object");
891        assert!(paths.contains_key("/"));
892        assert_eq!(paths["/"]["get"]["operationId"], "list");
893        assert!(!paths.contains_key("/openapi.json"));
894        assert!(!paths.contains_key("/health"));
895        assert!(!paths.contains_key("/docs"));
896        assert!(!paths.contains_key("/schema/pricing"));
897        assert!(!paths.contains_key("/schema/pricing/{rules}"));
898        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
899    }
900
901    #[test]
902    fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
903        let engine = create_engine_with_code(
904            "spec pricing
905            data quantity: 10
906            rule total: quantity * 2",
907        );
908        let spec = generate_openapi(&engine, true);
909
910        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
911        assert!(has_param(get_params, "x-explanations"));
912
913        let rule_result = &spec["components"]["schemas"]["LemmaRuleResult"];
914        assert!(rule_result["properties"]["explanation"].is_object());
915        assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
916        assert!(rule_result["properties"]["rule_type"]["type"] == "string");
917
918        let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
919        assert!(evaluate["required"]
920            .as_array()
921            .unwrap()
922            .contains(&json!("spec")));
923        assert!(evaluate["required"]
924            .as_array()
925            .unwrap()
926            .contains(&json!("effective")));
927        assert!(evaluate["required"]
928            .as_array()
929            .unwrap()
930            .contains(&json!("result")));
931        let total_ref = evaluate["properties"]["result"]["properties"]["total"]["$ref"]
932            .as_str()
933            .unwrap();
934        assert_eq!(total_ref, "#/components/schemas/LemmaRuleResult");
935    }
936
937    #[test]
938    fn test_generate_openapi_multiple_specs() {
939        let engine = create_engine_with_files(vec![
940            (
941                "pricing.lemma",
942                "spec pricing
943                data quantity: 10
944                rule total: quantity * 2",
945            ),
946            (
947                "shipping.lemma",
948                "spec shipping
949                data weight: 5
950                rule cost: weight * 3",
951            ),
952        ]);
953        let spec = generate_openapi(&engine, false);
954
955        assert!(spec["paths"]["/pricing"].is_object());
956        assert!(spec["paths"]["/shipping"].is_object());
957    }
958
959    #[test]
960    fn test_nested_spec_path_schema_refs_are_valid() {
961        let engine = create_engine_with_code(
962            "spec bc
963        data x: number
964        rule result: x",
965        );
966        let spec = generate_openapi(&engine, false);
967
968        assert!(spec["paths"]["/bc"]["post"].is_object());
969        let body_ref = spec["paths"]["/bc"]["post"]["requestBody"]["content"]
970            ["application/x-www-form-urlencoded"]["schema"]["$ref"]
971            .as_str()
972            .unwrap();
973        assert_eq!(body_ref, "#/components/schemas/bc_request");
974        assert!(spec["components"]["schemas"]["bc_request"].is_object());
975        assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
976    }
977
978    // =======================================================================
979    // generate_openapi_effective with explicit timestamp
980    // =======================================================================
981
982    #[test]
983    fn test_generate_openapi_effective_reflects_specific_time() {
984        let engine = create_engine_with_code(
985            "spec pricing
986            data quantity: 10
987            rule total: quantity * 2",
988        );
989        let effective = date(2025, 6, 15);
990        let spec = generate_openapi_effective(&engine, false, &effective);
991
992        assert_eq!(spec["openapi"], "3.1.0");
993        let version = spec["info"]["version"].as_str().unwrap();
994        assert!(
995            version.contains("2025-06-15"),
996            "version string should contain the effective date, got: {}",
997            version
998        );
999    }
1000
1001    #[test]
1002    fn test_effective_shows_correct_temporal_version_interface() {
1003        let engine = create_engine_with_files(vec![(
1004            "policy.lemma",
1005            r#"
1006spec policy
1007data base: 100
1008rule discount: 10
1009
1010spec policy 2025-06-01
1011data base: 200
1012data premium: boolean
1013rule discount: 20
1014rule surcharge:
1015  5
1016  unless premium then 10
1017"#,
1018        )]);
1019
1020        let before = date(2025, 3, 1);
1021        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1022
1023        assert!(spec_v1["paths"]["/policy"].is_object());
1024        let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1025        let v1_result = &v1_evaluate["properties"]["result"]["properties"];
1026        assert_eq!(
1027            v1_result["discount"]["$ref"].as_str(),
1028            Some("#/components/schemas/LemmaRuleResult"),
1029            "v1 should have discount rule"
1030        );
1031        assert!(
1032            v1_result["surcharge"].is_null(),
1033            "v1 must NOT have surcharge rule"
1034        );
1035        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1036        assert!(
1037            v1_request["properties"]["premium"].is_null(),
1038            "v1 must NOT have premium data"
1039        );
1040
1041        let after = date(2025, 8, 1);
1042        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1043
1044        let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1045        let v2_result = &v2_evaluate["properties"]["result"]["properties"];
1046        assert!(
1047            v2_result["discount"]["$ref"].is_string(),
1048            "v2 should have discount rule"
1049        );
1050        assert!(
1051            v2_result["surcharge"]["$ref"].is_string(),
1052            "v2 should have surcharge rule"
1053        );
1054        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1055        assert!(
1056            v2_request["properties"]["premium"].is_object(),
1057            "v2 should have premium data"
1058        );
1059    }
1060
1061    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1062    /// describing the half-open `[effective_from, effective_to)` validity
1063    /// range of the version resolved at the document's effective instant.
1064    ///
1065    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1066    /// - Latest row: `x-effective-to` = `null` (no successor).
1067    /// - Unversioned spec (no declared `effective_from`): both extensions are
1068    ///   `null`.
1069    #[test]
1070    fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1071        let engine = create_engine_with_files(vec![(
1072            "policy.lemma",
1073            r#"
1074spec policy 2025-01-01
1075data base: 10
1076rule total: base
1077
1078spec policy 2026-01-01
1079data base: 99
1080rule total: base
1081"#,
1082        )]);
1083
1084        let at_earlier = date(2025, 6, 1);
1085        let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1086        let earlier_path = &earlier_doc["paths"]["/policy"];
1087        assert_eq!(
1088            earlier_path["x-effective-from"].as_str(),
1089            Some("2025-01-01"),
1090            "earlier version effective_from on PathItem"
1091        );
1092        assert_eq!(
1093            earlier_path["x-effective-to"].as_str(),
1094            Some("2026-01-01"),
1095            "earlier version effective_to equals next version's effective_from"
1096        );
1097
1098        let at_latest = date(2026, 6, 1);
1099        let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1100        let latest_path = &latest_doc["paths"]["/policy"];
1101        assert_eq!(
1102            latest_path["x-effective-from"].as_str(),
1103            Some("2026-01-01"),
1104            "latest version effective_from on PathItem"
1105        );
1106        assert!(
1107            latest_path["x-effective-to"].is_null(),
1108            "latest version has no successor; x-effective-to must be null: {latest_path}"
1109        );
1110    }
1111
1112    /// Unversioned specs (no declared `effective_from`) have both extensions
1113    /// serialised as JSON `null`, not omitted.
1114    #[test]
1115    fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1116        let engine = create_engine_with_code(
1117            "spec pricing
1118            data quantity: 10
1119            rule total: quantity * 2",
1120        );
1121        let document = generate_openapi(&engine, false);
1122        let path_item = &document["paths"]["/pricing"];
1123        assert!(
1124            path_item["x-effective-from"].is_null(),
1125            "unversioned spec: x-effective-from must be null: {path_item}"
1126        );
1127        assert!(
1128            path_item["x-effective-to"].is_null(),
1129            "unversioned spec: x-effective-to must be null: {path_item}"
1130        );
1131    }
1132
1133    // =======================================================================
1134    // temporal_api_sources
1135    // =======================================================================
1136
1137    #[test]
1138    fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1139        let engine = create_engine_with_files(vec![(
1140            "policy.lemma",
1141            r#"
1142spec policy
1143data base: 100
1144rule discount: 10
1145
1146spec policy 2025-06-01
1147data base: 200
1148rule discount: 20
1149"#,
1150        )]);
1151
1152        let sources = temporal_api_sources(&engine);
1153
1154        assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1155
1156        assert_eq!(sources[0].title, "Now");
1157        assert_eq!(sources[0].slug, NOW_SLUG);
1158        assert_eq!(sources[0].url, "/openapi.json");
1159
1160        assert_eq!(sources[1].title, "Effective 2025-06-01");
1161        assert_eq!(sources[1].slug, "2025-06-01");
1162        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1163    }
1164
1165    #[test]
1166    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1167        let engine = create_engine_with_files(vec![
1168            (
1169                "policy.lemma",
1170                r#"
1171spec policy
1172data base: 100
1173rule discount: 10
1174
1175spec policy 2025-06-01
1176data base: 200
1177rule discount: 20
1178"#,
1179            ),
1180            (
1181                "rates.lemma",
1182                r#"
1183spec rates
1184data rate: 5
1185rule total: rate * 2
1186
1187spec rates 2025-03-01
1188data rate: 7
1189rule total: rate * 2
1190
1191spec rates 2025-06-01
1192data rate: 9
1193rule total: rate * 2
1194"#,
1195            ),
1196        ]);
1197
1198        let sources = temporal_api_sources(&engine);
1199
1200        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1201        assert!(
1202            slugs.contains(&"2025-03-01"),
1203            "should contain rates boundary"
1204        );
1205        assert!(
1206            slugs.contains(&"2025-06-01"),
1207            "should contain shared boundary"
1208        );
1209        assert!(slugs.contains(&NOW_SLUG), "should contain now");
1210        assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1211    }
1212
1213    #[test]
1214    fn test_temporal_sources_ordered_chronologically() {
1215        let engine = create_engine_with_files(vec![(
1216            "policy.lemma",
1217            r#"
1218spec policy
1219data base: 100
1220rule discount: 10
1221
1222spec policy 2024-01-01
1223data base: 50
1224rule discount: 5
1225
1226spec policy 2025-06-01
1227data base: 200
1228rule discount: 20
1229"#,
1230        )]);
1231
1232        let sources = temporal_api_sources(&engine);
1233        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1234        assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1235    }
1236
1237    // =======================================================================
1238    // Type-specific parameter tests
1239    // =======================================================================
1240
1241    #[test]
1242    fn test_post_schema_text_with_options_has_enum() {
1243        let engine = create_engine_with_code(
1244            "spec test
1245            data product: text -> option \"A\" -> option \"B\"
1246            rule result: product",
1247        );
1248        let spec = generate_openapi(&engine, false);
1249
1250        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1251        assert!(product_prop["enum"].is_array());
1252        let enums = product_prop["enum"].as_array().unwrap();
1253        assert_eq!(enums.len(), 2);
1254        assert_eq!(enums[0], "A");
1255        assert_eq!(enums[1], "B");
1256    }
1257
1258    #[test]
1259    fn test_post_schema_boolean_is_string_with_enum() {
1260        let engine = create_engine_with_code(
1261            "spec test
1262            data is_active: boolean
1263            rule result: is_active",
1264        );
1265        let spec = generate_openapi(&engine, false);
1266
1267        let schema = &spec["components"]["schemas"]["test_request"];
1268        let is_active = &schema["properties"]["is_active"];
1269        assert_eq!(is_active["type"], "string");
1270        assert_eq!(is_active["enum"], json!(["true", "false"]));
1271    }
1272
1273    #[test]
1274    fn test_post_schema_number_is_string() {
1275        let engine = create_engine_with_code(
1276            "spec test
1277            data quantity: number
1278            rule result: quantity",
1279        );
1280        let spec = generate_openapi(&engine, false);
1281
1282        let schema = &spec["components"]["schemas"]["test_request"];
1283        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1284    }
1285
1286    #[test]
1287    fn test_data_with_default_is_not_required() {
1288        let engine = create_engine_with_code(
1289            "spec test
1290            data quantity: 10
1291            data name: text
1292            rule result: quantity
1293            rule label: name",
1294        );
1295        let spec = generate_openapi(&engine, false);
1296
1297        let schema = &spec["components"]["schemas"]["test_request"];
1298        let required = schema["required"]
1299            .as_array()
1300            .expect("required should be array");
1301
1302        assert!(required.contains(&Value::String("name".to_string())));
1303        assert!(!required.contains(&Value::String("quantity".to_string())));
1304    }
1305
1306    #[test]
1307    fn test_help_and_default_in_openapi() {
1308        let engine = create_engine_with_code(
1309            r#"spec test
1310data quantity: number -> help "Number of items to order" -> default 10
1311data active: boolean -> help "Whether the feature is enabled" -> default true
1312rule result:
1313  quantity
1314  unless active then 0
1315"#,
1316        );
1317        let spec = generate_openapi(&engine, false);
1318
1319        let req_schema = &spec["components"]["schemas"]["test_request"];
1320        assert!(req_schema["properties"]["quantity"]["description"]
1321            .as_str()
1322            .unwrap()
1323            .contains("Number of items to order"));
1324        assert_eq!(
1325            req_schema["properties"]["quantity"]["default"]
1326                .as_str()
1327                .unwrap(),
1328            "10"
1329        );
1330        assert!(req_schema["properties"]["active"]["description"]
1331            .as_str()
1332            .unwrap()
1333            .contains("Whether the feature is enabled"));
1334        assert_eq!(
1335            req_schema["properties"]["active"]["default"]
1336                .as_str()
1337                .unwrap(),
1338            "true"
1339        );
1340    }
1341}