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.effective_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.resolve_package("go");
50        let go_module_path = go_pkg
51            .as_ref()
52            .and_then(|p| p.module.as_ref())
53            .cloned()
54            .unwrap_or_else(|| module_path.clone());
55        let replace_path = go_pkg.as_ref().and_then(|p| p.path.as_ref()).cloned();
56        let go_version = go_pkg
57            .as_ref()
58            .and_then(|p| p.version.as_ref())
59            .cloned()
60            .unwrap_or_else(|| "v0.0.0".to_string());
61        let field_resolver = FieldResolver::new(
62            &e2e_config.fields,
63            &e2e_config.fields_optional,
64            &e2e_config.result_fields,
65            &e2e_config.fields_array,
66        );
67
68        // Generate go.mod. In registry mode, omit the `replace` directive so the
69        // module is fetched from the Go module proxy.
70        let effective_replace = match e2e_config.dep_mode {
71            crate::config::DependencyMode::Registry => None,
72            crate::config::DependencyMode::Local => replace_path.as_deref().map(String::from),
73        };
74        files.push(GeneratedFile {
75            path: output_base.join("go.mod"),
76            content: render_go_mod(&go_module_path, effective_replace.as_deref(), &go_version),
77            generated_header: false,
78        });
79
80        // Generate test files per category.
81        for group in groups {
82            let active: Vec<&Fixture> = group
83                .fixtures
84                .iter()
85                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
86                .collect();
87
88            if active.is_empty() {
89                continue;
90            }
91
92            let filename = format!("{}_test.go", sanitize_filename(&group.category));
93            let content = render_test_file(
94                &group.category,
95                &active,
96                &module_path,
97                &import_alias,
98                &function_name,
99                result_var,
100                &e2e_config.call.args,
101                &field_resolver,
102                e2e_config,
103            );
104            files.push(GeneratedFile {
105                path: output_base.join(filename),
106                content,
107                generated_header: true,
108            });
109        }
110
111        Ok(files)
112    }
113
114    fn language_name(&self) -> &'static str {
115        "go"
116    }
117}
118
119fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
120    let mut out = String::new();
121    let _ = writeln!(out, "module e2e_go");
122    let _ = writeln!(out);
123    let _ = writeln!(out, "go 1.26");
124    let _ = writeln!(out);
125    let _ = writeln!(out, "require {go_module_path} {version}");
126
127    if let Some(path) = replace_path {
128        let _ = writeln!(out);
129        let _ = writeln!(out, "replace {go_module_path} => {path}");
130    }
131
132    out
133}
134
135#[allow(clippy::too_many_arguments)]
136fn render_test_file(
137    category: &str,
138    fixtures: &[&Fixture],
139    go_module_path: &str,
140    import_alias: &str,
141    function_name: &str,
142    result_var: &str,
143    args: &[crate::config::ArgMapping],
144    field_resolver: &FieldResolver,
145    e2e_config: &crate::config::E2eConfig,
146) -> String {
147    let mut out = String::new();
148
149    // Go convention: generated file marker must appear before the package declaration.
150    let _ = writeln!(out, "// Code generated by alef. DO NOT EDIT.");
151    let _ = writeln!(out);
152
153    // Determine if we need the "os" import (mock_url args).
154    let needs_os = args.iter().any(|a| a.arg_type == "mock_url");
155
156    // Determine if we need "encoding/json" (handle args with non-null config).
157    let needs_json = args.iter().any(|a| a.arg_type == "handle")
158        && fixtures.iter().any(|f| {
159            args.iter().filter(|a| a.arg_type == "handle").any(|a| {
160                let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
161                !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
162            })
163        });
164
165    // Determine if we need the "strings" import.
166    // Only count assertions whose fields are actually valid for the result type.
167    let needs_strings = fixtures.iter().any(|f| {
168        f.assertions.iter().any(|a| {
169            let type_needs_strings = if a.assertion_type == "equals" {
170                // equals with string values needs strings.TrimSpace
171                a.value.as_ref().is_some_and(|v| v.is_string())
172            } else {
173                matches!(
174                    a.assertion_type.as_str(),
175                    "contains" | "contains_all" | "not_contains" | "starts_with"
176                )
177            };
178            let field_valid = a
179                .field
180                .as_ref()
181                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
182                .unwrap_or(true);
183            type_needs_strings && field_valid
184        })
185    });
186
187    // Determine if we need the testify assert import (used for count_min, count_max).
188    let needs_assert = fixtures.iter().any(|f| {
189        f.assertions.iter().any(|a| {
190            let field_valid = a
191                .field
192                .as_ref()
193                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
194                .unwrap_or(true);
195            matches!(a.assertion_type.as_str(), "count_min" | "count_max") && field_valid
196        })
197    });
198
199    let _ = writeln!(out, "// E2e tests for category: {category}");
200    let _ = writeln!(out, "package e2e_test");
201    let _ = writeln!(out);
202    let _ = writeln!(out, "import (");
203    if needs_json {
204        let _ = writeln!(out, "\t\"encoding/json\"");
205    }
206    if needs_os {
207        let _ = writeln!(out, "\t\"os\"");
208    }
209    if needs_strings {
210        let _ = writeln!(out, "\t\"strings\"");
211    }
212    let _ = writeln!(out, "\t\"testing\"");
213    if needs_assert {
214        let _ = writeln!(out);
215        let _ = writeln!(out, "\t\"github.com/stretchr/testify/assert\"");
216    }
217    let _ = writeln!(out);
218    let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
219    let _ = writeln!(out, ")");
220    let _ = writeln!(out);
221
222    for (i, fixture) in fixtures.iter().enumerate() {
223        render_test_function(
224            &mut out,
225            fixture,
226            import_alias,
227            function_name,
228            result_var,
229            args,
230            field_resolver,
231            e2e_config,
232        );
233        if i + 1 < fixtures.len() {
234            let _ = writeln!(out);
235        }
236    }
237
238    // Clean up trailing newlines.
239    while out.ends_with("\n\n") {
240        out.pop();
241    }
242    if !out.ends_with('\n') {
243        out.push('\n');
244    }
245    out
246}
247
248#[allow(clippy::too_many_arguments)]
249fn render_test_function(
250    out: &mut String,
251    fixture: &Fixture,
252    import_alias: &str,
253    function_name: &str,
254    result_var: &str,
255    args: &[crate::config::ArgMapping],
256    field_resolver: &FieldResolver,
257    e2e_config: &crate::config::E2eConfig,
258) {
259    let fn_name = fixture.id.to_upper_camel_case();
260    let description = &fixture.description;
261
262    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
263
264    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, import_alias, e2e_config, &fixture.id);
265
266    let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
267    let _ = writeln!(out, "\t// {description}");
268
269    for line in &setup_lines {
270        let _ = writeln!(out, "\t{line}");
271    }
272
273    if expects_error {
274        let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({args_str})");
275        let _ = writeln!(out, "\tif err == nil {{");
276        let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
277        let _ = writeln!(out, "\t}}");
278        let _ = writeln!(out, "}}");
279        return;
280    }
281
282    // Check if any assertion actually uses the result variable.
283    // If all assertions are skipped (field not on result type), use `_` to avoid
284    // Go's "declared and not used" compile error.
285    let has_usable_assertion = fixture.assertions.iter().any(|a| {
286        if a.assertion_type == "not_error" || a.assertion_type == "error" {
287            return false;
288        }
289        match &a.field {
290            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
291            _ => true,
292        }
293    });
294
295    let result_binding = if has_usable_assertion {
296        result_var.to_string()
297    } else {
298        "_".to_string()
299    };
300
301    // Normal call: check for error assertions first.
302    let _ = writeln!(
303        out,
304        "\t{result_binding}, err := {import_alias}.{function_name}({args_str})"
305    );
306    let _ = writeln!(out, "\tif err != nil {{");
307    let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
308    let _ = writeln!(out, "\t}}");
309
310    // Collect optional fields referenced by assertions and emit nil-safe
311    // dereference blocks so that assertions can use plain string locals.
312    // Only dereference fields whose assertion values are strings (or that are
313    // used in string-oriented assertions like equals/contains with string values).
314    let mut optional_locals: std::collections::HashMap<String, String> = std::collections::HashMap::new();
315    for assertion in &fixture.assertions {
316        if let Some(f) = &assertion.field {
317            if !f.is_empty() {
318                let resolved = field_resolver.resolve(f);
319                if field_resolver.is_optional(resolved) && !optional_locals.contains_key(f.as_str()) {
320                    // Only create deref locals for string-valued fields.
321                    // Detect by checking if the assertion value is a string.
322                    let is_string_field = assertion.value.as_ref().is_some_and(|v| v.is_string());
323                    if !is_string_field {
324                        // Non-string optional fields (e.g., *uint64) are handled
325                        // by nil guards in render_assertion instead.
326                        continue;
327                    }
328                    let field_expr = field_resolver.accessor(f, "go", result_var);
329                    let local_var = go_local_name(&resolved.replace(['.', '[', ']'], "_"));
330                    if field_resolver.has_map_access(f) {
331                        // Go map access returns a value type (string), not a pointer.
332                        // Use the value directly — empty string means not present.
333                        let _ = writeln!(out, "\t{local_var} := {field_expr}");
334                    } else {
335                        let _ = writeln!(out, "\tvar {local_var} string");
336                        let _ = writeln!(out, "\tif {field_expr} != nil {{");
337                        let _ = writeln!(out, "\t\t{local_var} = *{field_expr}");
338                        let _ = writeln!(out, "\t}}");
339                    }
340                    optional_locals.insert(f.clone(), local_var);
341                }
342            }
343        }
344    }
345
346    // Emit assertions, wrapping in nil guards when an intermediate path segment is optional.
347    for assertion in &fixture.assertions {
348        if let Some(f) = &assertion.field {
349            if !f.is_empty() && !optional_locals.contains_key(f.as_str()) {
350                // Check if any prefix of the dotted path is optional (pointer in Go).
351                // e.g., "document.nodes" — if "document" is optional, guard the whole block.
352                let parts: Vec<&str> = f.split('.').collect();
353                let mut guard_expr: Option<String> = None;
354                for i in 1..parts.len() {
355                    let prefix = parts[..i].join(".");
356                    let resolved_prefix = field_resolver.resolve(&prefix);
357                    if field_resolver.is_optional(resolved_prefix) {
358                        let accessor = field_resolver.accessor(&prefix, "go", result_var);
359                        guard_expr = Some(accessor);
360                        break;
361                    }
362                }
363                if let Some(guard) = guard_expr {
364                    // Only emit nil guard if the assertion will actually produce code
365                    // (not just a skip comment), to avoid empty branches (SA9003).
366                    if field_resolver.is_valid_for_result(f) {
367                        let _ = writeln!(out, "\tif {guard} != nil {{");
368                        // Render into a temporary buffer so we can re-indent by one
369                        // tab level to sit inside the nil-guard block.
370                        let mut nil_buf = String::new();
371                        render_assertion(&mut nil_buf, assertion, result_var, field_resolver, &optional_locals);
372                        for line in nil_buf.lines() {
373                            let _ = writeln!(out, "\t{line}");
374                        }
375                        let _ = writeln!(out, "\t}}");
376                    } else {
377                        render_assertion(out, assertion, result_var, field_resolver, &optional_locals);
378                    }
379                    continue;
380                }
381            }
382        }
383        render_assertion(out, assertion, result_var, field_resolver, &optional_locals);
384    }
385
386    let _ = writeln!(out, "}}");
387}
388
389/// Build setup lines (e.g. handle creation) and the argument list for the function call.
390///
391/// Returns `(setup_lines, args_string)`.
392fn build_args_and_setup(
393    input: &serde_json::Value,
394    args: &[crate::config::ArgMapping],
395    import_alias: &str,
396    e2e_config: &crate::config::E2eConfig,
397    fixture_id: &str,
398) -> (Vec<String>, String) {
399    use heck::ToUpperCamelCase;
400
401    if args.is_empty() {
402        return (Vec::new(), json_to_go(input));
403    }
404
405    let overrides = e2e_config.call.overrides.get("go");
406    let options_type = overrides.and_then(|o| o.options_type.as_deref());
407
408    let mut setup_lines: Vec<String> = Vec::new();
409    let mut parts: Vec<String> = Vec::new();
410
411    for arg in args {
412        if arg.arg_type == "mock_url" {
413            setup_lines.push(format!(
414                "{} := os.Getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
415                arg.name,
416            ));
417            parts.push(arg.name.clone());
418            continue;
419        }
420
421        if arg.arg_type == "handle" {
422            // Generate a CreateEngine (or equivalent) call and pass the variable.
423            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
424            let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
425            if config_value.is_null()
426                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
427            {
428                setup_lines.push(format!(
429                    "{name}, createErr := {import_alias}.{constructor_name}()\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}",
430                    name = arg.name,
431                ));
432            } else {
433                let json_str = serde_json::to_string(config_value).unwrap_or_default();
434                let go_literal = go_string_literal(&json_str);
435                let name = &arg.name;
436                setup_lines.push(format!(
437                    "var {name}Config {import_alias}.CrawlConfig\n\tif err := json.Unmarshal([]byte({go_literal}), &{name}Config); err != nil {{\n\t\tt.Fatalf(\"config parse failed: %v\", err)\n\t}}"
438                ));
439                setup_lines.push(format!(
440                    "{name}, createErr := {import_alias}.{constructor_name}(&{name}Config)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}"
441                ));
442            }
443            parts.push(arg.name.clone());
444            continue;
445        }
446
447        let val = input.get(&arg.field);
448        match val {
449            None | Some(serde_json::Value::Null) if arg.optional => {
450                // Optional arg with no fixture value: skip entirely.
451                continue;
452            }
453            None | Some(serde_json::Value::Null) => {
454                // Required arg with no fixture value: pass a language-appropriate default.
455                let default_val = match arg.arg_type.as_str() {
456                    "string" => "\"\"".to_string(),
457                    "int" | "integer" => "0".to_string(),
458                    "float" | "number" => "0.0".to_string(),
459                    "bool" | "boolean" => "false".to_string(),
460                    _ => "nil".to_string(),
461                };
462                parts.push(default_val);
463            }
464            Some(v) => {
465                // For json_object args with options_type: construct using functional options.
466                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
467                    if let Some(obj) = v.as_object() {
468                        let with_calls: Vec<String> = obj
469                            .iter()
470                            .map(|(k, vv)| {
471                                let func_name = format!("With{}{}", opts_type, k.to_upper_camel_case());
472                                let go_val = json_to_go(vv);
473                                format!("htmd.{func_name}({go_val})")
474                            })
475                            .collect();
476                        let new_fn = format!("New{opts_type}");
477                        parts.push(format!("htmd.{new_fn}({})", with_calls.join(", ")));
478                        continue;
479                    }
480                }
481                parts.push(json_to_go(v));
482            }
483        }
484    }
485
486    (setup_lines, parts.join(", "))
487}
488
489fn render_assertion(
490    out: &mut String,
491    assertion: &Assertion,
492    result_var: &str,
493    field_resolver: &FieldResolver,
494    optional_locals: &std::collections::HashMap<String, String>,
495) {
496    // Skip assertions on fields that don't exist on the result type.
497    if let Some(f) = &assertion.field {
498        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
499            let _ = writeln!(out, "\t// skipped: field '{f}' not available on result type");
500            return;
501        }
502    }
503
504    let field_expr = match &assertion.field {
505        Some(f) if !f.is_empty() => {
506            // Use the local variable if the field was dereferenced above.
507            if let Some(local_var) = optional_locals.get(f.as_str()) {
508                local_var.clone()
509            } else {
510                field_resolver.accessor(f, "go", result_var)
511            }
512        }
513        _ => result_var.to_string(),
514    };
515
516    // Check if the field (after resolution) is optional, which means it's a pointer in Go.
517    // Also check if a `.length` suffix's parent is optional (e.g., metadata.headings.length
518    // where metadata.headings is optional → len() needs dereference).
519    let is_optional = assertion
520        .field
521        .as_ref()
522        .map(|f| {
523            let resolved = field_resolver.resolve(f);
524            let check_path = resolved
525                .strip_suffix(".length")
526                .or_else(|| resolved.strip_suffix(".count"))
527                .or_else(|| resolved.strip_suffix(".size"))
528                .unwrap_or(resolved);
529            field_resolver.is_optional(check_path) && !optional_locals.contains_key(f.as_str())
530        })
531        .unwrap_or(false);
532
533    // When field_expr is `len(X)` and X is an optional (pointer) field, rewrite to `len(*X)`
534    // and we'll wrap with a nil guard in the assertion handlers.
535    let field_expr = if is_optional && field_expr.starts_with("len(") && field_expr.ends_with(')') {
536        let inner = &field_expr[4..field_expr.len() - 1];
537        format!("len(*{inner})")
538    } else {
539        field_expr
540    };
541    // Build the nil-guard expression for the inner pointer (without len wrapper).
542    let nil_guard_expr = if is_optional && field_expr.starts_with("len(*") {
543        Some(field_expr[5..field_expr.len() - 1].to_string())
544    } else {
545        None
546    };
547
548    // For optional non-string fields that weren't dereferenced into locals,
549    // we need to dereference the pointer in comparisons.
550    let deref_field_expr = if is_optional && !field_expr.starts_with("len(") {
551        format!("*{field_expr}")
552    } else {
553        field_expr.clone()
554    };
555
556    // Detect array element access (e.g., `result.Assets[0].ContentHash`).
557    // When the field_expr contains `[0]`, we must guard against an out-of-bounds
558    // panic by checking that the array is non-empty first.
559    // Extract the array slice expression (everything before `[0]`).
560    let array_guard: Option<String> = if let Some(idx) = field_expr.find("[0]") {
561        let array_expr = &field_expr[..idx];
562        Some(array_expr.to_string())
563    } else {
564        None
565    };
566
567    // Render the assertion into a temporary buffer first, then wrap with the array
568    // bounds guard (if needed) by adding one extra level of indentation.
569    let mut assertion_buf = String::new();
570    let out_ref = &mut assertion_buf;
571
572    match assertion.assertion_type.as_str() {
573        "equals" => {
574            if let Some(expected) = &assertion.value {
575                let go_val = json_to_go(expected);
576                // For string equality, trim whitespace to handle trailing newlines from the converter.
577                if expected.is_string() {
578                    // Wrap field expression with strings.TrimSpace() for string comparisons.
579                    let trimmed_field = if is_optional && !field_expr.starts_with("len(") {
580                        format!("strings.TrimSpace(*{field_expr})")
581                    } else {
582                        format!("strings.TrimSpace({field_expr})")
583                    };
584                    if is_optional && !field_expr.starts_with("len(") {
585                        let _ = writeln!(out_ref, "\tif {field_expr} != nil && {trimmed_field} != {go_val} {{");
586                    } else {
587                        let _ = writeln!(out_ref, "\tif {trimmed_field} != {go_val} {{");
588                    }
589                } else {
590                    if is_optional && !field_expr.starts_with("len(") {
591                        let _ = writeln!(out_ref, "\tif {field_expr} != nil && {deref_field_expr} != {go_val} {{");
592                    } else {
593                        let _ = writeln!(out_ref, "\tif {field_expr} != {go_val} {{");
594                    }
595                }
596                let _ = writeln!(out_ref, "\t\tt.Errorf(\"equals mismatch: got %v\", {field_expr})");
597                let _ = writeln!(out_ref, "\t}}");
598            }
599        }
600        "contains" => {
601            if let Some(expected) = &assertion.value {
602                let go_val = json_to_go(expected);
603                let field_for_contains = if is_optional
604                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
605                {
606                    format!("string(*{field_expr})")
607                } else {
608                    format!("string({field_expr})")
609                };
610                let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
611                let _ = writeln!(
612                    out_ref,
613                    "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
614                );
615                let _ = writeln!(out_ref, "\t}}");
616            }
617        }
618        "contains_all" => {
619            if let Some(values) = &assertion.values {
620                for val in values {
621                    let go_val = json_to_go(val);
622                    let field_for_contains = if is_optional
623                        && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
624                    {
625                        format!("string(*{field_expr})")
626                    } else {
627                        format!("string({field_expr})")
628                    };
629                    let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
630                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
631                    let _ = writeln!(out_ref, "\t}}");
632                }
633            }
634        }
635        "not_contains" => {
636            if let Some(expected) = &assertion.value {
637                let go_val = json_to_go(expected);
638                let field_for_contains = if is_optional
639                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
640                {
641                    format!("string(*{field_expr})")
642                } else {
643                    format!("string({field_expr})")
644                };
645                let _ = writeln!(out_ref, "\tif strings.Contains({field_for_contains}, {go_val}) {{");
646                let _ = writeln!(
647                    out_ref,
648                    "\t\tt.Errorf(\"expected NOT to contain %s, got %v\", {go_val}, {field_expr})"
649                );
650                let _ = writeln!(out_ref, "\t}}");
651            }
652        }
653        "not_empty" => {
654            if is_optional {
655                let _ = writeln!(out_ref, "\tif {field_expr} == nil || len(*{field_expr}) == 0 {{");
656            } else {
657                let _ = writeln!(out_ref, "\tif len({field_expr}) == 0 {{");
658            }
659            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected non-empty value\")");
660            let _ = writeln!(out_ref, "\t}}");
661        }
662        "is_empty" => {
663            if is_optional {
664                let _ = writeln!(out_ref, "\tif {field_expr} != nil && len(*{field_expr}) != 0 {{");
665            } else {
666                let _ = writeln!(out_ref, "\tif len({field_expr}) != 0 {{");
667            }
668            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected empty value, got %v\", {field_expr})");
669            let _ = writeln!(out_ref, "\t}}");
670        }
671        "contains_any" => {
672            if let Some(values) = &assertion.values {
673                let field_for_contains = if is_optional
674                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
675                {
676                    format!("*{field_expr}")
677                } else {
678                    field_expr.clone()
679                };
680                let _ = writeln!(out_ref, "\t{{");
681                let _ = writeln!(out_ref, "\t\tfound := false");
682                for val in values {
683                    let go_val = json_to_go(val);
684                    let _ = writeln!(
685                        out_ref,
686                        "\t\tif strings.Contains({field_for_contains}, {go_val}) {{ found = true }}"
687                    );
688                }
689                let _ = writeln!(out_ref, "\t\tif !found {{");
690                let _ = writeln!(
691                    out_ref,
692                    "\t\t\tt.Errorf(\"expected to contain at least one of the specified values\")"
693                );
694                let _ = writeln!(out_ref, "\t\t}}");
695                let _ = writeln!(out_ref, "\t}}");
696            }
697        }
698        "greater_than" => {
699            if let Some(val) = &assertion.value {
700                let go_val = json_to_go(val);
701                // Use `< N+1` instead of `<= N` to avoid golangci-lint sloppyLen
702                // warning when N is 0 (len(x) <= 0 → len(x) < 1).
703                if let Some(n) = val.as_u64() {
704                    let next = n + 1;
705                    let _ = writeln!(out_ref, "\tif {field_expr} < {next} {{");
706                } else {
707                    let _ = writeln!(out_ref, "\tif {field_expr} <= {go_val} {{");
708                }
709                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
710                let _ = writeln!(out_ref, "\t}}");
711            }
712        }
713        "less_than" => {
714            if let Some(val) = &assertion.value {
715                let go_val = json_to_go(val);
716                let _ = writeln!(out_ref, "\tif {field_expr} >= {go_val} {{");
717                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected < {go_val}, got %v\", {field_expr})");
718                let _ = writeln!(out_ref, "\t}}");
719            }
720        }
721        "greater_than_or_equal" => {
722            if let Some(val) = &assertion.value {
723                let go_val = json_to_go(val);
724                if let Some(ref guard) = nil_guard_expr {
725                    let _ = writeln!(out_ref, "\tif {guard} != nil {{");
726                    let _ = writeln!(out_ref, "\t\tif {field_expr} < {go_val} {{");
727                    let _ = writeln!(
728                        out_ref,
729                        "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})"
730                    );
731                    let _ = writeln!(out_ref, "\t\t}}");
732                    let _ = writeln!(out_ref, "\t}}");
733                } else {
734                    let _ = writeln!(out_ref, "\tif {field_expr} < {go_val} {{");
735                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})");
736                    let _ = writeln!(out_ref, "\t}}");
737                }
738            }
739        }
740        "less_than_or_equal" => {
741            if let Some(val) = &assertion.value {
742                let go_val = json_to_go(val);
743                let _ = writeln!(out_ref, "\tif {field_expr} > {go_val} {{");
744                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected <= {go_val}, got %v\", {field_expr})");
745                let _ = writeln!(out_ref, "\t}}");
746            }
747        }
748        "starts_with" => {
749            if let Some(expected) = &assertion.value {
750                let go_val = json_to_go(expected);
751                let field_for_prefix = if is_optional
752                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
753                {
754                    format!("string(*{field_expr})")
755                } else {
756                    format!("string({field_expr})")
757                };
758                let _ = writeln!(out_ref, "\tif !strings.HasPrefix({field_for_prefix}, {go_val}) {{");
759                let _ = writeln!(
760                    out_ref,
761                    "\t\tt.Errorf(\"expected to start with %s, got %v\", {go_val}, {field_expr})"
762                );
763                let _ = writeln!(out_ref, "\t}}");
764            }
765        }
766        "count_min" => {
767            if let Some(val) = &assertion.value {
768                if let Some(n) = val.as_u64() {
769                    if is_optional {
770                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
771                        let _ = writeln!(
772                            out_ref,
773                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected at least {n} elements\")"
774                        );
775                        let _ = writeln!(out_ref, "\t}}");
776                    } else {
777                        let _ = writeln!(
778                            out_ref,
779                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected at least {n} elements\")"
780                        );
781                    }
782                }
783            }
784        }
785        "not_error" => {
786            // Already handled by the `if err != nil` check above.
787        }
788        "error" => {
789            // Handled at the test function level.
790        }
791        other => {
792            let _ = writeln!(out_ref, "\t// TODO: unsupported assertion type: {other}");
793        }
794    }
795
796    // If the assertion accesses an array element via [0], wrap the generated code in a
797    // bounds check to prevent an index-out-of-range panic when the array is empty.
798    if let Some(ref arr) = array_guard {
799        if !assertion_buf.is_empty() {
800            let _ = writeln!(out, "\tif len({arr}) > 0 {{");
801            // Re-indent each line by one additional tab level.
802            for line in assertion_buf.lines() {
803                let _ = writeln!(out, "\t{line}");
804            }
805            let _ = writeln!(out, "\t}}");
806        }
807    } else {
808        out.push_str(&assertion_buf);
809    }
810}
811
812/// Go common initialisms — words that must be all-caps in Go names.
813/// Sourced from revive's var-naming rule (github.com/mgechev/revive).
814const GO_INITIALISMS: &[&str] = &[
815    "ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IDS", "IP", "JSON",
816    "LHS", "QPS", "RAM", "RHS", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "UID", "UUID",
817    "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS",
818];
819
820/// Convert a snake_case field name to a Go-idiomatic local variable name.
821/// Splits on `_`, applies Go initialism rules (HTML, URL, ID, etc.),
822/// and joins as lowerCamelCase.
823fn go_local_name(snake: &str) -> String {
824    let words: Vec<&str> = snake.split('_').filter(|w| !w.is_empty()).collect();
825    if words.is_empty() {
826        return String::new();
827    }
828    let mut result = String::new();
829    for (i, word) in words.iter().enumerate() {
830        let upper = word.to_uppercase();
831        if GO_INITIALISMS.contains(&upper.as_str()) {
832            if i == 0 {
833                // First word of a local var → all lowercase initialism
834                result.push_str(&upper.to_lowercase());
835            } else {
836                result.push_str(&upper);
837            }
838        } else if i == 0 {
839            // First word → all lowercase
840            result.push_str(&word.to_lowercase());
841        } else {
842            // Subsequent words → capitalize first letter
843            let mut chars = word.chars();
844            if let Some(c) = chars.next() {
845                result.extend(c.to_uppercase());
846                result.push_str(&chars.as_str().to_lowercase());
847            }
848        }
849    }
850    result
851}
852
853/// Convert a `serde_json::Value` to a Go literal string.
854fn json_to_go(value: &serde_json::Value) -> String {
855    match value {
856        serde_json::Value::String(s) => go_string_literal(s),
857        serde_json::Value::Bool(b) => b.to_string(),
858        serde_json::Value::Number(n) => n.to_string(),
859        serde_json::Value::Null => "nil".to_string(),
860        // For complex types, serialize to JSON string and pass as literal.
861        other => go_string_literal(&other.to_string()),
862    }
863}