Skip to main content

openapi_to_rust/
test_helpers.rs

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
9/// Result of a generation test
10pub struct GenerationTestResult {
11    /// Temporary directory containing all generated files
12    pub temp_dir: TempDir,
13    /// Path to the test project directory
14    pub project_dir: PathBuf,
15    /// Path to the generated source directory
16    pub generated_src_dir: PathBuf,
17    /// Generated files
18    pub files: Vec<GeneratedFile>,
19    /// Compilation output (if compilation was run)
20    pub compilation_output: Option<std::process::Output>,
21    /// Path to Cargo.toml
22    pub cargo_toml_path: PathBuf,
23}
24
25impl GenerationTestResult {
26    /// Read a generated file by name
27    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    /// Check if compilation succeeded
33    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    /// Get compilation errors
41    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    /// Get test output
49    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
56/// Configuration for a generation test
57pub struct GenerationTest {
58    /// Name of the test (used for project name)
59    pub name: String,
60    /// OpenAPI specification
61    pub spec: Value,
62    /// Generator configuration (optional overrides)
63    pub config_overrides: Option<GeneratorConfigOverrides>,
64    /// Test scenarios to generate
65    pub test_scenarios: Vec<TestScenario>,
66    /// Whether to run tests after compilation (only if compilation is enabled)
67    pub run_tests: bool,
68    /// Whether to compile the generated code (false by default, must opt-in)
69    pub compile: bool,
70    /// Additional dependencies for Cargo.toml
71    pub extra_dependencies: Vec<(String, String)>,
72}
73
74/// A test scenario to generate
75#[derive(Clone)]
76pub struct TestScenario {
77    /// Name of the test function
78    pub name: String,
79    /// Type to test
80    pub target_type: String,
81    /// Test behavior
82    pub behavior: TestBehavior,
83}
84
85/// Different test behaviors we can generate
86#[derive(Clone)]
87pub enum TestBehavior {
88    /// Test serialization/deserialization round trip
89    RoundTrip {
90        /// JSON value to test with
91        json: Value,
92        /// Expected field values to assert (field_name -> expected_value)
93        assertions: Vec<(String, Value)>,
94    },
95    /// Test creating an instance with specific values
96    Construction {
97        /// Field values to set (field_name -> value_expr)
98        /// Value expressions are simple literals or enum variants
99        fields: Vec<(String, FieldValue)>,
100        /// Assertions to make on the created instance
101        assertions: Vec<ConstructionAssertion>,
102    },
103    /// Test that a type exists and compiles
104    CompileOnly,
105    /// Test enum variant matching
106    EnumMatch {
107        /// JSON to deserialize
108        json: Value,
109        /// Expected variant name
110        expected_variant: String,
111        /// Fields to check in the variant (field_name -> expected_value)
112        variant_assertions: Vec<(String, Value)>,
113    },
114}
115
116/// Value to use when constructing a field
117#[derive(Clone)]
118pub enum FieldValue {
119    /// String literal
120    String(String),
121    /// Integer literal
122    Integer(i64),
123    /// Float literal
124    Float(f64),
125    /// Boolean literal
126    Boolean(bool),
127    /// Null value
128    Null,
129    /// Enum variant (e.g., "MyEnum::Variant")
130    EnumVariant(String),
131    /// Array of values
132    Array(Vec<FieldValue>),
133    /// Struct construction (type_name, fields)
134    Struct(String, Vec<(String, FieldValue)>),
135}
136
137/// Assertion to make on a constructed value
138#[derive(Clone)]
139pub enum ConstructionAssertion {
140    /// Assert serialized JSON contains a string
141    JsonContains(String),
142    /// Assert a field equals a value
143    FieldEquals(String, Value),
144    /// Assert successful serialization
145    CanSerialize,
146}
147
148/// Overrides for generator configuration
149pub 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    /// Create a new test with the given name and spec
157    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    /// Enable compilation for this test
166    pub fn with_compilation(mut self) -> Self {
167        self.compile = true;
168        self
169    }
170
171    /// Enable compilation only if OPENAPI_GEN_COMPILE_TESTS is set
172    pub fn with_env_compilation(mut self) -> Self {
173        self.compile = should_compile_tests();
174        self
175    }
176
177    /// Add a test scenario
178    pub fn with_scenario(mut self, scenario: TestScenario) -> Self {
179        self.test_scenarios.push(scenario);
180        self
181    }
182
183    /// Set whether to run tests (only applies if compilation is enabled)
184    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, // Opt-in compilation
200        }
201    }
202}
203
204/// Check if we should run full compilation tests (via environment variable)
205pub 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
211/// Fast test function that only validates syntax (no compilation)
212/// When called from tests, automatically creates/verifies snapshots
213pub fn test_generation(name: &str, spec: Value) -> Result<String, Box<dyn std::error::Error>> {
214    // Analyze the spec
215    let mut analyzer = SchemaAnalyzer::new(spec)?;
216    let mut analysis = analyzer.analyze()?;
217
218    // Generate code
219    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    // The code is already validated by syn in the generator
227
228    // Automatically assert snapshot when insta is available
229    insta::assert_snapshot!(name, &generated_code);
230
231    Ok(generated_code)
232}
233
234/// Run a complete generation test
235pub fn run_generation_test(
236    test: GenerationTest,
237) -> Result<GenerationTestResult, Box<dyn std::error::Error>> {
238    // Create temporary directory
239    let temp_dir = TempDir::new()?;
240    let project_dir = temp_dir.path().join(&test.name);
241    fs::create_dir(&project_dir)?;
242
243    // Set up paths
244    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    // Analyze the spec
250    let mut analyzer = SchemaAnalyzer::new(test.spec.clone())?;
251    let analysis = analyzer.analyze()?;
252
253    // Configure generator
254    let config = GeneratorConfig {
255        spec_path: PathBuf::from("test.json"), // Not used when we pass analysis directly
256        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    // Generate code
296    let generator = CodeGenerator::new(config);
297    let mut analysis_mut = analysis;
298    let types_content = generator.generate(&mut analysis_mut)?;
299
300    // Create files list with just the types file for now
301    let files = vec![GeneratedFile {
302        path: "types.rs".into(),
303        content: types_content.clone(),
304    }];
305
306    // Write generated files
307    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    // Create Cargo.toml
316    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        // Q2 typed-scalar deps (default-on; harmless when unused).
328        ("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    // Add extra dependencies
336    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    // Create generated/mod.rs
363    fs::write(generated_src_dir.join("mod.rs"), "pub mod types;\n")?;
364
365    // Create lib.rs with tests
366    let lib_rs = create_lib_rs(&test, &files);
367    fs::write(src_dir.join("lib.rs"), lib_rs)?;
368
369    // Only compile if explicitly requested
370    let compilation_output = if !test.compile {
371        None
372    } else if test.run_tests {
373        // Run tests if requested
374        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        // Just check compilation
385        Some(
386            Command::new("cargo")
387                .arg("check")
388                .current_dir(&project_dir)
389                .env("RUST_BACKTRACE", "1")
390                .output()?,
391        )
392    };
393
394    // Debug: print generated types for failing tests
395    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    // Add test module
417    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    // Always add basic compilation test
422    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    // Generate tests from scenarios
431    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
440/// Generate test code from a test scenario
441fn generate_test_from_scenario(scenario: &TestScenario) -> String {
442    let mut test_code = String::new();
443
444    // Convert type names to valid Rust type names (remove underscores)
445    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            // Generate round-trip test
452            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            // Add assertions
458            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 serialization round-trip
465            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            // Generate construction test
475            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            // Add assertions
488            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            // Generate match pattern for struct variants with named fields
530            if variant_assertions.is_empty() {
531                // No assertions needed, just check the variant
532                test_code.push_str(&format!(
533                    "            {target_type}::{expected_variant} {{ .. }} => {{\n"
534                ));
535                test_code.push_str("                // Variant matched successfully\n");
536            } else {
537                // Has assertions - generate struct variant pattern with field bindings
538                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                    // Always add .. to allow partial matching
549                    let bindings = field_bindings.join(", ");
550                    test_code.push_str(&format!(
551                        "            {target_type}::{expected_variant} {{ {bindings}, .. }} => {{\n"
552                    ));
553
554                    // Add assertions on the fields
555                    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
575/// Convert a FieldValue to Rust code
576fn 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
603/// Helper to create a minimal OpenAPI spec for testing
604pub 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
617/// Assert that the test compiled successfully
618pub 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
630/// Find a type definition in the generated files
631pub 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
646/// Assert that a type name doesn't contain underscores
647pub 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
666/// Convert a type name to valid Rust type name (PascalCase without underscores)
667fn 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                // Skip underscore/separator and make next char uppercase
691                next_upper = true;
692            }
693            _ => {
694                // Other special characters - treat as word boundary
695                next_upper = true;
696            }
697        }
698    }
699
700    result
701}