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_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToUpperCamelCase;
11use std::fmt::Write as FmtWrite;
12use std::path::PathBuf;
13
14use super::E2eCodegen;
15
16/// Go e2e code generator.
17pub struct GoCodegen;
18
19impl E2eCodegen for GoCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        _alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let lang = self.language_name();
27        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
28
29        let mut files = Vec::new();
30
31        // Resolve call config with overrides.
32        let call = &e2e_config.call;
33        let overrides = call.overrides.get(lang);
34        let module_path = overrides
35            .and_then(|o| o.module.as_ref())
36            .cloned()
37            .unwrap_or_else(|| call.module.clone());
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.clone());
42        let import_alias = overrides
43            .and_then(|o| o.alias.as_ref())
44            .cloned()
45            .unwrap_or_else(|| "pkg".to_string());
46        let result_var = &call.result_var;
47
48        // Resolve package config.
49        let go_pkg = e2e_config.resolve_package("go");
50        let go_module_path = go_pkg
51            .as_ref()
52            .and_then(|p| p.module.as_ref())
53            .cloned()
54            .unwrap_or_else(|| module_path.clone());
55        let replace_path = go_pkg.as_ref().and_then(|p| p.path.as_ref()).cloned();
56        let go_version = go_pkg
57            .as_ref()
58            .and_then(|p| p.version.as_ref())
59            .cloned()
60            .unwrap_or_else(|| "v0.0.0".to_string());
61        let field_resolver = FieldResolver::new(
62            &e2e_config.fields,
63            &e2e_config.fields_optional,
64            &e2e_config.result_fields,
65            &e2e_config.fields_array,
66        );
67
68        // Generate go.mod. In registry mode, omit the `replace` directive so the
69        // module is fetched from the Go module proxy.
70        let effective_replace = match e2e_config.dep_mode {
71            crate::config::DependencyMode::Registry => None,
72            crate::config::DependencyMode::Local => replace_path.as_deref().map(String::from),
73        };
74        files.push(GeneratedFile {
75            path: output_base.join("go.mod"),
76            content: render_go_mod(&go_module_path, effective_replace.as_deref(), &go_version),
77            generated_header: false,
78        });
79
80        // Generate test files per category.
81        for group in groups {
82            let active: Vec<&Fixture> = group
83                .fixtures
84                .iter()
85                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
86                .collect();
87
88            if active.is_empty() {
89                continue;
90            }
91
92            let filename = format!("{}_test.go", sanitize_filename(&group.category));
93            let content = render_test_file(
94                &group.category,
95                &active,
96                &module_path,
97                &import_alias,
98                &function_name,
99                result_var,
100                &e2e_config.call.args,
101                &field_resolver,
102                e2e_config,
103            );
104            files.push(GeneratedFile {
105                path: output_base.join(filename),
106                content,
107                generated_header: true,
108            });
109        }
110
111        Ok(files)
112    }
113
114    fn language_name(&self) -> &'static str {
115        "go"
116    }
117}
118
119fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
120    let mut out = String::new();
121    let _ = writeln!(out, "module e2e_go");
122    let _ = writeln!(out);
123    let _ = writeln!(out, "go 1.26");
124    let _ = writeln!(out);
125    let _ = writeln!(out, "require {go_module_path} {version}");
126
127    if let Some(path) = replace_path {
128        let _ = writeln!(out);
129        let _ = writeln!(out, "replace {go_module_path} => {path}");
130    }
131
132    out
133}
134
135#[allow(clippy::too_many_arguments)]
136fn render_test_file(
137    category: &str,
138    fixtures: &[&Fixture],
139    go_module_path: &str,
140    import_alias: &str,
141    function_name: &str,
142    result_var: &str,
143    args: &[crate::config::ArgMapping],
144    field_resolver: &FieldResolver,
145    e2e_config: &crate::config::E2eConfig,
146) -> String {
147    let mut out = String::new();
148
149    // Go convention: generated file marker must appear before the package declaration.
150    let _ = writeln!(out, "// Code generated by alef. DO NOT EDIT.");
151    let _ = writeln!(out);
152
153    // Determine if we need the "os" import (mock_url args).
154    let needs_os = args.iter().any(|a| a.arg_type == "mock_url");
155
156    // Determine if we need "encoding/json" (handle args with non-null config).
157    let needs_json = args.iter().any(|a| a.arg_type == "handle")
158        && fixtures.iter().any(|f| {
159            args.iter().filter(|a| a.arg_type == "handle").any(|a| {
160                let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
161                !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
162            })
163        });
164
165    // Determine if we need the "fmt" import (CustomTemplate visitor actions with placeholders).
166    let needs_fmt = fixtures.iter().any(|f| {
167        f.visitor.as_ref().is_some_and(|v| {
168            v.callbacks.values().any(|action| {
169                if let CallbackAction::CustomTemplate { template } = action {
170                    template.contains('{')
171                } else {
172                    false
173                }
174            })
175        })
176    });
177
178    // Determine if we need the "strings" import.
179    // Only count assertions whose fields are actually valid for the result type.
180    let needs_strings = fixtures.iter().any(|f| {
181        f.assertions.iter().any(|a| {
182            let type_needs_strings = if a.assertion_type == "equals" {
183                // equals with string values needs strings.TrimSpace
184                a.value.as_ref().is_some_and(|v| v.is_string())
185            } else {
186                matches!(
187                    a.assertion_type.as_str(),
188                    "contains" | "contains_all" | "not_contains" | "starts_with"
189                )
190            };
191            let field_valid = a
192                .field
193                .as_ref()
194                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
195                .unwrap_or(true);
196            type_needs_strings && field_valid
197        })
198    });
199
200    // Determine if we need the testify assert import (used for count_min, count_max).
201    let needs_assert = fixtures.iter().any(|f| {
202        f.assertions.iter().any(|a| {
203            let field_valid = a
204                .field
205                .as_ref()
206                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
207                .unwrap_or(true);
208            matches!(a.assertion_type.as_str(), "count_min" | "count_max") && field_valid
209        })
210    });
211
212    let _ = writeln!(out, "// E2e tests for category: {category}");
213    let _ = writeln!(out, "package e2e_test");
214    let _ = writeln!(out);
215    let _ = writeln!(out, "import (");
216    if needs_json {
217        let _ = writeln!(out, "\t\"encoding/json\"");
218    }
219    if needs_fmt {
220        let _ = writeln!(out, "\t\"fmt\"");
221    }
222    if needs_os {
223        let _ = writeln!(out, "\t\"os\"");
224    }
225    if needs_strings {
226        let _ = writeln!(out, "\t\"strings\"");
227    }
228    let _ = writeln!(out, "\t\"testing\"");
229    if needs_assert {
230        let _ = writeln!(out);
231        let _ = writeln!(out, "\t\"github.com/stretchr/testify/assert\"");
232    }
233    let _ = writeln!(out);
234    let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
235    let _ = writeln!(out, ")");
236    let _ = writeln!(out);
237
238    // Emit package-level visitor structs (must be outside any function in Go).
239    for fixture in fixtures.iter() {
240        if let Some(visitor_spec) = &fixture.visitor {
241            let struct_name = visitor_struct_name(&fixture.id);
242            emit_go_visitor_struct(&mut out, &struct_name, visitor_spec, import_alias);
243            let _ = writeln!(out);
244        }
245    }
246
247    for (i, fixture) in fixtures.iter().enumerate() {
248        render_test_function(
249            &mut out,
250            fixture,
251            import_alias,
252            function_name,
253            result_var,
254            args,
255            field_resolver,
256            e2e_config,
257        );
258        if i + 1 < fixtures.len() {
259            let _ = writeln!(out);
260        }
261    }
262
263    // Clean up trailing newlines.
264    while out.ends_with("\n\n") {
265        out.pop();
266    }
267    if !out.ends_with('\n') {
268        out.push('\n');
269    }
270    out
271}
272
273#[allow(clippy::too_many_arguments)]
274fn render_test_function(
275    out: &mut String,
276    fixture: &Fixture,
277    import_alias: &str,
278    function_name: &str,
279    result_var: &str,
280    args: &[crate::config::ArgMapping],
281    field_resolver: &FieldResolver,
282    e2e_config: &crate::config::E2eConfig,
283) {
284    let fn_name = fixture.id.to_upper_camel_case();
285    let description = &fixture.description;
286
287    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
288
289    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, import_alias, e2e_config, &fixture.id);
290
291    // Build visitor if present — struct is at package level, just instantiate here.
292    let mut visitor_arg = String::new();
293    if fixture.visitor.is_some() {
294        let struct_name = visitor_struct_name(&fixture.id);
295        setup_lines.push(format!("visitor := &{struct_name}{{}}"));
296        visitor_arg = "visitor".to_string();
297    }
298
299    let final_args = if visitor_arg.is_empty() {
300        args_str
301    } else {
302        format!("{args_str}, {visitor_arg}")
303    };
304
305    let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
306    let _ = writeln!(out, "\t// {description}");
307
308    for line in &setup_lines {
309        let _ = writeln!(out, "\t{line}");
310    }
311
312    if expects_error {
313        let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({final_args})");
314        let _ = writeln!(out, "\tif err == nil {{");
315        let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
316        let _ = writeln!(out, "\t}}");
317        let _ = writeln!(out, "}}");
318        return;
319    }
320
321    // Check if any assertion actually uses the result variable.
322    // If all assertions are skipped (field not on result type), use `_` to avoid
323    // Go's "declared and not used" compile error.
324    let has_usable_assertion = fixture.assertions.iter().any(|a| {
325        if a.assertion_type == "not_error" || a.assertion_type == "error" {
326            return false;
327        }
328        match &a.field {
329            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
330            _ => true,
331        }
332    });
333
334    let result_binding = if has_usable_assertion {
335        result_var.to_string()
336    } else {
337        "_".to_string()
338    };
339
340    // Normal call: check for error assertions first.
341    let _ = writeln!(
342        out,
343        "\t{result_binding}, err := {import_alias}.{function_name}({final_args})"
344    );
345    let _ = writeln!(out, "\tif err != nil {{");
346    let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
347    let _ = writeln!(out, "\t}}");
348
349    // Collect optional fields referenced by assertions and emit nil-safe
350    // dereference blocks so that assertions can use plain string locals.
351    // Only dereference fields whose assertion values are strings (or that are
352    // used in string-oriented assertions like equals/contains with string values).
353    let mut optional_locals: std::collections::HashMap<String, String> = std::collections::HashMap::new();
354    for assertion in &fixture.assertions {
355        if let Some(f) = &assertion.field {
356            if !f.is_empty() {
357                let resolved = field_resolver.resolve(f);
358                if field_resolver.is_optional(resolved) && !optional_locals.contains_key(f.as_str()) {
359                    // Only create deref locals for string-valued fields.
360                    // Detect by checking if the assertion value is a string.
361                    let is_string_field = assertion.value.as_ref().is_some_and(|v| v.is_string());
362                    if !is_string_field {
363                        // Non-string optional fields (e.g., *uint64) are handled
364                        // by nil guards in render_assertion instead.
365                        continue;
366                    }
367                    let field_expr = field_resolver.accessor(f, "go", result_var);
368                    let local_var = go_local_name(&resolved.replace(['.', '[', ']'], "_"));
369                    if field_resolver.has_map_access(f) {
370                        // Go map access returns a value type (string), not a pointer.
371                        // Use the value directly — empty string means not present.
372                        let _ = writeln!(out, "\t{local_var} := {field_expr}");
373                    } else {
374                        let _ = writeln!(out, "\tvar {local_var} string");
375                        let _ = writeln!(out, "\tif {field_expr} != nil {{");
376                        let _ = writeln!(out, "\t\t{local_var} = *{field_expr}");
377                        let _ = writeln!(out, "\t}}");
378                    }
379                    optional_locals.insert(f.clone(), local_var);
380                }
381            }
382        }
383    }
384
385    // Emit assertions, wrapping in nil guards when an intermediate path segment is optional.
386    for assertion in &fixture.assertions {
387        if let Some(f) = &assertion.field {
388            if !f.is_empty() && !optional_locals.contains_key(f.as_str()) {
389                // Check if any prefix of the dotted path is optional (pointer in Go).
390                // e.g., "document.nodes" — if "document" is optional, guard the whole block.
391                let parts: Vec<&str> = f.split('.').collect();
392                let mut guard_expr: Option<String> = None;
393                for i in 1..parts.len() {
394                    let prefix = parts[..i].join(".");
395                    let resolved_prefix = field_resolver.resolve(&prefix);
396                    if field_resolver.is_optional(resolved_prefix) {
397                        let accessor = field_resolver.accessor(&prefix, "go", result_var);
398                        guard_expr = Some(accessor);
399                        break;
400                    }
401                }
402                if let Some(guard) = guard_expr {
403                    // Only emit nil guard if the assertion will actually produce code
404                    // (not just a skip comment), to avoid empty branches (SA9003).
405                    if field_resolver.is_valid_for_result(f) {
406                        let _ = writeln!(out, "\tif {guard} != nil {{");
407                        // Render into a temporary buffer so we can re-indent by one
408                        // tab level to sit inside the nil-guard block.
409                        let mut nil_buf = String::new();
410                        render_assertion(&mut nil_buf, assertion, result_var, field_resolver, &optional_locals);
411                        for line in nil_buf.lines() {
412                            let _ = writeln!(out, "\t{line}");
413                        }
414                        let _ = writeln!(out, "\t}}");
415                    } else {
416                        render_assertion(out, assertion, result_var, field_resolver, &optional_locals);
417                    }
418                    continue;
419                }
420            }
421        }
422        render_assertion(out, assertion, result_var, field_resolver, &optional_locals);
423    }
424
425    let _ = writeln!(out, "}}");
426}
427
428/// Build setup lines (e.g. handle creation) and the argument list for the function call.
429///
430/// Returns `(setup_lines, args_string)`.
431fn build_args_and_setup(
432    input: &serde_json::Value,
433    args: &[crate::config::ArgMapping],
434    import_alias: &str,
435    e2e_config: &crate::config::E2eConfig,
436    fixture_id: &str,
437) -> (Vec<String>, String) {
438    use heck::ToUpperCamelCase;
439
440    if args.is_empty() {
441        return (Vec::new(), json_to_go(input));
442    }
443
444    let overrides = e2e_config.call.overrides.get("go");
445    let options_type = overrides.and_then(|o| o.options_type.as_deref());
446
447    let mut setup_lines: Vec<String> = Vec::new();
448    let mut parts: Vec<String> = Vec::new();
449
450    for arg in args {
451        if arg.arg_type == "mock_url" {
452            setup_lines.push(format!(
453                "{} := os.Getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
454                arg.name,
455            ));
456            parts.push(arg.name.clone());
457            continue;
458        }
459
460        if arg.arg_type == "handle" {
461            // Generate a CreateEngine (or equivalent) call and pass the variable.
462            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
463            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
464            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
465            if config_value.is_null()
466                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
467            {
468                setup_lines.push(format!(
469                    "{name}, createErr := {import_alias}.{constructor_name}(nil)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}",
470                    name = arg.name,
471                ));
472            } else {
473                let json_str = serde_json::to_string(config_value).unwrap_or_default();
474                let go_literal = go_string_literal(&json_str);
475                let name = &arg.name;
476                setup_lines.push(format!(
477                    "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}}"
478                ));
479                setup_lines.push(format!(
480                    "{name}, createErr := {import_alias}.{constructor_name}(&{name}Config)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}"
481                ));
482            }
483            parts.push(arg.name.clone());
484            continue;
485        }
486
487        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
488        let val = input.get(field);
489        match val {
490            None | Some(serde_json::Value::Null) if arg.optional => {
491                // Optional arg with no fixture value: skip entirely.
492                continue;
493            }
494            None | Some(serde_json::Value::Null) => {
495                // Required arg with no fixture value: pass a language-appropriate default.
496                let default_val = match arg.arg_type.as_str() {
497                    "string" => "\"\"".to_string(),
498                    "int" | "integer" => "0".to_string(),
499                    "float" | "number" => "0.0".to_string(),
500                    "bool" | "boolean" => "false".to_string(),
501                    _ => "nil".to_string(),
502                };
503                parts.push(default_val);
504            }
505            Some(v) => {
506                // For json_object args with options_type: construct using functional options.
507                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
508                    if let Some(obj) = v.as_object() {
509                        let with_calls: Vec<String> = obj
510                            .iter()
511                            .map(|(k, vv)| {
512                                let func_name = format!("With{}{}", opts_type, k.to_upper_camel_case());
513                                let go_val = json_to_go(vv);
514                                format!("htmd.{func_name}({go_val})")
515                            })
516                            .collect();
517                        let new_fn = format!("New{opts_type}");
518                        parts.push(format!("htmd.{new_fn}({})", with_calls.join(", ")));
519                        continue;
520                    }
521                }
522                parts.push(json_to_go(v));
523            }
524        }
525    }
526
527    (setup_lines, parts.join(", "))
528}
529
530fn render_assertion(
531    out: &mut String,
532    assertion: &Assertion,
533    result_var: &str,
534    field_resolver: &FieldResolver,
535    optional_locals: &std::collections::HashMap<String, String>,
536) {
537    // Skip assertions on fields that don't exist on the result type.
538    if let Some(f) = &assertion.field {
539        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
540            let _ = writeln!(out, "\t// skipped: field '{f}' not available on result type");
541            return;
542        }
543    }
544
545    let field_expr = match &assertion.field {
546        Some(f) if !f.is_empty() => {
547            // Use the local variable if the field was dereferenced above.
548            if let Some(local_var) = optional_locals.get(f.as_str()) {
549                local_var.clone()
550            } else {
551                field_resolver.accessor(f, "go", result_var)
552            }
553        }
554        _ => result_var.to_string(),
555    };
556
557    // Check if the field (after resolution) is optional, which means it's a pointer in Go.
558    // Also check if a `.length` suffix's parent is optional (e.g., metadata.headings.length
559    // where metadata.headings is optional → len() needs dereference).
560    let is_optional = assertion
561        .field
562        .as_ref()
563        .map(|f| {
564            let resolved = field_resolver.resolve(f);
565            let check_path = resolved
566                .strip_suffix(".length")
567                .or_else(|| resolved.strip_suffix(".count"))
568                .or_else(|| resolved.strip_suffix(".size"))
569                .unwrap_or(resolved);
570            field_resolver.is_optional(check_path) && !optional_locals.contains_key(f.as_str())
571        })
572        .unwrap_or(false);
573
574    // When field_expr is `len(X)` and X is an optional (pointer) field, rewrite to `len(*X)`
575    // and we'll wrap with a nil guard in the assertion handlers.
576    let field_expr = if is_optional && field_expr.starts_with("len(") && field_expr.ends_with(')') {
577        let inner = &field_expr[4..field_expr.len() - 1];
578        format!("len(*{inner})")
579    } else {
580        field_expr
581    };
582    // Build the nil-guard expression for the inner pointer (without len wrapper).
583    let nil_guard_expr = if is_optional && field_expr.starts_with("len(*") {
584        Some(field_expr[5..field_expr.len() - 1].to_string())
585    } else {
586        None
587    };
588
589    // For optional non-string fields that weren't dereferenced into locals,
590    // we need to dereference the pointer in comparisons.
591    let deref_field_expr = if is_optional && !field_expr.starts_with("len(") {
592        format!("*{field_expr}")
593    } else {
594        field_expr.clone()
595    };
596
597    // Detect array element access (e.g., `result.Assets[0].ContentHash`).
598    // When the field_expr contains `[0]`, we must guard against an out-of-bounds
599    // panic by checking that the array is non-empty first.
600    // Extract the array slice expression (everything before `[0]`).
601    let array_guard: Option<String> = if let Some(idx) = field_expr.find("[0]") {
602        let array_expr = &field_expr[..idx];
603        Some(array_expr.to_string())
604    } else {
605        None
606    };
607
608    // Render the assertion into a temporary buffer first, then wrap with the array
609    // bounds guard (if needed) by adding one extra level of indentation.
610    let mut assertion_buf = String::new();
611    let out_ref = &mut assertion_buf;
612
613    match assertion.assertion_type.as_str() {
614        "equals" => {
615            if let Some(expected) = &assertion.value {
616                let go_val = json_to_go(expected);
617                // For string equality, trim whitespace to handle trailing newlines from the converter.
618                if expected.is_string() {
619                    // Wrap field expression with strings.TrimSpace() for string comparisons.
620                    let trimmed_field = if is_optional && !field_expr.starts_with("len(") {
621                        format!("strings.TrimSpace(*{field_expr})")
622                    } else {
623                        format!("strings.TrimSpace({field_expr})")
624                    };
625                    if is_optional && !field_expr.starts_with("len(") {
626                        let _ = writeln!(out_ref, "\tif {field_expr} != nil && {trimmed_field} != {go_val} {{");
627                    } else {
628                        let _ = writeln!(out_ref, "\tif {trimmed_field} != {go_val} {{");
629                    }
630                } else if is_optional && !field_expr.starts_with("len(") {
631                    let _ = writeln!(out_ref, "\tif {field_expr} != nil && {deref_field_expr} != {go_val} {{");
632                } else {
633                    let _ = writeln!(out_ref, "\tif {field_expr} != {go_val} {{");
634                }
635                let _ = writeln!(out_ref, "\t\tt.Errorf(\"equals mismatch: got %v\", {field_expr})");
636                let _ = writeln!(out_ref, "\t}}");
637            }
638        }
639        "contains" => {
640            if let Some(expected) = &assertion.value {
641                let go_val = json_to_go(expected);
642                let field_for_contains = if is_optional
643                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
644                {
645                    format!("string(*{field_expr})")
646                } else {
647                    format!("string({field_expr})")
648                };
649                let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
650                let _ = writeln!(
651                    out_ref,
652                    "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
653                );
654                let _ = writeln!(out_ref, "\t}}");
655            }
656        }
657        "contains_all" => {
658            if let Some(values) = &assertion.values {
659                for val in values {
660                    let go_val = json_to_go(val);
661                    let field_for_contains = if is_optional
662                        && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
663                    {
664                        format!("string(*{field_expr})")
665                    } else {
666                        format!("string({field_expr})")
667                    };
668                    let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
669                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
670                    let _ = writeln!(out_ref, "\t}}");
671                }
672            }
673        }
674        "not_contains" => {
675            if let Some(expected) = &assertion.value {
676                let go_val = json_to_go(expected);
677                let field_for_contains = if is_optional
678                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
679                {
680                    format!("string(*{field_expr})")
681                } else {
682                    format!("string({field_expr})")
683                };
684                let _ = writeln!(out_ref, "\tif strings.Contains({field_for_contains}, {go_val}) {{");
685                let _ = writeln!(
686                    out_ref,
687                    "\t\tt.Errorf(\"expected NOT to contain %s, got %v\", {go_val}, {field_expr})"
688                );
689                let _ = writeln!(out_ref, "\t}}");
690            }
691        }
692        "not_empty" => {
693            if is_optional {
694                let _ = writeln!(out_ref, "\tif {field_expr} == nil || len(*{field_expr}) == 0 {{");
695            } else {
696                let _ = writeln!(out_ref, "\tif len({field_expr}) == 0 {{");
697            }
698            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected non-empty value\")");
699            let _ = writeln!(out_ref, "\t}}");
700        }
701        "is_empty" => {
702            if is_optional {
703                let _ = writeln!(out_ref, "\tif {field_expr} != nil && len(*{field_expr}) != 0 {{");
704            } else {
705                let _ = writeln!(out_ref, "\tif len({field_expr}) != 0 {{");
706            }
707            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected empty value, got %v\", {field_expr})");
708            let _ = writeln!(out_ref, "\t}}");
709        }
710        "contains_any" => {
711            if let Some(values) = &assertion.values {
712                let field_for_contains = if is_optional
713                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
714                {
715                    format!("*{field_expr}")
716                } else {
717                    field_expr.clone()
718                };
719                let _ = writeln!(out_ref, "\t{{");
720                let _ = writeln!(out_ref, "\t\tfound := false");
721                for val in values {
722                    let go_val = json_to_go(val);
723                    let _ = writeln!(
724                        out_ref,
725                        "\t\tif strings.Contains({field_for_contains}, {go_val}) {{ found = true }}"
726                    );
727                }
728                let _ = writeln!(out_ref, "\t\tif !found {{");
729                let _ = writeln!(
730                    out_ref,
731                    "\t\t\tt.Errorf(\"expected to contain at least one of the specified values\")"
732                );
733                let _ = writeln!(out_ref, "\t\t}}");
734                let _ = writeln!(out_ref, "\t}}");
735            }
736        }
737        "greater_than" => {
738            if let Some(val) = &assertion.value {
739                let go_val = json_to_go(val);
740                // Use `< N+1` instead of `<= N` to avoid golangci-lint sloppyLen
741                // warning when N is 0 (len(x) <= 0 → len(x) < 1).
742                if let Some(n) = val.as_u64() {
743                    let next = n + 1;
744                    let _ = writeln!(out_ref, "\tif {field_expr} < {next} {{");
745                } else {
746                    let _ = writeln!(out_ref, "\tif {field_expr} <= {go_val} {{");
747                }
748                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
749                let _ = writeln!(out_ref, "\t}}");
750            }
751        }
752        "less_than" => {
753            if let Some(val) = &assertion.value {
754                let go_val = json_to_go(val);
755                let _ = writeln!(out_ref, "\tif {field_expr} >= {go_val} {{");
756                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected < {go_val}, got %v\", {field_expr})");
757                let _ = writeln!(out_ref, "\t}}");
758            }
759        }
760        "greater_than_or_equal" => {
761            if let Some(val) = &assertion.value {
762                let go_val = json_to_go(val);
763                if let Some(ref guard) = nil_guard_expr {
764                    let _ = writeln!(out_ref, "\tif {guard} != nil {{");
765                    let _ = writeln!(out_ref, "\t\tif {field_expr} < {go_val} {{");
766                    let _ = writeln!(
767                        out_ref,
768                        "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})"
769                    );
770                    let _ = writeln!(out_ref, "\t\t}}");
771                    let _ = writeln!(out_ref, "\t}}");
772                } else {
773                    let _ = writeln!(out_ref, "\tif {field_expr} < {go_val} {{");
774                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})");
775                    let _ = writeln!(out_ref, "\t}}");
776                }
777            }
778        }
779        "less_than_or_equal" => {
780            if let Some(val) = &assertion.value {
781                let go_val = json_to_go(val);
782                let _ = writeln!(out_ref, "\tif {field_expr} > {go_val} {{");
783                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected <= {go_val}, got %v\", {field_expr})");
784                let _ = writeln!(out_ref, "\t}}");
785            }
786        }
787        "starts_with" => {
788            if let Some(expected) = &assertion.value {
789                let go_val = json_to_go(expected);
790                let field_for_prefix = if is_optional
791                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
792                {
793                    format!("string(*{field_expr})")
794                } else {
795                    format!("string({field_expr})")
796                };
797                let _ = writeln!(out_ref, "\tif !strings.HasPrefix({field_for_prefix}, {go_val}) {{");
798                let _ = writeln!(
799                    out_ref,
800                    "\t\tt.Errorf(\"expected to start with %s, got %v\", {go_val}, {field_expr})"
801                );
802                let _ = writeln!(out_ref, "\t}}");
803            }
804        }
805        "count_min" => {
806            if let Some(val) = &assertion.value {
807                if let Some(n) = val.as_u64() {
808                    if is_optional {
809                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
810                        let _ = writeln!(
811                            out_ref,
812                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected at least {n} elements\")"
813                        );
814                        let _ = writeln!(out_ref, "\t}}");
815                    } else {
816                        let _ = writeln!(
817                            out_ref,
818                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected at least {n} elements\")"
819                        );
820                    }
821                }
822            }
823        }
824        "count_equals" => {
825            if let Some(val) = &assertion.value {
826                if let Some(n) = val.as_u64() {
827                    if is_optional {
828                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
829                        let _ = writeln!(
830                            out_ref,
831                            "\t\tassert.Equal(t, len(*{field_expr}), {n}, \"expected exactly {n} elements\")"
832                        );
833                        let _ = writeln!(out_ref, "\t}}");
834                    } else {
835                        let _ = writeln!(
836                            out_ref,
837                            "\tassert.Equal(t, len({field_expr}), {n}, \"expected exactly {n} elements\")"
838                        );
839                    }
840                }
841            }
842        }
843        "is_true" => {
844            if is_optional {
845                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
846                let _ = writeln!(out_ref, "\t\tassert.True(t, *{field_expr}, \"expected true\")");
847                let _ = writeln!(out_ref, "\t}}");
848            } else {
849                let _ = writeln!(out_ref, "\tassert.True(t, {field_expr}, \"expected true\")");
850            }
851        }
852        "not_error" => {
853            // Already handled by the `if err != nil` check above.
854        }
855        "error" => {
856            // Handled at the test function level.
857        }
858        other => {
859            let _ = writeln!(out_ref, "\t// TODO: unsupported assertion type: {other}");
860        }
861    }
862
863    // If the assertion accesses an array element via [0], wrap the generated code in a
864    // bounds check to prevent an index-out-of-range panic when the array is empty.
865    if let Some(ref arr) = array_guard {
866        if !assertion_buf.is_empty() {
867            let _ = writeln!(out, "\tif len({arr}) > 0 {{");
868            // Re-indent each line by one additional tab level.
869            for line in assertion_buf.lines() {
870                let _ = writeln!(out, "\t{line}");
871            }
872            let _ = writeln!(out, "\t}}");
873        }
874    } else {
875        out.push_str(&assertion_buf);
876    }
877}
878
879/// Go common initialisms — words that must be all-caps in Go names.
880/// Sourced from revive's var-naming rule (github.com/mgechev/revive).
881const GO_INITIALISMS: &[&str] = &[
882    "ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IDS", "IP", "JSON",
883    "LHS", "QPS", "RAM", "RHS", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "UID", "UUID",
884    "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS",
885];
886
887/// Convert a snake_case field name to a Go-idiomatic local variable name.
888/// Splits on `_`, applies Go initialism rules (HTML, URL, ID, etc.),
889/// and joins as lowerCamelCase.
890fn go_local_name(snake: &str) -> String {
891    let words: Vec<&str> = snake.split('_').filter(|w| !w.is_empty()).collect();
892    if words.is_empty() {
893        return String::new();
894    }
895    let mut result = String::new();
896    for (i, word) in words.iter().enumerate() {
897        let upper = word.to_uppercase();
898        if GO_INITIALISMS.contains(&upper.as_str()) {
899            if i == 0 {
900                // First word of a local var → all lowercase initialism
901                result.push_str(&upper.to_lowercase());
902            } else {
903                result.push_str(&upper);
904            }
905        } else if i == 0 {
906            // First word → all lowercase
907            result.push_str(&word.to_lowercase());
908        } else {
909            // Subsequent words → capitalize first letter
910            let mut chars = word.chars();
911            if let Some(c) = chars.next() {
912                result.extend(c.to_uppercase());
913                result.push_str(&chars.as_str().to_lowercase());
914            }
915        }
916    }
917    result
918}
919
920/// Convert a `serde_json::Value` to a Go literal string.
921fn json_to_go(value: &serde_json::Value) -> String {
922    match value {
923        serde_json::Value::String(s) => go_string_literal(s),
924        serde_json::Value::Bool(b) => b.to_string(),
925        serde_json::Value::Number(n) => n.to_string(),
926        serde_json::Value::Null => "nil".to_string(),
927        // For complex types, serialize to JSON string and pass as literal.
928        other => go_string_literal(&other.to_string()),
929    }
930}
931
932// ---------------------------------------------------------------------------
933// Visitor generation
934// ---------------------------------------------------------------------------
935
936/// Derive a unique, exported Go struct name for a visitor from a fixture ID.
937///
938/// E.g. `visitor_continue_default` → `visitorContinueDefault` (unexported, avoids
939/// polluting the exported API of the test package while still being package-level).
940fn visitor_struct_name(fixture_id: &str) -> String {
941    use heck::ToUpperCamelCase;
942    // Use UpperCamelCase so Go treats it as exported — required for method sets.
943    format!("testVisitor{}", fixture_id.to_upper_camel_case())
944}
945
946/// Emit a package-level Go struct declaration and all its visitor methods.
947fn emit_go_visitor_struct(
948    out: &mut String,
949    struct_name: &str,
950    visitor_spec: &crate::fixture::VisitorSpec,
951    import_alias: &str,
952) {
953    let _ = writeln!(out, "type {struct_name} struct{{}}");
954    for (method_name, action) in &visitor_spec.callbacks {
955        emit_go_visitor_method(out, struct_name, method_name, action, import_alias);
956    }
957}
958
959/// Emit a Go visitor method for a callback action on the named struct.
960fn emit_go_visitor_method(
961    out: &mut String,
962    struct_name: &str,
963    method_name: &str,
964    action: &CallbackAction,
965    import_alias: &str,
966) {
967    let camel_method = method_to_camel(method_name);
968    let params = match method_name {
969        "visit_link" => format!("_ {import_alias}.NodeContext, href, text, title string"),
970        "visit_image" => format!("_ {import_alias}.NodeContext, src, alt, title string"),
971        "visit_heading" => format!("_ {import_alias}.NodeContext, level int, text, id string"),
972        "visit_code_block" => format!("_ {import_alias}.NodeContext, lang, code string"),
973        "visit_code_inline"
974        | "visit_strong"
975        | "visit_emphasis"
976        | "visit_strikethrough"
977        | "visit_underline"
978        | "visit_subscript"
979        | "visit_superscript"
980        | "visit_mark"
981        | "visit_button"
982        | "visit_summary"
983        | "visit_figcaption"
984        | "visit_definition_term"
985        | "visit_definition_description" => format!("_ {import_alias}.NodeContext, text string"),
986        "visit_text" => format!("_ {import_alias}.NodeContext, text string"),
987        "visit_list_item" => {
988            format!("_ {import_alias}.NodeContext, ordered bool, marker, text string")
989        }
990        "visit_blockquote" => format!("_ {import_alias}.NodeContext, content string, depth int"),
991        "visit_table_row" => format!("_ {import_alias}.NodeContext, cells []string, isHeader bool"),
992        "visit_custom_element" => format!("_ {import_alias}.NodeContext, tagName, html string"),
993        "visit_form" => format!("_ {import_alias}.NodeContext, actionUrl, method string"),
994        "visit_input" => format!("_ {import_alias}.NodeContext, inputType, name, value string"),
995        "visit_audio" | "visit_video" | "visit_iframe" => {
996            format!("_ {import_alias}.NodeContext, src string")
997        }
998        "visit_details" => format!("_ {import_alias}.NodeContext, isOpen bool"),
999        _ => format!("_ {import_alias}.NodeContext"),
1000    };
1001
1002    let _ = writeln!(
1003        out,
1004        "func (v *{struct_name}) {camel_method}({params}) {import_alias}.VisitResult {{"
1005    );
1006    match action {
1007        CallbackAction::Skip => {
1008            let _ = writeln!(out, "\treturn {import_alias}.VisitResultSkip");
1009        }
1010        CallbackAction::Continue => {
1011            let _ = writeln!(out, "\treturn {import_alias}.VisitResultContinue");
1012        }
1013        CallbackAction::PreserveHtml => {
1014            let _ = writeln!(out, "\treturn {import_alias}.VisitResultPreserveHtml");
1015        }
1016        CallbackAction::Custom { output } => {
1017            let escaped = go_string_literal(output);
1018            let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped})");
1019        }
1020        CallbackAction::CustomTemplate { template } => {
1021            // Convert {var} placeholders to %s format verbs and collect arg names.
1022            // E.g. `QUOTE: "{text}"` → fmt.Sprintf("QUOTE: \"%s\"", text)
1023            let (fmt_str, fmt_args) = template_to_sprintf(template);
1024            let escaped_fmt = go_string_literal(&fmt_str);
1025            if fmt_args.is_empty() {
1026                let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped_fmt})");
1027            } else {
1028                let args_str = fmt_args.join(", ");
1029                let _ = writeln!(
1030                    out,
1031                    "\treturn {import_alias}.VisitResultCustom(fmt.Sprintf({escaped_fmt}, {args_str}))"
1032                );
1033            }
1034        }
1035    }
1036    let _ = writeln!(out, "}}");
1037}
1038
1039/// Convert a `{var}` template string into a `fmt.Sprintf` format string and argument list.
1040///
1041/// For example, `QUOTE: "{text}"` becomes `("QUOTE: \"%s\"", vec!["text"])`.
1042fn template_to_sprintf(template: &str) -> (String, Vec<String>) {
1043    let mut fmt_str = String::new();
1044    let mut args: Vec<String> = Vec::new();
1045    let mut chars = template.chars().peekable();
1046    while let Some(c) = chars.next() {
1047        if c == '{' {
1048            // Collect placeholder name until '}'.
1049            let mut name = String::new();
1050            for inner in chars.by_ref() {
1051                if inner == '}' {
1052                    break;
1053                }
1054                name.push(inner);
1055            }
1056            fmt_str.push_str("%s");
1057            args.push(name);
1058        } else {
1059            fmt_str.push(c);
1060        }
1061    }
1062    (fmt_str, args)
1063}
1064
1065/// Convert snake_case method names to Go camelCase.
1066fn method_to_camel(snake: &str) -> String {
1067    use heck::ToUpperCamelCase;
1068    snake.to_upper_camel_case()
1069}