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::{DateTimeValue, Engine, SpecVersion};
19use serde_json::{json, Map, Value};
20use std::collections::BTreeSet;
21use std::sync::OnceLock;
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/// The Show document definition, hoisted from `api.v1.json` `$defs/Show`.
27pub const SHOW_SCHEMA_REF: &str = "#/components/schemas/Show";
28
29/// A single Scalar API reference source entry.
30///
31/// Each temporal version boundary gets its own source so Scalar renders a
32/// version switcher in the UI.
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct ApiSource {
35    pub title: String,
36    pub slug: String,
37    pub url: String,
38}
39
40/// Compute the list of Scalar multi-source entries for temporal versioning.
41///
42/// Returns one [`ApiSource`] per distinct temporal version boundary across all
43/// loaded specs, plus one **now** source (slug [`NOW_SLUG`]) that uses no `effective`
44/// query (evaluation instant = request time). That entry is first (Scalar default),
45/// then boundaries in descending chronological order (newest first).
46///
47/// If there are no temporal version boundaries (all specs are unversioned),
48/// returns a single **now** entry.
49pub fn temporal_api_sources(engine: &Engine) -> Vec<ApiSource> {
50    let mut all_boundaries: std::collections::BTreeSet<DateTimeValue> =
51        std::collections::BTreeSet::new();
52
53    for repo in engine.specs() {
54        for version in &repo.specs {
55            if let Some(af) = &version.effective_from {
56                all_boundaries.insert(af.clone());
57            }
58        }
59    }
60
61    if all_boundaries.is_empty() {
62        return vec![ApiSource {
63            title: "Now".to_string(),
64            slug: NOW_SLUG.to_string(),
65            url: "/openapi.json".to_string(),
66        }];
67    }
68
69    let mut sources: Vec<ApiSource> = Vec::with_capacity(all_boundaries.len() + 1);
70
71    sources.push(ApiSource {
72        title: "Now".to_string(),
73        slug: NOW_SLUG.to_string(),
74        url: "/openapi.json".to_string(),
75    });
76
77    for boundary in all_boundaries.iter().rev() {
78        let label = boundary.to_string();
79        sources.push(ApiSource {
80            title: format!("Effective {}", label),
81            slug: label.clone(),
82            url: format!("/openapi.json?effective={}", label),
83        });
84    }
85
86    sources
87}
88
89/// Generate a complete OpenAPI 3.1 specification using the current time.
90///
91/// Convenience wrapper around [`generate_openapi_effective`]. The document reflects
92/// only the specs and interfaces active at `DateTimeValue::now()`.
93pub fn generate_openapi(engine: &Engine, explain: bool) -> Value {
94    generate_openapi_effective(engine, explain, &DateTimeValue::now())
95}
96
97/// Generate a complete OpenAPI 3.1 specification for a specific point in time.
98///
99/// The specification includes:
100/// - `GET /` — loaded specs (`Engine::specs`)
101/// - `/{spec}` GET (show: `Show` from `api.v1.json`) and POST (evaluate: `Response` /
102///   `RuleResult` from `api.v1.json`) with optional `Accept-Datetime` header
103/// - `?rules=` on POST only, to limit evaluated rules
104/// - `x-effective-from` / `x-effective-to` vendor extensions on each PathItem
105///   exposing the half-open `[effective_from, effective_to)` range of the version
106///   resolved at the document's effective instant (both `null` when unbounded)
107///
108/// CLI `lemma server` also exposes shell routes (`/openapi.json`, `/health`, `/docs`) that are
109/// intentionally omitted from the generated document.
110///
111/// When `explain` is true, the document adds the `x-explain` header parameter
112/// to evaluation operations and describes the optional `explanation` field on rule results.
113pub fn generate_openapi_effective(
114    engine: &Engine,
115    explain: bool,
116    effective: &DateTimeValue,
117) -> Value {
118    let mut paths = Map::new();
119    let mut components_schemas = Map::new();
120
121    components_schemas.extend(hoisted_show_component_schemas().clone());
122    let mut row_schemas: std::collections::BTreeMap<String, Value> =
123        std::collections::BTreeMap::new();
124
125    let repositories = engine.specs();
126    let workspace = repositories
127        .iter()
128        .find(|r| r.repository.is_none())
129        .map(|r| r.specs.as_slice())
130        .expect("BUG: default repository must exist in specs()");
131
132    let is_active = |version: &SpecVersion| -> bool {
133        let after_start = match &version.effective_from {
134            None => true,
135            Some(from) => effective >= from,
136        };
137        let before_end = match &version.effective_to {
138            None => true,
139            Some(to) => effective < to,
140        };
141        after_start && before_end
142    };
143
144    let active_specs: Vec<&SpecVersion> = workspace
145        .iter()
146        .filter(|version| is_active(version))
147        .collect();
148
149    let unique_spec_names: std::collections::BTreeSet<&str> = active_specs
150        .iter()
151        .map(|version| version.name.as_str())
152        .collect();
153    let unique_spec_names: Vec<String> = unique_spec_names.into_iter().map(String::from).collect();
154
155    paths.insert("/".to_string(), index_path_item(engine));
156
157    let mut tag_descriptions: std::collections::BTreeMap<String, String> =
158        std::collections::BTreeMap::new();
159
160    for version in &active_specs {
161        let spec_name = &version.name;
162        if let Ok(show) = engine.show(None, spec_name, Some(effective)) {
163            let tag_description = show
164                .commentary
165                .clone()
166                .unwrap_or_else(|| format!("Show and evaluate `{spec_name}`"));
167            tag_descriptions.insert(spec_name.clone(), tag_description);
168
169            let artifacts = build_spec_openapi_artifacts(
170                spec_name,
171                &show,
172                &lemma::api::show_document(engine, &show),
173                (
174                    version.effective_from.as_ref(),
175                    version.effective_to.as_ref(),
176                ),
177                explain,
178            );
179            paths.insert(format!("/{spec_name}"), artifacts.path_item);
180            for (name, schema_value) in artifacts.component_schemas {
181                components_schemas.insert(name, schema_value);
182            }
183            for (name, schema_value) in artifacts.row_schemas {
184                match row_schemas.get(&name) {
185                    Some(existing) => assert_eq!(
186                        existing, &schema_value,
187                        "BUG: row schema '{name}' names one row spec, so every Show document renders it identically"
188                    ),
189                    None => {
190                        row_schemas.insert(name, schema_value);
191                    }
192                }
193            }
194        }
195    }
196    components_schemas.extend(row_schemas);
197
198    let mut tags = vec![json!({
199        "name": "Specs",
200        "description": "Loaded specs"
201    })];
202    for spec_name in &unique_spec_names {
203        let safe_tag = spec_name.replace('.', "_");
204        let description = tag_descriptions
205            .get(spec_name)
206            .cloned()
207            .unwrap_or_else(|| format!("Show and evaluate `{spec_name}`"));
208        tags.push(json!({
209            "name": safe_tag,
210            "x-displayName": spec_name,
211            "description": description
212        }));
213    }
214
215    let spec_tags: Vec<Value> = unique_spec_names
216        .iter()
217        .map(|n| Value::String(n.replace('.', "_")))
218        .collect();
219
220    let tag_groups = vec![
221        json!({ "name": "Overview", "tags": ["Specs"] }),
222        json!({ "name": "Specs", "tags": spec_tags }),
223    ];
224
225    let version_label = format!("{} (effective {})", env!("CARGO_PKG_VERSION"), effective);
226
227    json!({
228        "openapi": "3.1.0",
229        "info": {
230            "title": "Lemma API",
231            "description": "Lemma is a declarative language for business logic: pricing, tax, eligibility, contracts, and policies. This API returns, shows, and evaluates loaded specs. Learn more at [LemmaBase.com](https://lemmabase.com).",
232            "version": version_label
233        },
234        "tags": tags,
235        "x-tagGroups": tag_groups,
236        "paths": Value::Object(paths),
237        "components": {
238            "schemas": Value::Object(components_schemas)
239        }
240    })
241}
242
243// ---------------------------------------------------------------------------
244// Index path (loaded specs)
245// ---------------------------------------------------------------------------
246
247fn index_path_item(engine: &Engine) -> Value {
248    let repositories = engine.specs();
249    let example = serde_json::to_value(&repositories).expect("BUG: Engine::specs must serialize");
250
251    json!({
252        "get": {
253            "operationId": "specs",
254            "summary": "Loaded specs",
255            "tags": ["Specs"],
256            "responses": {
257                "200": {
258                    "description": "Loaded repositories and their specs",
259                    "content": {
260                        "application/json": {
261                            "schema": {
262                                "type": "array",
263                                "items": {
264                                    "type": "object",
265                                    "properties": {
266                                        "repository": {
267                                            "type": ["string", "null"],
268                                            "description": "Repository name. Null for the default repository."
269                                        },
270                                        "specs": {
271                                            "type": "array",
272                                            "description": "Specs in this repository",
273                                            "items": {
274                                                "type": "object",
275                                                "properties": {
276                                                    "name": {
277                                                        "type": "string",
278                                                        "description": "Spec name"
279                                                    },
280                                                    "effective_from": {
281                                                        "type": ["string", "null"],
282                                                        "description": "When this version starts. Null if unbounded."
283                                                    },
284                                                    "effective_to": {
285                                                        "type": ["string", "null"],
286                                                        "description": "When this version ends (exclusive). Null if this is the latest."
287                                                    }
288                                                },
289                                                "required": ["name"]
290                                            }
291                                        }
292                                    },
293                                    "required": ["specs"]
294                                }
295                            },
296                            "example": example
297                        }
298                    }
299                }
300            }
301        }
302    })
303}
304
305// ---------------------------------------------------------------------------
306// Shared response schemas
307// ---------------------------------------------------------------------------
308
309fn error_response_schema() -> Value {
310    json!({
311        "description": "Bad request",
312        "content": {
313            "application/json": {
314                "schema": {
315                    "type": "object",
316                    "properties": {
317                        "error": { "type": "string" }
318                    },
319                    "required": ["error"]
320                }
321            }
322        }
323    })
324}
325
326fn not_found_response_schema() -> Value {
327    json!({
328        "description": "Spec not found",
329        "content": {
330            "application/json": {
331                "schema": {
332                    "type": "object",
333                    "properties": {
334                        "error": { "type": "string" }
335                    },
336                    "required": ["error"]
337                }
338            }
339        }
340    })
341}
342
343fn memento_spec_response_headers() -> Value {
344    json!({
345        "Memento-Datetime": {
346            "description": "`effective_from` of the resolved version, when versioned",
347            "schema": { "type": "string" }
348        },
349        "Vary": {
350            "description": "Varies with Accept-Datetime",
351            "schema": { "type": "string", "example": "Accept-Datetime" }
352        }
353    })
354}
355
356const JSON_SCHEMA_METASCHEMA: &str = "https://json-schema.org/draft/2020-12/schema";
357const DEFS_REF_PREFIX: &str = "#/$defs/";
358
359/// `$defs/Show` and evaluate `RuleResult` / `RuleResultValue` / row-spec defs, plus
360/// each local `$ref` closure, rewritten to `#/components/schemas/...`.
361/// Drops the JSON Schema metaschema `allOf` `$ref` so OpenAPI UIs do not fetch it.
362fn hoisted_show_component_schemas() -> &'static Map<String, Value> {
363    static SCHEMAS: OnceLock<Map<String, Value>> = OnceLock::new();
364    SCHEMAS.get_or_init(build_hoisted_show_component_schemas)
365}
366
367const EVALUATE_DEF_SEEDS: &[&str] = &[
368    "RuleResult",
369    "RuleResultValue",
370    "SpecResult",
371    "SpecRuleResult",
372    "RowReference",
373    "RowResult",
374    "LemmaType",
375];
376
377fn build_hoisted_show_component_schemas() -> Map<String, Value> {
378    let document: Value = serde_json::from_str(lemma::API_V1_SCHEMA_JSON)
379        .expect("BUG: engine/schemas/api.v1.json must be JSON");
380    let defs = document["$defs"]
381        .as_object()
382        .expect("BUG: api.v1.json must have $defs");
383    let show = show_object_without_metaschema(defs);
384    let mut needed: BTreeSet<String> = BTreeSet::new();
385    collect_defs_ref_names(&show, &mut needed);
386    for seed in EVALUATE_DEF_SEEDS {
387        needed.insert((*seed).to_string());
388    }
389    let mut pending: Vec<String> = needed.iter().cloned().collect();
390    while let Some(name) = pending.pop() {
391        let def = defs
392            .get(&name)
393            .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"));
394        let mut more = BTreeSet::new();
395        collect_defs_ref_names(def, &mut more);
396        for extra in more {
397            if needed.insert(extra.clone()) {
398                pending.push(extra);
399            }
400        }
401    }
402    let mut schemas = Map::new();
403    let mut show = show;
404    rewrite_defs_refs(&mut show);
405    schemas.insert("Show".to_string(), show);
406    for name in needed {
407        let mut schema = defs
408            .get(&name)
409            .unwrap_or_else(|| panic!("BUG: api.v1.json $defs missing {name}"))
410            .clone();
411        rewrite_defs_refs(&mut schema);
412        schemas.insert(name, schema);
413    }
414    schemas
415}
416
417fn show_object_without_metaschema(defs: &Map<String, Value>) -> Value {
418    let show = defs
419        .get("Show")
420        .expect("BUG: api.v1.json $defs must define Show");
421    let all_of = show["allOf"]
422        .as_array()
423        .expect("BUG: $defs/Show must be allOf[metaschema, object]");
424    assert_eq!(
425        all_of.len(),
426        2,
427        "BUG: $defs/Show allOf must have metaschema $ref then object"
428    );
429    assert_eq!(
430        all_of[0]["$ref"].as_str(),
431        Some(JSON_SCHEMA_METASCHEMA),
432        "BUG: $defs/Show allOf[0] must $ref the JSON Schema 2020-12 metaschema"
433    );
434    let mut object = all_of[1].clone();
435    if let Some(description) = show.get("description") {
436        object["description"] = description.clone();
437    }
438    object
439}
440
441fn collect_defs_ref_names(value: &Value, names: &mut BTreeSet<String>) {
442    let mut pending: Vec<&Value> = vec![value];
443    while let Some(value) = pending.pop() {
444        match value {
445            Value::Object(map) => {
446                if let Some(Value::String(target)) = map.get("$ref") {
447                    if let Some(name) = target.strip_prefix(DEFS_REF_PREFIX) {
448                        assert!(
449                            !name.contains('/'),
450                            "BUG: nested JSON pointer $ref {target} is not a $defs name"
451                        );
452                        names.insert(name.to_string());
453                    } else {
454                        assert_eq!(
455                            target, JSON_SCHEMA_METASCHEMA,
456                            "BUG: unexpected $ref {target} in api.v1.json Show closure"
457                        );
458                    }
459                }
460                pending.extend(map.values());
461            }
462            Value::Array(items) => pending.extend(items.iter()),
463            _ => {}
464        }
465    }
466}
467
468/// GET `/{spec}` body: the Show document, a JSON Schema for the spec's evaluation-data inputs.
469fn build_get_show_response() -> Value {
470    json!({
471        "$ref": SHOW_SCHEMA_REF,
472        "description": "Draft 2020-12 JSON Schema for this spec's evaluation-data inputs (properties, required, $defs) with the rule graph under x-lemma-rules."
473    })
474}
475
476/// POST evaluate body: matches engine [`lemma::Response`] JSON shape.
477fn build_evaluate_response_schema(show: &lemma::Show, rule_names: &[String]) -> Value {
478    let mut result_props = Map::new();
479    for rule_name in rule_names {
480        if show.rules.contains_key(rule_name) {
481            result_props.insert(
482                rule_name.clone(),
483                json!({
484                    "$ref": "#/components/schemas/RuleResult"
485                }),
486            );
487        }
488    }
489
490    json!({
491        "type": "object",
492        "required": ["spec", "effective", "results"],
493        "properties": {
494            "spec": {
495                "type": "string",
496                "description": "Spec that was evaluated"
497            },
498            "effective": {
499                "type": "string",
500                "description": "Datetime used to pick the version and evaluate"
501            },
502            "spec_effective_from": {
503                "type": "string",
504                "description": "When the resolved version starts"
505            },
506            "spec_effective_to": {
507                "type": "string",
508                "description": "When the resolved version ends (exclusive). Absent if unbounded."
509            },
510            "results": {
511                "type": "object",
512                "description": "Results by rule name. Honors `?rules=`.",
513                "properties": Value::Object(result_props)
514            },
515            "rows": {
516                "type": "array",
517                "description": "List rows the results reach, in first-reached order. Omitted when none.",
518                "items": { "$ref": "#/components/schemas/RowResult" }
519            }
520        }
521    })
522}
523
524// ---------------------------------------------------------------------------
525// Spec path items
526// ---------------------------------------------------------------------------
527
528struct SpecOpenApiArtifacts {
529    path_item: Value,
530    component_schemas: Map<String, Value>,
531    /// The Show document's `$defs`, refs rewritten to `#/components/schemas/`.
532    row_schemas: Map<String, Value>,
533}
534
535fn spec_component_show_names(spec_name: &str) -> (String, String, String, String) {
536    let safe_name = spec_name.replace('.', "_");
537    (
538        format!("{safe_name}_get_show"),
539        format!("{safe_name}_evaluate_response"),
540        format!("{safe_name}_request"),
541        format!("{safe_name}_form_request"),
542    )
543}
544
545/// Build the PathItem and per-spec component schemas for `/{spec_name}`.
546///
547/// `effective_range` is the half-open `[effective_from, effective_to)`
548/// validity range of the temporal version resolved at the OpenAPI document's
549/// effective instant. Both bounds are emitted as the `x-effective-from` /
550/// `x-effective-to` vendor extensions on the PathItem so tooling can render
551/// the active version's window. `None` in either position (unbounded start for the first row,
552/// unbounded end for the latest row) is serialised as JSON `null`.
553fn build_spec_openapi_artifacts(
554    spec_name: &str,
555    show: &lemma::Show,
556    document: &Value,
557    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
558    explain: bool,
559) -> SpecOpenApiArtifacts {
560    let rule_names: Vec<String> = show.rules.keys().cloned().collect();
561    let (
562        get_show_component_name,
563        evaluate_response_schema_name,
564        post_body_schema_name,
565        post_form_body_schema_name,
566    ) = spec_component_show_names(spec_name);
567
568    let mut component_schemas = Map::new();
569    component_schemas.insert(get_show_component_name.clone(), build_get_show_response());
570    component_schemas.insert(
571        evaluate_response_schema_name.clone(),
572        build_evaluate_response_schema(show, &rule_names),
573    );
574    component_schemas.insert(
575        post_body_schema_name.clone(),
576        build_post_request_schema(document),
577    );
578    // Form bodies are flat string fields only. List inputs need a JSON array, so a form
579    // schema would have to drop them and /docs would hide the list. Offer form only when
580    // every input can be a form field.
581    let form_body =
582        (!show_document_has_list_input(document)).then(|| build_post_form_request_schema(document));
583    if let Some(form_body) = &form_body {
584        component_schemas.insert(post_form_body_schema_name.clone(), form_body.clone());
585    }
586    let mut row_schemas = Map::new();
587    if let Some(defs) = document.get("$defs") {
588        for (name, schema) in defs.as_object().expect("BUG: Show $defs is an object") {
589            let mut schema = schema.clone();
590            rewrite_defs_refs(&mut schema);
591            row_schemas.insert(name.clone(), schema);
592        }
593    }
594
595    let path_item = build_spec_path_item_with_show_refs(
596        spec_name,
597        (
598            &get_show_component_name,
599            &evaluate_response_schema_name,
600            &post_body_schema_name,
601            form_body
602                .as_ref()
603                .map(|_| post_form_body_schema_name.as_str()),
604        ),
605        &rule_names,
606        explain,
607        effective_range,
608    );
609
610    SpecOpenApiArtifacts {
611        path_item,
612        component_schemas,
613        row_schemas,
614    }
615}
616
617fn x_explain_header_parameter() -> Value {
618    json!({
619        "name": "x-explain",
620        "in": "header",
621        "required": false,
622        // Scalar skips optional params unless enabled; keep this on by default.
623        "x-disabled": false,
624        "description": "Include explanations. Server needs `--explain`.",
625        "schema": { "type": "string", "default": "true" }
626    })
627}
628
629fn accept_datetime_header_parameter() -> Value {
630    json!({
631        "name": "Accept-Datetime",
632        "in": "header",
633        "required": false,
634        // Scalar skips optional params unless enabled; keep this on by default.
635        "x-disabled": false,
636        "description": "Evaluate at this datetime (Lemma forms: year, year-month, date, or datetime). Omit the header to evaluate at the current time.",
637        "schema": { "type": "string" },
638        "example": "2025-01-01"
639    })
640}
641
642/// Build the PathItem for `/{spec_name}` (GET show + POST evaluate).
643///
644/// `post_form_body_schema_name` is `None` when the evaluate body has list inputs: form
645/// cannot carry lists, so documenting a form body would omit them from `/docs`.
646fn build_spec_path_item_with_show_refs(
647    spec_name: &str,
648    component_names: (&str, &str, &str, Option<&str>),
649    rule_names: &[String],
650    explain: bool,
651    effective_range: (Option<&DateTimeValue>, Option<&DateTimeValue>),
652) -> Value {
653    let (
654        get_show_component_name,
655        evaluate_response_schema_name,
656        post_body_schema_name,
657        post_form_body_schema_name,
658    ) = component_names;
659    let (effective_from, effective_to) = effective_range;
660
661    let get_show_ref = json!({
662        "$ref": format!("#/components/schemas/{}", get_show_component_name)
663    });
664    let evaluate_schema_ref = json!({
665        "$ref": format!("#/components/schemas/{}", evaluate_response_schema_name)
666    });
667    let body_ref = json!({
668        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
669    });
670
671    let tag = spec_name.replace('.', "_");
672
673    let rules_example = if rule_names.is_empty() {
674        String::new()
675    } else {
676        rule_names.join(",")
677    };
678
679    let rules_param = json!({
680        "name": "rules",
681        "in": "query",
682        "required": false,
683        // Scalar skips optional params unless enabled; keep this on by default so
684        // `/docs` Try Request actually sends `?rules=` (and Accept-Datetime).
685        "x-disabled": false,
686        "description": "Rules to evaluate, comma-separated. Omit for all.",
687        "schema": { "type": "string" },
688        "example": rules_example
689    });
690
691    let mut get_parameters: Vec<Value> = Vec::new();
692    get_parameters.push(accept_datetime_header_parameter());
693    if explain {
694        get_parameters.push(x_explain_header_parameter());
695    }
696
697    let get_operation_id = format!("get_{}", spec_name);
698    let post_operation_id = format!("post_{}", spec_name);
699
700    let mut post_parameters: Vec<Value> = vec![rules_param];
701    post_parameters.push(accept_datetime_header_parameter());
702    if explain {
703        post_parameters.push(x_explain_header_parameter());
704    }
705
706    let datetime_or_null = |dt: Option<&DateTimeValue>| -> Value {
707        match dt {
708            Some(d) => Value::String(d.to_string()),
709            None => Value::Null,
710        }
711    };
712
713    // First media type is Scalar's default body encoding. Form is first only when every
714    // input fits a form field; otherwise JSON alone so list inputs stay in /docs.
715    let request_content = match post_form_body_schema_name {
716        Some(form_name) => json!({
717            "application/x-www-form-urlencoded": {
718                "schema": { "$ref": format!("#/components/schemas/{form_name}") }
719            },
720            "application/json": {
721                "schema": body_ref
722            }
723        }),
724        None => json!({
725            "application/json": {
726                "schema": body_ref
727            }
728        }),
729    };
730
731    json!({
732        "x-effective-from": datetime_or_null(effective_from),
733        "x-effective-to": datetime_or_null(effective_to),
734        "get": {
735            "operationId": get_operation_id,
736            "summary": "Show spec",
737            "tags": [tag],
738            "parameters": get_parameters,
739            "responses": {
740                "200": {
741                    "description": "Spec interface",
742                    "headers": memento_spec_response_headers(),
743                    "content": {
744                        "application/json": {
745                            "schema": get_show_ref
746                        }
747                    }
748                },
749                "400": error_response_schema(),
750                "404": not_found_response_schema()
751            }
752        },
753        "post": {
754            "operationId": post_operation_id,
755            "summary": "Evaluate",
756            "tags": [tag],
757            "parameters": post_parameters,
758            "requestBody": {
759                "required": true,
760                "content": request_content
761            },
762            "responses": {
763                "200": {
764                    "description": "Evaluation results",
765                    "headers": memento_spec_response_headers(),
766                    "content": {
767                        "application/json": {
768                            "schema": evaluate_schema_ref
769                        }
770                    }
771                },
772                "400": error_response_schema(),
773                "404": not_found_response_schema()
774            }
775        }
776    })
777}
778
779/// True when any Show property is a list (`type: array`). Those inputs are JSON-only.
780fn show_document_has_list_input(document: &Value) -> bool {
781    document["properties"]
782        .as_object()
783        .expect("BUG: Show properties is an object")
784        .values()
785        .any(|property| {
786            let scalar = property
787                .get("oneOf")
788                .map_or(property, |alternatives| &alternatives[0]);
789            scalar["type"].as_str() == Some("array")
790        })
791}
792
793// ---------------------------------------------------------------------------
794// POST request bodies, derived from the Show document
795// ---------------------------------------------------------------------------
796
797/// JSON body: the Show document's `properties` and `required`, without the spec-level
798/// annotations. Row `$ref`s point at the hoisted `LemmaRow_*` components.
799fn build_post_request_schema(document: &Value) -> Value {
800    let mut schema = json!({
801        "type": "object",
802        "additionalProperties": true,
803        "properties": document["properties"].clone()
804    });
805    if let Some(required) = document.get("required") {
806        schema["required"] = required.clone();
807    }
808    rewrite_defs_refs(&mut schema);
809    schema
810}
811
812fn rewrite_defs_refs(value: &mut Value) {
813    let mut pending: Vec<&mut Value> = vec![value];
814    while let Some(value) = pending.pop() {
815        match value {
816            Value::Object(map) => {
817                if let Some(Value::String(target)) = map.get_mut("$ref") {
818                    if let Some(name) = target.strip_prefix("#/$defs/") {
819                        *target = format!("#/components/schemas/{name}");
820                    }
821                }
822                pending.extend(map.values_mut());
823            }
824            Value::Array(items) => pending.extend(items.iter_mut()),
825            _ => {}
826        }
827    }
828}
829
830/// Form keywords copied from a Show property: a form field is one literal string.
831/// `pattern` is not copied: Show only uses it for the boolean text alternative, and form
832/// already forces booleans to `enum: ["true","false"]`.
833const FORM_KEYWORDS: [&str; 5] = ["enum", "minLength", "maxLength", "description", "format"];
834
835/// Lists are JSON-only: a form field is one literal, and a boolean is the string
836/// `true`/`false`.
837fn build_post_form_request_schema(document: &Value) -> Value {
838    let mut properties = Map::new();
839    for (name, property) in document["properties"]
840        .as_object()
841        .expect("BUG: Show properties is an object")
842    {
843        let mut field = Map::new();
844        field.insert("type".to_string(), json!("string"));
845        let scalar = property
846            .get("oneOf")
847            .map_or(property, |alternatives| &alternatives[0]);
848        match scalar["type"].as_str() {
849            Some("array") => continue,
850            Some("boolean") => {
851                field.insert("enum".to_string(), json!(["true", "false"]));
852            }
853            Some("string") => {}
854            other => unreachable!("BUG: Show property '{name}' has type {other:?}"),
855        }
856        for keyword in FORM_KEYWORDS {
857            if let Some(value) = scalar.get(keyword).or_else(|| property.get(keyword)) {
858                field.insert(keyword.to_string(), value.clone());
859            }
860        }
861        if let Some(default) = property.get("default") {
862            field.insert("default".to_string(), form_literal(default));
863        }
864        if let Some(examples) = property.get("examples") {
865            let examples = examples
866                .as_array()
867                .expect("BUG: Show examples is an array")
868                .iter()
869                .map(form_literal)
870                .collect();
871            field.insert("examples".to_string(), Value::Array(examples));
872        }
873        properties.insert(name.clone(), Value::Object(field));
874    }
875    let mut schema = json!({
876        "type": "object",
877        "properties": Value::Object(properties)
878    });
879    if let Some(required) = document.get("required") {
880        schema["required"] = required.clone();
881    }
882    schema
883}
884
885fn form_literal(value: &Value) -> Value {
886    match value {
887        Value::String(_) => value.clone(),
888        Value::Bool(boolean) => Value::String(boolean.to_string()),
889        other => unreachable!("BUG: scalar Show literal is a string or boolean, got {other}"),
890    }
891}
892
893// ---------------------------------------------------------------------------
894// Helpers
895// ---------------------------------------------------------------------------
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use lemma::{DateGranularity, DateTimeValue, SourceType};
901
902    fn create_engine_with_code(code: &str) -> Engine {
903        let mut engine = Engine::new();
904        engine
905            .load([(
906                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
907                code.to_string(),
908            )])
909            .expect("failed to parse lemma code");
910        engine
911    }
912
913    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
914        let mut engine = Engine::new();
915        for (name, code) in files {
916            engine
917                .load([(
918                    SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(name))),
919                    code.to_string(),
920                )])
921                .expect("failed to parse lemma code");
922        }
923        engine
924    }
925
926    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
927        DateTimeValue {
928            year,
929            month,
930            day,
931            hour: 0,
932            minute: 0,
933            second: 0,
934            microsecond: 0,
935            timezone: None,
936            granularity: DateGranularity::Full,
937        }
938    }
939
940    fn has_param(params: &Value, name: &str) -> bool {
941        params
942            .as_array()
943            .map(|a| a.iter().any(|p| p["name"] == name))
944            .unwrap_or(false)
945    }
946
947    // =======================================================================
948    // Basic spec structure (pre-existing, adapted)
949    // =======================================================================
950
951    #[test]
952    fn test_generate_openapi_x_tag_groups() {
953        let engine = create_engine_with_code(
954            "spec pricing
955            data quantity: 10
956            rule total: quantity * 2",
957        );
958        let spec = generate_openapi(&engine, false);
959
960        let groups = spec["x-tagGroups"]
961            .as_array()
962            .expect("x-tagGroups should be array");
963        assert_eq!(groups.len(), 2);
964        assert_eq!(groups[0]["name"], "Overview");
965        assert_eq!(groups[0]["tags"], json!(["Specs"]));
966        assert_eq!(groups[1]["name"], "Specs");
967        assert_eq!(groups[1]["tags"], json!(["pricing"]));
968    }
969
970    #[test]
971    fn test_tag_description_uses_spec_commentary_when_present() {
972        let engine = create_engine_with_code(
973            r#"spec artikel
974"""
975Artikel pricing for Dutch VAT bands.
976"""
977data price: 10
978rule total: price"#,
979        );
980        let spec = generate_openapi(&engine, false);
981        let tags = spec["tags"].as_array().expect("tags array");
982        let artikel = tags
983            .iter()
984            .find(|t| t["name"] == "artikel")
985            .expect("artikel tag");
986        assert_eq!(
987            artikel["description"],
988            "Artikel pricing for Dutch VAT bands."
989        );
990    }
991
992    #[test]
993    fn test_tag_description_falls_back_without_commentary() {
994        let engine = create_engine_with_code(
995            "spec pricing
996            data quantity: 10
997            rule total: quantity * 2",
998        );
999        let spec = generate_openapi(&engine, false);
1000        let tags = spec["tags"].as_array().expect("tags array");
1001        let pricing = tags
1002            .iter()
1003            .find(|t| t["name"] == "pricing")
1004            .expect("pricing tag");
1005        assert_eq!(pricing["description"], "Show and evaluate `pricing`");
1006    }
1007
1008    #[test]
1009    fn test_spec_path_has_get_and_post() {
1010        let engine = create_engine_with_code(
1011            "spec pricing
1012            data quantity: 10
1013            rule total: quantity * 2",
1014        );
1015        let spec = generate_openapi(&engine, false);
1016
1017        assert!(
1018            spec["paths"]["/pricing"].is_object(),
1019            "single spec path /pricing"
1020        );
1021        assert!(spec["paths"]["/pricing"]["get"].is_object());
1022        assert!(spec["paths"]["/pricing"]["post"].is_object());
1023
1024        assert_eq!(
1025            spec["paths"]["/pricing"]["get"]["operationId"],
1026            "get_pricing"
1027        );
1028        assert_eq!(
1029            spec["paths"]["/pricing"]["post"]["operationId"],
1030            "post_pricing"
1031        );
1032        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
1033
1034        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
1035            .as_array()
1036            .expect("parameters array");
1037        let param_names: Vec<&str> = get_params
1038            .iter()
1039            .map(|p| p["name"].as_str().unwrap())
1040            .collect();
1041        assert!(
1042            !param_names.contains(&"rules"),
1043            "GET must not have rules query param (show is full interface)"
1044        );
1045        let post_params = spec["paths"]["/pricing"]["post"]["parameters"]
1046            .as_array()
1047            .expect("post parameters array");
1048        let post_param_names: Vec<&str> = post_params
1049            .iter()
1050            .map(|p| p["name"].as_str().unwrap())
1051            .collect();
1052        assert!(
1053            post_param_names.contains(&"rules"),
1054            "POST must have rules query param"
1055        );
1056        assert!(
1057            param_names.contains(&"Accept-Datetime"),
1058            "GET must have Accept-Datetime header"
1059        );
1060        let post_rules = post_params
1061            .iter()
1062            .find(|p| p["name"] == "rules")
1063            .expect("rules param");
1064        assert_eq!(
1065            post_rules["x-disabled"], false,
1066            "Scalar must enable optional rules by default or Try Request omits ?rules="
1067        );
1068        let accept = get_params
1069            .iter()
1070            .find(|p| p["name"] == "Accept-Datetime")
1071            .expect("Accept-Datetime param");
1072        assert_eq!(
1073            accept["x-disabled"], false,
1074            "Scalar must enable optional Accept-Datetime by default or Try Request omits it"
1075        );
1076
1077        let get_ref = spec["paths"]["/pricing"]["get"]["responses"]["200"]["content"]
1078            ["application/json"]["schema"]["$ref"]
1079            .as_str()
1080            .unwrap();
1081        let post_ref = spec["paths"]["/pricing"]["post"]["responses"]["200"]["content"]
1082            ["application/json"]["schema"]["$ref"]
1083            .as_str()
1084            .unwrap();
1085        assert_eq!(get_ref, "#/components/schemas/pricing_get_show");
1086        assert_eq!(post_ref, "#/components/schemas/pricing_evaluate_response");
1087        assert_ne!(get_ref, post_ref);
1088
1089        let get_show = &spec["components"]["schemas"]["pricing_get_show"];
1090        assert_eq!(get_show["$ref"], SHOW_SCHEMA_REF);
1091        assert_eq!(SHOW_SCHEMA_REF, "#/components/schemas/Show");
1092        let show = &spec["components"]["schemas"]["Show"];
1093        assert_eq!(show["type"], "object");
1094        let mut stack = vec![show];
1095        while let Some(node) = stack.pop() {
1096            match node {
1097                Value::Object(map) => {
1098                    if let Some(Value::String(target)) = map.get("$ref") {
1099                        assert!(
1100                            !target.starts_with("http"),
1101                            "Show schema must not $ref {target}"
1102                        );
1103                    }
1104                    stack.extend(map.values());
1105                }
1106                Value::Array(items) => stack.extend(items),
1107                _ => {}
1108            }
1109        }
1110
1111        let h200 = &spec["paths"]["/pricing"]["get"]["responses"]["200"];
1112        assert!(h200["headers"]["Memento-Datetime"].is_object());
1113        assert!(h200["headers"]["Vary"].is_object());
1114    }
1115
1116    #[test]
1117    fn suggestion_only_field_is_required_with_json_schema_default() {
1118        let engine = create_engine_with_code(
1119            r#"
1120spec age_check
1121data age: number -> suggest 18
1122rule adult: age >= 18
1123"#,
1124        );
1125        let spec = generate_openapi(&engine, false);
1126        let body = &spec["components"]["schemas"]["age_check_request"];
1127        let required = body["required"]
1128            .as_array()
1129            .expect("required array")
1130            .iter()
1131            .map(|v| v.as_str().unwrap())
1132            .collect::<Vec<_>>();
1133        assert!(
1134            required.contains(&"age"),
1135            "suggest-only fields must stay required, got {required:?}"
1136        );
1137        assert!(body["properties"]["age"].get("default").is_none());
1138        assert_eq!(body["properties"]["age"]["examples"], json!(["18"]));
1139    }
1140
1141    /// The generated OpenAPI document describes the public spec surface only.
1142    /// Server shell routes (`/openapi.json`, `/health`, `/docs`) are
1143    /// intentionally omitted; consumers must not rely on them for code
1144    /// generation or contract inspection.
1145    #[test]
1146    fn test_openapi_omits_shell_and_unlisted_schema_routes() {
1147        let engine = create_engine_with_code(
1148            "spec pricing
1149            data quantity: 10
1150            rule total: quantity * 2",
1151        );
1152        let spec = generate_openapi(&engine, false);
1153
1154        let paths = spec["paths"].as_object().expect("paths object");
1155        assert!(paths.contains_key("/"));
1156        assert_eq!(paths["/"]["get"]["operationId"], "specs");
1157        assert!(!paths.contains_key("/openapi.json"));
1158        assert!(!paths.contains_key("/health"));
1159        assert!(!paths.contains_key("/docs"));
1160        assert!(!paths.contains_key("/schema/pricing"));
1161        assert!(!paths.contains_key("/schema/pricing/{rules}"));
1162        assert!(!paths.keys().any(|key| key.starts_with("/schema/")));
1163    }
1164
1165    #[test]
1166    fn test_generate_openapi_explain_adds_x_explain_and_explanation_schema() {
1167        let engine = create_engine_with_code(
1168            "spec pricing
1169            data quantity: 10
1170            rule total: quantity * 2",
1171        );
1172        let spec = generate_openapi(&engine, true);
1173
1174        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
1175        assert!(has_param(get_params, "x-explain"));
1176
1177        let rule_result = &spec["components"]["schemas"]["RuleResult"];
1178        assert_eq!(
1179            rule_result["properties"]["explanation"]["$ref"],
1180            "#/components/schemas/RuleNode"
1181        );
1182        assert!(rule_result["properties"]["vetoed"]["type"] == "boolean");
1183        assert!(rule_result["properties"]["rule_type"]["type"] == "string");
1184
1185        let evaluate = &spec["components"]["schemas"]["pricing_evaluate_response"];
1186        assert!(evaluate["required"]
1187            .as_array()
1188            .unwrap()
1189            .contains(&json!("spec")));
1190        assert!(evaluate["required"]
1191            .as_array()
1192            .unwrap()
1193            .contains(&json!("effective")));
1194        assert!(evaluate["required"]
1195            .as_array()
1196            .unwrap()
1197            .contains(&json!("results")));
1198        let total_ref = evaluate["properties"]["results"]["properties"]["total"]["$ref"]
1199            .as_str()
1200            .unwrap();
1201        assert_eq!(total_ref, "#/components/schemas/RuleResult");
1202    }
1203
1204    #[test]
1205    fn test_generate_openapi_multiple_specs() {
1206        let engine = create_engine_with_files(vec![
1207            (
1208                "pricing.lemma",
1209                "spec pricing
1210                data quantity: 10
1211                rule total: quantity * 2",
1212            ),
1213            (
1214                "shipping.lemma",
1215                "spec shipping
1216                data weight: 5
1217                rule cost: weight * 3",
1218            ),
1219        ]);
1220        let spec = generate_openapi(&engine, false);
1221
1222        assert!(spec["paths"]["/pricing"].is_object());
1223        assert!(spec["paths"]["/shipping"].is_object());
1224    }
1225
1226    #[test]
1227    fn test_nested_spec_path_schema_refs_are_valid() {
1228        let engine = create_engine_with_code(
1229            "spec bc
1230        data x: number
1231        rule result: x",
1232        );
1233        let spec = generate_openapi(&engine, false);
1234
1235        assert!(spec["paths"]["/bc"]["post"].is_object());
1236        let post_content = &spec["paths"]["/bc"]["post"]["requestBody"]["content"];
1237        let content_keys: Vec<&str> = post_content
1238            .as_object()
1239            .expect("requestBody.content object")
1240            .keys()
1241            .map(|k| k.as_str())
1242            .collect();
1243        assert_eq!(
1244            content_keys.first().copied(),
1245            Some("application/x-www-form-urlencoded"),
1246            "form-urlencoded must be first so Scalar docs default to Form URL Encoded"
1247        );
1248        let body_ref = post_content["application/json"]["schema"]["$ref"]
1249            .as_str()
1250            .unwrap();
1251        let form_body_ref = post_content["application/x-www-form-urlencoded"]["schema"]["$ref"]
1252            .as_str()
1253            .unwrap();
1254        assert_eq!(body_ref, "#/components/schemas/bc_request");
1255        assert_eq!(form_body_ref, "#/components/schemas/bc_form_request");
1256        assert!(spec["components"]["schemas"]["bc_request"].is_object());
1257        assert!(spec["components"]["schemas"]["bc_form_request"].is_object());
1258        assert!(spec["components"]["schemas"]["bc_request"]["properties"]["x"].is_object());
1259        assert!(spec["components"]["schemas"]["bc_form_request"]["properties"]["x"].is_object());
1260    }
1261
1262    // =======================================================================
1263    // generate_openapi_effective with explicit timestamp
1264    // =======================================================================
1265
1266    #[test]
1267    fn test_generate_openapi_effective_reflects_specific_time() {
1268        let engine = create_engine_with_code(
1269            "spec pricing
1270            data quantity: 10
1271            rule total: quantity * 2",
1272        );
1273        let effective = date(2025, 6, 15);
1274        let spec = generate_openapi_effective(&engine, false, &effective);
1275
1276        assert_eq!(spec["openapi"], "3.1.0");
1277        let version = spec["info"]["version"].as_str().unwrap();
1278        assert!(
1279            version.contains("2025-06-15"),
1280            "version string should contain the effective date, got: {}",
1281            version
1282        );
1283    }
1284
1285    #[test]
1286    fn test_effective_shows_correct_temporal_version_interface() {
1287        let engine = create_engine_with_files(vec![(
1288            "policy.lemma",
1289            r#"
1290spec policy
1291data base: 100
1292rule discount: 10
1293
1294spec policy 2025-06-01
1295data base: 200
1296data premium: boolean
1297rule discount: 20
1298rule surcharge:
1299  5
1300  unless premium then 10
1301"#,
1302        )]);
1303
1304        let before = date(2025, 3, 1);
1305        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1306
1307        assert!(spec_v1["paths"]["/policy"].is_object());
1308        let v1_evaluate = &spec_v1["components"]["schemas"]["policy_evaluate_response"];
1309        let v1_result = &v1_evaluate["properties"]["results"]["properties"];
1310        assert_eq!(
1311            v1_result["discount"]["$ref"].as_str(),
1312            Some("#/components/schemas/RuleResult"),
1313            "v1 should have discount rule"
1314        );
1315        assert!(
1316            v1_result["surcharge"].is_null(),
1317            "v1 must NOT have surcharge rule"
1318        );
1319        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1320        assert!(
1321            v1_request["properties"]["premium"].is_null(),
1322            "v1 must NOT have premium data"
1323        );
1324
1325        let after = date(2025, 8, 1);
1326        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1327
1328        let v2_evaluate = &spec_v2["components"]["schemas"]["policy_evaluate_response"];
1329        let v2_result = &v2_evaluate["properties"]["results"]["properties"];
1330        assert!(
1331            v2_result["discount"]["$ref"].is_string(),
1332            "v2 should have discount rule"
1333        );
1334        assert!(
1335            v2_result["surcharge"]["$ref"].is_string(),
1336            "v2 should have surcharge rule"
1337        );
1338        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1339        assert!(
1340            v2_request["properties"]["premium"].is_object(),
1341            "v2 should have premium data"
1342        );
1343    }
1344
1345    /// Each spec PathItem carries `x-effective-from` and `x-effective-to`
1346    /// describing the half-open `[effective_from, effective_to)` validity
1347    /// range of the version resolved at the document's effective instant.
1348    ///
1349    /// - Earlier row: `x-effective-to` = next row's `effective_from`.
1350    /// - Latest row: `x-effective-to` = `null` (no successor).
1351    /// - Unversioned spec (no declared `effective_from`): both extensions are
1352    ///   `null`.
1353    #[test]
1354    fn test_spec_path_item_exposes_half_open_effective_range_as_vendor_extensions() {
1355        let engine = create_engine_with_files(vec![(
1356            "policy.lemma",
1357            r#"
1358spec policy 2025-01-01
1359data base: 10
1360rule total: base
1361
1362spec policy 2026-01-01
1363data base: 99
1364rule total: base
1365"#,
1366        )]);
1367
1368        let at_earlier = date(2025, 6, 1);
1369        let earlier_doc = generate_openapi_effective(&engine, false, &at_earlier);
1370        let earlier_path = &earlier_doc["paths"]["/policy"];
1371        assert_eq!(
1372            earlier_path["x-effective-from"].as_str(),
1373            Some("2025-01-01"),
1374            "earlier version effective_from on PathItem"
1375        );
1376        assert_eq!(
1377            earlier_path["x-effective-to"].as_str(),
1378            Some("2026-01-01"),
1379            "earlier version effective_to equals next version's effective_from"
1380        );
1381
1382        let at_latest = date(2026, 6, 1);
1383        let latest_doc = generate_openapi_effective(&engine, false, &at_latest);
1384        let latest_path = &latest_doc["paths"]["/policy"];
1385        assert_eq!(
1386            latest_path["x-effective-from"].as_str(),
1387            Some("2026-01-01"),
1388            "latest version effective_from on PathItem"
1389        );
1390        assert!(
1391            latest_path["x-effective-to"].is_null(),
1392            "latest version has no successor; x-effective-to must be null: {latest_path}"
1393        );
1394    }
1395
1396    /// Unversioned specs (no declared `effective_from`) have both extensions
1397    /// serialised as JSON `null`, not omitted.
1398    #[test]
1399    fn test_spec_path_item_effective_extensions_null_for_unversioned_spec() {
1400        let engine = create_engine_with_code(
1401            "spec pricing
1402            data quantity: 10
1403            rule total: quantity * 2",
1404        );
1405        let document = generate_openapi(&engine, false);
1406        let path_item = &document["paths"]["/pricing"];
1407        assert!(
1408            path_item["x-effective-from"].is_null(),
1409            "unversioned spec: x-effective-from must be null: {path_item}"
1410        );
1411        assert!(
1412            path_item["x-effective-to"].is_null(),
1413            "unversioned spec: x-effective-to must be null: {path_item}"
1414        );
1415    }
1416
1417    // =======================================================================
1418    // temporal_api_sources
1419    // =======================================================================
1420
1421    #[test]
1422    fn test_temporal_sources_versioned_returns_boundaries_plus_now() {
1423        let engine = create_engine_with_files(vec![(
1424            "policy.lemma",
1425            r#"
1426spec policy
1427data base: 100
1428rule discount: 10
1429
1430spec policy 2025-06-01
1431data base: 200
1432rule discount: 20
1433"#,
1434        )]);
1435
1436        let sources = temporal_api_sources(&engine);
1437
1438        assert_eq!(sources.len(), 2, "should have 1 now + 1 boundary");
1439
1440        assert_eq!(sources[0].title, "Now");
1441        assert_eq!(sources[0].slug, NOW_SLUG);
1442        assert_eq!(sources[0].url, "/openapi.json");
1443
1444        assert_eq!(sources[1].title, "Effective 2025-06-01");
1445        assert_eq!(sources[1].slug, "2025-06-01");
1446        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1447    }
1448
1449    #[test]
1450    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1451        let engine = create_engine_with_files(vec![
1452            (
1453                "policy.lemma",
1454                r#"
1455spec policy
1456data base: 100
1457rule discount: 10
1458
1459spec policy 2025-06-01
1460data base: 200
1461rule discount: 20
1462"#,
1463            ),
1464            (
1465                "rates.lemma",
1466                r#"
1467spec rates
1468data rate: 5
1469rule total: rate * 2
1470
1471spec rates 2025-03-01
1472data rate: 7
1473rule total: rate * 2
1474
1475spec rates 2025-06-01
1476data rate: 9
1477rule total: rate * 2
1478"#,
1479            ),
1480        ]);
1481
1482        let sources = temporal_api_sources(&engine);
1483
1484        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1485        assert!(
1486            slugs.contains(&"2025-03-01"),
1487            "should contain rates boundary"
1488        );
1489        assert!(
1490            slugs.contains(&"2025-06-01"),
1491            "should contain shared boundary"
1492        );
1493        assert!(slugs.contains(&NOW_SLUG), "should contain now");
1494        assert_eq!(slugs.len(), 3, "2 unique boundaries + now");
1495    }
1496
1497    #[test]
1498    fn test_temporal_sources_ordered_chronologically() {
1499        let engine = create_engine_with_files(vec![(
1500            "policy.lemma",
1501            r#"
1502spec policy
1503data base: 100
1504rule discount: 10
1505
1506spec policy 2024-01-01
1507data base: 50
1508rule discount: 5
1509
1510spec policy 2025-06-01
1511data base: 200
1512rule discount: 20
1513"#,
1514        )]);
1515
1516        let sources = temporal_api_sources(&engine);
1517        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1518        assert_eq!(slugs, vec![NOW_SLUG, "2025-06-01", "2024-01-01"]);
1519    }
1520
1521    // =======================================================================
1522    // Type-specific parameter tests
1523    // =======================================================================
1524
1525    #[test]
1526    fn test_post_schema_text_with_options_has_enum() {
1527        let engine = create_engine_with_code(
1528            "spec test
1529            data sku: text -> option \"A\" -> option \"B\"
1530            rule result: sku",
1531        );
1532        let spec = generate_openapi(&engine, false);
1533
1534        let sku_prop = &spec["components"]["schemas"]["test_request"]["properties"]["sku"];
1535        assert!(sku_prop["enum"].is_array());
1536        let enums = sku_prop["enum"].as_array().unwrap();
1537        assert_eq!(enums.len(), 2);
1538        assert_eq!(enums[0], "A");
1539        assert_eq!(enums[1], "B");
1540    }
1541
1542    #[test]
1543    fn test_post_schema_boolean_is_json_boolean() {
1544        let engine = create_engine_with_code(
1545            "spec test
1546            data is_active: boolean
1547            rule result: is_active",
1548        );
1549        let spec = generate_openapi(&engine, false);
1550
1551        let schema = &spec["components"]["schemas"]["test_request"];
1552        let is_active = &schema["properties"]["is_active"];
1553        assert_eq!(is_active["oneOf"][0]["type"], "boolean");
1554
1555        let form_schema = &spec["components"]["schemas"]["test_form_request"];
1556        let form_is_active = &form_schema["properties"]["is_active"];
1557        assert_eq!(form_is_active["type"], "string");
1558        assert_eq!(form_is_active["enum"], json!(["true", "false"]));
1559    }
1560
1561    #[test]
1562    fn test_post_schema_number_is_string() {
1563        let engine = create_engine_with_code(
1564            "spec test
1565            data quantity: number
1566            rule result: quantity",
1567        );
1568        let spec = generate_openapi(&engine, false);
1569
1570        let schema = &spec["components"]["schemas"]["test_request"];
1571        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1572        assert!(schema["properties"]["quantity"].get("pattern").is_none());
1573    }
1574
1575    #[test]
1576    fn test_post_schema_date_and_time_use_standard_formats() {
1577        let engine = create_engine_with_code(
1578            "spec test
1579            data start: date
1580            data at: time
1581            rule result: start
1582            rule when: at",
1583        );
1584        let spec = generate_openapi(&engine, false);
1585
1586        let schema = &spec["components"]["schemas"]["test_request"];
1587        assert_eq!(
1588            schema["properties"]["start"]["oneOf"],
1589            json!([
1590                {"type": "string", "format": "date"},
1591                {"type": "string", "format": "date-time"}
1592            ])
1593        );
1594        assert_eq!(schema["properties"]["at"]["format"], "time");
1595        let form = &spec["components"]["schemas"]["test_form_request"];
1596        assert_eq!(form["properties"]["start"]["format"], "date");
1597        assert_eq!(form["properties"]["at"]["format"], "time");
1598    }
1599
1600    #[test]
1601    fn test_data_with_default_is_not_required() {
1602        let engine = create_engine_with_code(
1603            "spec test
1604            data quantity: 10
1605            data name: text
1606            rule result: quantity
1607            rule label: name",
1608        );
1609        let spec = generate_openapi(&engine, false);
1610
1611        let schema = &spec["components"]["schemas"]["test_request"];
1612        let required = schema["required"]
1613            .as_array()
1614            .expect("required should be array");
1615
1616        assert!(required.contains(&Value::String("name".to_string())));
1617        assert!(!required.contains(&Value::String("quantity".to_string())));
1618    }
1619
1620    #[test]
1621    fn test_help_and_default_in_openapi() {
1622        let engine = create_engine_with_code(
1623            r#"spec test
1624data quantity: number -> help "Number of items to order" -> suggest 10
1625data active: boolean -> help "Whether the feature is enabled" -> suggest true
1626rule result:
1627  quantity
1628  unless active then 0
1629"#,
1630        );
1631        let spec = generate_openapi(&engine, false);
1632
1633        let req_schema = &spec["components"]["schemas"]["test_request"];
1634        assert!(req_schema["properties"]["quantity"]["description"]
1635            .as_str()
1636            .unwrap()
1637            .contains("Number of items to order"));
1638        assert_eq!(
1639            req_schema["properties"]["quantity"]["examples"],
1640            json!(["10"])
1641        );
1642        assert!(req_schema["properties"]["active"]["description"]
1643            .as_str()
1644            .unwrap()
1645            .contains("Whether the feature is enabled"));
1646        assert_eq!(
1647            req_schema["properties"]["active"]["examples"],
1648            json!([true])
1649        );
1650        assert_eq!(
1651            spec["components"]["schemas"]["test_form_request"]["properties"]["active"]["examples"],
1652            json!(["true"])
1653        );
1654        let required = req_schema["required"]
1655            .as_array()
1656            .expect("required array")
1657            .iter()
1658            .map(|v| v.as_str().unwrap())
1659            .collect::<Vec<_>>();
1660        assert!(required.contains(&"quantity"));
1661        assert!(required.contains(&"active"));
1662    }
1663
1664    const LIST_CODE: &str = r#"spec line
1665data price: number
1666data qty: number -> suggest 1
1667rule cost: price * qty
1668
1669spec order
1670uses line
1671data lines: line list
1672data discounts: number list
1673  -> with 5, 10
1674rule total: lines -> sum cost
1675rule biggest: lines -> sort cost desc -> first
1676rule discount_total: discounts -> sum
1677
1678spec part
1679data own_cost: number
1680data components: part list
1681rule unit_cost:
1682  own_cost + (components -> sum components.unit_cost)
1683  unless components -> count is 0 then own_cost
1684"#;
1685
1686    fn row_ref(schema: &Value) -> &str {
1687        schema["items"]["$ref"]
1688            .as_str()
1689            .unwrap_or_else(|| panic!("array of row refs expected: {schema}"))
1690    }
1691
1692    #[test]
1693    fn instance_list_is_array_of_row_objects_and_never_required() {
1694        let engine = create_engine_with_code(LIST_CODE);
1695        let document = generate_openapi(&engine, false);
1696        let schemas = &document["components"]["schemas"];
1697        let request = &schemas["order_request"];
1698        let lines = &request["properties"]["lines"];
1699        assert_eq!(lines["type"], "array");
1700        assert_eq!(row_ref(lines), "#/components/schemas/LemmaRow_line");
1701        assert!(
1702            request.get("required").is_none(),
1703            "lists are empty when absent, never required: {request}"
1704        );
1705
1706        let row = &schemas["LemmaRow_line"];
1707        assert_eq!(row["type"], "object");
1708        assert_eq!(row["additionalProperties"], true);
1709        assert_eq!(row["title"], "line row");
1710        assert_eq!(row["properties"]["price"]["type"], "string");
1711        assert_eq!(row["properties"]["qty"]["examples"], json!(["1"]));
1712        assert_eq!(row["required"], json!(["price", "qty"]));
1713    }
1714
1715    #[test]
1716    fn value_list_is_array_of_literals_with_array_default() {
1717        let engine = create_engine_with_code(LIST_CODE);
1718        let document = generate_openapi(&engine, false);
1719        let discounts =
1720            &document["components"]["schemas"]["order_request"]["properties"]["discounts"];
1721        assert_eq!(discounts["type"], "array");
1722        assert_eq!(discounts["items"], json!({ "type": "string" }));
1723        assert_eq!(discounts["default"], json!(["5", "10"]));
1724    }
1725
1726    const SOURCE_ROWS_CODE: &str = r#"spec article
1727data price: number
1728
1729spec line
1730data qty: number
1731uses article: article
1732rule cost: qty * article.price
1733
1734spec order
1735uses line
1736data lines: line list
1737  -> with qty: 2, article.price: 5
1738  -> with qty: 1
1739rule total: lines -> sum cost
1740
1741spec bracket
1742data threshold: number
1743data income: number
1744rule applies: income > threshold
1745
1746spec tax
1747data income: number
1748uses bracket
1749data brackets: bracket list
1750  -> with threshold: 0, income: income
1751rule applicable: brackets -> where applies -> count
1752"#;
1753
1754    #[test]
1755    fn instance_list_default_is_source_rows_as_objects() {
1756        let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1757        let document = generate_openapi(&engine, false);
1758        let lines = &document["components"]["schemas"]["order_request"]["properties"]["lines"];
1759        assert_eq!(
1760            lines["default"],
1761            json!([{ "qty": "2", "article.price": "5" }, { "qty": "1" }])
1762        );
1763    }
1764
1765    #[test]
1766    fn instance_list_with_referenced_fields_has_no_default() {
1767        let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1768        let document = generate_openapi(&engine, false);
1769        let brackets = &document["components"]["schemas"]["tax_request"]["properties"]["brackets"];
1770        assert_eq!(brackets["type"], "array");
1771        assert!(brackets.get("default").is_none(), "{brackets}");
1772        assert!(
1773            brackets["x-lemma-value"]["list"][0]["fields"]["income"]["reference"] == "income",
1774            "{brackets}"
1775        );
1776    }
1777
1778    #[test]
1779    fn row_schema_accepts_fields_of_row_spec_uses() {
1780        let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1781        let document = generate_openapi(&engine, false);
1782        let row = &document["components"]["schemas"]["LemmaRow_line"];
1783        assert_eq!(row["additionalProperties"], true);
1784        assert_eq!(row["properties"]["article.price"]["type"], "string");
1785    }
1786
1787    #[test]
1788    fn dotted_top_level_keys_are_request_properties() {
1789        let engine = create_engine_with_code(SOURCE_ROWS_CODE);
1790        let document = generate_openapi(&engine, false);
1791        let schemas = &document["components"]["schemas"];
1792        let request = &schemas["line_request"];
1793        assert_eq!(request["properties"]["article.price"]["type"], "string");
1794        assert_eq!(request["required"], json!(["article.price", "qty"]));
1795        assert_eq!(
1796            schemas["line_form_request"]["properties"]["article.price"]["type"],
1797            "string"
1798        );
1799    }
1800
1801    #[test]
1802    fn self_list_row_schema_refers_to_itself() {
1803        let engine = create_engine_with_code(LIST_CODE);
1804        let document = generate_openapi(&engine, false);
1805        let schemas = &document["components"]["schemas"];
1806        assert_eq!(
1807            row_ref(&schemas["part_request"]["properties"]["components"]),
1808            "#/components/schemas/LemmaRow_part"
1809        );
1810        assert_eq!(
1811            row_ref(&schemas["LemmaRow_part"]["properties"]["components"]),
1812            "#/components/schemas/LemmaRow_part"
1813        );
1814    }
1815
1816    #[test]
1817    fn list_inputs_omit_form_body_so_docs_keep_lists() {
1818        let engine = create_engine_with_code(LIST_CODE);
1819        let document = generate_openapi(&engine, false);
1820        let content = &document["paths"]["/order"]["post"]["requestBody"]["content"];
1821        let content_keys: Vec<&str> = content
1822            .as_object()
1823            .expect("requestBody.content object")
1824            .keys()
1825            .map(|k| k.as_str())
1826            .collect();
1827        assert_eq!(
1828            content_keys,
1829            ["application/json"],
1830            "form would drop list inputs; JSON-only keeps them in /docs: {content}"
1831        );
1832        assert!(
1833            document["components"]["schemas"]
1834                .get("order_form_request")
1835                .is_none(),
1836            "no stripped form schema when lists are present"
1837        );
1838        assert_eq!(
1839            document["components"]["schemas"]["order_request"]["properties"]["lines"]["type"],
1840            "array"
1841        );
1842    }
1843
1844    #[test]
1845    fn nested_uses_self_list_is_on_json_body_not_form() {
1846        let engine = create_engine_with_code(
1847            r#"
1848spec ingredient
1849data percentage: 10%
1850data componenten: ingredient list
1851rule total:
1852  percentage
1853  unless componenten -> count > 0 then componenten -> sum total
1854
1855spec artikel
1856uses ingredient
1857rule prijs: ingredient.total
1858"#,
1859        );
1860        let document = generate_openapi(&engine, false);
1861        let content = &document["paths"]["/artikel"]["post"]["requestBody"]["content"];
1862        assert!(content.get("application/x-www-form-urlencoded").is_none());
1863        assert_eq!(
1864            content["application/json"]["schema"]["$ref"],
1865            "#/components/schemas/artikel_request"
1866        );
1867        let request = &document["components"]["schemas"]["artikel_request"]["properties"];
1868        assert_eq!(request["ingredient.componenten"]["type"], "array");
1869        assert_eq!(
1870            row_ref(&request["ingredient.componenten"]),
1871            "#/components/schemas/LemmaRow_ingredient"
1872        );
1873        assert!(request["ingredient.percentage"].is_object());
1874    }
1875
1876    #[test]
1877    fn evaluate_response_describes_rows_and_list_values() {
1878        let engine = create_engine_with_code(LIST_CODE);
1879        let document = generate_openapi(&engine, false);
1880        let schemas = &document["components"]["schemas"];
1881        assert_eq!(
1882            schemas["order_evaluate_response"]["properties"]["rows"]["items"]["$ref"],
1883            "#/components/schemas/RowResult"
1884        );
1885        let rule_result = &schemas["RuleResult"]["properties"];
1886        assert_eq!(
1887            rule_result["row"]["$ref"],
1888            "#/components/schemas/RowReference"
1889        );
1890        assert_eq!(
1891            rule_result["list"]["items"]["$ref"],
1892            "#/components/schemas/RuleResultValue"
1893        );
1894        assert_eq!(
1895            rule_result["spec"]["$ref"],
1896            "#/components/schemas/SpecResult"
1897        );
1898        assert_eq!(
1899            schemas["RuleResultValue"]["properties"]["list"]["items"]["$ref"],
1900            "#/components/schemas/RuleResultValue"
1901        );
1902    }
1903
1904    #[test]
1905    fn shared_row_schema_across_parents_does_not_panic() {
1906        let code = r#"
1907spec article
1908data price: number
1909
1910spec line
1911uses article: article
1912data qty: number
1913rule cost: qty * article.price
1914
1915spec order
1916uses line
1917data lines: line list
1918rule total: lines -> sum cost
1919
1920spec cart
1921uses line
1922data lines: line list
1923rule subtotal: lines -> sum qty
1924"#;
1925        let engine = create_engine_with_code(code);
1926        let document = generate_openapi(&engine, false);
1927        let row = &document["components"]["schemas"]["LemmaRow_line"];
1928        assert_eq!(row["type"], "object");
1929        assert_eq!(
1930            row["properties"]["qty"]["x-lemma-needed-by-rules"],
1931            json!([])
1932        );
1933        assert!(document["paths"].get("/order").is_some());
1934        assert!(document["paths"].get("/cart").is_some());
1935    }
1936
1937    #[test]
1938    fn every_schema_ref_resolves() {
1939        let engine = create_engine_with_code(LIST_CODE);
1940        let document = generate_openapi(&engine, false);
1941        let schemas = document["components"]["schemas"]
1942            .as_object()
1943            .expect("components.schemas");
1944        let mut stack = vec![&document];
1945        let mut refs = 0;
1946        while let Some(node) = stack.pop() {
1947            match node {
1948                Value::Object(map) => {
1949                    if let Some(Value::String(target)) = map.get("$ref") {
1950                        let name = target
1951                            .strip_prefix("#/components/schemas/")
1952                            .unwrap_or_else(|| panic!("unexpected $ref {target}"));
1953                        assert!(schemas.contains_key(name), "dangling $ref {target}");
1954                        refs += 1;
1955                    }
1956                    stack.extend(map.values());
1957                }
1958                Value::Array(items) => stack.extend(items),
1959                _ => {}
1960            }
1961        }
1962        assert!(refs > 0);
1963    }
1964}