Skip to main content

alef_e2e/codegen/
go.rs

1//! Go e2e test generator using testing.T.
2
3use crate::config::E2eConfig;
4use crate::escape::{go_string_literal, sanitize_filename};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToUpperCamelCase;
11use std::fmt::Write as FmtWrite;
12use std::path::PathBuf;
13
14use super::E2eCodegen;
15
16/// Go e2e code generator.
17pub struct GoCodegen;
18
19impl E2eCodegen for GoCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        _alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let lang = self.language_name();
27        let output_base = PathBuf::from(&e2e_config.output).join(lang);
28
29        let mut files = Vec::new();
30
31        // Resolve call config with overrides.
32        let call = &e2e_config.call;
33        let overrides = call.overrides.get(lang);
34        let module_path = overrides
35            .and_then(|o| o.module.as_ref())
36            .cloned()
37            .unwrap_or_else(|| call.module.clone());
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.clone());
42        let import_alias = overrides
43            .and_then(|o| o.alias.as_ref())
44            .cloned()
45            .unwrap_or_else(|| "pkg".to_string());
46        let result_var = &call.result_var;
47
48        // Resolve package config.
49        let go_pkg = e2e_config.packages.get("go");
50        let go_module_path = go_pkg
51            .and_then(|p| p.module.as_ref())
52            .cloned()
53            .unwrap_or_else(|| module_path.clone());
54        let replace_path = go_pkg.and_then(|p| p.path.as_ref()).cloned();
55        let go_version = go_pkg
56            .and_then(|p| p.version.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "v0.0.0".to_string());
59        let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
60
61        // Generate go.mod.
62        files.push(GeneratedFile {
63            path: output_base.join("go.mod"),
64            content: render_go_mod(&go_module_path, replace_path.as_deref(), &go_version),
65            generated_header: false,
66        });
67
68        // Generate test files per category.
69        for group in groups {
70            let active: Vec<&Fixture> = group
71                .fixtures
72                .iter()
73                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
74                .collect();
75
76            if active.is_empty() {
77                continue;
78            }
79
80            let filename = format!("{}_test.go", sanitize_filename(&group.category));
81            let content = render_test_file(
82                &group.category,
83                &active,
84                &go_module_path,
85                &import_alias,
86                &function_name,
87                result_var,
88                &e2e_config.call.args,
89                &field_resolver,
90            );
91            files.push(GeneratedFile {
92                path: output_base.join(filename),
93                content,
94                generated_header: true,
95            });
96        }
97
98        Ok(files)
99    }
100
101    fn language_name(&self) -> &'static str {
102        "go"
103    }
104}
105
106fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
107    let mut out = String::new();
108    let _ = writeln!(out, "module e2e_go");
109    let _ = writeln!(out);
110    let _ = writeln!(out, "go 1.23");
111    let _ = writeln!(out);
112    let _ = writeln!(out, "require {go_module_path} {version}");
113
114    if let Some(path) = replace_path {
115        let _ = writeln!(out);
116        let _ = writeln!(out, "replace {go_module_path} => {path}");
117    }
118
119    out
120}
121
122fn render_test_file(
123    category: &str,
124    fixtures: &[&Fixture],
125    go_module_path: &str,
126    import_alias: &str,
127    function_name: &str,
128    result_var: &str,
129    args: &[crate::config::ArgMapping],
130    field_resolver: &FieldResolver,
131) -> String {
132    let mut out = String::new();
133
134    // Determine if we need the "strings" import.
135    let needs_strings = fixtures.iter().any(|f| {
136        f.assertions.iter().any(|a| {
137            matches!(
138                a.assertion_type.as_str(),
139                "equals" | "contains" | "contains_all" | "not_contains" | "starts_with"
140            )
141        })
142    });
143
144    let _ = writeln!(out, "// E2e tests for category: {category}");
145    let _ = writeln!(out, "package e2e_test");
146    let _ = writeln!(out);
147    let _ = writeln!(out, "import (");
148    if needs_strings {
149        let _ = writeln!(out, "\t\"strings\"");
150    }
151    let _ = writeln!(out, "\t\"testing\"");
152    let _ = writeln!(out);
153    let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
154    let _ = writeln!(out, ")");
155    let _ = writeln!(out);
156
157    for (i, fixture) in fixtures.iter().enumerate() {
158        render_test_function(
159            &mut out,
160            fixture,
161            import_alias,
162            function_name,
163            result_var,
164            args,
165            field_resolver,
166        );
167        if i + 1 < fixtures.len() {
168            let _ = writeln!(out);
169        }
170    }
171
172    // Clean up trailing newlines.
173    while out.ends_with("\n\n") {
174        out.pop();
175    }
176    if !out.ends_with('\n') {
177        out.push('\n');
178    }
179    out
180}
181
182fn render_test_function(
183    out: &mut String,
184    fixture: &Fixture,
185    import_alias: &str,
186    function_name: &str,
187    result_var: &str,
188    args: &[crate::config::ArgMapping],
189    field_resolver: &FieldResolver,
190) {
191    let fn_name = fixture.id.to_upper_camel_case();
192    let description = &fixture.description;
193
194    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
195
196    let args_str = build_args_string(&fixture.input, args);
197
198    let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
199    let _ = writeln!(out, "\t// {description}");
200
201    if expects_error {
202        let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({args_str})");
203        let _ = writeln!(out, "\tif err == nil {{");
204        let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
205        let _ = writeln!(out, "\t}}");
206        let _ = writeln!(out, "}}");
207        return;
208    }
209
210    // Normal call: check for error assertions first.
211    let _ = writeln!(out, "\t{result_var}, err := {import_alias}.{function_name}({args_str})");
212    let _ = writeln!(out, "\tif err != nil {{");
213    let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
214    let _ = writeln!(out, "\t}}");
215
216    // Emit assertions.
217    for assertion in &fixture.assertions {
218        render_assertion(out, assertion, result_var, field_resolver);
219    }
220
221    let _ = writeln!(out, "}}");
222}
223
224fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
225    if args.is_empty() {
226        return json_to_go(input);
227    }
228
229    let parts: Vec<String> = args
230        .iter()
231        .filter_map(|arg| {
232            let val = input.get(&arg.field)?;
233            if val.is_null() && arg.optional {
234                return None;
235            }
236            Some(json_to_go(val))
237        })
238        .collect();
239
240    parts.join(", ")
241}
242
243fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
244    let field_expr = match &assertion.field {
245        Some(f) if !f.is_empty() => field_resolver.accessor(f, "go", result_var),
246        _ => result_var.to_string(),
247    };
248
249    match assertion.assertion_type.as_str() {
250        "equals" => {
251            if let Some(expected) = &assertion.value {
252                let go_val = json_to_go(expected);
253                let _ = writeln!(out, "\tif strings.TrimSpace({field_expr}) != {go_val} {{");
254                let _ = writeln!(out, "\t\tt.Errorf(\"equals mismatch: got %q\", {field_expr})");
255                let _ = writeln!(out, "\t}}");
256            }
257        }
258        "contains" => {
259            if let Some(expected) = &assertion.value {
260                let go_val = json_to_go(expected);
261                let _ = writeln!(out, "\tif !strings.Contains({field_expr}, {go_val}) {{");
262                let _ = writeln!(
263                    out,
264                    "\t\tt.Errorf(\"expected to contain %s, got %q\", {go_val}, {field_expr})"
265                );
266                let _ = writeln!(out, "\t}}");
267            }
268        }
269        "contains_all" => {
270            if let Some(values) = &assertion.values {
271                for val in values {
272                    let go_val = json_to_go(val);
273                    let _ = writeln!(out, "\tif !strings.Contains({field_expr}, {go_val}) {{");
274                    let _ = writeln!(out, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
275                    let _ = writeln!(out, "\t}}");
276                }
277            }
278        }
279        "not_contains" => {
280            if let Some(expected) = &assertion.value {
281                let go_val = json_to_go(expected);
282                let _ = writeln!(out, "\tif strings.Contains({field_expr}, {go_val}) {{");
283                let _ = writeln!(
284                    out,
285                    "\t\tt.Errorf(\"expected NOT to contain %s, got %q\", {go_val}, {field_expr})"
286                );
287                let _ = writeln!(out, "\t}}");
288            }
289        }
290        "not_empty" => {
291            let _ = writeln!(out, "\tif len({field_expr}) == 0 {{");
292            let _ = writeln!(out, "\t\tt.Errorf(\"expected non-empty value\")");
293            let _ = writeln!(out, "\t}}");
294        }
295        "starts_with" => {
296            if let Some(expected) = &assertion.value {
297                let go_val = json_to_go(expected);
298                let _ = writeln!(out, "\tif !strings.HasPrefix({field_expr}, {go_val}) {{");
299                let _ = writeln!(
300                    out,
301                    "\t\tt.Errorf(\"expected to start with %s, got %q\", {go_val}, {field_expr})"
302                );
303                let _ = writeln!(out, "\t}}");
304            }
305        }
306        "not_error" => {
307            // Already handled by the `if err != nil` check above.
308        }
309        "error" => {
310            // Handled at the test function level.
311        }
312        other => {
313            let _ = writeln!(out, "\t// TODO: unsupported assertion type: {other}");
314        }
315    }
316}
317
318/// Convert a `serde_json::Value` to a Go literal string.
319fn json_to_go(value: &serde_json::Value) -> String {
320    match value {
321        serde_json::Value::String(s) => go_string_literal(s),
322        serde_json::Value::Bool(b) => b.to_string(),
323        serde_json::Value::Number(n) => n.to_string(),
324        serde_json::Value::Null => "nil".to_string(),
325        // For complex types, serialize to JSON string and pass as literal.
326        other => go_string_literal(&other.to_string()),
327    }
328}