1use crate::{CodeGenerator, GeneratedFile, GeneratorConfig, SchemaAnalyzer};
2use serde_json::Value;
3use std::env;
4use std::fs;
5use std::path::PathBuf;
6use std::process::Command;
7use tempfile::TempDir;
8
9pub struct GenerationTestResult {
11 pub temp_dir: TempDir,
13 pub project_dir: PathBuf,
15 pub generated_src_dir: PathBuf,
17 pub files: Vec<GeneratedFile>,
19 pub compilation_output: Option<std::process::Output>,
21 pub cargo_toml_path: PathBuf,
23}
24
25impl GenerationTestResult {
26 pub fn read_file(&self, filename: &str) -> std::io::Result<String> {
28 let path = self.generated_src_dir.join(filename);
29 fs::read_to_string(path)
30 }
31
32 pub fn compiled_successfully(&self) -> bool {
34 self.compilation_output
35 .as_ref()
36 .map(|o| o.status.success())
37 .unwrap_or(false)
38 }
39
40 pub fn compilation_errors(&self) -> String {
42 self.compilation_output
43 .as_ref()
44 .map(|o| String::from_utf8_lossy(&o.stderr).to_string())
45 .unwrap_or_default()
46 }
47
48 pub fn test_output(&self) -> Option<String> {
50 self.compilation_output
51 .as_ref()
52 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
53 }
54}
55
56pub struct GenerationTest {
58 pub name: String,
60 pub spec: Value,
62 pub config_overrides: Option<GeneratorConfigOverrides>,
64 pub test_scenarios: Vec<TestScenario>,
66 pub run_tests: bool,
68 pub compile: bool,
70 pub extra_dependencies: Vec<(String, String)>,
72}
73
74#[derive(Clone)]
76pub struct TestScenario {
77 pub name: String,
79 pub target_type: String,
81 pub behavior: TestBehavior,
83}
84
85#[derive(Clone)]
87pub enum TestBehavior {
88 RoundTrip {
90 json: Value,
92 assertions: Vec<(String, Value)>,
94 },
95 Construction {
97 fields: Vec<(String, FieldValue)>,
100 assertions: Vec<ConstructionAssertion>,
102 },
103 CompileOnly,
105 EnumMatch {
107 json: Value,
109 expected_variant: String,
111 variant_assertions: Vec<(String, Value)>,
113 },
114}
115
116#[derive(Clone)]
118pub enum FieldValue {
119 String(String),
121 Integer(i64),
123 Float(f64),
125 Boolean(bool),
127 Null,
129 EnumVariant(String),
131 Array(Vec<FieldValue>),
133 Struct(String, Vec<(String, FieldValue)>),
135}
136
137#[derive(Clone)]
139pub enum ConstructionAssertion {
140 JsonContains(String),
142 FieldEquals(String, Value),
144 CanSerialize,
146}
147
148pub struct GeneratorConfigOverrides {
150 pub module_name: Option<String>,
151 pub enable_sse_client: Option<bool>,
152 pub enable_async_client: Option<bool>,
153}
154
155impl GenerationTest {
156 pub fn new(name: impl Into<String>, spec: Value) -> Self {
158 Self {
159 name: name.into(),
160 spec,
161 ..Default::default()
162 }
163 }
164
165 pub fn with_compilation(mut self) -> Self {
167 self.compile = true;
168 self
169 }
170
171 pub fn with_env_compilation(mut self) -> Self {
173 self.compile = should_compile_tests();
174 self
175 }
176
177 pub fn with_scenario(mut self, scenario: TestScenario) -> Self {
179 self.test_scenarios.push(scenario);
180 self
181 }
182
183 pub fn run_tests(mut self, run: bool) -> Self {
185 self.run_tests = run;
186 self
187 }
188}
189
190impl Default for GenerationTest {
191 fn default() -> Self {
192 Self {
193 name: "test".to_string(),
194 spec: Value::Null,
195 config_overrides: None,
196 test_scenarios: vec![],
197 run_tests: true,
198 extra_dependencies: vec![],
199 compile: false, }
201 }
202}
203
204pub fn should_compile_tests() -> bool {
206 env::var("OPENAPI_GEN_COMPILE_TESTS")
207 .map(|v| v == "1" || v.to_lowercase() == "true")
208 .unwrap_or(false)
209}
210
211pub fn test_generation(name: &str, spec: Value) -> Result<String, Box<dyn std::error::Error>> {
214 let mut analyzer = SchemaAnalyzer::new(spec)?;
216 let mut analysis = analyzer.analyze()?;
217
218 let config = GeneratorConfig {
220 module_name: name.to_string(),
221 ..Default::default()
222 };
223 let generator = CodeGenerator::new(config);
224 let generated_code = generator.generate(&mut analysis)?;
225
226 insta::assert_snapshot!(name, &generated_code);
230
231 Ok(generated_code)
232}
233
234pub fn run_generation_test(
236 test: GenerationTest,
237) -> Result<GenerationTestResult, Box<dyn std::error::Error>> {
238 let temp_dir = TempDir::new()?;
240 let project_dir = temp_dir.path().join(&test.name);
241 fs::create_dir(&project_dir)?;
242
243 let src_dir = project_dir.join("src");
245 fs::create_dir(&src_dir)?;
246 let generated_src_dir = src_dir.join("generated");
247 fs::create_dir(&generated_src_dir)?;
248
249 let mut analyzer = SchemaAnalyzer::new(test.spec.clone())?;
251 let analysis = analyzer.analyze()?;
252
253 let config = GeneratorConfig {
255 spec_path: PathBuf::from("test.json"), output_dir: generated_src_dir.clone(),
257 module_name: test
258 .config_overrides
259 .as_ref()
260 .and_then(|o| o.module_name.clone())
261 .unwrap_or_else(|| "generated".to_string()),
262 enable_sse_client: test
263 .config_overrides
264 .as_ref()
265 .and_then(|o| o.enable_sse_client)
266 .unwrap_or(false),
267 enable_async_client: test
268 .config_overrides
269 .as_ref()
270 .and_then(|o| o.enable_async_client)
271 .unwrap_or(false),
272 enable_specta: false,
273 type_mappings: {
274 let mut mappings = std::collections::BTreeMap::new();
275 mappings.insert("integer".to_string(), "i64".to_string());
276 mappings.insert("number".to_string(), "f64".to_string());
277 mappings.insert("string".to_string(), "String".to_string());
278 mappings.insert("boolean".to_string(), "bool".to_string());
279 mappings
280 },
281 streaming_config: None,
282 nullable_field_overrides: std::collections::BTreeMap::new(),
283 extensible_enum_overrides: std::collections::BTreeMap::new(),
284 schema_extensions: vec![],
285 http_client_config: None,
286 retry_config: None,
287 tracing_enabled: true,
288 auth_config: None,
289 enable_registry: false,
290 registry_only: false,
291 types: crate::type_mapping::TypeMappingConfig::default(),
292 server: None,
293 };
294
295 let generator = CodeGenerator::new(config);
297 let mut analysis_mut = analysis;
298 let types_content = generator.generate(&mut analysis_mut)?;
299
300 let files = vec![GeneratedFile {
302 path: "types.rs".into(),
303 content: types_content.clone(),
304 }];
305
306 for file in &files {
308 let dest_path = generated_src_dir.join(&file.path);
309 if let Some(parent) = dest_path.parent() {
310 fs::create_dir_all(parent)?;
311 }
312 fs::write(dest_path, &file.content)?;
313 }
314
315 let mut dependencies = vec![
317 ("serde", r#"{ version = "1.0", features = ["derive"] }"#),
318 ("serde_json", r#""1.0""#),
319 ("async-trait", r#""0.1""#),
320 (
321 "reqwest",
322 r#"{ version = "0.12", features = ["json", "stream"] }"#,
323 ),
324 ("futures-util", r#""0.3""#),
325 ("tokio", r#"{ version = "1.0", features = ["full"] }"#),
326 ("tracing", r#""0.1""#),
327 ("chrono", r#"{ version = "0.4", features = ["serde"] }"#),
329 ("uuid", r#"{ version = "1", features = ["serde", "v4"] }"#),
330 ("url", r#"{ version = "2", features = ["serde"] }"#),
331 ("bytes", r#"{ version = "1", features = ["serde"] }"#),
332 ("base64", r#""0.22""#),
333 ];
334
335 for (name, version) in &test.extra_dependencies {
337 dependencies.push((name.as_str(), version.as_str()));
338 }
339
340 let deps_str = dependencies
341 .iter()
342 .map(|(name, ver)| format!("{name} = {ver}"))
343 .collect::<Vec<_>>()
344 .join("\n");
345
346 let cargo_toml = format!(
347 r#"[package]
348name = "{}"
349version = "0.1.0"
350edition = "2021"
351
352[dependencies]
353{}
354"#,
355 test.name.replace('-', "_"),
356 deps_str
357 );
358
359 let cargo_toml_path = project_dir.join("Cargo.toml");
360 fs::write(&cargo_toml_path, cargo_toml)?;
361
362 fs::write(generated_src_dir.join("mod.rs"), "pub mod types;\n")?;
364
365 let lib_rs = create_lib_rs(&test, &files);
367 fs::write(src_dir.join("lib.rs"), lib_rs)?;
368
369 let compilation_output = if !test.compile {
371 None
372 } else if test.run_tests {
373 Some(
375 Command::new("cargo")
376 .arg("test")
377 .arg("--")
378 .arg("--nocapture")
379 .current_dir(&project_dir)
380 .env("RUST_BACKTRACE", "1")
381 .output()?,
382 )
383 } else {
384 Some(
386 Command::new("cargo")
387 .arg("check")
388 .current_dir(&project_dir)
389 .env("RUST_BACKTRACE", "1")
390 .output()?,
391 )
392 };
393
394 if let Some(ref output) = compilation_output {
396 if !output.status.success() {
397 if let Ok(types_content) = fs::read_to_string(generated_src_dir.join("types.rs")) {
398 eprintln!("Generated types.rs:\n{types_content}");
399 }
400 }
401 }
402
403 Ok(GenerationTestResult {
404 temp_dir,
405 project_dir,
406 generated_src_dir,
407 files,
408 compilation_output,
409 cargo_toml_path,
410 })
411}
412
413fn create_lib_rs(test: &GenerationTest, _generated_files: &[GeneratedFile]) -> String {
414 let mut lib_content = String::from("pub mod generated;\n\n");
415
416 lib_content.push_str("#[cfg(test)]\nmod tests {\n");
418 lib_content.push_str(" use super::generated::types::*;\n");
419 lib_content.push_str(" use serde_json;\n\n");
420
421 lib_content.push_str(" #[test]\n");
423 lib_content.push_str(" fn test_compilation() {\n");
424 lib_content.push_str(&format!(
425 " // Generated types compile for test: {}\n",
426 test.name
427 ));
428 lib_content.push_str(" }\n\n");
429
430 for scenario in &test.test_scenarios {
432 lib_content.push_str(&generate_test_from_scenario(scenario));
433 lib_content.push('\n');
434 }
435
436 lib_content.push_str("}\n");
437 lib_content
438}
439
440fn generate_test_from_scenario(scenario: &TestScenario) -> String {
442 let mut test_code = String::new();
443
444 let target_type = to_rust_type_name(&scenario.target_type);
446
447 test_code.push_str(&format!(" #[test]\n fn {}() {{\n", scenario.name));
448
449 match &scenario.behavior {
450 TestBehavior::RoundTrip { json, assertions } => {
451 test_code.push_str(&format!(" let json_str = r#\"{json}\"#;\n"));
453 test_code.push_str(&format!(
454 " let parsed: {target_type} = serde_json::from_str(json_str).unwrap();\n"
455 ));
456
457 for (field, expected) in assertions {
459 test_code.push_str(&format!(
460 " assert_eq!(parsed.{field}, serde_json::json!({expected}));\n"
461 ));
462 }
463
464 test_code
466 .push_str(" let serialized = serde_json::to_string(&parsed).unwrap();\n");
467 test_code.push_str(&format!(
468 " let round_trip: {target_type} = serde_json::from_str(&serialized).unwrap();\n"
469 ));
470 test_code.push_str(" assert_eq!(serde_json::to_value(parsed).unwrap(), serde_json::to_value(round_trip).unwrap());\n");
471 }
472
473 TestBehavior::Construction { fields, assertions } => {
474 test_code.push_str(&format!(" let instance = {target_type} {{\n"));
476
477 for (field_name, field_value) in fields {
478 test_code.push_str(&format!(
479 " {}: {},\n",
480 field_name,
481 field_value_to_code(field_value)
482 ));
483 }
484
485 test_code.push_str(" };\n\n");
486
487 for assertion in assertions {
489 match assertion {
490 ConstructionAssertion::JsonContains(text) => {
491 test_code.push_str(
492 " let json = serde_json::to_string(&instance).unwrap();\n",
493 );
494 test_code
495 .push_str(&format!(" assert!(json.contains(\"{text}\"));\n"));
496 }
497 ConstructionAssertion::FieldEquals(field, value) => {
498 test_code.push_str(&format!(
499 " assert_eq!(instance.{field}, serde_json::json!({value}));\n"
500 ));
501 }
502 ConstructionAssertion::CanSerialize => {
503 test_code.push_str(
504 " let _json = serde_json::to_string(&instance).unwrap();\n",
505 );
506 }
507 }
508 }
509 }
510
511 TestBehavior::CompileOnly => {
512 test_code.push_str(&format!(
513 " // Type {target_type} exists and compiles\n"
514 ));
515 test_code.push_str(&format!(" let _: Option<{target_type}> = None;\n"));
516 }
517
518 TestBehavior::EnumMatch {
519 json,
520 expected_variant,
521 variant_assertions,
522 } => {
523 test_code.push_str(&format!(" let json_str = r#\"{json}\"#;\n"));
524 test_code.push_str(&format!(
525 " let parsed: {target_type} = serde_json::from_str(json_str).unwrap();\n"
526 ));
527 test_code.push_str(" match parsed {\n");
528
529 if variant_assertions.is_empty() {
531 test_code.push_str(&format!(
533 " {target_type}::{expected_variant} {{ .. }} => {{\n"
534 ));
535 test_code.push_str(" // Variant matched successfully\n");
536 } else {
537 let field_bindings: Vec<String> = variant_assertions
539 .iter()
540 .map(|(field, _)| field.clone())
541 .collect();
542
543 if field_bindings.is_empty() {
544 test_code.push_str(&format!(
545 " {target_type}::{expected_variant} {{ .. }} => {{\n"
546 ));
547 } else {
548 let bindings = field_bindings.join(", ");
550 test_code.push_str(&format!(
551 " {target_type}::{expected_variant} {{ {bindings}, .. }} => {{\n"
552 ));
553
554 for (field, expected) in variant_assertions {
556 test_code.push_str(&format!(
557 " assert_eq!({field}, serde_json::json!({expected}));\n"
558 ));
559 }
560 }
561 }
562
563 test_code.push_str(" }\n");
564 test_code.push_str(&format!(
565 " _ => panic!(\"Expected {expected_variant} variant\"),\n"
566 ));
567 test_code.push_str(" }\n");
568 }
569 }
570
571 test_code.push_str(" }\n");
572 test_code
573}
574
575fn field_value_to_code(value: &FieldValue) -> String {
577 match value {
578 FieldValue::String(s) => format!("\"{s}\".to_string()"),
579 FieldValue::Integer(i) => i.to_string(),
580 FieldValue::Float(f) => f.to_string(),
581 FieldValue::Boolean(b) => b.to_string(),
582 FieldValue::Null => "None".to_string(),
583 FieldValue::EnumVariant(variant) => variant.clone(),
584 FieldValue::Array(values) => {
585 let items: Vec<String> = values.iter().map(field_value_to_code).collect();
586 format!("vec![{}]", items.join(", "))
587 }
588 FieldValue::Struct(type_name, fields) => {
589 let mut code = format!("{type_name} {{\n");
590 for (field_name, field_value) in fields {
591 code.push_str(&format!(
592 " {}: {},\n",
593 field_name,
594 field_value_to_code(field_value)
595 ));
596 }
597 code.push_str(" }");
598 code
599 }
600 }
601}
602
603pub fn minimal_spec(schemas: Value) -> Value {
605 serde_json::json!({
606 "openapi": "3.0.0",
607 "info": {
608 "title": "Test API",
609 "version": "1.0.0"
610 },
611 "components": {
612 "schemas": schemas
613 }
614 })
615}
616
617pub fn assert_compilation_success(result: &GenerationTestResult) {
619 if let Some(output) = &result.compilation_output {
620 if !output.status.success() {
621 let stderr = String::from_utf8_lossy(&output.stderr);
622 let stdout = String::from_utf8_lossy(&output.stdout);
623 panic!("Compilation failed!\nSTDERR:\n{stderr}\nSTDOUT:\n{stdout}");
624 }
625 } else {
626 panic!("No compilation was run");
627 }
628}
629
630pub fn find_type_definition(result: &GenerationTestResult, type_name: &str) -> Option<String> {
632 for file in &result.files {
633 for line in file.content.lines() {
634 if (line.contains(&format!("struct {type_name}"))
635 || line.contains(&format!("enum {type_name}"))
636 || line.contains(&format!("type {type_name} =")))
637 && !line.trim().starts_with("//")
638 {
639 return Some(line.to_string());
640 }
641 }
642 }
643 None
644}
645
646pub fn assert_no_underscores_in_type_name(type_definition: &str) {
648 if let Some(type_name) = extract_type_name(type_definition) {
649 assert!(
650 !type_name.contains('_'),
651 "Type name '{type_name}' should not contain underscores"
652 );
653 }
654}
655
656fn extract_type_name(type_def: &str) -> Option<String> {
657 let parts: Vec<&str> = type_def.split_whitespace().collect();
658 if parts.len() >= 2 {
659 let name = parts[1].split('<').next()?.split('=').next()?.trim();
660 Some(name.to_string())
661 } else {
662 None
663 }
664}
665
666fn to_rust_type_name(s: &str) -> String {
668 let mut result = String::new();
669 let mut next_upper = true;
670
671 for c in s.chars() {
672 match c {
673 'a'..='z' => {
674 if next_upper {
675 result.push(c.to_ascii_uppercase());
676 next_upper = false;
677 } else {
678 result.push(c);
679 }
680 }
681 'A'..='Z' => {
682 result.push(c);
683 next_upper = false;
684 }
685 '0'..='9' => {
686 result.push(c);
687 next_upper = false;
688 }
689 '_' | '-' | '.' | ' ' => {
690 next_upper = true;
692 }
693 _ => {
694 next_upper = true;
696 }
697 }
698 }
699
700 result
701}