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, explanations_enabled: bool) -> Value {
91    generate_openapi_effective(engine, explanations_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 `?rules=` query parameter; path is spec id (name or name~hash)
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 `explanations_enabled` is true, the spec adds the `x-explanations` header parameter
103/// to evaluation endpoints and describes the optional `explanation` object in responses.
104pub fn generate_openapi_effective(
105    engine: &Engine,
106    explanations_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 Ok(plan) = engine.get_plan(spec_name, Some(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, explanations_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                    explanations_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| match engine.schema(name, Some(effective)) {
238            Ok(s) => {
239                let facts_count = s.facts.keys().filter(|n| !n.contains('.')).count();
240                let rules_count = s.rules.len();
241                json!({
242                    "name": name,
243                    "facts": facts_count,
244                    "rules": rules_count
245                })
246            }
247            Err(e) => json!({
248                "name": name,
249                "schema_error": true,
250                "message": e.to_string()
251            }),
252        })
253        .collect();
254
255    json!({
256        "get": {
257            "operationId": "list",
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                                        "schema_error": { "type": "boolean" },
274                                        "message": { "type": "string" }
275                                    },
276                                    "required": ["name"]
277                                }
278                            },
279                            "example": spec_items
280                        }
281                    }
282                }
283            }
284        }
285    })
286}
287
288fn health_path_item() -> Value {
289    json!({
290        "get": {
291            "operationId": "healthCheck",
292            "summary": "Health check",
293            "tags": ["Meta"],
294            "responses": {
295                "200": {
296                    "description": "Server is healthy",
297                    "content": {
298                        "application/json": {
299                            "schema": {
300                                "type": "object",
301                                "properties": {
302                                    "status": { "type": "string" },
303                                    "service": { "type": "string" },
304                                    "version": { "type": "string" }
305                                },
306                                "required": ["status", "service", "version"]
307                            }
308                        }
309                    }
310                }
311            }
312        }
313    })
314}
315
316fn openapi_json_path_item() -> Value {
317    json!({
318        "get": {
319            "operationId": "getOpenApiSpec",
320            "summary": "OpenAPI 3.1 specification",
321            "tags": ["Meta"],
322            "responses": {
323                "200": {
324                    "description": "OpenAPI specification as JSON",
325                    "content": {
326                        "application/json": {
327                            "schema": { "type": "object" }
328                        }
329                    }
330                }
331            }
332        }
333    })
334}
335
336// ---------------------------------------------------------------------------
337// Shared response schemas
338// ---------------------------------------------------------------------------
339
340fn error_response_schema() -> Value {
341    json!({
342        "description": "Evaluation error",
343        "content": {
344            "application/json": {
345                "schema": {
346                    "type": "object",
347                    "properties": {
348                        "error": { "type": "string" }
349                    },
350                    "required": ["error"]
351                }
352            }
353        }
354    })
355}
356
357fn not_found_response_schema() -> Value {
358    json!({
359        "description": "Spec not found",
360        "content": {
361            "application/json": {
362                "schema": {
363                    "type": "object",
364                    "properties": {
365                        "error": { "type": "string" }
366                    },
367                    "required": ["error"]
368                }
369            }
370        }
371    })
372}
373
374// ---------------------------------------------------------------------------
375// Spec path items
376// ---------------------------------------------------------------------------
377
378fn x_explanations_header_parameter() -> Value {
379    json!({
380        "name": "x-explanations",
381        "in": "header",
382        "required": false,
383        "description": "Set to request explanation objects in the response (server must be started with --explanations)",
384        "schema": { "type": "string", "default": "true" }
385    })
386}
387
388fn accept_datetime_header_parameter() -> Value {
389    json!({
390        "name": "Accept-Datetime",
391        "in": "header",
392        "required": false,
393        "description": "RFC 7089 (Memento): resolve the spec version active at this datetime. Omit for current. Path may be spec id (name or name~hash) to pin to a content version.",
394        "schema": { "type": "string", "format": "date-time" },
395        "example": "Sat, 01 Jan 2025 00:00:00 GMT"
396    })
397}
398
399fn build_spec_path_item(
400    spec_name: &str,
401    _facts: &[InputFact],
402    response_schema_name: &str,
403    post_body_schema_name: &str,
404    rule_names: &[String],
405    explanations_enabled: bool,
406) -> Value {
407    let response_ref = json!({
408        "$ref": format!("#/components/schemas/{}", response_schema_name)
409    });
410    let body_ref = json!({
411        "$ref": format!("#/components/schemas/{}", post_body_schema_name)
412    });
413
414    let tag = spec_name.replace('/', "_");
415
416    let rules_example = if rule_names.is_empty() {
417        String::new()
418    } else {
419        rule_names.join(",")
420    };
421
422    let rules_param = json!({
423        "name": "rules",
424        "in": "query",
425        "required": false,
426        "description": "Comma-separated list of rule names (GET: scope schema; POST: evaluate only these). Omit for all.",
427        "schema": { "type": "string" },
428        "example": rules_example
429    });
430
431    let mut get_parameters: Vec<Value> = vec![rules_param.clone()];
432    get_parameters.push(accept_datetime_header_parameter());
433    if explanations_enabled {
434        get_parameters.push(x_explanations_header_parameter());
435    }
436
437    let get_summary = "Schema of resolved version (spec, facts, rules, meta, versions)".to_string();
438    let post_summary = "Evaluate".to_string();
439    let get_operation_id = format!("get_{}", spec_name);
440    let post_operation_id = format!("post_{}", spec_name);
441
442    let mut post_parameters: Vec<Value> = vec![rules_param];
443    post_parameters.push(accept_datetime_header_parameter());
444    if explanations_enabled {
445        post_parameters.push(x_explanations_header_parameter());
446    }
447
448    json!({
449        "get": {
450            "operationId": get_operation_id,
451            "summary": get_summary,
452            "tags": [tag],
453            "parameters": get_parameters,
454            "responses": {
455                "200": {
456                    "description": "Schema of resolved version. Includes spec identity, hash, facts, rules, meta, and versions. Headers: ETag, Memento-Datetime, Vary.",
457                    "content": {
458                        "application/json": {
459                            "schema": response_ref
460                        }
461                    }
462                },
463                "400": error_response_schema(),
464                "404": not_found_response_schema()
465            }
466        },
467        "post": {
468            "operationId": post_operation_id,
469            "summary": post_summary,
470            "tags": [tag],
471            "parameters": post_parameters,
472            "requestBody": {
473                "required": true,
474                "content": {
475                    "application/x-www-form-urlencoded": {
476                        "schema": body_ref
477                    }
478                }
479            },
480            "responses": {
481                "200": {
482                    "description": "Evaluation results with traceability envelope (spec, effective, hash, result). 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            }
492        }
493    })
494}
495
496// ---------------------------------------------------------------------------
497// Help and default from Lemma types
498// ---------------------------------------------------------------------------
499
500/// Extract the type's help text for use as description. Always has a value for non-Veto types.
501fn type_help(lemma_type: &LemmaType) -> String {
502    match &lemma_type.specifications {
503        TypeSpecification::Boolean { help, .. } => help.clone(),
504        TypeSpecification::Scale { help, .. } => help.clone(),
505        TypeSpecification::Number { help, .. } => help.clone(),
506        TypeSpecification::Ratio { help, .. } => help.clone(),
507        TypeSpecification::Text { help, .. } => help.clone(),
508        TypeSpecification::Date { help, .. } => help.clone(),
509        TypeSpecification::Time { help, .. } => help.clone(),
510        TypeSpecification::Duration { help, .. } => help.clone(),
511        TypeSpecification::Veto { .. } => String::new(),
512        TypeSpecification::Undetermined => unreachable!(
513            "BUG: type_help called with Undetermined sentinel type; this type must never reach OpenAPI generation"
514        ),
515    }
516}
517
518/// Default value as a string for form-encoded POST body schema.
519fn type_default_as_string(lemma_type: &LemmaType) -> Option<String> {
520    match &lemma_type.specifications {
521        TypeSpecification::Boolean { default, .. } => default.map(|b| b.to_string()),
522        TypeSpecification::Scale { default, .. } => {
523            default.as_ref().map(|(d, u)| format!("{} {}", d, u))
524        }
525        TypeSpecification::Number { default, .. } => default.as_ref().map(|d| d.to_string()),
526        TypeSpecification::Ratio { default, .. } => default.as_ref().map(|d| d.to_string()),
527        TypeSpecification::Text { default, .. } => default.clone(),
528        TypeSpecification::Date { default, .. } => default.as_ref().map(|dt| format!("{}", dt)),
529        TypeSpecification::Time { default, .. } => default.as_ref().map(|t| format!("{}", t)),
530        TypeSpecification::Duration { default, .. } => {
531            default.as_ref().map(|(v, u)| format!("{} {}", v, u))
532        }
533        TypeSpecification::Veto { .. } => None,
534        TypeSpecification::Undetermined => unreachable!(
535            "BUG: type_default_as_string called with Undetermined sentinel type; this type must never reach OpenAPI generation"
536        ),
537    }
538}
539
540// ---------------------------------------------------------------------------
541// POST request body schema generation (form-encoded — all string values)
542// ---------------------------------------------------------------------------
543
544fn build_post_request_schema(facts: &[InputFact]) -> Value {
545    let mut properties = Map::new();
546    let mut required = Vec::new();
547
548    for fact in facts {
549        properties.insert(
550            fact.name.clone(),
551            build_post_property_schema(&fact.lemma_type, fact.default_value.as_ref()),
552        );
553        if fact.default_value.is_none() {
554            required.push(Value::String(fact.name.clone()));
555        }
556    }
557
558    let mut schema = json!({
559        "type": "object",
560        "properties": Value::Object(properties)
561    });
562    if !required.is_empty() {
563        schema["required"] = Value::Array(required);
564    }
565    schema
566}
567
568fn build_post_property_schema(
569    lemma_type: &LemmaType,
570    fact_value: Option<&lemma::LiteralValue>,
571) -> Value {
572    let mut schema = build_post_type_schema(lemma_type);
573
574    let help = type_help(lemma_type);
575    if !help.is_empty() {
576        schema["description"] = Value::String(help);
577    }
578
579    // Priority: fact's actual value > type's default > nothing
580    let default_str = fact_value
581        .map(|v| v.display_value())
582        .or_else(|| type_default_as_string(lemma_type));
583    if let Some(d) = default_str {
584        schema["default"] = Value::String(d);
585    }
586
587    schema
588}
589
590fn build_post_type_schema(lemma_type: &LemmaType) -> Value {
591    match &lemma_type.specifications {
592        TypeSpecification::Text { options, .. } => {
593            let mut schema = json!({ "type": "string" });
594            if !options.is_empty() {
595                schema["enum"] =
596                    Value::Array(options.iter().map(|o| Value::String(o.clone())).collect());
597            }
598            schema
599        }
600        TypeSpecification::Boolean { .. } => {
601            json!({ "type": "string", "enum": ["true", "false"] })
602        }
603        _ => json!({ "type": "string" }),
604    }
605}
606
607// ---------------------------------------------------------------------------
608// Response schema generation
609// ---------------------------------------------------------------------------
610
611fn build_response_schema(
612    schema: &lemma::SpecSchema,
613    rule_names: &[String],
614    explanations_enabled: bool,
615) -> Value {
616    let mut properties = Map::new();
617
618    let explanation_prop = explanations_enabled.then(|| {
619        json!({
620            "type": "object",
621            "description": "Explanation tree (included when x-explanations header is sent and server started with --explanations)"
622        })
623    });
624
625    for rule_name in rule_names {
626        if let Some(rule_type) = schema.rules.get(rule_name) {
627            let result_type_name = type_base_name(rule_type);
628            let mut value_props = Map::new();
629            value_props.insert(
630                "value".to_string(),
631                json!({
632                    "type": "string",
633                    "description": format!("Computed value (type: {})", result_type_name)
634                }),
635            );
636            if let Some(ref p) = explanation_prop {
637                value_props.insert("explanation".to_string(), p.clone());
638            }
639            let mut veto_props = Map::new();
640            veto_props.insert(
641                "veto_reason".to_string(),
642                json!({
643                    "type": "string",
644                    "description": "Reason the rule was vetoed (no value produced)"
645                }),
646            );
647            if let Some(ref p) = explanation_prop {
648                veto_props.insert("explanation".to_string(), p.clone());
649            }
650            let value_branch = json!({
651                "type": "object",
652                "properties": Value::Object(value_props),
653                "required": ["value"]
654            });
655            let veto_branch = json!({
656                "type": "object",
657                "properties": Value::Object(veto_props)
658            });
659            properties.insert(
660                rule_name.clone(),
661                json!({
662                    "oneOf": [ value_branch, veto_branch ]
663                }),
664            );
665        }
666    }
667
668    json!({
669        "type": "object",
670        "properties": Value::Object(properties)
671    })
672}
673
674// ---------------------------------------------------------------------------
675// Helpers
676// ---------------------------------------------------------------------------
677
678/// Get a human-readable base type name for display purposes.
679fn type_base_name(lemma_type: &LemmaType) -> String {
680    if let Some(ref name) = lemma_type.name {
681        return name.clone();
682    }
683    match &lemma_type.specifications {
684        TypeSpecification::Boolean { .. } => "boolean".to_string(),
685        TypeSpecification::Number { .. } => "number".to_string(),
686        TypeSpecification::Scale { .. } => "scale".to_string(),
687        TypeSpecification::Text { .. } => "text".to_string(),
688        TypeSpecification::Date { .. } => "date".to_string(),
689        TypeSpecification::Time { .. } => "time".to_string(),
690        TypeSpecification::Duration { .. } => "duration".to_string(),
691        TypeSpecification::Ratio { .. } => "ratio".to_string(),
692        TypeSpecification::Veto { .. } => "veto".to_string(),
693        TypeSpecification::Undetermined => unreachable!(
694            "BUG: type_base_name called with Undetermined sentinel type; this type must never reach OpenAPI generation"
695        ),
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use lemma::parsing::ast::DateTimeValue;
703    use lemma::SourceType;
704
705    fn create_engine_with_code(code: &str) -> Engine {
706        let mut engine = Engine::new();
707        engine
708            .load(code, SourceType::Labeled("test.lemma"))
709            .expect("failed to parse lemma code");
710        engine
711    }
712
713    fn create_engine_with_files(files: Vec<(&str, &str)>) -> Engine {
714        let mut engine = Engine::new();
715        for (name, code) in files {
716            engine
717                .load(code, SourceType::Labeled(name))
718                .expect("failed to parse lemma code");
719        }
720        engine
721    }
722
723    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
724        DateTimeValue {
725            year,
726            month,
727            day,
728            hour: 0,
729            minute: 0,
730            second: 0,
731            microsecond: 0,
732            timezone: None,
733        }
734    }
735
736    fn has_param(params: &Value, name: &str) -> bool {
737        params
738            .as_array()
739            .map(|a| a.iter().any(|p| p["name"] == name))
740            .unwrap_or(false)
741    }
742
743    fn find_param<'a>(params: &'a Value, name: &str) -> &'a Value {
744        params
745            .as_array()
746            .expect("parameters should be array")
747            .iter()
748            .find(|p| p["name"] == name)
749            .unwrap_or_else(|| panic!("parameter '{}' not found", name))
750    }
751
752    // =======================================================================
753    // Basic spec structure (pre-existing, adapted)
754    // =======================================================================
755
756    #[test]
757    fn test_generate_openapi_has_required_fields() {
758        let engine =
759            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
760        let spec = generate_openapi(&engine, false);
761
762        assert_eq!(spec["openapi"], "3.1.0");
763        assert!(spec["info"]["title"].is_string());
764        assert!(spec["tags"].is_array());
765        assert!(spec["paths"].is_object());
766        assert!(spec["components"]["schemas"].is_object());
767    }
768
769    #[test]
770    fn test_generate_openapi_tags_order() {
771        let engine =
772            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
773        let spec = generate_openapi(&engine, false);
774
775        let tags = spec["tags"].as_array().expect("tags should be array");
776        let tag_names: Vec<&str> = tags.iter().map(|t| t["name"].as_str().unwrap()).collect();
777        assert_eq!(tag_names, vec!["Specs", "pricing", "Meta"]);
778    }
779
780    #[test]
781    fn test_generate_openapi_x_tag_groups() {
782        let engine =
783            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
784        let spec = generate_openapi(&engine, false);
785
786        let groups = spec["x-tagGroups"]
787            .as_array()
788            .expect("x-tagGroups should be array");
789        assert_eq!(groups.len(), 3);
790        assert_eq!(groups[0]["name"], "Overview");
791        assert_eq!(groups[0]["tags"], json!(["Specs"]));
792        assert_eq!(groups[1]["name"], "Specs");
793        assert_eq!(groups[1]["tags"], json!(["pricing"]));
794        assert_eq!(groups[2]["name"], "Meta");
795        assert_eq!(groups[2]["tags"], json!(["Meta"]));
796    }
797
798    #[test]
799    fn test_index_endpoint_uses_specs_tag() {
800        let engine =
801            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
802        let spec = generate_openapi(&engine, false);
803
804        let index_tag = &spec["paths"]["/"]["get"]["tags"][0];
805        assert_eq!(index_tag, "Specs");
806    }
807
808    #[test]
809    fn test_spec_path_has_get_and_post() {
810        let engine =
811            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
812        let spec = generate_openapi(&engine, false);
813
814        assert!(
815            spec["paths"]["/pricing"].is_object(),
816            "single spec path /pricing"
817        );
818        assert!(spec["paths"]["/pricing"]["get"].is_object());
819        assert!(spec["paths"]["/pricing"]["post"].is_object());
820
821        assert_eq!(
822            spec["paths"]["/pricing"]["get"]["operationId"],
823            "get_pricing"
824        );
825        assert_eq!(
826            spec["paths"]["/pricing"]["post"]["operationId"],
827            "post_pricing"
828        );
829        assert_eq!(spec["paths"]["/pricing"]["get"]["tags"][0], "pricing");
830
831        let get_params = spec["paths"]["/pricing"]["get"]["parameters"]
832            .as_array()
833            .expect("parameters array");
834        let param_names: Vec<&str> = get_params
835            .iter()
836            .map(|p| p["name"].as_str().unwrap())
837            .collect();
838        assert!(
839            param_names.contains(&"rules"),
840            "GET must have rules query param"
841        );
842        assert!(
843            param_names.contains(&"Accept-Datetime"),
844            "GET must have Accept-Datetime header"
845        );
846    }
847
848    #[test]
849    fn test_spec_endpoint_has_accept_datetime_and_rules() {
850        let engine =
851            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
852        let spec = generate_openapi(&engine, false);
853
854        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
855        assert!(has_param(get_params, "Accept-Datetime"));
856        assert!(has_param(get_params, "rules"));
857
858        let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
859        assert!(has_param(post_params, "Accept-Datetime"));
860    }
861
862    #[test]
863    fn test_generate_openapi_meta_routes() {
864        let engine =
865            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
866        let spec = generate_openapi(&engine, false);
867
868        assert!(spec["paths"]["/"].is_object());
869        assert!(spec["paths"]["/health"].is_object());
870        assert!(spec["paths"]["/openapi.json"].is_object());
871        assert!(spec["paths"]["/docs"].is_null());
872    }
873
874    #[test]
875    fn test_generate_openapi_spec_routes() {
876        let engine =
877            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
878        let spec = generate_openapi(&engine, false);
879
880        assert!(spec["paths"]["/pricing"].is_object());
881        assert!(spec["paths"]["/pricing"]["get"].is_object());
882        assert!(spec["paths"]["/pricing"]["post"].is_object());
883    }
884
885    #[test]
886    fn test_generate_openapi_schemas() {
887        let engine =
888            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
889        let spec = generate_openapi(&engine, false);
890
891        assert!(spec["components"]["schemas"]["pricing_response"].is_object());
892        assert!(spec["components"]["schemas"]["pricing_request"].is_object());
893    }
894
895    #[test]
896    fn test_generate_openapi_explanations_enabled_adds_x_explanations_and_explanation_schema() {
897        let engine =
898            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
899        let spec = generate_openapi(&engine, true);
900
901        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
902        assert!(has_param(get_params, "x-explanations"));
903
904        let response_schema = &spec["components"]["schemas"]["pricing_response"];
905        let total_props = &response_schema["properties"]["total"]["oneOf"];
906        let first_branch = &total_props[0]["properties"];
907        assert!(first_branch["explanation"].is_object());
908    }
909
910    #[test]
911    fn test_generate_openapi_multiple_specs() {
912        let engine = create_engine_with_files(vec![
913            (
914                "pricing.lemma",
915                "spec pricing\nfact quantity: 10\nrule total: quantity * 2",
916            ),
917            (
918                "shipping.lemma",
919                "spec shipping\nfact weight: 5\nrule cost: weight * 3",
920            ),
921        ]);
922        let spec = generate_openapi(&engine, false);
923
924        assert!(spec["paths"]["/pricing"].is_object());
925        assert!(spec["paths"]["/shipping"].is_object());
926    }
927
928    #[test]
929    fn test_nested_spec_path_schema_refs_are_valid() {
930        let engine = create_engine_with_code("spec a/b/c\nfact x: [number]\nrule result: x");
931        let spec = generate_openapi(&engine, false);
932
933        assert!(spec["paths"]["/a/b/c"]["post"].is_object());
934        let body_ref = spec["paths"]["/a/b/c"]["post"]["requestBody"]["content"]
935            ["application/x-www-form-urlencoded"]["schema"]["$ref"]
936            .as_str()
937            .unwrap();
938        assert_eq!(body_ref, "#/components/schemas/a_b_c_request");
939        assert!(spec["components"]["schemas"]["a_b_c_request"].is_object());
940        assert!(spec["components"]["schemas"]["a_b_c_request"]["properties"]["x"].is_object());
941    }
942
943    #[test]
944    fn test_spec_endpoint_has_accept_datetime_header() {
945        let engine =
946            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
947        let spec = generate_openapi(&engine, false);
948
949        let get_params = &spec["paths"]["/pricing"]["get"]["parameters"];
950        assert!(
951            has_param(get_params, "Accept-Datetime"),
952            "GET must have Accept-Datetime header"
953        );
954        let accept_dt = find_param(get_params, "Accept-Datetime");
955        assert_eq!(accept_dt["in"], "header");
956        assert_eq!(accept_dt["required"], false);
957
958        let post_params = &spec["paths"]["/pricing"]["post"]["parameters"];
959        assert!(
960            has_param(post_params, "Accept-Datetime"),
961            "POST must have Accept-Datetime header"
962        );
963    }
964
965    // =======================================================================
966    // generate_openapi_effective with explicit timestamp
967    // =======================================================================
968
969    #[test]
970    fn test_generate_openapi_effective_reflects_specific_time() {
971        let engine =
972            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
973        let effective = date(2025, 6, 15);
974        let spec = generate_openapi_effective(&engine, false, &effective);
975
976        assert_eq!(spec["openapi"], "3.1.0");
977        let version = spec["info"]["version"].as_str().unwrap();
978        assert!(
979            version.contains("2025-06-15"),
980            "version string should contain the effective date, got: {}",
981            version
982        );
983    }
984
985    #[test]
986    fn test_effective_shows_correct_temporal_version_interface() {
987        let engine = create_engine_with_files(vec![(
988            "policy.lemma",
989            r#"
990spec policy
991fact base: 100
992rule discount: 10
993
994spec policy 2025-06-01
995fact base: 200
996fact premium: [boolean]
997rule discount: 20
998rule surcharge: 5
999"#,
1000        )]);
1001
1002        let before = date(2025, 3, 1);
1003        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1004
1005        assert!(spec_v1["paths"]["/policy"].is_object());
1006        let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1007        assert!(
1008            v1_response["properties"]["discount"].is_object(),
1009            "v1 should have discount rule"
1010        );
1011        assert!(
1012            v1_response["properties"]["surcharge"].is_null(),
1013            "v1 must NOT have surcharge rule"
1014        );
1015        let v1_request = &spec_v1["components"]["schemas"]["policy_request"];
1016        assert!(
1017            v1_request["properties"]["premium"].is_null(),
1018            "v1 must NOT have premium fact"
1019        );
1020
1021        let after = date(2025, 8, 1);
1022        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1023
1024        let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1025        assert!(
1026            v2_response["properties"]["discount"].is_object(),
1027            "v2 should have discount rule"
1028        );
1029        assert!(
1030            v2_response["properties"]["surcharge"].is_object(),
1031            "v2 should have surcharge rule"
1032        );
1033        let v2_request = &spec_v2["components"]["schemas"]["policy_request"];
1034        assert!(
1035            v2_request["properties"]["premium"].is_object(),
1036            "v2 should have premium fact"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_effective_per_rule_endpoints_match_temporal_version() {
1042        let engine = create_engine_with_files(vec![(
1043            "policy.lemma",
1044            r#"
1045spec policy
1046fact base: 100
1047rule discount: 10
1048
1049spec policy 2025-06-01
1050fact base: 200
1051rule discount: 20
1052rule surcharge: 5
1053"#,
1054        )]);
1055
1056        let before = date(2025, 3, 1);
1057        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1058        let v1_response = &spec_v1["components"]["schemas"]["policy_response"];
1059        assert!(
1060            v1_response["properties"]["discount"].is_object(),
1061            "v1 should have discount rule"
1062        );
1063        assert!(
1064            v1_response["properties"]["surcharge"].is_null(),
1065            "v1 must NOT have surcharge rule"
1066        );
1067
1068        let after = date(2025, 8, 1);
1069        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1070        let v2_response = &spec_v2["components"]["schemas"]["policy_response"];
1071        assert!(
1072            v2_response["properties"]["discount"].is_object(),
1073            "v2 should have discount rule"
1074        );
1075        assert!(
1076            v2_response["properties"]["surcharge"].is_object(),
1077            "v2 should have surcharge rule"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_effective_tags_reflect_temporal_version() {
1083        let engine = create_engine_with_files(vec![(
1084            "policy.lemma",
1085            r#"
1086spec policy
1087fact base: 100
1088rule discount: 10
1089
1090spec policy 2025-06-01
1091fact base: 200
1092rule discount: 20
1093rule surcharge: 5
1094"#,
1095        )]);
1096
1097        let before = date(2025, 3, 1);
1098        let spec_v1 = generate_openapi_effective(&engine, false, &before);
1099        let v1_tags: Vec<&str> = spec_v1["tags"]
1100            .as_array()
1101            .unwrap()
1102            .iter()
1103            .map(|t| t["name"].as_str().unwrap())
1104            .collect();
1105        assert!(v1_tags.contains(&"policy"));
1106
1107        let after = date(2025, 8, 1);
1108        let spec_v2 = generate_openapi_effective(&engine, false, &after);
1109        let v2_tags: Vec<&str> = spec_v2["tags"]
1110            .as_array()
1111            .unwrap()
1112            .iter()
1113            .map(|t| t["name"].as_str().unwrap())
1114            .collect();
1115        assert!(v2_tags.contains(&"policy"));
1116    }
1117
1118    // =======================================================================
1119    // temporal_api_sources
1120    // =======================================================================
1121
1122    #[test]
1123    fn test_temporal_sources_unversioned_returns_single_current() {
1124        let engine =
1125            create_engine_with_code("spec pricing\nfact quantity: 10\nrule total: quantity * 2");
1126        let sources = temporal_api_sources(&engine);
1127
1128        assert_eq!(sources.len(), 1);
1129        assert_eq!(sources[0].title, "Current");
1130        assert_eq!(sources[0].slug, "current");
1131        assert_eq!(sources[0].url, "/openapi.json");
1132    }
1133
1134    #[test]
1135    fn test_temporal_sources_versioned_returns_boundaries_plus_current() {
1136        let engine = create_engine_with_files(vec![(
1137            "policy.lemma",
1138            r#"
1139spec policy
1140fact base: 100
1141rule discount: 10
1142
1143spec policy 2025-06-01
1144fact base: 200
1145rule discount: 20
1146"#,
1147        )]);
1148
1149        let sources = temporal_api_sources(&engine);
1150
1151        assert_eq!(sources.len(), 2, "should have 1 current + 1 boundary");
1152
1153        assert_eq!(sources[0].title, "Current");
1154        assert_eq!(sources[0].slug, "current");
1155        assert_eq!(sources[0].url, "/openapi.json");
1156
1157        assert_eq!(sources[1].title, "Effective 2025-06-01");
1158        assert_eq!(sources[1].slug, "2025-06-01");
1159        assert_eq!(sources[1].url, "/openapi.json?effective=2025-06-01");
1160    }
1161
1162    #[test]
1163    fn test_temporal_sources_multiple_specs_merged_boundaries() {
1164        let engine = create_engine_with_files(vec![
1165            (
1166                "policy.lemma",
1167                r#"
1168spec policy
1169fact base: 100
1170rule discount: 10
1171
1172spec policy 2025-06-01
1173fact base: 200
1174rule discount: 20
1175"#,
1176            ),
1177            (
1178                "rates.lemma",
1179                r#"
1180spec rates
1181fact rate: 5
1182rule total: rate * 2
1183
1184spec rates 2025-03-01
1185fact rate: 7
1186rule total: rate * 2
1187
1188spec rates 2025-06-01
1189fact rate: 9
1190rule total: rate * 2
1191"#,
1192            ),
1193        ]);
1194
1195        let sources = temporal_api_sources(&engine);
1196
1197        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1198        assert!(
1199            slugs.contains(&"2025-03-01"),
1200            "should contain rates boundary"
1201        );
1202        assert!(
1203            slugs.contains(&"2025-06-01"),
1204            "should contain shared boundary"
1205        );
1206        assert!(slugs.contains(&"current"), "should contain current");
1207        assert_eq!(slugs.len(), 3, "2 unique boundaries + current");
1208    }
1209
1210    #[test]
1211    fn test_temporal_sources_ordered_chronologically() {
1212        let engine = create_engine_with_files(vec![(
1213            "policy.lemma",
1214            r#"
1215spec policy
1216fact base: 100
1217rule discount: 10
1218
1219spec policy 2024-01-01
1220fact base: 50
1221rule discount: 5
1222
1223spec policy 2025-06-01
1224fact base: 200
1225rule discount: 20
1226"#,
1227        )]);
1228
1229        let sources = temporal_api_sources(&engine);
1230        let slugs: Vec<&str> = sources.iter().map(|s| s.slug.as_str()).collect();
1231        assert_eq!(slugs, vec!["current", "2025-06-01", "2024-01-01"]);
1232    }
1233
1234    // =======================================================================
1235    // Type-specific parameter tests
1236    // =======================================================================
1237
1238    #[test]
1239    fn test_post_schema_text_with_options_has_enum() {
1240        let engine = create_engine_with_code(
1241            "spec test\nfact product: [text -> option \"A\" -> option \"B\"]\nrule result: product",
1242        );
1243        let spec = generate_openapi(&engine, false);
1244
1245        let product_prop = &spec["components"]["schemas"]["test_request"]["properties"]["product"];
1246        assert!(product_prop["enum"].is_array());
1247        let enums = product_prop["enum"].as_array().unwrap();
1248        assert_eq!(enums.len(), 2);
1249        assert_eq!(enums[0], "A");
1250        assert_eq!(enums[1], "B");
1251    }
1252
1253    #[test]
1254    fn test_post_schema_boolean_is_string_with_enum() {
1255        let engine =
1256            create_engine_with_code("spec test\nfact is_active: [boolean]\nrule result: is_active");
1257        let spec = generate_openapi(&engine, false);
1258
1259        let schema = &spec["components"]["schemas"]["test_request"];
1260        let is_active = &schema["properties"]["is_active"];
1261        assert_eq!(is_active["type"], "string");
1262        assert_eq!(is_active["enum"], json!(["true", "false"]));
1263    }
1264
1265    #[test]
1266    fn test_post_schema_number_is_string() {
1267        let engine =
1268            create_engine_with_code("spec test\nfact quantity: [number]\nrule result: quantity");
1269        let spec = generate_openapi(&engine, false);
1270
1271        let schema = &spec["components"]["schemas"]["test_request"];
1272        assert_eq!(schema["properties"]["quantity"]["type"], "string");
1273    }
1274
1275    #[test]
1276    fn test_post_schema_date_is_string() {
1277        let engine =
1278            create_engine_with_code("spec test\nfact deadline: [date]\nrule result: deadline");
1279        let spec = generate_openapi(&engine, false);
1280
1281        let schema = &spec["components"]["schemas"]["test_request"];
1282        assert_eq!(schema["properties"]["deadline"]["type"], "string");
1283    }
1284
1285    #[test]
1286    fn test_fact_with_default_is_not_required() {
1287        let engine = create_engine_with_code(
1288            "spec test\nfact quantity: 10\nfact name: [text]\nrule result: quantity",
1289        );
1290        let spec = generate_openapi(&engine, false);
1291
1292        let schema = &spec["components"]["schemas"]["test_request"];
1293        let required = schema["required"]
1294            .as_array()
1295            .expect("required should be array");
1296
1297        assert!(required.contains(&Value::String("name".to_string())));
1298        assert!(!required.contains(&Value::String("quantity".to_string())));
1299    }
1300
1301    #[test]
1302    fn test_help_and_default_in_openapi() {
1303        let engine = create_engine_with_code(
1304            r#"spec test
1305fact quantity: [number -> help "Number of items to order" -> default 10]
1306fact active: [boolean -> help "Whether the feature is enabled" -> default true]
1307rule result: quantity
1308"#,
1309        );
1310        let spec = generate_openapi(&engine, false);
1311
1312        let req_schema = &spec["components"]["schemas"]["test_request"];
1313        assert!(req_schema["properties"]["quantity"]["description"]
1314            .as_str()
1315            .unwrap()
1316            .contains("Number of items to order"));
1317        assert_eq!(
1318            req_schema["properties"]["quantity"]["default"]
1319                .as_str()
1320                .unwrap(),
1321            "10"
1322        );
1323        assert!(req_schema["properties"]["active"]["description"]
1324            .as_str()
1325            .unwrap()
1326            .contains("Whether the feature is enabled"));
1327        assert_eq!(
1328            req_schema["properties"]["active"]["default"]
1329                .as_str()
1330                .unwrap(),
1331            "true"
1332        );
1333    }
1334}