1use std::fmt::Write as FmtWrite;
4
5use crate::config::E2eConfig;
6use crate::escape::sanitize_filename;
7use crate::field_access::FieldResolver;
8use crate::fixture::{Fixture, FixtureGroup};
9
10use super::args::{emit_rust_visitor_method, render_rust_arg, resolve_visitor_trait};
11use super::assertions::render_assertion;
12use super::http::render_http_test_function;
13use super::mock_server::render_mock_server_setup;
14
15pub(super) fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
16 call_config
17 .overrides
18 .get("rust")
19 .and_then(|o| o.function.clone())
20 .unwrap_or_else(|| call_config.function.clone())
21}
22
23pub(super) fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
24 resolve_module_for_call(&e2e_config.call, dep_name)
25}
26
27pub(super) fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
28 let overrides = call_config.overrides.get("rust");
31 overrides
32 .and_then(|o| o.crate_name.clone())
33 .or_else(|| overrides.and_then(|o| o.module.clone()))
34 .unwrap_or_else(|| dep_name.to_string())
35}
36
37pub(super) fn is_skipped(fixture: &Fixture, language: &str) -> bool {
38 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
39}
40
41fn body_references_symbol(body: &str, symbol: &str) -> bool {
45 let bytes = body.as_bytes();
46 let sym = symbol.as_bytes();
47 let n = bytes.len();
48 let m = sym.len();
49 if m == 0 || m > n {
50 return false;
51 }
52 let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
53 let mut i = 0;
54 while i + m <= n {
55 if bytes[i..i + m] == *sym {
56 let before_ok = i == 0 || !is_word(bytes[i - 1]);
57 let after_ok = i + m == n || !is_word(bytes[i + m]);
58 if before_ok && after_ok {
59 return true;
60 }
61 }
62 i += 1;
63 }
64 false
65}
66
67pub fn render_test_file(
68 category: &str,
69 fixtures: &[&Fixture],
70 e2e_config: &E2eConfig,
71 dep_name: &str,
72 needs_mock_server: bool,
73) -> String {
74 let mut out = String::new();
75 out.push_str(&alef_core::hash::header(alef_core::hash::CommentStyle::DoubleSlash));
76 let _ = writeln!(out, "//! E2e tests for category: {category}");
77 let _ = writeln!(out);
78
79 let module = resolve_module(e2e_config, dep_name);
80 let field_resolver = FieldResolver::new(
81 &e2e_config.fields,
82 &e2e_config.fields_optional,
83 &e2e_config.result_fields,
84 &e2e_config.fields_array,
85 &e2e_config.fields_method_calls,
86 );
87
88 let file_has_http = fixtures.iter().any(|f| f.http.is_some());
90 let file_has_call_based = fixtures.iter().any(|f| {
93 if f.mock_response.is_some() {
94 return true;
95 }
96 if f.http.is_none() && f.mock_response.is_none() {
97 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
98 let fn_name = resolve_function_name_for_call(call_config);
99 return !fn_name.is_empty();
100 }
101 false
102 });
103
104 let rust_call_override = e2e_config.call.overrides.get("rust");
109 let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
110
111 if file_has_call_based && client_factory.is_none() {
113 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
114 for fixture in fixtures.iter().filter(|f| {
115 if f.mock_response.is_some() {
116 return true;
117 }
118 if f.http.is_none() && f.mock_response.is_none() {
119 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
120 let fn_name = resolve_function_name_for_call(call_config);
121 return !fn_name.is_empty();
122 }
123 false
124 }) {
125 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
126 let fn_name = resolve_function_name_for_call(call_config);
127 let mod_name = resolve_module_for_call(call_config, dep_name);
128 imported.insert((mod_name, fn_name));
129 }
130 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
132 for (mod_name, fn_name) in &imported {
133 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
134 }
135 for (mod_name, fns) in &by_module {
136 if fns.len() == 1 {
137 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
138 } else {
139 let joined = fns.join(", ");
140 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
141 }
142 }
143 }
144
145 if file_has_http {
147 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
148 }
149
150 let mut body_buf = String::new();
154 for fixture in fixtures {
155 render_test_function(
156 &mut body_buf,
157 fixture,
158 e2e_config,
159 dep_name,
160 &field_resolver,
161 client_factory,
162 );
163 let _ = writeln!(body_buf);
164 }
165
166 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
168 if has_handle_args && body_references_symbol(&body_buf, "CrawlConfig") {
169 let _ = writeln!(out, "use {module}::CrawlConfig;");
170 }
171 for arg in &e2e_config.call.args {
172 if arg.arg_type == "handle" {
173 use heck::ToSnakeCase;
174 let constructor_name = format!("create_{}", arg.name.to_snake_case());
175 if body_references_symbol(&body_buf, &constructor_name) {
176 let _ = writeln!(out, "use {module}::{constructor_name};");
177 }
178 }
179 }
180
181 if client_factory.is_some() && file_has_call_based {
184 let trait_imports: Vec<String> = e2e_config
185 .call
186 .overrides
187 .get("rust")
188 .map(|o| o.trait_imports.clone())
189 .unwrap_or_default();
190 for trait_name in &trait_imports {
191 let _ = writeln!(out, "use {module}::{trait_name};");
192 }
193 }
194
195 let file_needs_mock = needs_mock_server
197 && fixtures
198 .iter()
199 .any(|f| f.mock_response.is_some() || f.needs_mock_server());
200 if file_needs_mock {
201 let _ = writeln!(out, "mod common;");
202 let _ = writeln!(out, "mod mock_server;");
203 let _ = writeln!(out, "#[allow(unused_imports)]");
204 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
205 }
206
207 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
214 if file_needs_visitor {
215 let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
216 panic!(
217 "category '{}': fixture declares a visitor block but \
218 `[e2e.call.overrides.rust] visitor_trait` is not configured",
219 category
220 )
221 });
222 let _ = writeln!(
223 out,
224 "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
225 );
226 }
227
228 if file_has_call_based {
233 let rust_options_type = e2e_config
234 .call
235 .overrides
236 .get("rust")
237 .and_then(|o| o.options_type.as_deref());
238 if let Some(opts_type) = rust_options_type {
239 let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
242 if has_json_object_arg && body_references_symbol(&body_buf, opts_type) {
243 let _ = writeln!(out, "use {module}::{opts_type};");
244 }
245 }
246 }
247
248 if file_has_call_based {
252 let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
253 for fixture in fixtures.iter().filter(|f| {
254 if f.mock_response.is_some() {
255 return true;
256 }
257 if f.http.is_none() && f.mock_response.is_none() {
258 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
259 let fn_name = resolve_function_name_for_call(call_config);
260 return !fn_name.is_empty();
261 }
262 false
263 }) {
264 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
265 for arg in &call_config.args {
266 if arg.arg_type == "json_object" {
267 if let Some(ref elem_type) = arg.element_type {
268 element_types.insert(elem_type.clone());
269 }
270 }
271 }
272 }
273 for elem_type in &element_types {
274 if matches!(
277 elem_type.as_str(),
278 "String"
279 | "str"
280 | "bool"
281 | "i8"
282 | "i16"
283 | "i32"
284 | "i64"
285 | "i128"
286 | "isize"
287 | "u8"
288 | "u16"
289 | "u32"
290 | "u64"
291 | "u128"
292 | "usize"
293 | "f32"
294 | "f64"
295 | "char"
296 ) {
297 continue;
298 }
299 if body_references_symbol(&body_buf, elem_type) {
300 let _ = writeln!(out, "use {module}::{elem_type};");
301 }
302 }
303 }
304
305 let _ = writeln!(out);
306 out.push_str(&body_buf);
307
308 if !out.ends_with('\n') {
309 out.push('\n');
310 }
311 out
312}
313
314pub fn render_test_function(
315 out: &mut String,
316 fixture: &Fixture,
317 e2e_config: &E2eConfig,
318 dep_name: &str,
319 field_resolver: &FieldResolver,
320 client_factory: Option<&str>,
321) {
322 if fixture.http.is_some() {
324 render_http_test_function(out, fixture, dep_name);
325 return;
326 }
327
328 if fixture.http.is_none() && fixture.mock_response.is_none() {
334 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
335 let resolved_fn_name = resolve_function_name_for_call(call_config);
336 if resolved_fn_name.is_empty() {
337 let fn_name = crate::escape::sanitize_ident(&fixture.id);
338 let description = &fixture.description;
339 let _ = writeln!(out, "#[tokio::test]");
340 let _ = writeln!(out, "async fn test_{fn_name}() {{");
341 let _ = writeln!(out, " // {description}");
342 let _ = writeln!(
343 out,
344 " // TODO: implement when a callable API is available for this fixture type."
345 );
346 let _ = writeln!(out, "}}");
347 return;
348 }
349 }
351
352 let fn_name = crate::escape::sanitize_ident(&fixture.id);
353 let description = &fixture.description;
354 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
355 let function_name = resolve_function_name_for_call(call_config);
356 let module = resolve_module_for_call(call_config, dep_name);
357 let result_var = &call_config.result_var;
358 let has_mock = fixture.mock_response.is_some();
359
360 let rust_overrides = call_config.overrides.get("rust");
362
363 let returns_result = rust_overrides
366 .and_then(|o| o.returns_result)
367 .unwrap_or(if client_factory.is_some() {
368 true
369 } else {
370 call_config.returns_result
371 });
372
373 let is_async = call_config.r#async || has_mock;
375 if is_async {
376 let _ = writeln!(out, "#[tokio::test]");
377 let _ = writeln!(out, "async fn test_{fn_name}() {{");
378 } else {
379 let _ = writeln!(out, "#[test]");
380 let _ = writeln!(out, "fn test_{fn_name}() {{");
381 }
382 let _ = writeln!(out, " // {description}");
383
384 let final_out = out;
390 let mut body_buf = String::new();
391 let out = &mut body_buf;
392
393 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
395
396 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
398 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
399 let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
403
404 let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
409
410 let mut arg_exprs: Vec<String> = Vec::new();
412 let mut options_arg_name: Option<String> = None;
414 let mut error_context_handle_name: Option<String> = None;
417 for arg in &call_config.args {
418 let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
419 let var_name = &arg.name;
420 let (mut bindings, expr) = render_rust_arg(
421 var_name,
422 value,
423 &arg.arg_type,
424 arg.optional,
425 &module,
426 &fixture.id,
427 if has_mock {
428 Some("mock_server.url.as_str()")
429 } else {
430 None
431 },
432 arg.owned,
433 arg.element_type.as_deref(),
434 &e2e_config.test_documents_dir,
435 has_error_assertion,
436 );
437 if arg.arg_type == "json_object" {
441 if let Some(ref opts_type) = options_type {
442 bindings = bindings
443 .into_iter()
444 .map(|b| {
445 let prefix = format!("let {var_name} = ");
447 if b.starts_with(&prefix) {
448 format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
449 } else {
450 b
451 }
452 })
453 .collect();
454 }
455 }
456 if visitor_via_options && arg.arg_type == "json_object" {
459 options_arg_name = Some(var_name.clone());
460 bindings = bindings
461 .into_iter()
462 .map(|b| {
463 let prefix = format!("let {var_name}");
465 if b.starts_with(&prefix) {
466 format!("let mut {}", &b[4..])
467 } else {
468 b
469 }
470 })
471 .collect();
472 }
473 if has_error_assertion && arg.arg_type == "handle" {
477 error_context_handle_name = Some(var_name.clone());
478 }
479 for binding in &bindings {
480 let _ = writeln!(out, " {binding}");
481 }
482 let final_expr = if has_error_assertion && arg.arg_type == "handle" {
486 format!("&{var_name}")
490 } else if wrap_options_in_some && arg.arg_type == "json_object" {
491 if visitor_via_options {
492 let name = if let Some(rest) = expr.strip_prefix('&') {
495 rest.to_string()
496 } else {
497 expr.clone()
498 };
499 format!("Some({name})")
500 } else if let Some(rest) = expr.strip_prefix('&') {
501 format!("Some({rest}.clone())")
502 } else {
503 format!("Some({expr})")
504 }
505 } else {
506 expr
507 };
508 arg_exprs.push(final_expr);
509 }
510
511 if let Some(visitor_spec) = &fixture.visitor {
513 let visitor_trait = resolve_visitor_trait(rust_overrides)
516 .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
517 let _ = writeln!(out, " #[derive(Debug)]");
519 let _ = writeln!(out, " struct _TestVisitor;");
520 let _ = writeln!(out, " impl {visitor_trait} for _TestVisitor {{");
521 for (method_name, action) in &visitor_spec.callbacks {
522 emit_rust_visitor_method(out, method_name, action);
523 }
524 let _ = writeln!(out, " }}");
525 let _ = writeln!(
526 out,
527 " let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
528 );
529 if visitor_via_options {
530 let opts_name = options_arg_name.as_deref().unwrap_or("options");
532 let _ = writeln!(out, " {opts_name}.visitor = Some(visitor);");
533 } else {
534 arg_exprs.push("Some(visitor)".to_string());
536 }
537 } else {
538 arg_exprs.extend(extra_args);
541 }
542
543 let args_str = arg_exprs.join(", ");
544
545 let await_suffix = if is_async { ".await" } else { "" };
546
547 let call_expr = if let Some(factory) = client_factory {
551 let base_url_arg = if has_mock {
552 "Some(mock_server.url.clone())"
553 } else {
554 "None"
555 };
556 let _ = writeln!(
557 out,
558 " let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
559 );
560 format!("client.{function_name}({args_str})")
561 } else {
562 format!("{function_name}({args_str})")
563 };
564
565 let result_is_tree = call_config.result_var == "tree";
566 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
570 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
573 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
576
577 if has_error_assertion {
578 if let Some(ref handle_name) = error_context_handle_name {
582 let _ = writeln!(out, " let {result_var} = match {handle_name}_result {{");
583 let _ = writeln!(out, " Err(e) => Err(e),");
584 let _ = writeln!(out, " Ok({handle_name}) => {{");
585 let _ = writeln!(out, " {call_expr}{await_suffix}");
586 let _ = writeln!(out, " }}");
587 let _ = writeln!(out, " }};");
588 } else {
589 let _ = writeln!(out, " let {result_var} = {call_expr}{await_suffix};");
590 }
591 let has_non_error_assertions = fixture.assertions.iter().any(|a| {
594 !matches!(a.assertion_type.as_str(), "error" | "not_error")
595 && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
596 });
597 if returns_result && has_non_error_assertions {
600 let _ = writeln!(out, " let {result_var}_ok = {result_var}.as_ref().ok();");
602 }
603 for assertion in &fixture.assertions {
605 render_assertion(
606 out,
607 assertion,
608 result_var,
609 &module,
610 dep_name,
611 true,
612 &[],
613 field_resolver,
614 result_is_tree,
615 result_is_simple,
616 false,
617 false,
618 returns_result,
619 );
620 }
621 let _ = writeln!(out, "}}");
622 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
623 return;
624 }
625
626 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
628
629 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
634 let stream_var = "stream";
636 let chunks_var = "chunks";
638
639 let has_usable_assertion = fixture.assertions.iter().any(|a| {
645 if a.assertion_type == "not_error" || a.assertion_type == "error" {
646 return false;
647 }
648 if a.assertion_type == "method_result" {
649 let supported_checks = [
652 "equals",
653 "is_true",
654 "is_false",
655 "greater_than_or_equal",
656 "count_min",
657 "is_error",
658 "contains",
659 "not_empty",
660 "is_empty",
661 ];
662 let check = a.check.as_deref().unwrap_or("is_true");
663 if a.method.is_none() || !supported_checks.contains(&check) {
664 return false;
665 }
666 }
667 match &a.field {
668 Some(f) if !f.is_empty() => {
669 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
670 return true;
671 }
672 field_resolver.is_valid_for_result(f)
673 }
674 _ => true,
675 }
676 });
677
678 let result_binding = if is_streaming {
681 stream_var.to_string()
682 } else if has_usable_assertion {
683 result_var.to_string()
684 } else {
685 "_".to_string()
686 };
687
688 let has_field_access = fixture
692 .assertions
693 .iter()
694 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
695 let only_emptiness_checks = !has_field_access
696 && fixture.assertions.iter().all(|a| {
697 matches!(
698 a.assertion_type.as_str(),
699 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
700 )
701 });
702
703 let unwrap_suffix = if returns_result {
704 ".expect(\"should succeed\")"
705 } else {
706 ""
707 };
708 if is_streaming {
709 let _ = writeln!(out, " let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
711 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
712 "rust", stream_var, chunks_var,
713 ) {
714 let _ = writeln!(out, " {collect}");
715 }
716 } else if !returns_result || (only_emptiness_checks && !has_not_error) {
717 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
720 } else if has_not_error || !fixture.assertions.is_empty() {
721 let _ = writeln!(
722 out,
723 " let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
724 );
725 } else {
726 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
727 }
728
729 let string_assertion_types = [
735 "equals",
736 "contains",
737 "contains_all",
738 "contains_any",
739 "not_contains",
740 "starts_with",
741 "ends_with",
742 "min_length",
743 "max_length",
744 "matches_regex",
745 ];
746 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
748 for assertion in &fixture.assertions {
749 if let Some(f) = &assertion.field {
750 if !f.is_empty()
751 && string_assertion_types.contains(&assertion.assertion_type.as_str())
752 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
753 {
754 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
757 if !is_string_assertion {
758 continue;
759 }
760 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
761 let _ = writeln!(out, " {binding}");
762 unwrapped_fields.push((f.clone(), local_var));
763 }
764 }
765 }
766 }
767 }
768
769 for assertion in &fixture.assertions {
771 if assertion.assertion_type == "not_error" {
772 continue;
774 }
775 render_assertion(
776 out,
777 assertion,
778 result_var,
779 &module,
780 dep_name,
781 false,
782 &unwrapped_fields,
783 field_resolver,
784 result_is_tree,
785 result_is_simple,
786 result_is_vec,
787 result_is_option,
788 returns_result,
789 );
790 }
791
792 let _ = writeln!(out, "}}");
793 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
794}
795
796fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
804 if has_mock {
805 let var_name = if body.contains("mock_server.") {
806 "mock_server"
807 } else {
808 "_mock_server"
809 };
810 render_mock_server_setup(out, fixture, e2e_config, var_name);
811 }
812 out.push_str(body);
813}
814
815pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
817 groups
818 .iter()
819 .filter(|g| !g.fixtures.is_empty())
820 .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
821 .collect()
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn resolve_module_for_call_prefers_crate_name_override() {
830 use crate::config::CallConfig;
831 use std::collections::HashMap;
832 let mut overrides = HashMap::new();
833 overrides.insert(
834 "rust".to_string(),
835 crate::config::CallOverride {
836 crate_name: Some("custom_crate".to_string()),
837 module: Some("ignored_module".to_string()),
838 ..Default::default()
839 },
840 );
841 let call = CallConfig {
842 overrides,
843 ..Default::default()
844 };
845 let result = resolve_module_for_call(&call, "dep_name");
846 assert_eq!(result, "custom_crate");
847 }
848}