1use crate::config::E2eConfig;
4use crate::escape::{go_string_literal, sanitize_filename};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_codegen::naming::{go_param_name, to_go_name};
8use alef_core::backend::GeneratedFile;
9use alef_core::config::AlefConfig;
10use alef_core::hash::{self, CommentStyle};
11use anyhow::Result;
12use heck::ToUpperCamelCase;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18pub struct GoCodegen;
20
21impl E2eCodegen for GoCodegen {
22 fn generate(
23 &self,
24 groups: &[FixtureGroup],
25 e2e_config: &E2eConfig,
26 alef_config: &AlefConfig,
27 ) -> Result<Vec<GeneratedFile>> {
28 let lang = self.language_name();
29 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
30
31 let mut files = Vec::new();
32
33 let call = &e2e_config.call;
35 let overrides = call.overrides.get(lang);
36 let module_path = overrides
37 .and_then(|o| o.module.as_ref())
38 .cloned()
39 .unwrap_or_else(|| call.module.clone());
40 let import_alias = overrides
41 .and_then(|o| o.alias.as_ref())
42 .cloned()
43 .unwrap_or_else(|| "pkg".to_string());
44
45 let go_pkg = e2e_config.resolve_package("go");
47 let go_module_path = go_pkg
48 .as_ref()
49 .and_then(|p| p.module.as_ref())
50 .cloned()
51 .unwrap_or_else(|| module_path.clone());
52 let replace_path = go_pkg.as_ref().and_then(|p| p.path.as_ref()).cloned();
53 let go_version = go_pkg
54 .as_ref()
55 .and_then(|p| p.version.as_ref())
56 .cloned()
57 .unwrap_or_else(|| {
58 alef_config
59 .resolved_version()
60 .map(|v| format!("v{v}"))
61 .unwrap_or_else(|| "v0.0.0".to_string())
62 });
63 let field_resolver = FieldResolver::new(
64 &e2e_config.fields,
65 &e2e_config.fields_optional,
66 &e2e_config.result_fields,
67 &e2e_config.fields_array,
68 );
69
70 let effective_replace = match e2e_config.dep_mode {
73 crate::config::DependencyMode::Registry => None,
74 crate::config::DependencyMode::Local => replace_path.as_deref().map(String::from),
75 };
76 files.push(GeneratedFile {
77 path: output_base.join("go.mod"),
78 content: render_go_mod(&go_module_path, effective_replace.as_deref(), &go_version),
79 generated_header: false,
80 });
81
82 for group in groups {
84 let active: Vec<&Fixture> = group
85 .fixtures
86 .iter()
87 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
88 .collect();
89
90 if active.is_empty() {
91 continue;
92 }
93
94 let filename = format!("{}_test.go", sanitize_filename(&group.category));
95 let content = render_test_file(
96 &group.category,
97 &active,
98 &module_path,
99 &import_alias,
100 &field_resolver,
101 e2e_config,
102 );
103 files.push(GeneratedFile {
104 path: output_base.join(filename),
105 content,
106 generated_header: true,
107 });
108 }
109
110 Ok(files)
111 }
112
113 fn language_name(&self) -> &'static str {
114 "go"
115 }
116}
117
118fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
119 let mut out = String::new();
120 let _ = writeln!(out, "module e2e_go");
121 let _ = writeln!(out);
122 let _ = writeln!(out, "go 1.26");
123 let _ = writeln!(out);
124 let _ = writeln!(out, "require {go_module_path} {version}");
125
126 if let Some(path) = replace_path {
127 let _ = writeln!(out);
128 let _ = writeln!(out, "replace {go_module_path} => {path}");
129 }
130
131 out
132}
133
134fn render_test_file(
135 category: &str,
136 fixtures: &[&Fixture],
137 go_module_path: &str,
138 import_alias: &str,
139 field_resolver: &FieldResolver,
140 e2e_config: &crate::config::E2eConfig,
141) -> String {
142 let mut out = String::new();
143
144 out.push_str(&hash::header(CommentStyle::DoubleSlash));
146 let _ = writeln!(out);
147
148 let needs_os = fixtures.iter().any(|f| {
151 let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
152 call_args.iter().any(|a| a.arg_type == "mock_url")
153 });
154
155 let needs_json = fixtures.iter().any(|f| {
157 let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
158 call_args.iter().any(|a| a.arg_type == "handle") && {
159 call_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
166 let needs_fmt = fixtures.iter().any(|f| {
168 f.visitor.as_ref().is_some_and(|v| {
169 v.callbacks.values().any(|action| {
170 if let CallbackAction::CustomTemplate { template } = action {
171 template.contains('{')
172 } else {
173 false
174 }
175 })
176 })
177 });
178
179 let needs_strings = fixtures.iter().any(|f| {
182 f.assertions.iter().any(|a| {
183 let type_needs_strings = if a.assertion_type == "equals" {
184 a.value.as_ref().is_some_and(|v| v.is_string())
186 } else {
187 matches!(
188 a.assertion_type.as_str(),
189 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with"
190 )
191 };
192 let field_valid = a
193 .field
194 .as_ref()
195 .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
196 .unwrap_or(true);
197 type_needs_strings && field_valid
198 })
199 });
200
201 let needs_assert = fixtures.iter().any(|f| {
204 f.assertions.iter().any(|a| {
205 let field_valid = a
206 .field
207 .as_ref()
208 .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
209 .unwrap_or(true);
210 let type_needs_assert = matches!(
211 a.assertion_type.as_str(),
212 "count_min"
213 | "count_max"
214 | "is_true"
215 | "is_false"
216 | "method_result"
217 | "min_length"
218 | "max_length"
219 | "matches_regex"
220 );
221 type_needs_assert && field_valid
222 })
223 });
224
225 let _ = writeln!(out, "// E2e tests for category: {category}");
226 let _ = writeln!(out, "package e2e_test");
227 let _ = writeln!(out);
228 let _ = writeln!(out, "import (");
229 if needs_json {
230 let _ = writeln!(out, "\t\"encoding/json\"");
231 }
232 if needs_fmt {
233 let _ = writeln!(out, "\t\"fmt\"");
234 }
235 if needs_os {
236 let _ = writeln!(out, "\t\"os\"");
237 }
238 if needs_strings {
239 let _ = writeln!(out, "\t\"strings\"");
240 }
241 let _ = writeln!(out, "\t\"testing\"");
242 if needs_assert {
243 let _ = writeln!(out);
244 let _ = writeln!(out, "\t\"github.com/stretchr/testify/assert\"");
245 }
246 let _ = writeln!(out);
247 let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
248 let _ = writeln!(out, ")");
249 let _ = writeln!(out);
250
251 for fixture in fixtures.iter() {
253 if let Some(visitor_spec) = &fixture.visitor {
254 let struct_name = visitor_struct_name(&fixture.id);
255 emit_go_visitor_struct(&mut out, &struct_name, visitor_spec, import_alias);
256 let _ = writeln!(out);
257 }
258 }
259
260 for (i, fixture) in fixtures.iter().enumerate() {
261 render_test_function(&mut out, fixture, import_alias, field_resolver, e2e_config);
262 if i + 1 < fixtures.len() {
263 let _ = writeln!(out);
264 }
265 }
266
267 while out.ends_with("\n\n") {
269 out.pop();
270 }
271 if !out.ends_with('\n') {
272 out.push('\n');
273 }
274 out
275}
276
277fn render_test_function(
278 out: &mut String,
279 fixture: &Fixture,
280 import_alias: &str,
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 call_config = e2e_config.resolve_call(fixture.call.as_deref());
289 let lang = "go";
290 let overrides = call_config.overrides.get(lang);
291 let function_name = to_go_name(
292 overrides
293 .and_then(|o| o.function.as_ref())
294 .map(String::as_str)
295 .unwrap_or(&call_config.function),
296 );
297 let result_var = &call_config.result_var;
298 let args = &call_config.args;
299
300 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
301
302 let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, import_alias, e2e_config, &fixture.id);
303
304 let mut visitor_arg = String::new();
306 if fixture.visitor.is_some() {
307 let struct_name = visitor_struct_name(&fixture.id);
308 setup_lines.push(format!("visitor := &{struct_name}{{}}"));
309 visitor_arg = "visitor".to_string();
310 }
311
312 let final_args = if visitor_arg.is_empty() {
313 args_str
314 } else {
315 format!("{args_str}, {visitor_arg}")
316 };
317
318 let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
319 let _ = writeln!(out, "\t// {description}");
320
321 for line in &setup_lines {
322 let _ = writeln!(out, "\t{line}");
323 }
324
325 if expects_error {
326 let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({final_args})");
327 let _ = writeln!(out, "\tif err == nil {{");
328 let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
329 let _ = writeln!(out, "\t}}");
330 let _ = writeln!(out, "}}");
331 return;
332 }
333
334 let has_usable_assertion = fixture.assertions.iter().any(|a| {
338 if a.assertion_type == "not_error" || a.assertion_type == "error" {
339 return false;
340 }
341 if a.assertion_type == "method_result" {
343 return true;
344 }
345 match &a.field {
346 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
347 _ => true,
348 }
349 });
350
351 let result_binding = if has_usable_assertion {
352 result_var.to_string()
353 } else {
354 "_".to_string()
355 };
356
357 let _ = writeln!(
359 out,
360 "\t{result_binding}, err := {import_alias}.{function_name}({final_args})"
361 );
362 let _ = writeln!(out, "\tif err != nil {{");
363 let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
364 let _ = writeln!(out, "\t}}");
365
366 let mut optional_locals: std::collections::HashMap<String, String> = std::collections::HashMap::new();
371 for assertion in &fixture.assertions {
372 if let Some(f) = &assertion.field {
373 if !f.is_empty() {
374 let resolved = field_resolver.resolve(f);
375 if field_resolver.is_optional(resolved) && !optional_locals.contains_key(f.as_str()) {
376 let is_string_field = assertion.value.as_ref().is_some_and(|v| v.is_string());
379 if !is_string_field {
380 continue;
383 }
384 let field_expr = field_resolver.accessor(f, "go", result_var);
385 let local_var = go_param_name(&resolved.replace(['.', '[', ']'], "_"));
386 if field_resolver.has_map_access(f) {
387 let _ = writeln!(out, "\t{local_var} := {field_expr}");
390 } else {
391 let _ = writeln!(out, "\tvar {local_var} string");
392 let _ = writeln!(out, "\tif {field_expr} != nil {{");
393 let _ = writeln!(out, "\t\t{local_var} = *{field_expr}");
394 let _ = writeln!(out, "\t}}");
395 }
396 optional_locals.insert(f.clone(), local_var);
397 }
398 }
399 }
400 }
401
402 for assertion in &fixture.assertions {
404 if let Some(f) = &assertion.field {
405 if !f.is_empty() && !optional_locals.contains_key(f.as_str()) {
406 let parts: Vec<&str> = f.split('.').collect();
409 let mut guard_expr: Option<String> = None;
410 for i in 1..parts.len() {
411 let prefix = parts[..i].join(".");
412 let resolved_prefix = field_resolver.resolve(&prefix);
413 if field_resolver.is_optional(resolved_prefix) {
414 let accessor = field_resolver.accessor(&prefix, "go", result_var);
415 guard_expr = Some(accessor);
416 break;
417 }
418 }
419 if let Some(guard) = guard_expr {
420 if field_resolver.is_valid_for_result(f) {
423 let _ = writeln!(out, "\tif {guard} != nil {{");
424 let mut nil_buf = String::new();
427 render_assertion(
428 &mut nil_buf,
429 assertion,
430 result_var,
431 import_alias,
432 field_resolver,
433 &optional_locals,
434 );
435 for line in nil_buf.lines() {
436 let _ = writeln!(out, "\t{line}");
437 }
438 let _ = writeln!(out, "\t}}");
439 } else {
440 render_assertion(
441 out,
442 assertion,
443 result_var,
444 import_alias,
445 field_resolver,
446 &optional_locals,
447 );
448 }
449 continue;
450 }
451 }
452 }
453 render_assertion(
454 out,
455 assertion,
456 result_var,
457 import_alias,
458 field_resolver,
459 &optional_locals,
460 );
461 }
462
463 let _ = writeln!(out, "}}");
464}
465
466fn build_args_and_setup(
470 input: &serde_json::Value,
471 args: &[crate::config::ArgMapping],
472 import_alias: &str,
473 e2e_config: &crate::config::E2eConfig,
474 fixture_id: &str,
475) -> (Vec<String>, String) {
476 use heck::ToUpperCamelCase;
477
478 if args.is_empty() {
479 return (Vec::new(), json_to_go(input));
480 }
481
482 let overrides = e2e_config.call.overrides.get("go");
483 let options_type = overrides.and_then(|o| o.options_type.as_deref());
484
485 let mut setup_lines: Vec<String> = Vec::new();
486 let mut parts: Vec<String> = Vec::new();
487
488 for arg in args {
489 if arg.arg_type == "mock_url" {
490 setup_lines.push(format!(
491 "{} := os.Getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
492 arg.name,
493 ));
494 parts.push(arg.name.clone());
495 continue;
496 }
497
498 if arg.arg_type == "handle" {
499 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
501 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
502 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
503 if config_value.is_null()
504 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
505 {
506 setup_lines.push(format!(
507 "{name}, createErr := {import_alias}.{constructor_name}(nil)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}",
508 name = arg.name,
509 ));
510 } else {
511 let json_str = serde_json::to_string(config_value).unwrap_or_default();
512 let go_literal = go_string_literal(&json_str);
513 let name = &arg.name;
514 setup_lines.push(format!(
515 "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}}"
516 ));
517 setup_lines.push(format!(
518 "{name}, createErr := {import_alias}.{constructor_name}(&{name}Config)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}"
519 ));
520 }
521 parts.push(arg.name.clone());
522 continue;
523 }
524
525 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
526 let val = input.get(field);
527 match val {
528 None | Some(serde_json::Value::Null) if arg.optional => {
529 continue;
531 }
532 None | Some(serde_json::Value::Null) => {
533 let default_val = match arg.arg_type.as_str() {
535 "string" => "\"\"".to_string(),
536 "int" | "integer" => "0".to_string(),
537 "float" | "number" => "0.0".to_string(),
538 "bool" | "boolean" => "false".to_string(),
539 _ => "nil".to_string(),
540 };
541 parts.push(default_val);
542 }
543 Some(v) => {
544 if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
546 if let Some(obj) = v.as_object() {
547 let with_calls: Vec<String> = obj
548 .iter()
549 .map(|(k, vv)| {
550 let func_name = format!("With{}{}", opts_type, k.to_upper_camel_case());
551 let go_val = json_to_go(vv);
552 format!("htmd.{func_name}({go_val})")
553 })
554 .collect();
555 let new_fn = format!("New{opts_type}");
556 parts.push(format!("htmd.{new_fn}({})", with_calls.join(", ")));
557 continue;
558 }
559 }
560 parts.push(json_to_go(v));
561 }
562 }
563 }
564
565 (setup_lines, parts.join(", "))
566}
567
568fn render_assertion(
569 out: &mut String,
570 assertion: &Assertion,
571 result_var: &str,
572 import_alias: &str,
573 field_resolver: &FieldResolver,
574 optional_locals: &std::collections::HashMap<String, String>,
575) {
576 if let Some(f) = &assertion.field {
578 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
579 let _ = writeln!(out, "\t// skipped: field '{f}' not available on result type");
580 return;
581 }
582 }
583
584 let field_expr = match &assertion.field {
585 Some(f) if !f.is_empty() => {
586 if let Some(local_var) = optional_locals.get(f.as_str()) {
588 local_var.clone()
589 } else {
590 field_resolver.accessor(f, "go", result_var)
591 }
592 }
593 _ => result_var.to_string(),
594 };
595
596 let is_optional = assertion
600 .field
601 .as_ref()
602 .map(|f| {
603 let resolved = field_resolver.resolve(f);
604 let check_path = resolved
605 .strip_suffix(".length")
606 .or_else(|| resolved.strip_suffix(".count"))
607 .or_else(|| resolved.strip_suffix(".size"))
608 .unwrap_or(resolved);
609 field_resolver.is_optional(check_path) && !optional_locals.contains_key(f.as_str())
610 })
611 .unwrap_or(false);
612
613 let field_expr = if is_optional && field_expr.starts_with("len(") && field_expr.ends_with(')') {
616 let inner = &field_expr[4..field_expr.len() - 1];
617 format!("len(*{inner})")
618 } else {
619 field_expr
620 };
621 let nil_guard_expr = if is_optional && field_expr.starts_with("len(*") {
623 Some(field_expr[5..field_expr.len() - 1].to_string())
624 } else {
625 None
626 };
627
628 let deref_field_expr = if is_optional && !field_expr.starts_with("len(") {
631 format!("*{field_expr}")
632 } else {
633 field_expr.clone()
634 };
635
636 let array_guard: Option<String> = if let Some(idx) = field_expr.find("[0]") {
641 let array_expr = &field_expr[..idx];
642 Some(array_expr.to_string())
643 } else {
644 None
645 };
646
647 let mut assertion_buf = String::new();
650 let out_ref = &mut assertion_buf;
651
652 match assertion.assertion_type.as_str() {
653 "equals" => {
654 if let Some(expected) = &assertion.value {
655 let go_val = json_to_go(expected);
656 if expected.is_string() {
658 let trimmed_field = if is_optional && !field_expr.starts_with("len(") {
660 format!("strings.TrimSpace(*{field_expr})")
661 } else {
662 format!("strings.TrimSpace({field_expr})")
663 };
664 if is_optional && !field_expr.starts_with("len(") {
665 let _ = writeln!(out_ref, "\tif {field_expr} != nil && {trimmed_field} != {go_val} {{");
666 } else {
667 let _ = writeln!(out_ref, "\tif {trimmed_field} != {go_val} {{");
668 }
669 } else if is_optional && !field_expr.starts_with("len(") {
670 let _ = writeln!(out_ref, "\tif {field_expr} != nil && {deref_field_expr} != {go_val} {{");
671 } else {
672 let _ = writeln!(out_ref, "\tif {field_expr} != {go_val} {{");
673 }
674 let _ = writeln!(out_ref, "\t\tt.Errorf(\"equals mismatch: got %v\", {field_expr})");
675 let _ = writeln!(out_ref, "\t}}");
676 }
677 }
678 "contains" => {
679 if let Some(expected) = &assertion.value {
680 let go_val = json_to_go(expected);
681 let field_for_contains = if is_optional
682 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
683 {
684 format!("string(*{field_expr})")
685 } else {
686 format!("string({field_expr})")
687 };
688 let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
689 let _ = writeln!(
690 out_ref,
691 "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
692 );
693 let _ = writeln!(out_ref, "\t}}");
694 }
695 }
696 "contains_all" => {
697 if let Some(values) = &assertion.values {
698 for val in values {
699 let go_val = json_to_go(val);
700 let field_for_contains = if is_optional
701 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
702 {
703 format!("string(*{field_expr})")
704 } else {
705 format!("string({field_expr})")
706 };
707 let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
708 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
709 let _ = writeln!(out_ref, "\t}}");
710 }
711 }
712 }
713 "not_contains" => {
714 if let Some(expected) = &assertion.value {
715 let go_val = json_to_go(expected);
716 let field_for_contains = if is_optional
717 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
718 {
719 format!("string(*{field_expr})")
720 } else {
721 format!("string({field_expr})")
722 };
723 let _ = writeln!(out_ref, "\tif strings.Contains({field_for_contains}, {go_val}) {{");
724 let _ = writeln!(
725 out_ref,
726 "\t\tt.Errorf(\"expected NOT to contain %s, got %v\", {go_val}, {field_expr})"
727 );
728 let _ = writeln!(out_ref, "\t}}");
729 }
730 }
731 "not_empty" => {
732 if is_optional {
733 let _ = writeln!(out_ref, "\tif {field_expr} == nil || len(*{field_expr}) == 0 {{");
734 } else {
735 let _ = writeln!(out_ref, "\tif len({field_expr}) == 0 {{");
736 }
737 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected non-empty value\")");
738 let _ = writeln!(out_ref, "\t}}");
739 }
740 "is_empty" => {
741 if is_optional {
742 let _ = writeln!(out_ref, "\tif {field_expr} != nil && len(*{field_expr}) != 0 {{");
743 } else {
744 let _ = writeln!(out_ref, "\tif len({field_expr}) != 0 {{");
745 }
746 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected empty value, got %v\", {field_expr})");
747 let _ = writeln!(out_ref, "\t}}");
748 }
749 "contains_any" => {
750 if let Some(values) = &assertion.values {
751 let field_for_contains = if is_optional
752 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
753 {
754 format!("*{field_expr}")
755 } else {
756 field_expr.clone()
757 };
758 let _ = writeln!(out_ref, "\t{{");
759 let _ = writeln!(out_ref, "\t\tfound := false");
760 for val in values {
761 let go_val = json_to_go(val);
762 let _ = writeln!(
763 out_ref,
764 "\t\tif strings.Contains({field_for_contains}, {go_val}) {{ found = true }}"
765 );
766 }
767 let _ = writeln!(out_ref, "\t\tif !found {{");
768 let _ = writeln!(
769 out_ref,
770 "\t\t\tt.Errorf(\"expected to contain at least one of the specified values\")"
771 );
772 let _ = writeln!(out_ref, "\t\t}}");
773 let _ = writeln!(out_ref, "\t}}");
774 }
775 }
776 "greater_than" => {
777 if let Some(val) = &assertion.value {
778 let go_val = json_to_go(val);
779 if let Some(n) = val.as_u64() {
782 let next = n + 1;
783 let _ = writeln!(out_ref, "\tif {field_expr} < {next} {{");
784 } else {
785 let _ = writeln!(out_ref, "\tif {field_expr} <= {go_val} {{");
786 }
787 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
788 let _ = writeln!(out_ref, "\t}}");
789 }
790 }
791 "less_than" => {
792 if let Some(val) = &assertion.value {
793 let go_val = json_to_go(val);
794 let _ = writeln!(out_ref, "\tif {field_expr} >= {go_val} {{");
795 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected < {go_val}, got %v\", {field_expr})");
796 let _ = writeln!(out_ref, "\t}}");
797 }
798 }
799 "greater_than_or_equal" => {
800 if let Some(val) = &assertion.value {
801 let go_val = json_to_go(val);
802 if let Some(ref guard) = nil_guard_expr {
803 let _ = writeln!(out_ref, "\tif {guard} != nil {{");
804 let _ = writeln!(out_ref, "\t\tif {field_expr} < {go_val} {{");
805 let _ = writeln!(
806 out_ref,
807 "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})"
808 );
809 let _ = writeln!(out_ref, "\t\t}}");
810 let _ = writeln!(out_ref, "\t}}");
811 } else {
812 let _ = writeln!(out_ref, "\tif {field_expr} < {go_val} {{");
813 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})");
814 let _ = writeln!(out_ref, "\t}}");
815 }
816 }
817 }
818 "less_than_or_equal" => {
819 if let Some(val) = &assertion.value {
820 let go_val = json_to_go(val);
821 let _ = writeln!(out_ref, "\tif {field_expr} > {go_val} {{");
822 let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected <= {go_val}, got %v\", {field_expr})");
823 let _ = writeln!(out_ref, "\t}}");
824 }
825 }
826 "starts_with" => {
827 if let Some(expected) = &assertion.value {
828 let go_val = json_to_go(expected);
829 let field_for_prefix = if is_optional
830 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
831 {
832 format!("string(*{field_expr})")
833 } else {
834 format!("string({field_expr})")
835 };
836 let _ = writeln!(out_ref, "\tif !strings.HasPrefix({field_for_prefix}, {go_val}) {{");
837 let _ = writeln!(
838 out_ref,
839 "\t\tt.Errorf(\"expected to start with %s, got %v\", {go_val}, {field_expr})"
840 );
841 let _ = writeln!(out_ref, "\t}}");
842 }
843 }
844 "count_min" => {
845 if let Some(val) = &assertion.value {
846 if let Some(n) = val.as_u64() {
847 if is_optional {
848 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
849 let _ = writeln!(
850 out_ref,
851 "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected at least {n} elements\")"
852 );
853 let _ = writeln!(out_ref, "\t}}");
854 } else {
855 let _ = writeln!(
856 out_ref,
857 "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected at least {n} elements\")"
858 );
859 }
860 }
861 }
862 }
863 "count_equals" => {
864 if let Some(val) = &assertion.value {
865 if let Some(n) = val.as_u64() {
866 if is_optional {
867 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
868 let _ = writeln!(
869 out_ref,
870 "\t\tassert.Equal(t, len(*{field_expr}), {n}, \"expected exactly {n} elements\")"
871 );
872 let _ = writeln!(out_ref, "\t}}");
873 } else {
874 let _ = writeln!(
875 out_ref,
876 "\tassert.Equal(t, len({field_expr}), {n}, \"expected exactly {n} elements\")"
877 );
878 }
879 }
880 }
881 }
882 "is_true" => {
883 if is_optional {
884 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
885 let _ = writeln!(out_ref, "\t\tassert.True(t, *{field_expr}, \"expected true\")");
886 let _ = writeln!(out_ref, "\t}}");
887 } else {
888 let _ = writeln!(out_ref, "\tassert.True(t, {field_expr}, \"expected true\")");
889 }
890 }
891 "is_false" => {
892 if is_optional {
893 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
894 let _ = writeln!(out_ref, "\t\tassert.False(t, *{field_expr}, \"expected false\")");
895 let _ = writeln!(out_ref, "\t}}");
896 } else {
897 let _ = writeln!(out_ref, "\tassert.False(t, {field_expr}, \"expected false\")");
898 }
899 }
900 "method_result" => {
901 if let Some(method_name) = &assertion.method {
902 let info = build_go_method_call(result_var, method_name, assertion.args.as_ref(), import_alias);
903 let check = assertion.check.as_deref().unwrap_or("is_true");
904 let deref_expr = if info.is_pointer {
907 format!("*{}", info.call_expr)
908 } else {
909 info.call_expr.clone()
910 };
911 match check {
912 "equals" => {
913 if let Some(val) = &assertion.value {
914 if val.is_boolean() {
915 if val.as_bool() == Some(true) {
916 let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
917 } else {
918 let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
919 }
920 } else {
921 let go_val = if let Some(cast) = info.value_cast {
925 if val.is_number() {
926 format!("{cast}({})", json_to_go(val))
927 } else {
928 json_to_go(val)
929 }
930 } else {
931 json_to_go(val)
932 };
933 let _ = writeln!(
934 out_ref,
935 "\tassert.Equal(t, {go_val}, {deref_expr}, \"method_result equals assertion failed\")"
936 );
937 }
938 }
939 }
940 "is_true" => {
941 let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
942 }
943 "is_false" => {
944 let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
945 }
946 "greater_than_or_equal" => {
947 if let Some(val) = &assertion.value {
948 let n = val.as_u64().unwrap_or(0);
949 let cast = info.value_cast.unwrap_or("uint");
951 let _ = writeln!(
952 out_ref,
953 "\tassert.GreaterOrEqual(t, {deref_expr}, {cast}({n}), \"expected >= {n}\")"
954 );
955 }
956 }
957 "count_min" => {
958 if let Some(val) = &assertion.value {
959 let n = val.as_u64().unwrap_or(0);
960 let _ = writeln!(
961 out_ref,
962 "\tassert.GreaterOrEqual(t, len({deref_expr}), {n}, \"expected at least {n} elements\")"
963 );
964 }
965 }
966 "contains" => {
967 if let Some(val) = &assertion.value {
968 let go_val = json_to_go(val);
969 let _ = writeln!(
970 out_ref,
971 "\tassert.Contains(t, {deref_expr}, {go_val}, \"expected result to contain value\")"
972 );
973 }
974 }
975 "is_error" => {
976 let _ = writeln!(out_ref, "\t{{");
977 let _ = writeln!(out_ref, "\t\t_, methodErr := {}", info.call_expr);
978 let _ = writeln!(out_ref, "\t\tassert.Error(t, methodErr)");
979 let _ = writeln!(out_ref, "\t}}");
980 }
981 other_check => {
982 panic!("Go e2e generator: unsupported method_result check type: {other_check}");
983 }
984 }
985 } else {
986 panic!("Go e2e generator: method_result assertion missing 'method' field");
987 }
988 }
989 "min_length" => {
990 if let Some(val) = &assertion.value {
991 if let Some(n) = val.as_u64() {
992 if is_optional {
993 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
994 let _ = writeln!(
995 out_ref,
996 "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected length >= {n}\")"
997 );
998 let _ = writeln!(out_ref, "\t}}");
999 } else {
1000 let _ = writeln!(
1001 out_ref,
1002 "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected length >= {n}\")"
1003 );
1004 }
1005 }
1006 }
1007 }
1008 "max_length" => {
1009 if let Some(val) = &assertion.value {
1010 if let Some(n) = val.as_u64() {
1011 if is_optional {
1012 let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1013 let _ = writeln!(
1014 out_ref,
1015 "\t\tassert.LessOrEqual(t, len(*{field_expr}), {n}, \"expected length <= {n}\")"
1016 );
1017 let _ = writeln!(out_ref, "\t}}");
1018 } else {
1019 let _ = writeln!(
1020 out_ref,
1021 "\tassert.LessOrEqual(t, len({field_expr}), {n}, \"expected length <= {n}\")"
1022 );
1023 }
1024 }
1025 }
1026 }
1027 "ends_with" => {
1028 if let Some(expected) = &assertion.value {
1029 let go_val = json_to_go(expected);
1030 let field_for_suffix = if is_optional
1031 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1032 {
1033 format!("string(*{field_expr})")
1034 } else {
1035 format!("string({field_expr})")
1036 };
1037 let _ = writeln!(out_ref, "\tif !strings.HasSuffix({field_for_suffix}, {go_val}) {{");
1038 let _ = writeln!(
1039 out_ref,
1040 "\t\tt.Errorf(\"expected to end with %s, got %v\", {go_val}, {field_expr})"
1041 );
1042 let _ = writeln!(out_ref, "\t}}");
1043 }
1044 }
1045 "matches_regex" => {
1046 if let Some(expected) = &assertion.value {
1047 let go_val = json_to_go(expected);
1048 let field_for_regex = if is_optional
1049 && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1050 {
1051 format!("*{field_expr}")
1052 } else {
1053 field_expr.clone()
1054 };
1055 let _ = writeln!(
1056 out_ref,
1057 "\tassert.Regexp(t, {go_val}, {field_for_regex}, \"expected value to match regex\")"
1058 );
1059 }
1060 }
1061 "not_error" => {
1062 }
1064 "error" => {
1065 }
1067 other => {
1068 panic!("Go e2e generator: unsupported assertion type: {other}");
1069 }
1070 }
1071
1072 if let Some(ref arr) = array_guard {
1075 if !assertion_buf.is_empty() {
1076 let _ = writeln!(out, "\tif len({arr}) > 0 {{");
1077 for line in assertion_buf.lines() {
1079 let _ = writeln!(out, "\t{line}");
1080 }
1081 let _ = writeln!(out, "\t}}");
1082 }
1083 } else {
1084 out.push_str(&assertion_buf);
1085 }
1086}
1087
1088struct GoMethodCallInfo {
1090 call_expr: String,
1092 is_pointer: bool,
1094 value_cast: Option<&'static str>,
1097}
1098
1099fn build_go_method_call(
1114 result_var: &str,
1115 method_name: &str,
1116 args: Option<&serde_json::Value>,
1117 import_alias: &str,
1118) -> GoMethodCallInfo {
1119 match method_name {
1120 "root_node_type" => GoMethodCallInfo {
1121 call_expr: format!("{import_alias}.RootNodeInfo({result_var}).Kind"),
1122 is_pointer: false,
1123 value_cast: None,
1124 },
1125 "named_children_count" => GoMethodCallInfo {
1126 call_expr: format!("{import_alias}.RootNodeInfo({result_var}).NamedChildCount"),
1127 is_pointer: false,
1128 value_cast: Some("uint"),
1129 },
1130 "has_error_nodes" => GoMethodCallInfo {
1131 call_expr: format!("{import_alias}.TreeHasErrorNodes({result_var})"),
1132 is_pointer: true,
1133 value_cast: None,
1134 },
1135 "error_count" | "tree_error_count" => GoMethodCallInfo {
1136 call_expr: format!("{import_alias}.TreeErrorCount({result_var})"),
1137 is_pointer: true,
1138 value_cast: Some("uint"),
1139 },
1140 "tree_to_sexp" => GoMethodCallInfo {
1141 call_expr: format!("{import_alias}.TreeToSexp({result_var})"),
1142 is_pointer: true,
1143 value_cast: None,
1144 },
1145 "contains_node_type" => {
1146 let node_type = args
1147 .and_then(|a| a.get("node_type"))
1148 .and_then(|v| v.as_str())
1149 .unwrap_or("");
1150 GoMethodCallInfo {
1151 call_expr: format!("{import_alias}.TreeContainsNodeType({result_var}, \"{node_type}\")"),
1152 is_pointer: true,
1153 value_cast: None,
1154 }
1155 }
1156 "find_nodes_by_type" => {
1157 let node_type = args
1158 .and_then(|a| a.get("node_type"))
1159 .and_then(|v| v.as_str())
1160 .unwrap_or("");
1161 GoMethodCallInfo {
1162 call_expr: format!("{import_alias}.FindNodesByType({result_var}, \"{node_type}\")"),
1163 is_pointer: true,
1164 value_cast: None,
1165 }
1166 }
1167 "run_query" => {
1168 let query_source = args
1169 .and_then(|a| a.get("query_source"))
1170 .and_then(|v| v.as_str())
1171 .unwrap_or("");
1172 let language = args
1173 .and_then(|a| a.get("language"))
1174 .and_then(|v| v.as_str())
1175 .unwrap_or("");
1176 let query_lit = go_string_literal(query_source);
1177 let lang_lit = go_string_literal(language);
1178 GoMethodCallInfo {
1180 call_expr: format!("{import_alias}.RunQuery({result_var}, {lang_lit}, {query_lit}, []byte(source))"),
1181 is_pointer: false,
1182 value_cast: None,
1183 }
1184 }
1185 other => {
1186 let method_pascal = other.to_upper_camel_case();
1187 GoMethodCallInfo {
1188 call_expr: format!("{result_var}.{method_pascal}()"),
1189 is_pointer: false,
1190 value_cast: None,
1191 }
1192 }
1193 }
1194}
1195
1196fn json_to_go(value: &serde_json::Value) -> String {
1198 match value {
1199 serde_json::Value::String(s) => go_string_literal(s),
1200 serde_json::Value::Bool(b) => b.to_string(),
1201 serde_json::Value::Number(n) => n.to_string(),
1202 serde_json::Value::Null => "nil".to_string(),
1203 other => go_string_literal(&other.to_string()),
1205 }
1206}
1207
1208fn visitor_struct_name(fixture_id: &str) -> String {
1217 use heck::ToUpperCamelCase;
1218 format!("testVisitor{}", fixture_id.to_upper_camel_case())
1220}
1221
1222fn emit_go_visitor_struct(
1224 out: &mut String,
1225 struct_name: &str,
1226 visitor_spec: &crate::fixture::VisitorSpec,
1227 import_alias: &str,
1228) {
1229 let _ = writeln!(out, "type {struct_name} struct{{}}");
1230 for (method_name, action) in &visitor_spec.callbacks {
1231 emit_go_visitor_method(out, struct_name, method_name, action, import_alias);
1232 }
1233}
1234
1235fn emit_go_visitor_method(
1237 out: &mut String,
1238 struct_name: &str,
1239 method_name: &str,
1240 action: &CallbackAction,
1241 import_alias: &str,
1242) {
1243 let camel_method = method_to_camel(method_name);
1244 let params = match method_name {
1245 "visit_link" => format!("_ {import_alias}.NodeContext, href, text, title string"),
1246 "visit_image" => format!("_ {import_alias}.NodeContext, src, alt, title string"),
1247 "visit_heading" => format!("_ {import_alias}.NodeContext, level int, text, id string"),
1248 "visit_code_block" => format!("_ {import_alias}.NodeContext, lang, code string"),
1249 "visit_code_inline"
1250 | "visit_strong"
1251 | "visit_emphasis"
1252 | "visit_strikethrough"
1253 | "visit_underline"
1254 | "visit_subscript"
1255 | "visit_superscript"
1256 | "visit_mark"
1257 | "visit_button"
1258 | "visit_summary"
1259 | "visit_figcaption"
1260 | "visit_definition_term"
1261 | "visit_definition_description" => format!("_ {import_alias}.NodeContext, text string"),
1262 "visit_text" => format!("_ {import_alias}.NodeContext, text string"),
1263 "visit_list_item" => {
1264 format!("_ {import_alias}.NodeContext, ordered bool, marker, text string")
1265 }
1266 "visit_blockquote" => format!("_ {import_alias}.NodeContext, content string, depth int"),
1267 "visit_table_row" => format!("_ {import_alias}.NodeContext, cells []string, isHeader bool"),
1268 "visit_custom_element" => format!("_ {import_alias}.NodeContext, tagName, html string"),
1269 "visit_form" => format!("_ {import_alias}.NodeContext, actionUrl, method string"),
1270 "visit_input" => format!("_ {import_alias}.NodeContext, inputType, name, value string"),
1271 "visit_audio" | "visit_video" | "visit_iframe" => {
1272 format!("_ {import_alias}.NodeContext, src string")
1273 }
1274 "visit_details" => format!("_ {import_alias}.NodeContext, isOpen bool"),
1275 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1276 format!("_ {import_alias}.NodeContext, output string")
1277 }
1278 "visit_list_start" => format!("_ {import_alias}.NodeContext, ordered bool"),
1279 "visit_list_end" => format!("_ {import_alias}.NodeContext, ordered bool, output string"),
1280 _ => format!("_ {import_alias}.NodeContext"),
1281 };
1282
1283 let _ = writeln!(
1284 out,
1285 "func (v *{struct_name}) {camel_method}({params}) {import_alias}.VisitResult {{"
1286 );
1287 match action {
1288 CallbackAction::Skip => {
1289 let _ = writeln!(out, "\treturn {import_alias}.VisitResultSkip");
1290 }
1291 CallbackAction::Continue => {
1292 let _ = writeln!(out, "\treturn {import_alias}.VisitResultContinue");
1293 }
1294 CallbackAction::PreserveHtml => {
1295 let _ = writeln!(out, "\treturn {import_alias}.VisitResultPreserveHtml");
1296 }
1297 CallbackAction::Custom { output } => {
1298 let escaped = go_string_literal(output);
1299 let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped})");
1300 }
1301 CallbackAction::CustomTemplate { template } => {
1302 let (fmt_str, fmt_args) = template_to_sprintf(template);
1305 let escaped_fmt = go_string_literal(&fmt_str);
1306 if fmt_args.is_empty() {
1307 let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped_fmt})");
1308 } else {
1309 let args_str = fmt_args.join(", ");
1310 let _ = writeln!(
1311 out,
1312 "\treturn {import_alias}.VisitResultCustom(fmt.Sprintf({escaped_fmt}, {args_str}))"
1313 );
1314 }
1315 }
1316 }
1317 let _ = writeln!(out, "}}");
1318}
1319
1320fn template_to_sprintf(template: &str) -> (String, Vec<String>) {
1324 let mut fmt_str = String::new();
1325 let mut args: Vec<String> = Vec::new();
1326 let mut chars = template.chars().peekable();
1327 while let Some(c) = chars.next() {
1328 if c == '{' {
1329 let mut name = String::new();
1331 for inner in chars.by_ref() {
1332 if inner == '}' {
1333 break;
1334 }
1335 name.push(inner);
1336 }
1337 fmt_str.push_str("%s");
1338 args.push(name);
1339 } else {
1340 fmt_str.push(c);
1341 }
1342 }
1343 (fmt_str, args)
1344}
1345
1346fn method_to_camel(snake: &str) -> String {
1348 use heck::ToUpperCamelCase;
1349 snake.to_upper_camel_case()
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::*;
1355 use crate::config::{CallConfig, E2eConfig};
1356 use crate::field_access::FieldResolver;
1357 use crate::fixture::{Assertion, Fixture};
1358
1359 fn make_fixture(id: &str) -> Fixture {
1360 Fixture {
1361 id: id.to_string(),
1362 category: None,
1363 description: "test fixture".to_string(),
1364 tags: vec![],
1365 skip: None,
1366 call: None,
1367 input: serde_json::Value::Null,
1368 mock_response: None,
1369 source: String::new(),
1370 http: None,
1371 assertions: vec![Assertion {
1372 assertion_type: "not_error".to_string(),
1373 field: None,
1374 value: None,
1375 values: None,
1376 method: None,
1377 args: None,
1378 check: None,
1379 }],
1380 visitor: None,
1381 }
1382 }
1383
1384 #[test]
1388 fn test_go_method_name_uses_go_casing() {
1389 let e2e_config = E2eConfig {
1390 call: CallConfig {
1391 function: "clean_extracted_text".to_string(),
1392 module: "github.com/example/mylib".to_string(),
1393 result_var: "result".to_string(),
1394 r#async: false,
1395 path: None,
1396 method: None,
1397 args: vec![],
1398 overrides: std::collections::HashMap::new(),
1399 },
1400 ..E2eConfig::default()
1401 };
1402
1403 let fixture = make_fixture("basic_text");
1404 let resolver = FieldResolver::new(
1405 &std::collections::HashMap::new(),
1406 &std::collections::HashSet::new(),
1407 &std::collections::HashSet::new(),
1408 &std::collections::HashSet::new(),
1409 );
1410 let mut out = String::new();
1411 render_test_function(&mut out, &fixture, "kreuzberg", &resolver, &e2e_config);
1412
1413 assert!(
1414 out.contains("kreuzberg.CleanExtractedText("),
1415 "expected Go-cased method name 'CleanExtractedText', got:\n{out}"
1416 );
1417 assert!(
1418 !out.contains("kreuzberg.clean_extracted_text("),
1419 "must not emit raw snake_case method name, got:\n{out}"
1420 );
1421 }
1422}