1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use anyhow::Result;
14use heck::ToUpperCamelCase;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20pub struct PhpCodegen;
22
23impl E2eCodegen for PhpCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let lang = self.language_name();
31 let output_base = PathBuf::from(&e2e_config.output).join(lang);
32
33 let mut files = Vec::new();
34
35 let call = &e2e_config.call;
37 let overrides = call.overrides.get(lang);
38 let function_name = overrides
39 .and_then(|o| o.function.as_ref())
40 .cloned()
41 .unwrap_or_else(|| call.function.clone());
42 let class_name = overrides
43 .and_then(|o| o.class.as_ref())
44 .cloned()
45 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
46 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
47 if call.module.is_empty() {
48 "Kreuzberg".to_string()
49 } else {
50 call.module.to_upper_camel_case()
51 }
52 });
53 let result_var = &call.result_var;
54
55 let php_pkg = e2e_config.packages.get("php");
57 let pkg_name = php_pkg
58 .and_then(|p| p.name.as_ref())
59 .cloned()
60 .unwrap_or_else(|| format!("kreuzberg/{}", alef_config.crate_config.name));
61 let pkg_path = php_pkg
62 .and_then(|p| p.path.as_ref())
63 .cloned()
64 .unwrap_or_else(|| "../../packages/php".to_string());
65
66 files.push(GeneratedFile {
68 path: output_base.join("composer.json"),
69 content: render_composer_json(&pkg_name, &pkg_path),
70 generated_header: false,
71 });
72
73 files.push(GeneratedFile {
75 path: output_base.join("phpunit.xml"),
76 content: render_phpunit_xml(),
77 generated_header: false,
78 });
79
80 let tests_base = output_base.join("tests");
82 let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
83
84 for group in groups {
85 let active: Vec<&Fixture> = group
86 .fixtures
87 .iter()
88 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
89 .collect();
90
91 if active.is_empty() {
92 continue;
93 }
94
95 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
96 let filename = format!("{test_class}.php");
97 let content = render_test_file(
98 &group.category,
99 &active,
100 &namespace,
101 &class_name,
102 &function_name,
103 result_var,
104 &test_class,
105 &e2e_config.call.args,
106 &field_resolver,
107 );
108 files.push(GeneratedFile {
109 path: tests_base.join(filename),
110 content,
111 generated_header: true,
112 });
113 }
114
115 Ok(files)
116 }
117
118 fn language_name(&self) -> &'static str {
119 "php"
120 }
121}
122
123fn render_composer_json(pkg_name: &str, pkg_path: &str) -> String {
128 format!(
129 r#"{{
130 "name": "kreuzberg/e2e-php",
131 "description": "E2e tests for PHP bindings",
132 "type": "project",
133 "require-dev": {{
134 "phpunit/phpunit": "^11.0",
135 "{pkg_name}": "*"
136 }},
137 "repositories": [
138 {{
139 "type": "path",
140 "url": "{pkg_path}"
141 }}
142 ],
143 "autoload-dev": {{
144 "psr-4": {{
145 "Kreuzberg\\E2e\\": "tests/"
146 }}
147 }}
148}}
149"#
150 )
151}
152
153fn render_phpunit_xml() -> String {
154 r#"<?xml version="1.0" encoding="UTF-8"?>
155<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
156 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
157 bootstrap="vendor/autoload.php"
158 colors="true"
159 failOnRisky="true"
160 failOnWarning="true">
161 <testsuites>
162 <testsuite name="e2e">
163 <directory>tests</directory>
164 </testsuite>
165 </testsuites>
166</phpunit>
167"#
168 .to_string()
169}
170
171#[allow(clippy::too_many_arguments)]
172fn render_test_file(
173 category: &str,
174 fixtures: &[&Fixture],
175 namespace: &str,
176 class_name: &str,
177 function_name: &str,
178 result_var: &str,
179 test_class: &str,
180 args: &[crate::config::ArgMapping],
181 field_resolver: &FieldResolver,
182) -> String {
183 let mut out = String::new();
184 let _ = writeln!(out, "<?php");
185 let _ = writeln!(out);
186 let _ = writeln!(out, "declare(strict_types=1);");
187 let _ = writeln!(out);
188 let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
189 let _ = writeln!(out);
190 let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
191 let _ = writeln!(out, "use {namespace}\\{class_name};");
192 let _ = writeln!(out);
193 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
194 let _ = writeln!(out, "final class {test_class} extends TestCase");
195 let _ = writeln!(out, "{{");
196
197 for (i, fixture) in fixtures.iter().enumerate() {
198 render_test_method(
199 &mut out,
200 fixture,
201 class_name,
202 function_name,
203 result_var,
204 args,
205 field_resolver,
206 );
207 if i + 1 < fixtures.len() {
208 let _ = writeln!(out);
209 }
210 }
211
212 let _ = writeln!(out, "}}");
213 out
214}
215
216fn render_test_method(
217 out: &mut String,
218 fixture: &Fixture,
219 class_name: &str,
220 function_name: &str,
221 result_var: &str,
222 args: &[crate::config::ArgMapping],
223 field_resolver: &FieldResolver,
224) {
225 let method_name = sanitize_filename(&fixture.id);
226 let description = &fixture.description;
227 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
228
229 let args_str = build_args_string(&fixture.input, args);
230
231 let _ = writeln!(out, " /** {description} */");
232 let _ = writeln!(out, " public function test_{method_name}(): void");
233 let _ = writeln!(out, " {{");
234
235 if expects_error {
236 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
237 let _ = writeln!(out, " {class_name}::{function_name}({args_str});");
238 let _ = writeln!(out, " }}");
239 return;
240 }
241
242 let _ = writeln!(
243 out,
244 " ${result_var} = {class_name}::{function_name}({args_str});"
245 );
246
247 for assertion in &fixture.assertions {
248 render_assertion(out, assertion, result_var, field_resolver);
249 }
250
251 let _ = writeln!(out, " }}");
252}
253
254fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
255 if args.is_empty() {
256 return json_to_php(input);
257 }
258
259 let parts: Vec<String> = args
260 .iter()
261 .filter_map(|arg| {
262 let val = input.get(&arg.field)?;
263 if val.is_null() && arg.optional {
264 return None;
265 }
266 Some(json_to_php(val))
267 })
268 .collect();
269
270 parts.join(", ")
271}
272
273fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
274 let field_expr = match &assertion.field {
275 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
276 _ => format!("${result_var}"),
277 };
278
279 match assertion.assertion_type.as_str() {
280 "equals" => {
281 if let Some(expected) = &assertion.value {
282 let php_val = json_to_php(expected);
283 let _ = writeln!(out, " $this->assertEquals({php_val}, trim({field_expr}));");
284 }
285 }
286 "contains" => {
287 if let Some(expected) = &assertion.value {
288 let php_val = json_to_php(expected);
289 let _ = writeln!(
290 out,
291 " $this->assertStringContainsString({php_val}, {field_expr});"
292 );
293 }
294 }
295 "contains_all" => {
296 if let Some(values) = &assertion.values {
297 for val in values {
298 let php_val = json_to_php(val);
299 let _ = writeln!(
300 out,
301 " $this->assertStringContainsString({php_val}, {field_expr});"
302 );
303 }
304 }
305 }
306 "not_contains" => {
307 if let Some(expected) = &assertion.value {
308 let php_val = json_to_php(expected);
309 let _ = writeln!(
310 out,
311 " $this->assertStringNotContainsString({php_val}, {field_expr});"
312 );
313 }
314 }
315 "not_empty" => {
316 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
317 }
318 "is_empty" => {
319 let _ = writeln!(out, " $this->assertEmpty({field_expr});");
320 }
321 "starts_with" => {
322 if let Some(expected) = &assertion.value {
323 let php_val = json_to_php(expected);
324 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
325 }
326 }
327 "ends_with" => {
328 if let Some(expected) = &assertion.value {
329 let php_val = json_to_php(expected);
330 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
331 }
332 }
333 "min_length" => {
334 if let Some(val) = &assertion.value {
335 if let Some(n) = val.as_u64() {
336 let _ = writeln!(
337 out,
338 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
339 );
340 }
341 }
342 }
343 "max_length" => {
344 if let Some(val) = &assertion.value {
345 if let Some(n) = val.as_u64() {
346 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
347 }
348 }
349 }
350 "not_error" => {
351 }
353 "error" => {
354 }
356 other => {
357 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
358 }
359 }
360}
361
362fn json_to_php(value: &serde_json::Value) -> String {
364 match value {
365 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
366 serde_json::Value::Bool(true) => "true".to_string(),
367 serde_json::Value::Bool(false) => "false".to_string(),
368 serde_json::Value::Number(n) => n.to_string(),
369 serde_json::Value::Null => "null".to_string(),
370 serde_json::Value::Array(arr) => {
371 let items: Vec<String> = arr.iter().map(json_to_php).collect();
372 format!("[{}]", items.join(", "))
373 }
374 serde_json::Value::Object(map) => {
375 let items: Vec<String> = map
376 .iter()
377 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
378 .collect();
379 format!("[{}]", items.join(", "))
380 }
381 }
382}