Skip to main content

alef_e2e/codegen/
php.rs

1//! PHP e2e test generator using PHPUnit.
2//!
3//! Generates `e2e/php/composer.json`, `e2e/php/phpunit.xml`, and
4//! `tests/{Category}Test.php` files from JSON fixtures, driven entirely by
5//! `E2eConfig` and `CallConfig`.
6
7use 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
20/// PHP e2e code generator.
21pub 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        // Resolve call config with overrides.
36        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        // Resolve package config.
56        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        // Generate composer.json.
67        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        // Generate phpunit.xml.
74        files.push(GeneratedFile {
75            path: output_base.join("phpunit.xml"),
76            content: render_phpunit_xml(),
77            generated_header: false,
78        });
79
80        // Generate test files per category.
81        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
123// ---------------------------------------------------------------------------
124// Rendering
125// ---------------------------------------------------------------------------
126
127fn 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            // Already handled by the call succeeding without exception.
352        }
353        "error" => {
354            // Handled at the test method level.
355        }
356        other => {
357            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
358        }
359    }
360}
361
362/// Convert a `serde_json::Value` to a PHP literal string.
363fn 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}