Skip to main content

lemma_openapi/
lib.rs

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