1use 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
16pub 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 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 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 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 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 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
77pub 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 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 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 assert!(code.contains("from __future__ import annotations"));
393 assert!(code.contains("from dataclasses import dataclass"));
394
395 assert!(code.contains("@dataclass\nclass Product:"));
397 assert!(code.contains("price: Optional[float]"));
398 assert!(code.contains("rating: Optional[float]"));
399
400 assert!(code.contains("@dataclass\nclass Category:"));
402
403 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 assert!(
414 code.contains("belongs_to_category"),
415 "relationship traversal"
416 );
417
418 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 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 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 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 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}