1use intent_parser::ast;
10use serde_json::{Map, Value, json};
11
12use crate::{doc_text, format_ensures_item, format_expr, to_snake_case};
13
14pub fn generate(file: &ast::File) -> Value {
16 let mut schemas = Map::new();
17 let mut paths = Map::new();
18
19 let entity_names: Vec<String> = file
21 .items
22 .iter()
23 .filter_map(|item| match item {
24 ast::TopLevelItem::Entity(e) => Some(e.name.clone()),
25 _ => None,
26 })
27 .collect();
28
29 let invariants: Vec<&ast::InvariantDecl> = file
31 .items
32 .iter()
33 .filter_map(|item| match item {
34 ast::TopLevelItem::Invariant(inv) => Some(inv),
35 _ => None,
36 })
37 .collect();
38
39 for item in &file.items {
40 match item {
41 ast::TopLevelItem::Entity(entity) => {
42 let schema = entity_to_schema(entity, &entity_names);
43 schemas.insert(entity.name.clone(), schema);
44 }
45 ast::TopLevelItem::Action(action) => {
46 let path = action_to_path(action, &entity_names, &invariants);
47 let route = format!("/actions/{}", action.name);
48 paths.insert(route, path);
49 }
50 _ => {}
51 }
52 }
53
54 schemas.insert("Violation".to_string(), violation_schema());
56 schemas.insert("ActionResult".to_string(), action_result_schema());
57
58 let mut info = Map::new();
59 info.insert(
60 "title".to_string(),
61 json!(format!("{} API", file.module.name)),
62 );
63 info.insert("version".to_string(), json!("0.1.0"));
64 if let Some(doc) = &file.doc {
65 info.insert("description".to_string(), json!(doc_text(doc)));
66 }
67
68 json!({
69 "openapi": "3.0.3",
70 "info": Value::Object(info),
71 "paths": Value::Object(paths),
72 "components": {
73 "schemas": Value::Object(schemas)
74 }
75 })
76}
77
78fn entity_to_schema(entity: &ast::EntityDecl, entity_names: &[String]) -> Value {
81 let mut properties = Map::new();
82 let mut required = Vec::new();
83
84 for field in &entity.fields {
85 let schema = type_to_schema(&field.ty, entity_names);
86 properties.insert(field.name.clone(), schema);
87 if !field.ty.optional {
88 required.push(json!(field.name));
89 }
90 }
91
92 let mut schema = Map::new();
93 schema.insert("type".to_string(), json!("object"));
94 schema.insert("properties".to_string(), Value::Object(properties));
95 if !required.is_empty() {
96 schema.insert("required".to_string(), Value::Array(required));
97 }
98 if let Some(doc) = &entity.doc {
99 schema.insert("description".to_string(), json!(doc_text(doc)));
100 }
101
102 Value::Object(schema)
103}
104
105fn type_to_schema(ty: &ast::TypeExpr, entity_names: &[String]) -> Value {
108 let base = type_kind_to_schema(&ty.ty, entity_names);
109 if ty.optional {
110 if let Value::Object(mut obj) = base {
112 obj.insert("nullable".to_string(), json!(true));
113 Value::Object(obj)
114 } else {
115 base
116 }
117 } else {
118 base
119 }
120}
121
122fn type_kind_to_schema(kind: &ast::TypeKind, entity_names: &[String]) -> Value {
123 match kind {
124 ast::TypeKind::Simple(name) => simple_type_schema(name, entity_names),
125 ast::TypeKind::Parameterized { name, .. } => simple_type_schema(name, entity_names),
126 ast::TypeKind::Union(variants) => {
127 let names: Vec<&str> = variants
128 .iter()
129 .filter_map(|v| match v {
130 ast::TypeKind::Simple(n) => Some(n.as_str()),
131 _ => None,
132 })
133 .collect();
134 json!({
135 "type": "string",
136 "enum": names
137 })
138 }
139 ast::TypeKind::List(inner) => {
140 json!({
141 "type": "array",
142 "items": type_to_schema(inner, entity_names)
143 })
144 }
145 ast::TypeKind::Set(inner) => {
146 json!({
147 "type": "array",
148 "items": type_to_schema(inner, entity_names),
149 "uniqueItems": true
150 })
151 }
152 ast::TypeKind::Map(_k, v) => {
153 json!({
154 "type": "object",
155 "additionalProperties": type_to_schema(v, entity_names)
156 })
157 }
158 }
159}
160
161fn simple_type_schema(name: &str, entity_names: &[String]) -> Value {
162 match name {
163 "UUID" => json!({ "type": "string", "format": "uuid" }),
164 "String" => json!({ "type": "string" }),
165 "Int" => json!({ "type": "integer", "format": "int64" }),
166 "Decimal" => json!({ "type": "number" }),
167 "Bool" => json!({ "type": "boolean" }),
168 "DateTime" => json!({ "type": "string", "format": "date-time" }),
169 "Email" => json!({ "type": "string", "format": "email" }),
170 "URL" => json!({ "type": "string", "format": "uri" }),
171 "CurrencyCode" => json!({ "type": "string" }),
172 other => {
173 if entity_names.contains(&other.to_string()) {
174 json!({ "$ref": format!("#/components/schemas/{other}") })
175 } else {
176 json!({ "type": "string" })
177 }
178 }
179 }
180}
181
182fn action_to_path(
185 action: &ast::ActionDecl,
186 entity_names: &[String],
187 invariants: &[&ast::InvariantDecl],
188) -> Value {
189 let mut description_parts = Vec::new();
190
191 if let Some(doc) = &action.doc {
192 description_parts.push(doc_text(doc));
193 }
194
195 if let Some(req) = &action.requires {
197 description_parts.push("**Preconditions (requires):**".to_string());
198 for cond in &req.conditions {
199 description_parts.push(format!("- `{}`", format_expr(cond)));
200 }
201 }
202
203 if let Some(ens) = &action.ensures {
205 description_parts.push("**Postconditions (ensures):**".to_string());
206 for item in &ens.items {
207 description_parts.push(format!("- `{}`", format_ensures_item(item)));
208 }
209 }
210
211 if let Some(props) = &action.properties {
213 description_parts.push("**Properties:**".to_string());
214 for entry in &props.entries {
215 description_parts.push(format!(
216 "- {}: {}",
217 entry.key,
218 crate::format_prop_value(&entry.value)
219 ));
220 }
221 }
222
223 if !invariants.is_empty() {
225 description_parts.push("**Invariants:**".to_string());
226 for inv in invariants {
227 let mut line = format!("- {}", inv.name);
228 if let Some(doc) = &inv.doc {
229 line.push_str(&format!(": {}", doc_text(doc)));
230 }
231 description_parts.push(line);
232 }
233 }
234
235 let description = description_parts.join("\n\n");
236
237 let request_schema = action_request_schema(action, entity_names);
239
240 let fn_name = to_snake_case(&action.name);
241
242 json!({
243 "post": {
244 "operationId": fn_name,
245 "summary": format!("Execute the {} action", action.name),
246 "description": description,
247 "requestBody": {
248 "required": true,
249 "content": {
250 "application/json": {
251 "schema": request_schema
252 }
253 }
254 },
255 "responses": {
256 "200": {
257 "description": "Action executed successfully, all contracts satisfied",
258 "content": {
259 "application/json": {
260 "schema": { "$ref": "#/components/schemas/ActionResult" }
261 }
262 }
263 },
264 "422": {
265 "description": "Contract violation (precondition, postcondition, or invariant failed)",
266 "content": {
267 "application/json": {
268 "schema": { "$ref": "#/components/schemas/ActionResult" }
269 }
270 }
271 },
272 "400": {
273 "description": "Malformed request or runtime error"
274 }
275 }
276 }
277 })
278}
279
280fn action_request_schema(action: &ast::ActionDecl, entity_names: &[String]) -> Value {
281 let mut param_properties = Map::new();
282 let mut param_required = Vec::new();
283
284 for param in &action.params {
285 let schema = type_to_schema(¶m.ty, entity_names);
286 param_properties.insert(param.name.clone(), schema);
287 if !param.ty.optional {
288 param_required.push(json!(param.name));
289 }
290 }
291
292 let mut params_schema = Map::new();
293 params_schema.insert("type".to_string(), json!("object"));
294 params_schema.insert("properties".to_string(), Value::Object(param_properties));
295 if !param_required.is_empty() {
296 params_schema.insert("required".to_string(), Value::Array(param_required));
297 }
298
299 json!({
300 "type": "object",
301 "required": ["params"],
302 "properties": {
303 "params": Value::Object(params_schema),
304 "state": {
305 "type": "object",
306 "description": "Entity instances keyed by type name (for quantifier/invariant evaluation)",
307 "additionalProperties": {
308 "type": "array",
309 "items": {}
310 }
311 }
312 }
313 })
314}
315
316fn violation_schema() -> Value {
319 json!({
320 "type": "object",
321 "required": ["kind", "message"],
322 "properties": {
323 "kind": {
324 "type": "string",
325 "enum": ["precondition_failed", "postcondition_failed", "invariant_violated", "edge_guard_triggered"]
326 },
327 "message": {
328 "type": "string"
329 }
330 }
331 })
332}
333
334fn action_result_schema() -> Value {
335 json!({
336 "type": "object",
337 "required": ["ok", "new_params", "violations"],
338 "properties": {
339 "ok": {
340 "type": "boolean",
341 "description": "Whether all contracts were satisfied"
342 },
343 "new_params": {
344 "type": "object",
345 "description": "Updated parameter values after postcondition application",
346 "additionalProperties": {}
347 },
348 "violations": {
349 "type": "array",
350 "items": {
351 "$ref": "#/components/schemas/Violation"
352 },
353 "description": "List of contract violations (empty if ok is true)"
354 }
355 }
356 })
357}