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