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