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, CallbackAction, Fixture, FixtureGroup};
7use alef_codegen::naming::{go_param_name, to_go_name};
8use alef_core::backend::GeneratedFile;
9use alef_core::config::AlefConfig;
10use alef_core::hash::{self, CommentStyle};
11use anyhow::Result;
12use heck::ToUpperCamelCase;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18/// Go e2e code generator.
19pub struct GoCodegen;
20
21impl E2eCodegen for GoCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let lang = self.language_name();
29        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
30
31        let mut files = Vec::new();
32
33        // Resolve call config with overrides (for module path and import alias).
34        let call = &e2e_config.call;
35        let overrides = call.overrides.get(lang);
36        let module_path = overrides
37            .and_then(|o| o.module.as_ref())
38            .cloned()
39            .unwrap_or_else(|| call.module.clone());
40        let import_alias = overrides
41            .and_then(|o| o.alias.as_ref())
42            .cloned()
43            .unwrap_or_else(|| "pkg".to_string());
44
45        // Resolve package config.
46        let go_pkg = e2e_config.resolve_package("go");
47        let go_module_path = go_pkg
48            .as_ref()
49            .and_then(|p| p.module.as_ref())
50            .cloned()
51            .unwrap_or_else(|| module_path.clone());
52        let replace_path = go_pkg.as_ref().and_then(|p| p.path.as_ref()).cloned();
53        let go_version = go_pkg
54            .as_ref()
55            .and_then(|p| p.version.as_ref())
56            .cloned()
57            .unwrap_or_else(|| {
58                alef_config
59                    .resolved_version()
60                    .map(|v| format!("v{v}"))
61                    .unwrap_or_else(|| "v0.0.0".to_string())
62            });
63        let field_resolver = FieldResolver::new(
64            &e2e_config.fields,
65            &e2e_config.fields_optional,
66            &e2e_config.result_fields,
67            &e2e_config.fields_array,
68        );
69
70        // Generate go.mod. In registry mode, omit the `replace` directive so the
71        // module is fetched from the Go module proxy.
72        let effective_replace = match e2e_config.dep_mode {
73            crate::config::DependencyMode::Registry => None,
74            crate::config::DependencyMode::Local => replace_path.as_deref().map(String::from),
75        };
76        files.push(GeneratedFile {
77            path: output_base.join("go.mod"),
78            content: render_go_mod(&go_module_path, effective_replace.as_deref(), &go_version),
79            generated_header: false,
80        });
81
82        // Generate test files per category.
83        for group in groups {
84            let active: Vec<&Fixture> = group
85                .fixtures
86                .iter()
87                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
88                .collect();
89
90            if active.is_empty() {
91                continue;
92            }
93
94            let filename = format!("{}_test.go", sanitize_filename(&group.category));
95            let content = render_test_file(
96                &group.category,
97                &active,
98                &module_path,
99                &import_alias,
100                &field_resolver,
101                e2e_config,
102            );
103            files.push(GeneratedFile {
104                path: output_base.join(filename),
105                content,
106                generated_header: true,
107            });
108        }
109
110        Ok(files)
111    }
112
113    fn language_name(&self) -> &'static str {
114        "go"
115    }
116}
117
118fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
119    let mut out = String::new();
120    let _ = writeln!(out, "module e2e_go");
121    let _ = writeln!(out);
122    let _ = writeln!(out, "go 1.26");
123    let _ = writeln!(out);
124    let _ = writeln!(out, "require (");
125    let _ = writeln!(out, "\t{go_module_path} {version}");
126    let _ = writeln!(out, "\tgithub.com/stretchr/testify v1.11.1");
127    let _ = writeln!(out, ")");
128
129    if let Some(path) = replace_path {
130        let _ = writeln!(out);
131        let _ = writeln!(out, "replace {go_module_path} => {path}");
132    }
133
134    out
135}
136
137fn render_test_file(
138    category: &str,
139    fixtures: &[&Fixture],
140    go_module_path: &str,
141    import_alias: &str,
142    field_resolver: &FieldResolver,
143    e2e_config: &crate::config::E2eConfig,
144) -> String {
145    let mut out = String::new();
146
147    // Go convention: generated file marker must appear before the package declaration.
148    out.push_str(&hash::header(CommentStyle::DoubleSlash));
149    let _ = writeln!(out);
150
151    // Determine if any fixture actually uses the pkg import.
152    // Fixtures without mock_response are emitted as t.Skip() stubs and don't reference the
153    // package — omit the import when no fixture needs it to avoid the Go "imported and not
154    // used" compile error.
155    let needs_pkg = fixtures.iter().any(|f| f.mock_response.is_some());
156
157    // Determine if we need the "os" import (mock_url args).
158    // Check all resolved per-fixture call args.
159    let needs_os = fixtures.iter().any(|f| {
160        let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
161        call_args.iter().any(|a| a.arg_type == "mock_url")
162    });
163
164    // Determine if we need "encoding/json" (handle args with non-null config,
165    // or json_object args that will be unmarshalled into a typed struct).
166    let needs_json = fixtures.iter().any(|f| {
167        let call = e2e_config.resolve_call(f.call.as_deref());
168        let call_args = &call.args;
169        // handle args with non-null config value
170        let has_handle = call_args.iter().any(|a| a.arg_type == "handle") && {
171            call_args.iter().filter(|a| a.arg_type == "handle").any(|a| {
172                let field = a.field.strip_prefix("input.").unwrap_or(&a.field);
173                let v = f.input.get(field).unwrap_or(&serde_json::Value::Null);
174                !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
175            })
176        };
177        // json_object args with options_type or array values (will use JSON unmarshal)
178        let go_override = call.overrides.get("go");
179        let opts_type = go_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
180            e2e_config
181                .call
182                .overrides
183                .get("go")
184                .and_then(|o| o.options_type.as_deref())
185        });
186        let has_json_obj = call_args.iter().any(|a| {
187            if a.arg_type != "json_object" {
188                return false;
189            }
190            let field = a.field.strip_prefix("input.").unwrap_or(&a.field);
191            let v = f.input.get(field).unwrap_or(&serde_json::Value::Null);
192            if v.is_array() {
193                return true;
194            } // array → []string unmarshal
195            opts_type.is_some() && v.is_object() && !v.as_object().is_some_and(|o| o.is_empty())
196        });
197        has_handle || has_json_obj
198    });
199
200    // Determine if we need "encoding/base64" (bytes-type args decoded at runtime).
201    let needs_base64 = fixtures.iter().any(|f| {
202        let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
203        call_args.iter().any(|a| {
204            if a.arg_type != "bytes" {
205                return false;
206            }
207            let field = a.field.strip_prefix("input.").unwrap_or(&a.field);
208            matches!(f.input.get(field), Some(serde_json::Value::String(_)))
209        })
210    });
211
212    // Determine if we need the "fmt" import (CustomTemplate visitor actions with placeholders).
213    let needs_fmt = fixtures.iter().any(|f| {
214        f.visitor.as_ref().is_some_and(|v| {
215            v.callbacks.values().any(|action| {
216                if let CallbackAction::CustomTemplate { template } = action {
217                    template.contains('{')
218                } else {
219                    false
220                }
221            })
222        })
223    });
224
225    // Determine if we need the "strings" import.
226    // Only count assertions whose fields are actually valid for the result type.
227    let needs_strings = fixtures.iter().any(|f| {
228        f.assertions.iter().any(|a| {
229            let type_needs_strings = if a.assertion_type == "equals" {
230                // equals with string values needs strings.TrimSpace
231                a.value.as_ref().is_some_and(|v| v.is_string())
232            } else {
233                matches!(
234                    a.assertion_type.as_str(),
235                    "contains" | "contains_all" | "contains_any" | "not_contains" | "starts_with" | "ends_with"
236                )
237            };
238            let field_valid = a
239                .field
240                .as_ref()
241                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
242                .unwrap_or(true);
243            type_needs_strings && field_valid
244        })
245    });
246
247    // Determine if we need the testify assert import (used for count_min, count_max,
248    // is_true, is_false, and method_result assertions).
249    let needs_assert = fixtures.iter().any(|f| {
250        f.assertions.iter().any(|a| {
251            let field_valid = a
252                .field
253                .as_ref()
254                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
255                .unwrap_or(true);
256            let type_needs_assert = matches!(
257                a.assertion_type.as_str(),
258                "count_min"
259                    | "count_max"
260                    | "is_true"
261                    | "is_false"
262                    | "method_result"
263                    | "min_length"
264                    | "max_length"
265                    | "matches_regex"
266            );
267            type_needs_assert && field_valid
268        })
269    });
270
271    let _ = writeln!(out, "// E2e tests for category: {category}");
272    let _ = writeln!(out, "package e2e_test");
273    let _ = writeln!(out);
274    let _ = writeln!(out, "import (");
275    if needs_base64 {
276        let _ = writeln!(out, "\t\"encoding/base64\"");
277    }
278    if needs_json {
279        let _ = writeln!(out, "\t\"encoding/json\"");
280    }
281    if needs_fmt {
282        let _ = writeln!(out, "\t\"fmt\"");
283    }
284    if needs_os {
285        let _ = writeln!(out, "\t\"os\"");
286    }
287    if needs_strings {
288        let _ = writeln!(out, "\t\"strings\"");
289    }
290    let _ = writeln!(out, "\t\"testing\"");
291    if needs_assert {
292        let _ = writeln!(out);
293        let _ = writeln!(out, "\t\"github.com/stretchr/testify/assert\"");
294    }
295    if needs_pkg {
296        let _ = writeln!(out);
297        let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
298    }
299    let _ = writeln!(out, ")");
300    let _ = writeln!(out);
301
302    // Emit package-level visitor structs (must be outside any function in Go).
303    for fixture in fixtures.iter() {
304        if let Some(visitor_spec) = &fixture.visitor {
305            let struct_name = visitor_struct_name(&fixture.id);
306            emit_go_visitor_struct(&mut out, &struct_name, visitor_spec, import_alias);
307            let _ = writeln!(out);
308        }
309    }
310
311    for (i, fixture) in fixtures.iter().enumerate() {
312        render_test_function(&mut out, fixture, import_alias, field_resolver, e2e_config);
313        if i + 1 < fixtures.len() {
314            let _ = writeln!(out);
315        }
316    }
317
318    // Clean up trailing newlines.
319    while out.ends_with("\n\n") {
320        out.pop();
321    }
322    if !out.ends_with('\n') {
323        out.push('\n');
324    }
325    out
326}
327
328fn render_test_function(
329    out: &mut String,
330    fixture: &Fixture,
331    import_alias: &str,
332    field_resolver: &FieldResolver,
333    e2e_config: &crate::config::E2eConfig,
334) {
335    let fn_name = fixture.id.to_upper_camel_case();
336    let description = &fixture.description;
337
338    // The Go binding wraps a C FFI layer and does not expose a HandleRequest or equivalent
339    // function that can be called from e2e tests.  Until Go-native HTTP integration tests
340    // are implemented, emit compilable stubs for all fixtures that don't use mock_response.
341    if fixture.mock_response.is_none() {
342        let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
343        let _ = writeln!(out, "\t// {description}");
344        let _ = writeln!(
345            out,
346            "\tt.Skip(\"TODO: implement Go e2e tests via the spikard Go binding API\")"
347        );
348        let _ = writeln!(out, "}}");
349        return;
350    }
351
352    // Resolve call config per-fixture (supports named calls via fixture.call).
353    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
354    let lang = "go";
355    let overrides = call_config.overrides.get(lang);
356    let function_name = to_go_name(
357        overrides
358            .and_then(|o| o.function.as_ref())
359            .map(String::as_str)
360            .unwrap_or(&call_config.function),
361    );
362    let result_var = &call_config.result_var;
363    let args = &call_config.args;
364
365    // Whether the function returns (value, error) or just (error) or just (value).
366    // Check Go override first, fall back to call-level returns_result.
367    let returns_result = overrides
368        .and_then(|o| o.returns_result)
369        .unwrap_or(call_config.returns_result);
370
371    // Whether the function returns only error (no value component), i.e. Result<(), E>.
372    // When returns_result=true and returns_void=true, Go emits `err :=` not `_, err :=`.
373    let returns_void = call_config.returns_void;
374
375    // result_is_simple: result is a scalar (*string, *bool, etc.) not a struct.
376    // Check Go override first, then Rust override as fallback.
377    let result_is_simple = overrides.map(|o| o.result_is_simple).unwrap_or_else(|| {
378        call_config
379            .overrides
380            .get("rust")
381            .map(|o| o.result_is_simple)
382            .unwrap_or(false)
383    });
384
385    // result_is_array: the simple result is a slice/array type (e.g., []string).
386    // Only relevant when result_is_simple is true.
387    let result_is_array = overrides.map(|o| o.result_is_array).unwrap_or(false);
388
389    // Per-call Go options_type, falling back to the default call's Go override.
390    let call_options_type = overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
391        e2e_config
392            .call
393            .overrides
394            .get("go")
395            .and_then(|o| o.options_type.as_deref())
396    });
397
398    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
399
400    let (mut setup_lines, args_str) =
401        build_args_and_setup(&fixture.input, args, import_alias, call_options_type, &fixture.id);
402
403    // Build visitor if present — struct is at package level, just instantiate here.
404    let mut visitor_arg = String::new();
405    if fixture.visitor.is_some() {
406        let struct_name = visitor_struct_name(&fixture.id);
407        setup_lines.push(format!("visitor := &{struct_name}{{}}"));
408        visitor_arg = "visitor".to_string();
409    }
410
411    let final_args = if visitor_arg.is_empty() {
412        args_str
413    } else {
414        format!("{args_str}, {visitor_arg}")
415    };
416
417    let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
418    let _ = writeln!(out, "\t// {description}");
419
420    for line in &setup_lines {
421        let _ = writeln!(out, "\t{line}");
422    }
423
424    if expects_error {
425        if returns_result && !returns_void {
426            let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({final_args})");
427        } else {
428            let _ = writeln!(out, "\terr := {import_alias}.{function_name}({final_args})");
429        }
430        let _ = writeln!(out, "\tif err == nil {{");
431        let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
432        let _ = writeln!(out, "\t}}");
433        let _ = writeln!(out, "}}");
434        return;
435    }
436
437    // Check if any assertion actually uses the result variable.
438    // If all assertions are skipped (field not on result type), use `_` to avoid
439    // Go's "declared and not used" compile error.
440    let has_usable_assertion = fixture.assertions.iter().any(|a| {
441        if a.assertion_type == "not_error" || a.assertion_type == "error" {
442            return false;
443        }
444        // method_result assertions always use the result variable.
445        if a.assertion_type == "method_result" {
446            return true;
447        }
448        match &a.field {
449            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
450            _ => true,
451        }
452    });
453
454    // For result_is_simple functions, the result variable IS the value (e.g. *string, *bool).
455    // We create a local `value` that dereferences it so assertions can use a plain type.
456    // For functions that return (value, error): emit `result, err :=`
457    // For functions that return only error: emit `err :=`
458    // For functions that return only a value (result_is_simple, no error): emit `result :=`
459    if !returns_result && result_is_simple {
460        // Function returns a single value, no error (e.g. *string, *bool).
461        let result_binding = if has_usable_assertion {
462            result_var.to_string()
463        } else {
464            "_".to_string()
465        };
466        // In Go, `_ :=` is invalid — must use `_ =` for the blank identifier.
467        let assign_op = if result_binding == "_" { "=" } else { ":=" };
468        let _ = writeln!(
469            out,
470            "\t{result_binding} {assign_op} {import_alias}.{function_name}({final_args})"
471        );
472        if has_usable_assertion && result_binding != "_" {
473            // Emit nil check and dereference for simple pointer results.
474            let _ = writeln!(out, "\tif {result_var} == nil {{");
475            let _ = writeln!(out, "\t\tt.Fatalf(\"expected non-nil result\")");
476            let _ = writeln!(out, "\t}}");
477            let _ = writeln!(out, "\tvalue := *{result_var}");
478        }
479    } else if !returns_result || returns_void {
480        // Function returns only error (either returns_result=false, or returns_result=true
481        // with returns_void=true meaning the Go function signature is `func(...) error`).
482        let _ = writeln!(out, "\terr := {import_alias}.{function_name}({final_args})");
483        let _ = writeln!(out, "\tif err != nil {{");
484        let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
485        let _ = writeln!(out, "\t}}");
486        // No result variable to use in assertions.
487        let _ = writeln!(out, "}}");
488        return;
489    } else {
490        // returns_result = true, returns_void = false: function returns (value, error).
491        let result_binding = if has_usable_assertion {
492            result_var.to_string()
493        } else {
494            "_".to_string()
495        };
496        let _ = writeln!(
497            out,
498            "\t{result_binding}, err := {import_alias}.{function_name}({final_args})"
499        );
500        let _ = writeln!(out, "\tif err != nil {{");
501        let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
502        let _ = writeln!(out, "\t}}");
503        if result_is_simple && has_usable_assertion && result_binding != "_" {
504            // Emit nil check and dereference for simple pointer results.
505            let _ = writeln!(out, "\tif {result_var} == nil {{");
506            let _ = writeln!(out, "\t\tt.Fatalf(\"expected non-nil result\")");
507            let _ = writeln!(out, "\t}}");
508            let _ = writeln!(out, "\tvalue := *{result_var}");
509        }
510    }
511
512    // For result_is_simple functions, assertions reference `value` (the dereferenced result).
513    let effective_result_var = if result_is_simple && has_usable_assertion {
514        "value".to_string()
515    } else {
516        result_var.to_string()
517    };
518
519    // Collect optional fields referenced by assertions and emit nil-safe
520    // dereference blocks so that assertions can use plain string locals.
521    // Only dereference fields whose assertion values are strings (or that are
522    // used in string-oriented assertions like equals/contains with string values).
523    let mut optional_locals: std::collections::HashMap<String, String> = std::collections::HashMap::new();
524    for assertion in &fixture.assertions {
525        if let Some(f) = &assertion.field {
526            if !f.is_empty() {
527                let resolved = field_resolver.resolve(f);
528                if field_resolver.is_optional(resolved) && !optional_locals.contains_key(f.as_str()) {
529                    // Only create deref locals for string-valued fields that are NOT arrays.
530                    // Array fields (e.g., *[]string) must keep their pointer form so
531                    // render_assertion can emit strings.Join(*field, " ") rather than
532                    // treating them as plain strings.
533                    let is_string_field = assertion.value.as_ref().is_some_and(|v| v.is_string());
534                    let is_array_field = field_resolver.is_array(resolved);
535                    if !is_string_field || is_array_field {
536                        // Non-string optional fields (e.g., *uint64) and array optional
537                        // fields (e.g., *[]string) are handled by nil guards in render_assertion.
538                        continue;
539                    }
540                    let field_expr = field_resolver.accessor(f, "go", &effective_result_var);
541                    let local_var = go_param_name(&resolved.replace(['.', '[', ']'], "_"));
542                    if field_resolver.has_map_access(f) {
543                        // Go map access returns a value type (string), not a pointer.
544                        // Use the value directly — empty string means not present.
545                        let _ = writeln!(out, "\t{local_var} := {field_expr}");
546                    } else {
547                        let _ = writeln!(out, "\tvar {local_var} string");
548                        let _ = writeln!(out, "\tif {field_expr} != nil {{");
549                        let _ = writeln!(out, "\t\t{local_var} = *{field_expr}");
550                        let _ = writeln!(out, "\t}}");
551                    }
552                    optional_locals.insert(f.clone(), local_var);
553                }
554            }
555        }
556    }
557
558    // Emit assertions, wrapping in nil guards when an intermediate path segment is optional.
559    for assertion in &fixture.assertions {
560        if let Some(f) = &assertion.field {
561            if !f.is_empty() && !optional_locals.contains_key(f.as_str()) {
562                // Check if any prefix of the dotted path is optional (pointer in Go).
563                // e.g., "document.nodes" — if "document" is optional, guard the whole block.
564                let parts: Vec<&str> = f.split('.').collect();
565                let mut guard_expr: Option<String> = None;
566                for i in 1..parts.len() {
567                    let prefix = parts[..i].join(".");
568                    let resolved_prefix = field_resolver.resolve(&prefix);
569                    if field_resolver.is_optional(resolved_prefix) {
570                        let accessor = field_resolver.accessor(&prefix, "go", &effective_result_var);
571                        guard_expr = Some(accessor);
572                        break;
573                    }
574                }
575                if let Some(guard) = guard_expr {
576                    // Only emit nil guard if the assertion will actually produce code
577                    // (not just a skip comment), to avoid empty branches (SA9003).
578                    if field_resolver.is_valid_for_result(f) {
579                        let _ = writeln!(out, "\tif {guard} != nil {{");
580                        // Render into a temporary buffer so we can re-indent by one
581                        // tab level to sit inside the nil-guard block.
582                        let mut nil_buf = String::new();
583                        render_assertion(
584                            &mut nil_buf,
585                            assertion,
586                            &effective_result_var,
587                            import_alias,
588                            field_resolver,
589                            &optional_locals,
590                            result_is_simple,
591                            result_is_array,
592                        );
593                        for line in nil_buf.lines() {
594                            let _ = writeln!(out, "\t{line}");
595                        }
596                        let _ = writeln!(out, "\t}}");
597                    } else {
598                        render_assertion(
599                            out,
600                            assertion,
601                            &effective_result_var,
602                            import_alias,
603                            field_resolver,
604                            &optional_locals,
605                            result_is_simple,
606                            result_is_array,
607                        );
608                    }
609                    continue;
610                }
611            }
612        }
613        render_assertion(
614            out,
615            assertion,
616            &effective_result_var,
617            import_alias,
618            field_resolver,
619            &optional_locals,
620            result_is_simple,
621            result_is_array,
622        );
623    }
624
625    let _ = writeln!(out, "}}");
626}
627
628/// Build setup lines (e.g. handle creation) and the argument list for the function call.
629///
630/// Returns `(setup_lines, args_string)`.
631fn build_args_and_setup(
632    input: &serde_json::Value,
633    args: &[crate::config::ArgMapping],
634    import_alias: &str,
635    options_type: Option<&str>,
636    fixture_id: &str,
637) -> (Vec<String>, String) {
638    use heck::ToUpperCamelCase;
639
640    if args.is_empty() {
641        return (Vec::new(), String::new());
642    }
643
644    let mut setup_lines: Vec<String> = Vec::new();
645    let mut parts: Vec<String> = Vec::new();
646
647    for arg in args {
648        if arg.arg_type == "mock_url" {
649            setup_lines.push(format!(
650                "{} := os.Getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
651                arg.name,
652            ));
653            parts.push(arg.name.clone());
654            continue;
655        }
656
657        if arg.arg_type == "handle" {
658            // Generate a CreateEngine (or equivalent) call and pass the variable.
659            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
660            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
661            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
662            if config_value.is_null()
663                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
664            {
665                setup_lines.push(format!(
666                    "{name}, createErr := {import_alias}.{constructor_name}(nil)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}",
667                    name = arg.name,
668                ));
669            } else {
670                let json_str = serde_json::to_string(config_value).unwrap_or_default();
671                let go_literal = go_string_literal(&json_str);
672                let name = &arg.name;
673                setup_lines.push(format!(
674                    "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}}"
675                ));
676                setup_lines.push(format!(
677                    "{name}, createErr := {import_alias}.{constructor_name}(&{name}Config)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}"
678                ));
679            }
680            parts.push(arg.name.clone());
681            continue;
682        }
683
684        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
685        let val = input.get(field);
686
687        // Handle bytes type: fixture stores base64-encoded bytes.
688        // Emit a Go base64.StdEncoding.DecodeString call to decode at runtime.
689        if arg.arg_type == "bytes" {
690            let var_name = format!("{}Bytes", arg.name);
691            match val {
692                None | Some(serde_json::Value::Null) => {
693                    if arg.optional {
694                        parts.push("nil".to_string());
695                    } else {
696                        parts.push("[]byte{}".to_string());
697                    }
698                }
699                Some(serde_json::Value::String(s)) => {
700                    let go_b64 = go_string_literal(s);
701                    setup_lines.push(format!("{var_name}, _ := base64.StdEncoding.DecodeString({go_b64})"));
702                    parts.push(var_name);
703                }
704                Some(other) => {
705                    parts.push(format!("[]byte({})", json_to_go(other)));
706                }
707            }
708            continue;
709        }
710
711        match val {
712            None | Some(serde_json::Value::Null) if arg.optional => {
713                // Optional arg absent: emit Go zero/nil for the type.
714                match arg.arg_type.as_str() {
715                    "string" => {
716                        // Optional string in Go bindings is *string → nil.
717                        parts.push("nil".to_string());
718                    }
719                    "json_object" => {
720                        // Optional config struct → zero-value struct.
721                        if let Some(opts_type) = options_type {
722                            parts.push(format!("{import_alias}.{opts_type}{{}}"));
723                        } else {
724                            parts.push("nil".to_string());
725                        }
726                    }
727                    _ => {
728                        parts.push("nil".to_string());
729                    }
730                }
731            }
732            None | Some(serde_json::Value::Null) => {
733                // Required arg with no fixture value: pass a language-appropriate default.
734                let default_val = match arg.arg_type.as_str() {
735                    "string" => "\"\"".to_string(),
736                    "int" | "integer" | "i64" => "0".to_string(),
737                    "float" | "number" => "0.0".to_string(),
738                    "bool" | "boolean" => "false".to_string(),
739                    "json_object" => {
740                        if let Some(opts_type) = options_type {
741                            format!("{import_alias}.{opts_type}{{}}")
742                        } else {
743                            "nil".to_string()
744                        }
745                    }
746                    _ => "nil".to_string(),
747                };
748                parts.push(default_val);
749            }
750            Some(v) => {
751                match arg.arg_type.as_str() {
752                    "json_object" => {
753                        // JSON arrays unmarshal into []string (Go slices).
754                        // JSON objects with a known options_type unmarshal into that type.
755                        let is_array = v.is_array();
756                        let is_empty_obj = !is_array && v.is_object() && v.as_object().is_some_and(|o| o.is_empty());
757                        if is_empty_obj {
758                            if let Some(opts_type) = options_type {
759                                parts.push(format!("{import_alias}.{opts_type}{{}}"));
760                            } else {
761                                parts.push("nil".to_string());
762                            }
763                        } else if is_array {
764                            // Array type — unmarshal into []string (typical for paths/texts).
765                            let json_str = serde_json::to_string(v).unwrap_or_default();
766                            let go_literal = go_string_literal(&json_str);
767                            let var_name = &arg.name;
768                            setup_lines.push(format!(
769                                "var {var_name} []string\n\tif err := json.Unmarshal([]byte({go_literal}), &{var_name}); err != nil {{\n\t\tt.Fatalf(\"config parse failed: %v\", err)\n\t}}"
770                            ));
771                            parts.push(var_name.to_string());
772                        } else if let Some(opts_type) = options_type {
773                            // Object with known type — unmarshal into typed struct.
774                            let json_str = serde_json::to_string(v).unwrap_or_default();
775                            let go_literal = go_string_literal(&json_str);
776                            let var_name = &arg.name;
777                            setup_lines.push(format!(
778                                "var {var_name} {import_alias}.{opts_type}\n\tif err := json.Unmarshal([]byte({go_literal}), &{var_name}); err != nil {{\n\t\tt.Fatalf(\"config parse failed: %v\", err)\n\t}}"
779                            ));
780                            parts.push(var_name.to_string());
781                        } else {
782                            parts.push(json_to_go(v));
783                        }
784                    }
785                    "string" if arg.optional => {
786                        // Optional string in Go is *string — take address of a local.
787                        let var_name = format!("{}Val", arg.name);
788                        let go_val = json_to_go(v);
789                        setup_lines.push(format!("{var_name} := {go_val}"));
790                        parts.push(format!("&{var_name}"));
791                    }
792                    _ => {
793                        parts.push(json_to_go(v));
794                    }
795                }
796            }
797        }
798    }
799
800    (setup_lines, parts.join(", "))
801}
802
803#[allow(clippy::too_many_arguments)]
804fn render_assertion(
805    out: &mut String,
806    assertion: &Assertion,
807    result_var: &str,
808    import_alias: &str,
809    field_resolver: &FieldResolver,
810    optional_locals: &std::collections::HashMap<String, String>,
811    result_is_simple: bool,
812    result_is_array: bool,
813) {
814    // Skip assertions on fields that don't exist on the result type.
815    // When result_is_simple, all field assertions operate on the scalar result directly.
816    if !result_is_simple {
817        if let Some(f) = &assertion.field {
818            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
819                let _ = writeln!(out, "\t// skipped: field '{f}' not available on result type");
820                return;
821            }
822        }
823    }
824
825    let field_expr = if result_is_simple {
826        // The result IS the value — field access is irrelevant.
827        result_var.to_string()
828    } else {
829        match &assertion.field {
830            Some(f) if !f.is_empty() => {
831                // Use the local variable if the field was dereferenced above.
832                if let Some(local_var) = optional_locals.get(f.as_str()) {
833                    local_var.clone()
834                } else {
835                    field_resolver.accessor(f, "go", result_var)
836                }
837            }
838            _ => result_var.to_string(),
839        }
840    };
841
842    // Check if the field (after resolution) is optional, which means it's a pointer in Go.
843    // Also check if a `.length` suffix's parent is optional (e.g., metadata.headings.length
844    // where metadata.headings is optional → len() needs dereference).
845    let is_optional = assertion
846        .field
847        .as_ref()
848        .map(|f| {
849            let resolved = field_resolver.resolve(f);
850            let check_path = resolved
851                .strip_suffix(".length")
852                .or_else(|| resolved.strip_suffix(".count"))
853                .or_else(|| resolved.strip_suffix(".size"))
854                .unwrap_or(resolved);
855            field_resolver.is_optional(check_path) && !optional_locals.contains_key(f.as_str())
856        })
857        .unwrap_or(false);
858
859    // When field_expr is `len(X)` and X is an optional (pointer) field, rewrite to `len(*X)`
860    // and we'll wrap with a nil guard in the assertion handlers.
861    let field_expr = if is_optional && field_expr.starts_with("len(") && field_expr.ends_with(')') {
862        let inner = &field_expr[4..field_expr.len() - 1];
863        format!("len(*{inner})")
864    } else {
865        field_expr
866    };
867    // Build the nil-guard expression for the inner pointer (without len wrapper).
868    let nil_guard_expr = if is_optional && field_expr.starts_with("len(*") {
869        Some(field_expr[5..field_expr.len() - 1].to_string())
870    } else {
871        None
872    };
873
874    // For optional non-string fields that weren't dereferenced into locals,
875    // we need to dereference the pointer in comparisons.
876    let deref_field_expr = if is_optional && !field_expr.starts_with("len(") {
877        format!("*{field_expr}")
878    } else {
879        field_expr.clone()
880    };
881
882    // Detect array element access (e.g., `result.Assets[0].ContentHash`).
883    // When the field_expr contains `[0]`, we must guard against an out-of-bounds
884    // panic by checking that the array is non-empty first.
885    // Extract the array slice expression (everything before `[0]`).
886    let array_guard: Option<String> = if let Some(idx) = field_expr.find("[0]") {
887        let array_expr = &field_expr[..idx];
888        Some(array_expr.to_string())
889    } else {
890        None
891    };
892
893    // Render the assertion into a temporary buffer first, then wrap with the array
894    // bounds guard (if needed) by adding one extra level of indentation.
895    let mut assertion_buf = String::new();
896    let out_ref = &mut assertion_buf;
897
898    match assertion.assertion_type.as_str() {
899        "equals" => {
900            if let Some(expected) = &assertion.value {
901                let go_val = json_to_go(expected);
902                // For string equality, trim whitespace to handle trailing newlines from the converter.
903                if expected.is_string() {
904                    // Wrap field expression with strings.TrimSpace() for string comparisons.
905                    let trimmed_field = if is_optional && !field_expr.starts_with("len(") {
906                        format!("strings.TrimSpace(*{field_expr})")
907                    } else {
908                        format!("strings.TrimSpace({field_expr})")
909                    };
910                    if is_optional && !field_expr.starts_with("len(") {
911                        let _ = writeln!(out_ref, "\tif {field_expr} != nil && {trimmed_field} != {go_val} {{");
912                    } else {
913                        let _ = writeln!(out_ref, "\tif {trimmed_field} != {go_val} {{");
914                    }
915                } else if is_optional && !field_expr.starts_with("len(") {
916                    let _ = writeln!(out_ref, "\tif {field_expr} != nil && {deref_field_expr} != {go_val} {{");
917                } else {
918                    let _ = writeln!(out_ref, "\tif {field_expr} != {go_val} {{");
919                }
920                let _ = writeln!(out_ref, "\t\tt.Errorf(\"equals mismatch: got %v\", {field_expr})");
921                let _ = writeln!(out_ref, "\t}}");
922            }
923        }
924        "contains" => {
925            if let Some(expected) = &assertion.value {
926                let go_val = json_to_go(expected);
927                // Determine the "string view" of the field expression.
928                // - *[]string → strings.Join(*field_expr, " ") for a nil-guarded check
929                // - *string → string(*field_expr)
930                // - string → string(field_expr) (or just field_expr for plain strings)
931                // - result_is_array (result_is_simple + array result) → strings.Join(field_expr, " ")
932                let resolved_field = assertion.field.as_deref().unwrap_or("");
933                let resolved_name = field_resolver.resolve(resolved_field);
934                let field_is_array = result_is_array || field_resolver.is_array(resolved_name);
935                let is_opt =
936                    is_optional && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()));
937                let field_for_contains = if is_opt && field_is_array {
938                    format!("strings.Join(*{field_expr}, \" \")")
939                } else if is_opt {
940                    format!("string(*{field_expr})")
941                } else if field_is_array {
942                    format!("strings.Join({field_expr}, \" \")")
943                } else {
944                    format!("string({field_expr})")
945                };
946                if is_opt {
947                    let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
948                    let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
949                    let _ = writeln!(
950                        out_ref,
951                        "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
952                    );
953                    let _ = writeln!(out_ref, "\t}}");
954                    let _ = writeln!(out_ref, "\t}}");
955                } else {
956                    let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
957                    let _ = writeln!(
958                        out_ref,
959                        "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
960                    );
961                    let _ = writeln!(out_ref, "\t}}");
962                }
963            }
964        }
965        "contains_all" => {
966            if let Some(values) = &assertion.values {
967                let resolved_field = assertion.field.as_deref().unwrap_or("");
968                let resolved_name = field_resolver.resolve(resolved_field);
969                let field_is_array = result_is_array || field_resolver.is_array(resolved_name);
970                let is_opt =
971                    is_optional && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()));
972                for val in values {
973                    let go_val = json_to_go(val);
974                    let field_for_contains = if is_opt && field_is_array {
975                        format!("strings.Join(*{field_expr}, \" \")")
976                    } else if is_opt {
977                        format!("string(*{field_expr})")
978                    } else if field_is_array {
979                        format!("strings.Join({field_expr}, \" \")")
980                    } else {
981                        format!("string({field_expr})")
982                    };
983                    if is_opt {
984                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
985                        let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
986                        let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
987                        let _ = writeln!(out_ref, "\t}}");
988                        let _ = writeln!(out_ref, "\t}}");
989                    } else {
990                        let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
991                        let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
992                        let _ = writeln!(out_ref, "\t}}");
993                    }
994                }
995            }
996        }
997        "not_contains" => {
998            if let Some(expected) = &assertion.value {
999                let go_val = json_to_go(expected);
1000                let resolved_field = assertion.field.as_deref().unwrap_or("");
1001                let resolved_name = field_resolver.resolve(resolved_field);
1002                let field_is_array = result_is_array || field_resolver.is_array(resolved_name);
1003                let is_opt =
1004                    is_optional && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()));
1005                let field_for_contains = if is_opt && field_is_array {
1006                    format!("strings.Join(*{field_expr}, \" \")")
1007                } else if is_opt {
1008                    format!("string(*{field_expr})")
1009                } else if field_is_array {
1010                    format!("strings.Join({field_expr}, \" \")")
1011                } else {
1012                    format!("string({field_expr})")
1013                };
1014                let _ = writeln!(out_ref, "\tif strings.Contains({field_for_contains}, {go_val}) {{");
1015                let _ = writeln!(
1016                    out_ref,
1017                    "\t\tt.Errorf(\"expected NOT to contain %s, got %v\", {go_val}, {field_expr})"
1018                );
1019                let _ = writeln!(out_ref, "\t}}");
1020            }
1021        }
1022        "not_empty" => {
1023            // For optional struct pointers (not arrays), just check != nil.
1024            // For optional slice/string pointers, check nil and len.
1025            let field_is_array = {
1026                let rf = assertion.field.as_deref().unwrap_or("");
1027                let rn = field_resolver.resolve(rf);
1028                field_resolver.is_array(rn)
1029            };
1030            if is_optional && !field_is_array {
1031                // Struct pointer: non-empty means not nil.
1032                let _ = writeln!(out_ref, "\tif {field_expr} == nil {{");
1033            } else if is_optional {
1034                let _ = writeln!(out_ref, "\tif {field_expr} == nil || len(*{field_expr}) == 0 {{");
1035            } else {
1036                let _ = writeln!(out_ref, "\tif len({field_expr}) == 0 {{");
1037            }
1038            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected non-empty value\")");
1039            let _ = writeln!(out_ref, "\t}}");
1040        }
1041        "is_empty" => {
1042            let field_is_array = {
1043                let rf = assertion.field.as_deref().unwrap_or("");
1044                let rn = field_resolver.resolve(rf);
1045                field_resolver.is_array(rn)
1046            };
1047            if is_optional && !field_is_array {
1048                // Struct pointer: empty means nil.
1049                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1050            } else if is_optional {
1051                let _ = writeln!(out_ref, "\tif {field_expr} != nil && len(*{field_expr}) != 0 {{");
1052            } else {
1053                let _ = writeln!(out_ref, "\tif len({field_expr}) != 0 {{");
1054            }
1055            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected empty value, got %v\", {field_expr})");
1056            let _ = writeln!(out_ref, "\t}}");
1057        }
1058        "contains_any" => {
1059            if let Some(values) = &assertion.values {
1060                let resolved_field = assertion.field.as_deref().unwrap_or("");
1061                let resolved_name = field_resolver.resolve(resolved_field);
1062                let field_is_array = field_resolver.is_array(resolved_name);
1063                let is_opt =
1064                    is_optional && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()));
1065                let field_for_contains = if is_opt && field_is_array {
1066                    format!("strings.Join(*{field_expr}, \" \")")
1067                } else if is_opt {
1068                    format!("*{field_expr}")
1069                } else if field_is_array {
1070                    format!("strings.Join({field_expr}, \" \")")
1071                } else {
1072                    field_expr.clone()
1073                };
1074                let _ = writeln!(out_ref, "\t{{");
1075                let _ = writeln!(out_ref, "\t\tfound := false");
1076                for val in values {
1077                    let go_val = json_to_go(val);
1078                    let _ = writeln!(
1079                        out_ref,
1080                        "\t\tif strings.Contains({field_for_contains}, {go_val}) {{ found = true }}"
1081                    );
1082                }
1083                let _ = writeln!(out_ref, "\t\tif !found {{");
1084                let _ = writeln!(
1085                    out_ref,
1086                    "\t\t\tt.Errorf(\"expected to contain at least one of the specified values\")"
1087                );
1088                let _ = writeln!(out_ref, "\t\t}}");
1089                let _ = writeln!(out_ref, "\t}}");
1090            }
1091        }
1092        "greater_than" => {
1093            if let Some(val) = &assertion.value {
1094                let go_val = json_to_go(val);
1095                // Use `< N+1` instead of `<= N` to avoid golangci-lint sloppyLen
1096                // warning when N is 0 (len(x) <= 0 → len(x) < 1).
1097                // For optional (pointer) fields, dereference and guard with nil check.
1098                if is_optional {
1099                    let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1100                    if let Some(n) = val.as_u64() {
1101                        let next = n + 1;
1102                        let _ = writeln!(out_ref, "\t\tif {deref_field_expr} < {next} {{");
1103                    } else {
1104                        let _ = writeln!(out_ref, "\t\tif {deref_field_expr} <= {go_val} {{");
1105                    }
1106                    let _ = writeln!(
1107                        out_ref,
1108                        "\t\t\tt.Errorf(\"expected > {go_val}, got %v\", {deref_field_expr})"
1109                    );
1110                    let _ = writeln!(out_ref, "\t\t}}");
1111                    let _ = writeln!(out_ref, "\t}}");
1112                } else if let Some(n) = val.as_u64() {
1113                    let next = n + 1;
1114                    let _ = writeln!(out_ref, "\tif {field_expr} < {next} {{");
1115                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
1116                    let _ = writeln!(out_ref, "\t}}");
1117                } else {
1118                    let _ = writeln!(out_ref, "\tif {field_expr} <= {go_val} {{");
1119                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
1120                    let _ = writeln!(out_ref, "\t}}");
1121                }
1122            }
1123        }
1124        "less_than" => {
1125            if let Some(val) = &assertion.value {
1126                let go_val = json_to_go(val);
1127                let _ = writeln!(out_ref, "\tif {field_expr} >= {go_val} {{");
1128                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected < {go_val}, got %v\", {field_expr})");
1129                let _ = writeln!(out_ref, "\t}}");
1130            }
1131        }
1132        "greater_than_or_equal" => {
1133            if let Some(val) = &assertion.value {
1134                let go_val = json_to_go(val);
1135                if let Some(ref guard) = nil_guard_expr {
1136                    let _ = writeln!(out_ref, "\tif {guard} != nil {{");
1137                    let _ = writeln!(out_ref, "\t\tif {field_expr} < {go_val} {{");
1138                    let _ = writeln!(
1139                        out_ref,
1140                        "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})"
1141                    );
1142                    let _ = writeln!(out_ref, "\t\t}}");
1143                    let _ = writeln!(out_ref, "\t}}");
1144                } else if is_optional && !field_expr.starts_with("len(") {
1145                    // Optional pointer field: nil-guard and dereference before comparison.
1146                    let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1147                    let _ = writeln!(out_ref, "\t\tif {deref_field_expr} < {go_val} {{");
1148                    let _ = writeln!(
1149                        out_ref,
1150                        "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {deref_field_expr})"
1151                    );
1152                    let _ = writeln!(out_ref, "\t\t}}");
1153                    let _ = writeln!(out_ref, "\t}}");
1154                } else {
1155                    let _ = writeln!(out_ref, "\tif {field_expr} < {go_val} {{");
1156                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})");
1157                    let _ = writeln!(out_ref, "\t}}");
1158                }
1159            }
1160        }
1161        "less_than_or_equal" => {
1162            if let Some(val) = &assertion.value {
1163                let go_val = json_to_go(val);
1164                let _ = writeln!(out_ref, "\tif {field_expr} > {go_val} {{");
1165                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected <= {go_val}, got %v\", {field_expr})");
1166                let _ = writeln!(out_ref, "\t}}");
1167            }
1168        }
1169        "starts_with" => {
1170            if let Some(expected) = &assertion.value {
1171                let go_val = json_to_go(expected);
1172                let field_for_prefix = if is_optional
1173                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1174                {
1175                    format!("string(*{field_expr})")
1176                } else {
1177                    format!("string({field_expr})")
1178                };
1179                let _ = writeln!(out_ref, "\tif !strings.HasPrefix({field_for_prefix}, {go_val}) {{");
1180                let _ = writeln!(
1181                    out_ref,
1182                    "\t\tt.Errorf(\"expected to start with %s, got %v\", {go_val}, {field_expr})"
1183                );
1184                let _ = writeln!(out_ref, "\t}}");
1185            }
1186        }
1187        "count_min" => {
1188            if let Some(val) = &assertion.value {
1189                if let Some(n) = val.as_u64() {
1190                    if is_optional {
1191                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1192                        let _ = writeln!(
1193                            out_ref,
1194                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected at least {n} elements\")"
1195                        );
1196                        let _ = writeln!(out_ref, "\t}}");
1197                    } else {
1198                        let _ = writeln!(
1199                            out_ref,
1200                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected at least {n} elements\")"
1201                        );
1202                    }
1203                }
1204            }
1205        }
1206        "count_equals" => {
1207            if let Some(val) = &assertion.value {
1208                if let Some(n) = val.as_u64() {
1209                    if is_optional {
1210                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1211                        let _ = writeln!(
1212                            out_ref,
1213                            "\t\tassert.Equal(t, len(*{field_expr}), {n}, \"expected exactly {n} elements\")"
1214                        );
1215                        let _ = writeln!(out_ref, "\t}}");
1216                    } else {
1217                        let _ = writeln!(
1218                            out_ref,
1219                            "\tassert.Equal(t, len({field_expr}), {n}, \"expected exactly {n} elements\")"
1220                        );
1221                    }
1222                }
1223            }
1224        }
1225        "is_true" => {
1226            if is_optional {
1227                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1228                let _ = writeln!(out_ref, "\t\tassert.True(t, *{field_expr}, \"expected true\")");
1229                let _ = writeln!(out_ref, "\t}}");
1230            } else {
1231                let _ = writeln!(out_ref, "\tassert.True(t, {field_expr}, \"expected true\")");
1232            }
1233        }
1234        "is_false" => {
1235            if is_optional {
1236                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1237                let _ = writeln!(out_ref, "\t\tassert.False(t, *{field_expr}, \"expected false\")");
1238                let _ = writeln!(out_ref, "\t}}");
1239            } else {
1240                let _ = writeln!(out_ref, "\tassert.False(t, {field_expr}, \"expected false\")");
1241            }
1242        }
1243        "method_result" => {
1244            if let Some(method_name) = &assertion.method {
1245                let info = build_go_method_call(result_var, method_name, assertion.args.as_ref(), import_alias);
1246                let check = assertion.check.as_deref().unwrap_or("is_true");
1247                // For pointer-returning functions, dereference with `*`. Value-returning
1248                // functions (e.g., NodeInfo field access) are used directly.
1249                let deref_expr = if info.is_pointer {
1250                    format!("*{}", info.call_expr)
1251                } else {
1252                    info.call_expr.clone()
1253                };
1254                match check {
1255                    "equals" => {
1256                        if let Some(val) = &assertion.value {
1257                            if val.is_boolean() {
1258                                if val.as_bool() == Some(true) {
1259                                    let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
1260                                } else {
1261                                    let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
1262                                }
1263                            } else {
1264                                // Apply type cast to numeric literals when the method returns
1265                                // a typed uint (e.g., *uint) to avoid reflect.DeepEqual
1266                                // mismatches between int and uint in testify's assert.Equal.
1267                                let go_val = if let Some(cast) = info.value_cast {
1268                                    if val.is_number() {
1269                                        format!("{cast}({})", json_to_go(val))
1270                                    } else {
1271                                        json_to_go(val)
1272                                    }
1273                                } else {
1274                                    json_to_go(val)
1275                                };
1276                                let _ = writeln!(
1277                                    out_ref,
1278                                    "\tassert.Equal(t, {go_val}, {deref_expr}, \"method_result equals assertion failed\")"
1279                                );
1280                            }
1281                        }
1282                    }
1283                    "is_true" => {
1284                        let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
1285                    }
1286                    "is_false" => {
1287                        let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
1288                    }
1289                    "greater_than_or_equal" => {
1290                        if let Some(val) = &assertion.value {
1291                            let n = val.as_u64().unwrap_or(0);
1292                            // Use the value_cast type if available (e.g., uint for named_children_count).
1293                            let cast = info.value_cast.unwrap_or("uint");
1294                            let _ = writeln!(
1295                                out_ref,
1296                                "\tassert.GreaterOrEqual(t, {deref_expr}, {cast}({n}), \"expected >= {n}\")"
1297                            );
1298                        }
1299                    }
1300                    "count_min" => {
1301                        if let Some(val) = &assertion.value {
1302                            let n = val.as_u64().unwrap_or(0);
1303                            let _ = writeln!(
1304                                out_ref,
1305                                "\tassert.GreaterOrEqual(t, len({deref_expr}), {n}, \"expected at least {n} elements\")"
1306                            );
1307                        }
1308                    }
1309                    "contains" => {
1310                        if let Some(val) = &assertion.value {
1311                            let go_val = json_to_go(val);
1312                            let _ = writeln!(
1313                                out_ref,
1314                                "\tassert.Contains(t, {deref_expr}, {go_val}, \"expected result to contain value\")"
1315                            );
1316                        }
1317                    }
1318                    "is_error" => {
1319                        let _ = writeln!(out_ref, "\t{{");
1320                        let _ = writeln!(out_ref, "\t\t_, methodErr := {}", info.call_expr);
1321                        let _ = writeln!(out_ref, "\t\tassert.Error(t, methodErr)");
1322                        let _ = writeln!(out_ref, "\t}}");
1323                    }
1324                    other_check => {
1325                        panic!("Go e2e generator: unsupported method_result check type: {other_check}");
1326                    }
1327                }
1328            } else {
1329                panic!("Go e2e generator: method_result assertion missing 'method' field");
1330            }
1331        }
1332        "min_length" => {
1333            if let Some(val) = &assertion.value {
1334                if let Some(n) = val.as_u64() {
1335                    if is_optional {
1336                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1337                        let _ = writeln!(
1338                            out_ref,
1339                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected length >= {n}\")"
1340                        );
1341                        let _ = writeln!(out_ref, "\t}}");
1342                    } else {
1343                        let _ = writeln!(
1344                            out_ref,
1345                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected length >= {n}\")"
1346                        );
1347                    }
1348                }
1349            }
1350        }
1351        "max_length" => {
1352            if let Some(val) = &assertion.value {
1353                if let Some(n) = val.as_u64() {
1354                    if is_optional {
1355                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1356                        let _ = writeln!(
1357                            out_ref,
1358                            "\t\tassert.LessOrEqual(t, len(*{field_expr}), {n}, \"expected length <= {n}\")"
1359                        );
1360                        let _ = writeln!(out_ref, "\t}}");
1361                    } else {
1362                        let _ = writeln!(
1363                            out_ref,
1364                            "\tassert.LessOrEqual(t, len({field_expr}), {n}, \"expected length <= {n}\")"
1365                        );
1366                    }
1367                }
1368            }
1369        }
1370        "ends_with" => {
1371            if let Some(expected) = &assertion.value {
1372                let go_val = json_to_go(expected);
1373                let field_for_suffix = if is_optional
1374                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1375                {
1376                    format!("string(*{field_expr})")
1377                } else {
1378                    format!("string({field_expr})")
1379                };
1380                let _ = writeln!(out_ref, "\tif !strings.HasSuffix({field_for_suffix}, {go_val}) {{");
1381                let _ = writeln!(
1382                    out_ref,
1383                    "\t\tt.Errorf(\"expected to end with %s, got %v\", {go_val}, {field_expr})"
1384                );
1385                let _ = writeln!(out_ref, "\t}}");
1386            }
1387        }
1388        "matches_regex" => {
1389            if let Some(expected) = &assertion.value {
1390                let go_val = json_to_go(expected);
1391                let field_for_regex = if is_optional
1392                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1393                {
1394                    format!("*{field_expr}")
1395                } else {
1396                    field_expr.clone()
1397                };
1398                let _ = writeln!(
1399                    out_ref,
1400                    "\tassert.Regexp(t, {go_val}, {field_for_regex}, \"expected value to match regex\")"
1401                );
1402            }
1403        }
1404        "not_error" => {
1405            // Already handled by the `if err != nil` check above.
1406        }
1407        "error" => {
1408            // Handled at the test function level.
1409        }
1410        other => {
1411            panic!("Go e2e generator: unsupported assertion type: {other}");
1412        }
1413    }
1414
1415    // If the assertion accesses an array element via [0], wrap the generated code in a
1416    // bounds check to prevent an index-out-of-range panic when the array is empty.
1417    if let Some(ref arr) = array_guard {
1418        if !assertion_buf.is_empty() {
1419            let _ = writeln!(out, "\tif len({arr}) > 0 {{");
1420            // Re-indent each line by one additional tab level.
1421            for line in assertion_buf.lines() {
1422                let _ = writeln!(out, "\t{line}");
1423            }
1424            let _ = writeln!(out, "\t}}");
1425        }
1426    } else {
1427        out.push_str(&assertion_buf);
1428    }
1429}
1430
1431/// Metadata about the return type of a Go method call for `method_result` assertions.
1432struct GoMethodCallInfo {
1433    /// The call expression string.
1434    call_expr: String,
1435    /// Whether the return type is a pointer (needs `*` dereference for value comparison).
1436    is_pointer: bool,
1437    /// Optional Go type cast to apply to numeric literal values in `equals` assertions
1438    /// (e.g., `"uint"` so that `0` becomes `uint(0)` to match `*uint` deref type).
1439    value_cast: Option<&'static str>,
1440}
1441
1442/// Build a Go call expression for a `method_result` assertion on a tree-sitter Tree.
1443///
1444/// Maps method names to the appropriate Go function calls, matching the Go binding API
1445/// in `packages/go/binding.go`. Returns a [`GoMethodCallInfo`] describing the call and
1446/// its return type characteristics.
1447///
1448/// Return types by method:
1449/// - `has_error_nodes`, `contains_node_type` → `*bool` (pointer)
1450/// - `error_count` → `*uint` (pointer, value_cast = "uint")
1451/// - `tree_to_sexp` → `*string` (pointer)
1452/// - `root_node_type` → `string` via `RootNodeInfo(tree).Kind` (value)
1453/// - `named_children_count` → `uint` via `RootNodeInfo(tree).NamedChildCount` (value, value_cast = "uint")
1454/// - `find_nodes_by_type` → `*[]NodeInfo` (pointer to slice)
1455/// - `run_query` → `(*[]QueryMatch, error)` (pointer + error; use `is_error` check type)
1456fn build_go_method_call(
1457    result_var: &str,
1458    method_name: &str,
1459    args: Option<&serde_json::Value>,
1460    import_alias: &str,
1461) -> GoMethodCallInfo {
1462    match method_name {
1463        "root_node_type" => GoMethodCallInfo {
1464            call_expr: format!("{import_alias}.RootNodeInfo({result_var}).Kind"),
1465            is_pointer: false,
1466            value_cast: None,
1467        },
1468        "named_children_count" => GoMethodCallInfo {
1469            call_expr: format!("{import_alias}.RootNodeInfo({result_var}).NamedChildCount"),
1470            is_pointer: false,
1471            value_cast: Some("uint"),
1472        },
1473        "has_error_nodes" => GoMethodCallInfo {
1474            call_expr: format!("{import_alias}.TreeHasErrorNodes({result_var})"),
1475            is_pointer: true,
1476            value_cast: None,
1477        },
1478        "error_count" | "tree_error_count" => GoMethodCallInfo {
1479            call_expr: format!("{import_alias}.TreeErrorCount({result_var})"),
1480            is_pointer: true,
1481            value_cast: Some("uint"),
1482        },
1483        "tree_to_sexp" => GoMethodCallInfo {
1484            call_expr: format!("{import_alias}.TreeToSexp({result_var})"),
1485            is_pointer: true,
1486            value_cast: None,
1487        },
1488        "contains_node_type" => {
1489            let node_type = args
1490                .and_then(|a| a.get("node_type"))
1491                .and_then(|v| v.as_str())
1492                .unwrap_or("");
1493            GoMethodCallInfo {
1494                call_expr: format!("{import_alias}.TreeContainsNodeType({result_var}, \"{node_type}\")"),
1495                is_pointer: true,
1496                value_cast: None,
1497            }
1498        }
1499        "find_nodes_by_type" => {
1500            let node_type = args
1501                .and_then(|a| a.get("node_type"))
1502                .and_then(|v| v.as_str())
1503                .unwrap_or("");
1504            GoMethodCallInfo {
1505                call_expr: format!("{import_alias}.FindNodesByType({result_var}, \"{node_type}\")"),
1506                is_pointer: true,
1507                value_cast: None,
1508            }
1509        }
1510        "run_query" => {
1511            let query_source = args
1512                .and_then(|a| a.get("query_source"))
1513                .and_then(|v| v.as_str())
1514                .unwrap_or("");
1515            let language = args
1516                .and_then(|a| a.get("language"))
1517                .and_then(|v| v.as_str())
1518                .unwrap_or("");
1519            let query_lit = go_string_literal(query_source);
1520            let lang_lit = go_string_literal(language);
1521            // RunQuery returns (*[]QueryMatch, error) — use is_error check type.
1522            GoMethodCallInfo {
1523                call_expr: format!("{import_alias}.RunQuery({result_var}, {lang_lit}, {query_lit}, []byte(source))"),
1524                is_pointer: false,
1525                value_cast: None,
1526            }
1527        }
1528        other => {
1529            let method_pascal = other.to_upper_camel_case();
1530            GoMethodCallInfo {
1531                call_expr: format!("{result_var}.{method_pascal}()"),
1532                is_pointer: false,
1533                value_cast: None,
1534            }
1535        }
1536    }
1537}
1538
1539/// Convert a `serde_json::Value` to a Go literal string.
1540fn json_to_go(value: &serde_json::Value) -> String {
1541    match value {
1542        serde_json::Value::String(s) => go_string_literal(s),
1543        serde_json::Value::Bool(b) => b.to_string(),
1544        serde_json::Value::Number(n) => n.to_string(),
1545        serde_json::Value::Null => "nil".to_string(),
1546        // For complex types, serialize to JSON string and pass as literal.
1547        other => go_string_literal(&other.to_string()),
1548    }
1549}
1550
1551// ---------------------------------------------------------------------------
1552// Visitor generation
1553// ---------------------------------------------------------------------------
1554
1555/// Derive a unique, exported Go struct name for a visitor from a fixture ID.
1556///
1557/// E.g. `visitor_continue_default` → `visitorContinueDefault` (unexported, avoids
1558/// polluting the exported API of the test package while still being package-level).
1559fn visitor_struct_name(fixture_id: &str) -> String {
1560    use heck::ToUpperCamelCase;
1561    // Use UpperCamelCase so Go treats it as exported — required for method sets.
1562    format!("testVisitor{}", fixture_id.to_upper_camel_case())
1563}
1564
1565/// Emit a package-level Go struct declaration and all its visitor methods.
1566fn emit_go_visitor_struct(
1567    out: &mut String,
1568    struct_name: &str,
1569    visitor_spec: &crate::fixture::VisitorSpec,
1570    import_alias: &str,
1571) {
1572    let _ = writeln!(out, "type {struct_name} struct{{}}");
1573    for (method_name, action) in &visitor_spec.callbacks {
1574        emit_go_visitor_method(out, struct_name, method_name, action, import_alias);
1575    }
1576}
1577
1578/// Emit a Go visitor method for a callback action on the named struct.
1579fn emit_go_visitor_method(
1580    out: &mut String,
1581    struct_name: &str,
1582    method_name: &str,
1583    action: &CallbackAction,
1584    import_alias: &str,
1585) {
1586    let camel_method = method_to_camel(method_name);
1587    let params = match method_name {
1588        "visit_link" => format!("_ {import_alias}.NodeContext, href, text, title string"),
1589        "visit_image" => format!("_ {import_alias}.NodeContext, src, alt, title string"),
1590        "visit_heading" => format!("_ {import_alias}.NodeContext, level int, text, id string"),
1591        "visit_code_block" => format!("_ {import_alias}.NodeContext, lang, code string"),
1592        "visit_code_inline"
1593        | "visit_strong"
1594        | "visit_emphasis"
1595        | "visit_strikethrough"
1596        | "visit_underline"
1597        | "visit_subscript"
1598        | "visit_superscript"
1599        | "visit_mark"
1600        | "visit_button"
1601        | "visit_summary"
1602        | "visit_figcaption"
1603        | "visit_definition_term"
1604        | "visit_definition_description" => format!("_ {import_alias}.NodeContext, text string"),
1605        "visit_text" => format!("_ {import_alias}.NodeContext, text string"),
1606        "visit_list_item" => {
1607            format!("_ {import_alias}.NodeContext, ordered bool, marker, text string")
1608        }
1609        "visit_blockquote" => format!("_ {import_alias}.NodeContext, content string, depth int"),
1610        "visit_table_row" => format!("_ {import_alias}.NodeContext, cells []string, isHeader bool"),
1611        "visit_custom_element" => format!("_ {import_alias}.NodeContext, tagName, html string"),
1612        "visit_form" => format!("_ {import_alias}.NodeContext, actionUrl, method string"),
1613        "visit_input" => format!("_ {import_alias}.NodeContext, inputType, name, value string"),
1614        "visit_audio" | "visit_video" | "visit_iframe" => {
1615            format!("_ {import_alias}.NodeContext, src string")
1616        }
1617        "visit_details" => format!("_ {import_alias}.NodeContext, isOpen bool"),
1618        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1619            format!("_ {import_alias}.NodeContext, output string")
1620        }
1621        "visit_list_start" => format!("_ {import_alias}.NodeContext, ordered bool"),
1622        "visit_list_end" => format!("_ {import_alias}.NodeContext, ordered bool, output string"),
1623        _ => format!("_ {import_alias}.NodeContext"),
1624    };
1625
1626    let _ = writeln!(
1627        out,
1628        "func (v *{struct_name}) {camel_method}({params}) {import_alias}.VisitResult {{"
1629    );
1630    match action {
1631        CallbackAction::Skip => {
1632            let _ = writeln!(out, "\treturn {import_alias}.VisitResultSkip");
1633        }
1634        CallbackAction::Continue => {
1635            let _ = writeln!(out, "\treturn {import_alias}.VisitResultContinue");
1636        }
1637        CallbackAction::PreserveHtml => {
1638            let _ = writeln!(out, "\treturn {import_alias}.VisitResultPreserveHtml");
1639        }
1640        CallbackAction::Custom { output } => {
1641            let escaped = go_string_literal(output);
1642            let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped})");
1643        }
1644        CallbackAction::CustomTemplate { template } => {
1645            // Convert {var} placeholders to %s format verbs and collect arg names.
1646            // E.g. `QUOTE: "{text}"` → fmt.Sprintf("QUOTE: \"%s\"", text)
1647            let (fmt_str, fmt_args) = template_to_sprintf(template);
1648            let escaped_fmt = go_string_literal(&fmt_str);
1649            if fmt_args.is_empty() {
1650                let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped_fmt})");
1651            } else {
1652                let args_str = fmt_args.join(", ");
1653                let _ = writeln!(
1654                    out,
1655                    "\treturn {import_alias}.VisitResultCustom(fmt.Sprintf({escaped_fmt}, {args_str}))"
1656                );
1657            }
1658        }
1659    }
1660    let _ = writeln!(out, "}}");
1661}
1662
1663/// Convert a `{var}` template string into a `fmt.Sprintf` format string and argument list.
1664///
1665/// For example, `QUOTE: "{text}"` becomes `("QUOTE: \"%s\"", vec!["text"])`.
1666fn template_to_sprintf(template: &str) -> (String, Vec<String>) {
1667    let mut fmt_str = String::new();
1668    let mut args: Vec<String> = Vec::new();
1669    let mut chars = template.chars().peekable();
1670    while let Some(c) = chars.next() {
1671        if c == '{' {
1672            // Collect placeholder name until '}'.
1673            let mut name = String::new();
1674            for inner in chars.by_ref() {
1675                if inner == '}' {
1676                    break;
1677                }
1678                name.push(inner);
1679            }
1680            fmt_str.push_str("%s");
1681            args.push(name);
1682        } else {
1683            fmt_str.push(c);
1684        }
1685    }
1686    (fmt_str, args)
1687}
1688
1689/// Convert snake_case method names to Go camelCase.
1690fn method_to_camel(snake: &str) -> String {
1691    use heck::ToUpperCamelCase;
1692    snake.to_upper_camel_case()
1693}
1694
1695#[cfg(test)]
1696mod tests {
1697    use super::*;
1698    use crate::config::{CallConfig, E2eConfig};
1699    use crate::field_access::FieldResolver;
1700    use crate::fixture::{Assertion, Fixture};
1701
1702    fn make_fixture(id: &str) -> Fixture {
1703        Fixture {
1704            id: id.to_string(),
1705            category: None,
1706            description: "test fixture".to_string(),
1707            tags: vec![],
1708            skip: None,
1709            call: None,
1710            input: serde_json::Value::Null,
1711            mock_response: None,
1712            source: String::new(),
1713            http: None,
1714            assertions: vec![Assertion {
1715                assertion_type: "not_error".to_string(),
1716                field: None,
1717                value: None,
1718                values: None,
1719                method: None,
1720                args: None,
1721                check: None,
1722            }],
1723            visitor: None,
1724        }
1725    }
1726
1727    /// snake_case function names in `[e2e.call]` must be routed through `to_go_name`
1728    /// so the emitted Go call uses the idiomatic CamelCase (e.g. `CleanExtractedText`
1729    /// instead of `clean_extracted_text`).
1730    #[test]
1731    fn test_go_method_name_uses_go_casing() {
1732        let e2e_config = E2eConfig {
1733            call: CallConfig {
1734                function: "clean_extracted_text".to_string(),
1735                module: "github.com/example/mylib".to_string(),
1736                result_var: "result".to_string(),
1737                r#async: false,
1738                path: None,
1739                method: None,
1740                args: vec![],
1741                overrides: std::collections::HashMap::new(),
1742                returns_result: true,
1743                returns_void: false,
1744                skip_languages: vec![],
1745            },
1746            ..E2eConfig::default()
1747        };
1748
1749        let fixture = make_fixture("basic_text");
1750        let resolver = FieldResolver::new(
1751            &std::collections::HashMap::new(),
1752            &std::collections::HashSet::new(),
1753            &std::collections::HashSet::new(),
1754            &std::collections::HashSet::new(),
1755        );
1756        let mut out = String::new();
1757        render_test_function(&mut out, &fixture, "kreuzberg", &resolver, &e2e_config);
1758
1759        assert!(
1760            out.contains("kreuzberg.CleanExtractedText("),
1761            "expected Go-cased method name 'CleanExtractedText', got:\n{out}"
1762        );
1763        assert!(
1764            !out.contains("kreuzberg.clean_extracted_text("),
1765            "must not emit raw snake_case method name, got:\n{out}"
1766        );
1767    }
1768}