Skip to main content

alef/e2e/codegen/
java.rs

1//! Java e2e test generator using JUnit 5.
2//!
3//! Generates `e2e/java/pom.xml` and language-package test classes
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::core::backend::GeneratedFile;
7use crate::core::config::ResolvedCrateConfig;
8use crate::e2e::config::E2eConfig;
9use crate::e2e::escape::sanitize_filename;
10use crate::e2e::fixture::{Fixture, FixtureGroup};
11use anyhow::Result;
12use heck::ToUpperCamelCase;
13use std::path::PathBuf;
14
15use super::E2eCodegen;
16use super::java_mvnw::{MAVEN_WRAPPER_PROPERTIES, MVNW_UNIX, MVNW_WINDOWS};
17
18/// Java e2e code generator.
19pub struct JavaCodegen;
20
21impl E2eCodegen for JavaCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        config: &ResolvedCrateConfig,
27        type_defs: &[crate::core::ir::TypeDef],
28        enums: &[crate::core::ir::EnumDef],
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let _module_path = overrides
39            .and_then(|o| o.module.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.module.clone());
42        let function_name = overrides
43            .and_then(|o| o.function.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.function.clone());
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .unwrap_or_else(|| config.name.to_upper_camel_case());
50        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
51        let result_var = &call.result_var;
52
53        // Resolve package config.
54        let java_pkg = e2e_config.resolve_package("java");
55        let pkg_name = java_pkg
56            .as_ref()
57            .and_then(|p| p.name.as_ref())
58            .cloned()
59            .unwrap_or_else(|| config.name.clone());
60
61        // Resolve Java package info for the dependency.
62        let java_group_id = config.java_group_id();
63        let binding_pkg = config.java_package();
64        let pkg_version = config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
65
66        // Generate pom.xml.
67        files.push(GeneratedFile {
68            path: output_base.join("pom.xml"),
69            content: project::render_pom_xml(
70                &pkg_name,
71                &java_group_id,
72                &pkg_version,
73                e2e_config.dep_mode,
74                &e2e_config.test_documents_relative_from(0),
75                &config.ffi_lib_name(),
76            ),
77            generated_header: false,
78        });
79
80        // Maven wrapper: ./mvnw + mvnw.cmd + .mvn/wrapper/maven-wrapper.properties.
81        // The wrapper scripts bootstrap-download maven-wrapper.jar from the URL in
82        // maven-wrapper.properties on first invocation, so alef does not need to
83        // emit the binary jar. The shebang on mvnw triggers 0755 chmod in the
84        // file writer.
85        files.push(GeneratedFile {
86            path: output_base.join("mvnw"),
87            content: MVNW_UNIX.to_string(),
88            generated_header: false,
89        });
90        files.push(GeneratedFile {
91            path: output_base.join("mvnw.cmd"),
92            content: MVNW_WINDOWS.to_string(),
93            generated_header: false,
94        });
95        files.push(GeneratedFile {
96            path: output_base
97                .join(".mvn")
98                .join("wrapper")
99                .join("maven-wrapper.properties"),
100            content: MAVEN_WRAPPER_PROPERTIES.to_string(),
101            generated_header: false,
102        });
103
104        // Check if there are HTTP fixtures that need server-pattern harness
105        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.http.is_some());
106        let uses_harness = has_http_fixtures && !e2e_config.harness.imports.is_empty();
107        // Detect mock-server need from fixture `mock_response` or `http.expected_response`
108        // shapes. Mirrors kotlin_android codegen.
109        let needs_mock_server = groups
110            .iter()
111            .flat_map(|g| g.fixtures.iter())
112            .any(|f| f.needs_mock_server());
113
114        // Generate test files per category. Path mirrors the configured Java
115        // package — `dev.myorg` becomes `dev/myorg`, etc. — so the package
116        // declaration in each test file matches its filesystem location.
117        let mut test_base = output_base.join("src").join("test").join("java");
118        for segment in java_group_id.split('.') {
119            test_base = test_base.join(segment);
120        }
121        let test_base = test_base.join("e2e");
122
123        // When any fixture needs a mock server, emit MockServerListener.java
124        // plus its META-INF SPI entry so JUnit Platform discovers and starts
125        // the `mock-server` binary once per launcher session. Without these
126        // the tests reference `mockServerUrl` but no server runs, and the
127        // existing service file (if left over from a prior alef version) points
128        // at a class that does not exist on the classpath.
129        if needs_mock_server {
130            files.push(GeneratedFile {
131                path: test_base.join("MockServerListener.java"),
132                content: project::render_mock_server_listener(&java_group_id),
133                generated_header: true,
134            });
135            files.push(GeneratedFile {
136                path: output_base
137                    .join("src")
138                    .join("test")
139                    .join("resources")
140                    .join("META-INF")
141                    .join("services")
142                    .join("org.junit.platform.launcher.LauncherSessionListener"),
143                content: format!("{java_group_id}.e2e.MockServerListener\n"),
144                generated_header: false,
145            });
146        }
147
148        // Emit fixture JSON files to src/test/resources/fixtures/ (avoids 65KB string literal limit)
149        let fixtures_resource_base = output_base.join("src").join("test").join("resources").join("fixtures");
150        for group in groups {
151            for fixture in &group.fixtures {
152                if fixture.http.is_none() {
153                    continue;
154                }
155                let http_data = fixture.http.as_ref().unwrap();
156                let fixture_json = serde_json::json!({
157                    "http": {
158                        "handler": {
159                            "route": &http_data.handler.route,
160                            "method": &http_data.handler.method,
161                            "body_schema": http_data.handler.body_schema.clone(),
162                        },
163                        "request": {
164                            "path": &http_data.request.path,
165                        },
166                        "expected_response": {
167                            "status_code": http_data.expected_response.status_code,
168                            "body": &http_data.expected_response.body,
169                            "headers": &http_data.expected_response.headers,
170                        }
171                    }
172                });
173                let fixture_json_str = serde_json::to_string(&fixture_json).unwrap_or_default();
174                files.push(GeneratedFile {
175                    path: fixtures_resource_base.join(format!("{}.json", fixture.id)),
176                    content: fixture_json_str,
177                    generated_header: false,
178                });
179            }
180        }
181
182        // Emit FixtureLoader.java helper for loading fixtures from classpath
183        if uses_harness {
184            files.push(GeneratedFile {
185                path: test_base.join("FixtureLoader.java"),
186                content: project::render_fixture_loader(&java_group_id),
187                generated_header: true,
188            });
189        }
190
191        // Emit HarnessMain.java if server-pattern harness is needed
192        if uses_harness {
193            files.push(GeneratedFile {
194                path: test_base.join("HarnessMain.java"),
195                content: project::render_harness_main(e2e_config, groups, &java_group_id, &binding_pkg),
196                generated_header: true,
197            });
198        }
199
200        // Collect all distinct sealed-union type names declared in `assert_enum_fields`
201        // across all call configs for this language.  For each such type we emit a
202        // `{TypeName}Display.java` helper that pattern-matches on variants from the IR;
203        // projects that declare no `assert_enum_fields` get no extra helper files.
204        let sealed_display_types: std::collections::BTreeSet<String> = std::iter::once(&e2e_config.call)
205            .chain(e2e_config.calls.values())
206            .filter_map(|c| c.overrides.get(lang))
207            .flat_map(|o| o.assert_enum_fields.values().cloned())
208            .collect();
209
210        for type_name in &sealed_display_types {
211            if let Some(enum_def) = enums.iter().find(|e| &e.name == type_name) {
212                files.push(GeneratedFile {
213                    path: test_base.join(format!("{type_name}Display.java")),
214                    content: project::render_sealed_display(type_name, enum_def, type_defs, &java_group_id),
215                    generated_header: true,
216                });
217            }
218        }
219
220        // Resolve options_type: prefer Java override, fall back to other languages' options_type.
221        // This ensures that when a call declares options_type in C#/Go/Python/PHP but not Java,
222        // Java e2e tests still properly deserialize json_object args via JsonUtil.fromJson().
223        let options_type = overrides.and_then(|o| o.options_type.clone()).or_else(|| {
224            // Inherit from non-Java language overrides (C# first, then C, Go, PHP, Python).
225            for cand in ["csharp", "c", "go", "php", "python"] {
226                if let Some(o) = e2e_config.call.overrides.get(cand) {
227                    if let Some(t) = &o.options_type {
228                        return Some(t.clone());
229                    }
230                }
231            }
232            None
233        });
234
235        // Resolve enum_fields and nested_types from Java override config.
236        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<std::collections::HashMap<String, String>> =
237            std::sync::LazyLock::new(std::collections::HashMap::new);
238        let _enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
239
240        // Build effective nested_types from configured overrides (empty by default).
241        let mut effective_nested_types: std::collections::HashMap<String, String> = std::collections::HashMap::new();
242        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
243            effective_nested_types.extend(overrides_map.clone());
244        }
245
246        // Resolve nested_types_optional from override (defaults to true for backward compatibility).
247        let nested_types_optional = overrides.map(|o| o.nested_types_optional).unwrap_or(true);
248
249        for group in groups {
250            let active: Vec<&Fixture> = group
251                .fixtures
252                .iter()
253                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
254                .collect();
255
256            if active.is_empty() {
257                continue;
258            }
259
260            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
261            let content = test_file::render_test_file(
262                &group.category,
263                &active,
264                &class_name,
265                &function_name,
266                &java_group_id,
267                &binding_pkg,
268                result_var,
269                &e2e_config.call.args,
270                options_type.as_deref(),
271                result_is_simple,
272                e2e_config,
273                &effective_nested_types,
274                nested_types_optional,
275                &config.adapters,
276                config,
277                type_defs,
278                uses_harness,
279            );
280            files.push(GeneratedFile {
281                path: test_base.join(class_file_name),
282                content,
283                generated_header: true,
284            });
285        }
286
287        Ok(files)
288    }
289
290    fn language_name(&self) -> &'static str {
291        "java"
292    }
293}
294
295mod args;
296mod assertions;
297mod http;
298mod project;
299mod stubs;
300mod test_file;
301mod test_method;
302mod values;
303mod visitor;
304
305pub use stubs::emit_test_backend;
306
307#[cfg(test)]
308mod tests;