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