Skip to main content

cortex_runtime/compiler/
codegen.rs

1//! Top-level code generation orchestrator.
2//!
3//! Calls each sub-generator (Python, TypeScript, OpenAPI, GraphQL, MCP) and
4//! assembles the complete set of generated files.
5
6use crate::compiler::codegen_graphql;
7use crate::compiler::codegen_mcp;
8use crate::compiler::codegen_openapi;
9use crate::compiler::codegen_python;
10use crate::compiler::codegen_typescript;
11use crate::compiler::models::*;
12use anyhow::Result;
13use std::fs;
14use std::path::Path;
15
16/// Generate all client files from a compiled schema.
17///
18/// Writes files to `output_dir` and returns metadata about what was generated.
19pub fn generate_all(schema: &CompiledSchema, output_dir: &Path) -> Result<GeneratedFiles> {
20    fs::create_dir_all(output_dir)?;
21
22    let mut files: Vec<GeneratedFile> = Vec::new();
23
24    // Python client
25    let python = codegen_python::generate_python(schema);
26    let python_path = output_dir.join("client.py");
27    fs::write(&python_path, &python)?;
28    files.push(GeneratedFile {
29        filename: "client.py".to_string(),
30        size: python.len(),
31        content: python,
32    });
33
34    // TypeScript client
35    let typescript = codegen_typescript::generate_typescript(schema);
36    let ts_path = output_dir.join("client.ts");
37    fs::write(&ts_path, &typescript)?;
38    files.push(GeneratedFile {
39        filename: "client.ts".to_string(),
40        size: typescript.len(),
41        content: typescript,
42    });
43
44    // OpenAPI spec
45    let openapi = codegen_openapi::generate_openapi(schema);
46    let openapi_path = output_dir.join("openapi.yaml");
47    fs::write(&openapi_path, &openapi)?;
48    files.push(GeneratedFile {
49        filename: "openapi.yaml".to_string(),
50        size: openapi.len(),
51        content: openapi,
52    });
53
54    // GraphQL schema
55    let graphql = codegen_graphql::generate_graphql(schema);
56    let graphql_path = output_dir.join("schema.graphql");
57    fs::write(&graphql_path, &graphql)?;
58    files.push(GeneratedFile {
59        filename: "schema.graphql".to_string(),
60        size: graphql.len(),
61        content: graphql,
62    });
63
64    // MCP tools
65    let mcp = codegen_mcp::generate_mcp(schema);
66    let mcp_path = output_dir.join("mcp_tools.json");
67    fs::write(&mcp_path, &mcp)?;
68    files.push(GeneratedFile {
69        filename: "mcp_tools.json".to_string(),
70        size: mcp.len(),
71        content: mcp,
72    });
73
74    Ok(GeneratedFiles { files })
75}
76
77/// Generate all client files as in-memory strings (no disk write).
78pub fn generate_all_in_memory(schema: &CompiledSchema) -> GeneratedFiles {
79    let python = codegen_python::generate_python(schema);
80    let typescript = codegen_typescript::generate_typescript(schema);
81    let openapi = codegen_openapi::generate_openapi(schema);
82    let graphql = codegen_graphql::generate_graphql(schema);
83    let mcp = codegen_mcp::generate_mcp(schema);
84
85    GeneratedFiles {
86        files: vec![
87            GeneratedFile {
88                filename: "client.py".to_string(),
89                size: python.len(),
90                content: python,
91            },
92            GeneratedFile {
93                filename: "client.ts".to_string(),
94                size: typescript.len(),
95                content: typescript,
96            },
97            GeneratedFile {
98                filename: "openapi.yaml".to_string(),
99                size: openapi.len(),
100                content: openapi,
101            },
102            GeneratedFile {
103                filename: "schema.graphql".to_string(),
104                size: graphql.len(),
105                content: graphql,
106            },
107            GeneratedFile {
108                filename: "mcp_tools.json".to_string(),
109                size: mcp.len(),
110                content: mcp,
111            },
112        ],
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use chrono::Utc;
120    use tempfile::TempDir;
121
122    fn test_schema() -> CompiledSchema {
123        CompiledSchema {
124            domain: "test.com".to_string(),
125            compiled_at: Utc::now(),
126            models: vec![DataModel {
127                name: "Product".to_string(),
128                schema_org_type: "Product".to_string(),
129                fields: vec![
130                    ModelField {
131                        name: "url".to_string(),
132                        field_type: FieldType::Url,
133                        source: FieldSource::Inferred,
134                        confidence: 1.0,
135                        nullable: false,
136                        example_values: vec!["https://test.com/p/1".to_string()],
137                        feature_dim: None,
138                    },
139                    ModelField {
140                        name: "name".to_string(),
141                        field_type: FieldType::String,
142                        source: FieldSource::JsonLd,
143                        confidence: 0.99,
144                        nullable: false,
145                        example_values: vec!["Widget".to_string()],
146                        feature_dim: None,
147                    },
148                    ModelField {
149                        name: "price".to_string(),
150                        field_type: FieldType::Float,
151                        source: FieldSource::JsonLd,
152                        confidence: 0.99,
153                        nullable: true,
154                        example_values: vec!["29.99".to_string()],
155                        feature_dim: Some(48),
156                    },
157                ],
158                instance_count: 100,
159                example_urls: vec!["https://test.com/p/1".to_string()],
160                search_action: None,
161                list_url: Some("https://test.com/products".to_string()),
162            }],
163            actions: vec![CompiledAction {
164                name: "search".to_string(),
165                belongs_to: "Site".to_string(),
166                is_instance_method: false,
167                http_method: "GET".to_string(),
168                endpoint_template: "/search?q={query}".to_string(),
169                params: vec![ActionParam {
170                    name: "query".to_string(),
171                    param_type: FieldType::String,
172                    required: true,
173                    default_value: None,
174                    source: "url_param".to_string(),
175                }],
176                requires_auth: false,
177                execution_path: "http".to_string(),
178                confidence: 0.9,
179            }],
180            relationships: vec![],
181            stats: SchemaStats {
182                total_models: 1,
183                total_fields: 3,
184                total_instances: 100,
185                avg_confidence: 0.99,
186            },
187        }
188    }
189
190    #[test]
191    fn test_generate_all_creates_files() {
192        let schema = test_schema();
193        let dir = TempDir::new().unwrap();
194
195        let result = generate_all(&schema, dir.path()).unwrap();
196        assert_eq!(result.files.len(), 5);
197
198        // Verify files exist on disk
199        assert!(dir.path().join("client.py").exists());
200        assert!(dir.path().join("client.ts").exists());
201        assert!(dir.path().join("openapi.yaml").exists());
202        assert!(dir.path().join("schema.graphql").exists());
203        assert!(dir.path().join("mcp_tools.json").exists());
204    }
205
206    #[test]
207    fn test_generate_all_in_memory() {
208        let schema = test_schema();
209        let result = generate_all_in_memory(&schema);
210        assert_eq!(result.files.len(), 5);
211
212        for file in &result.files {
213            assert!(
214                !file.content.is_empty(),
215                "{} should not be empty",
216                file.filename
217            );
218            assert!(file.size > 0);
219        }
220    }
221
222    // ── v4 Test Suite: Phase 1B — Code Generation ──
223
224    fn full_ecommerce_schema() -> CompiledSchema {
225        CompiledSchema {
226            domain: "shop.example.com".to_string(),
227            compiled_at: Utc::now(),
228            models: vec![
229                DataModel {
230                    name: "Product".to_string(),
231                    schema_org_type: "Product".to_string(),
232                    fields: vec![
233                        ModelField {
234                            name: "url".to_string(),
235                            field_type: FieldType::Url,
236                            source: FieldSource::Inferred,
237                            confidence: 1.0,
238                            nullable: false,
239                            example_values: vec!["https://shop.example.com/p/1".to_string()],
240                            feature_dim: None,
241                        },
242                        ModelField {
243                            name: "name".to_string(),
244                            field_type: FieldType::String,
245                            source: FieldSource::JsonLd,
246                            confidence: 0.99,
247                            nullable: false,
248                            example_values: vec!["Widget".to_string()],
249                            feature_dim: None,
250                        },
251                        ModelField {
252                            name: "price".to_string(),
253                            field_type: FieldType::Float,
254                            source: FieldSource::JsonLd,
255                            confidence: 0.99,
256                            nullable: true,
257                            example_values: vec!["29.99".to_string()],
258                            feature_dim: Some(48),
259                        },
260                        ModelField {
261                            name: "rating".to_string(),
262                            field_type: FieldType::Float,
263                            source: FieldSource::JsonLd,
264                            confidence: 0.95,
265                            nullable: true,
266                            example_values: vec!["4.5".to_string()],
267                            feature_dim: Some(52),
268                        },
269                        ModelField {
270                            name: "availability".to_string(),
271                            field_type: FieldType::Bool,
272                            source: FieldSource::Inferred,
273                            confidence: 0.85,
274                            nullable: true,
275                            example_values: vec![],
276                            feature_dim: Some(51),
277                        },
278                    ],
279                    instance_count: 500,
280                    example_urls: vec!["https://shop.example.com/p/1".to_string()],
281                    search_action: Some(CompiledAction {
282                        name: "search".to_string(),
283                        belongs_to: "Product".to_string(),
284                        is_instance_method: false,
285                        http_method: "GET".to_string(),
286                        endpoint_template: "/search?q={query}".to_string(),
287                        params: vec![],
288                        requires_auth: false,
289                        execution_path: "http".to_string(),
290                        confidence: 0.9,
291                    }),
292                    list_url: Some("https://shop.example.com/products".to_string()),
293                },
294                DataModel {
295                    name: "Category".to_string(),
296                    schema_org_type: "ProductListing".to_string(),
297                    fields: vec![
298                        ModelField {
299                            name: "url".to_string(),
300                            field_type: FieldType::Url,
301                            source: FieldSource::Inferred,
302                            confidence: 1.0,
303                            nullable: false,
304                            example_values: vec![],
305                            feature_dim: None,
306                        },
307                        ModelField {
308                            name: "name".to_string(),
309                            field_type: FieldType::String,
310                            source: FieldSource::Inferred,
311                            confidence: 0.8,
312                            nullable: false,
313                            example_values: vec![],
314                            feature_dim: None,
315                        },
316                    ],
317                    instance_count: 10,
318                    example_urls: vec!["https://shop.example.com/electronics".to_string()],
319                    search_action: None,
320                    list_url: None,
321                },
322            ],
323            actions: vec![
324                CompiledAction {
325                    name: "add_to_cart".to_string(),
326                    belongs_to: "Product".to_string(),
327                    is_instance_method: true,
328                    http_method: "POST".to_string(),
329                    endpoint_template: "/cart/add".to_string(),
330                    params: vec![ActionParam {
331                        name: "quantity".to_string(),
332                        param_type: FieldType::Integer,
333                        required: false,
334                        default_value: Some("1".to_string()),
335                        source: "json_body".to_string(),
336                    }],
337                    requires_auth: false,
338                    execution_path: "http".to_string(),
339                    confidence: 0.9,
340                },
341                CompiledAction {
342                    name: "search".to_string(),
343                    belongs_to: "Site".to_string(),
344                    is_instance_method: false,
345                    http_method: "GET".to_string(),
346                    endpoint_template: "/search?q={query}".to_string(),
347                    params: vec![ActionParam {
348                        name: "query".to_string(),
349                        param_type: FieldType::String,
350                        required: true,
351                        default_value: None,
352                        source: "url_param".to_string(),
353                    }],
354                    requires_auth: false,
355                    execution_path: "http".to_string(),
356                    confidence: 0.95,
357                },
358            ],
359            relationships: vec![ModelRelationship {
360                from_model: "Product".to_string(),
361                to_model: "Category".to_string(),
362                name: "belongs_to_category".to_string(),
363                cardinality: Cardinality::BelongsTo,
364                edge_count: 500,
365                traversal_hint: TraversalHint {
366                    edge_types: vec!["Breadcrumb".to_string()],
367                    forward: true,
368                },
369            }],
370            stats: SchemaStats {
371                total_models: 2,
372                total_fields: 7,
373                total_instances: 510,
374                avg_confidence: 0.93,
375            },
376        }
377    }
378
379    #[test]
380    fn test_v4_codegen_python_valid_syntax() {
381        let schema = full_ecommerce_schema();
382        let files = generate_all_in_memory(&schema);
383
384        let py_file = files
385            .files
386            .iter()
387            .find(|f| f.filename == "client.py")
388            .unwrap();
389        let code = &py_file.content;
390
391        // Must have imports
392        assert!(code.contains("from __future__ import annotations"));
393        assert!(code.contains("from dataclasses import dataclass"));
394
395        // Must have Product class
396        assert!(code.contains("@dataclass\nclass Product:"));
397        assert!(code.contains("price: Optional[float]"));
398        assert!(code.contains("rating: Optional[float]"));
399
400        // Must have Category class
401        assert!(code.contains("@dataclass\nclass Category:"));
402
403        // Must have methods
404        assert!(code.contains("def search("), "search method");
405        assert!(code.contains("def add_to_cart(self"), "add_to_cart method");
406        assert!(
407            code.contains("def _from_node(node)"),
408            "_from_node deserializer"
409        );
410        assert!(code.contains("def _field_to_dim("), "_field_to_dim helper");
411
412        // Must have relationship method
413        assert!(
414            code.contains("belongs_to_category"),
415            "relationship traversal"
416        );
417
418        // Should not have Python syntax errors (basic checks)
419        assert!(
420            !code.contains("None,\n    )"),
421            "trailing comma is fine in Python"
422        );
423    }
424
425    #[test]
426    fn test_v4_codegen_typescript_valid() {
427        let schema = full_ecommerce_schema();
428        let files = generate_all_in_memory(&schema);
429
430        let ts_file = files
431            .files
432            .iter()
433            .find(|f| f.filename == "client.ts")
434            .unwrap();
435        let code = &ts_file.content;
436
437        assert!(code.contains("interface Product"), "Product interface");
438        assert!(code.contains("interface Category"), "Category interface");
439        assert!(code.contains("price?:"), "optional price field");
440        assert!(code.contains("async function"), "async functions");
441    }
442
443    #[test]
444    fn test_v4_codegen_openapi_valid_yaml() {
445        let schema = full_ecommerce_schema();
446        let files = generate_all_in_memory(&schema);
447
448        let openapi = files
449            .files
450            .iter()
451            .find(|f| f.filename == "openapi.yaml")
452            .unwrap();
453        let code = &openapi.content;
454
455        assert!(code.contains("openapi: 3.0.3"), "OpenAPI version");
456        assert!(code.contains("paths:"), "paths section");
457        assert!(code.contains("components:"), "components section");
458        assert!(code.contains("/products"), "products path");
459        assert!(code.contains("schemas:"), "schemas section");
460        assert!(code.contains("Product:"), "Product schema");
461    }
462
463    #[test]
464    fn test_v4_codegen_graphql_valid() {
465        let schema = full_ecommerce_schema();
466        let files = generate_all_in_memory(&schema);
467
468        let gql = files
469            .files
470            .iter()
471            .find(|f| f.filename == "schema.graphql")
472            .unwrap();
473        let code = &gql.content;
474
475        assert!(code.contains("type Product"), "Product type");
476        assert!(code.contains("type Category"), "Category type");
477        assert!(code.contains("type Query"), "Query type");
478    }
479
480    #[test]
481    fn test_v4_codegen_mcp_valid_json() {
482        let schema = full_ecommerce_schema();
483        let files = generate_all_in_memory(&schema);
484
485        let mcp = files
486            .files
487            .iter()
488            .find(|f| f.filename == "mcp_tools.json")
489            .unwrap();
490
491        // Parse as JSON to validate
492        let parsed: serde_json::Value =
493            serde_json::from_str(&mcp.content).expect("MCP tools file should be valid JSON");
494
495        let tools = parsed.get("tools").expect("should have tools array");
496        assert!(tools.is_array());
497        assert!(
498            !tools.as_array().unwrap().is_empty(),
499            "should have at least 1 tool"
500        );
501
502        // Each tool should have name, description, inputSchema
503        for tool in tools.as_array().unwrap() {
504            assert!(tool.get("name").is_some(), "tool needs name");
505            assert!(tool.get("description").is_some(), "tool needs description");
506            assert!(tool.get("inputSchema").is_some(), "tool needs inputSchema");
507        }
508    }
509
510    #[test]
511    fn test_v4_codegen_files_to_disk() {
512        let schema = full_ecommerce_schema();
513        let dir = TempDir::new().unwrap();
514
515        let result = generate_all(&schema, dir.path()).unwrap();
516        assert_eq!(result.files.len(), 5);
517
518        // All files should exist and be non-empty
519        for file in &result.files {
520            let path = dir.path().join(&file.filename);
521            assert!(path.exists(), "{} should exist", file.filename);
522            let content = std::fs::read_to_string(&path).unwrap();
523            assert!(!content.is_empty(), "{} should not be empty", file.filename);
524        }
525    }
526
527    #[test]
528    fn test_v4_codegen_multiple_domains() {
529        // Test that codegen works for various domain styles
530        let domains = vec![
531            "amazon.com",
532            "best-buy.com",
533            "docs.python.org",
534            "my.site.co.uk",
535        ];
536
537        for domain in domains {
538            let mut schema = test_schema();
539            schema.domain = domain.to_string();
540            let result = generate_all_in_memory(&schema);
541            assert_eq!(
542                result.files.len(),
543                5,
544                "Should generate 5 files for {domain}"
545            );
546            for file in &result.files {
547                assert!(
548                    !file.content.is_empty(),
549                    "{} should not be empty for {domain}",
550                    file.filename
551                );
552            }
553        }
554    }
555}