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