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
41pub fn render_test_file(
42 category: &str,
43 fixtures: &[&Fixture],
44 e2e_config: &E2eConfig,
45 dep_name: &str,
46 needs_mock_server: bool,
47) -> String {
48 let mut out = String::new();
49 out.push_str(&alef_core::hash::header(alef_core::hash::CommentStyle::DoubleSlash));
50 let _ = writeln!(out, "//! E2e tests for category: {category}");
51 let _ = writeln!(out);
52
53 let module = resolve_module(e2e_config, dep_name);
54 let field_resolver = FieldResolver::new(
55 &e2e_config.fields,
56 &e2e_config.fields_optional,
57 &e2e_config.result_fields,
58 &e2e_config.fields_array,
59 &e2e_config.fields_method_calls,
60 );
61
62 let file_has_http = fixtures.iter().any(|f| f.http.is_some());
64 let file_has_call_based = fixtures.iter().any(|f| {
67 if f.mock_response.is_some() {
68 return true;
69 }
70 if f.http.is_none() && f.mock_response.is_none() {
71 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
72 let fn_name = resolve_function_name_for_call(call_config);
73 return !fn_name.is_empty();
74 }
75 false
76 });
77
78 let rust_call_override = e2e_config.call.overrides.get("rust");
83 let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
84
85 if file_has_call_based && client_factory.is_none() {
87 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
88 for fixture in fixtures.iter().filter(|f| {
89 if f.mock_response.is_some() {
90 return true;
91 }
92 if f.http.is_none() && f.mock_response.is_none() {
93 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
94 let fn_name = resolve_function_name_for_call(call_config);
95 return !fn_name.is_empty();
96 }
97 false
98 }) {
99 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
100 let fn_name = resolve_function_name_for_call(call_config);
101 let mod_name = resolve_module_for_call(call_config, dep_name);
102 imported.insert((mod_name, fn_name));
103 }
104 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
106 for (mod_name, fn_name) in &imported {
107 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
108 }
109 for (mod_name, fns) in &by_module {
110 if fns.len() == 1 {
111 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
112 } else {
113 let joined = fns.join(", ");
114 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
115 }
116 }
117 }
118
119 if file_has_http {
121 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
122 }
123
124 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
126 if has_handle_args {
127 let _ = writeln!(out, "use {module}::CrawlConfig;");
128 }
129 for arg in &e2e_config.call.args {
130 if arg.arg_type == "handle" {
131 use heck::ToSnakeCase;
132 let constructor_name = format!("create_{}", arg.name.to_snake_case());
133 let _ = writeln!(out, "use {module}::{constructor_name};");
134 }
135 }
136
137 if client_factory.is_some() && file_has_call_based {
140 let trait_imports: Vec<String> = e2e_config
141 .call
142 .overrides
143 .get("rust")
144 .map(|o| o.trait_imports.clone())
145 .unwrap_or_default();
146 for trait_name in &trait_imports {
147 let _ = writeln!(out, "use {module}::{trait_name};");
148 }
149 }
150
151 let file_needs_mock = needs_mock_server
153 && fixtures
154 .iter()
155 .any(|f| f.mock_response.is_some() || f.needs_mock_server());
156 if file_needs_mock {
157 let _ = writeln!(out, "mod common;");
158 let _ = writeln!(out, "mod mock_server;");
159 let _ = writeln!(out, "#[allow(unused_imports)]");
160 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
161 }
162
163 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
170 if file_needs_visitor {
171 let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
172 panic!(
173 "category '{}': fixture declares a visitor block but \
174 `[e2e.call.overrides.rust] visitor_trait` is not configured",
175 category
176 )
177 });
178 let _ = writeln!(
179 out,
180 "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
181 );
182 }
183
184 if file_has_call_based {
189 let rust_options_type = e2e_config
190 .call
191 .overrides
192 .get("rust")
193 .and_then(|o| o.options_type.as_deref());
194 if let Some(opts_type) = rust_options_type {
195 let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
198 if has_json_object_arg {
199 let _ = writeln!(out, "use {module}::{opts_type};");
200 }
201 }
202 }
203
204 if file_has_call_based {
208 let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
209 for fixture in fixtures.iter().filter(|f| {
210 if f.mock_response.is_some() {
211 return true;
212 }
213 if f.http.is_none() && f.mock_response.is_none() {
214 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
215 let fn_name = resolve_function_name_for_call(call_config);
216 return !fn_name.is_empty();
217 }
218 false
219 }) {
220 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
221 for arg in &call_config.args {
222 if arg.arg_type == "json_object" {
223 if let Some(ref elem_type) = arg.element_type {
224 element_types.insert(elem_type.clone());
225 }
226 }
227 }
228 }
229 for elem_type in &element_types {
230 if matches!(
233 elem_type.as_str(),
234 "String"
235 | "str"
236 | "bool"
237 | "i8"
238 | "i16"
239 | "i32"
240 | "i64"
241 | "i128"
242 | "isize"
243 | "u8"
244 | "u16"
245 | "u32"
246 | "u64"
247 | "u128"
248 | "usize"
249 | "f32"
250 | "f64"
251 | "char"
252 ) {
253 continue;
254 }
255 let _ = writeln!(out, "use {module}::{elem_type};");
256 }
257 }
258
259 let _ = writeln!(out);
260
261 for fixture in fixtures {
262 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver, client_factory);
263 let _ = writeln!(out);
264 }
265
266 if !out.ends_with('\n') {
267 out.push('\n');
268 }
269 out
270}
271
272pub fn render_test_function(
273 out: &mut String,
274 fixture: &Fixture,
275 e2e_config: &E2eConfig,
276 dep_name: &str,
277 field_resolver: &FieldResolver,
278 client_factory: Option<&str>,
279) {
280 if fixture.http.is_some() {
282 render_http_test_function(out, fixture, dep_name);
283 return;
284 }
285
286 if fixture.http.is_none() && fixture.mock_response.is_none() {
292 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
293 let resolved_fn_name = resolve_function_name_for_call(call_config);
294 if resolved_fn_name.is_empty() {
295 let fn_name = crate::escape::sanitize_ident(&fixture.id);
296 let description = &fixture.description;
297 let _ = writeln!(out, "#[tokio::test]");
298 let _ = writeln!(out, "async fn test_{fn_name}() {{");
299 let _ = writeln!(out, " // {description}");
300 let _ = writeln!(
301 out,
302 " // TODO: implement when a callable API is available for this fixture type."
303 );
304 let _ = writeln!(out, "}}");
305 return;
306 }
307 }
309
310 let fn_name = crate::escape::sanitize_ident(&fixture.id);
311 let description = &fixture.description;
312 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
313 let function_name = resolve_function_name_for_call(call_config);
314 let module = resolve_module_for_call(call_config, dep_name);
315 let result_var = &call_config.result_var;
316 let has_mock = fixture.mock_response.is_some();
317
318 let rust_overrides = call_config.overrides.get("rust");
320
321 let returns_result = rust_overrides
324 .and_then(|o| o.returns_result)
325 .unwrap_or(if client_factory.is_some() {
326 true
327 } else {
328 call_config.returns_result
329 });
330
331 let is_async = call_config.r#async || has_mock;
333 if is_async {
334 let _ = writeln!(out, "#[tokio::test]");
335 let _ = writeln!(out, "async fn test_{fn_name}() {{");
336 } else {
337 let _ = writeln!(out, "#[test]");
338 let _ = writeln!(out, "fn test_{fn_name}() {{");
339 }
340 let _ = writeln!(out, " // {description}");
341
342 let final_out = out;
348 let mut body_buf = String::new();
349 let out = &mut body_buf;
350
351 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
353
354 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
356 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
357 let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
361
362 let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
367
368 let mut arg_exprs: Vec<String> = Vec::new();
370 let mut options_arg_name: Option<String> = None;
372 let mut error_context_handle_name: Option<String> = None;
375 for arg in &call_config.args {
376 let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
377 let var_name = &arg.name;
378 let (mut bindings, expr) = render_rust_arg(
379 var_name,
380 value,
381 &arg.arg_type,
382 arg.optional,
383 &module,
384 &fixture.id,
385 if has_mock {
386 Some("mock_server.url.as_str()")
387 } else {
388 None
389 },
390 arg.owned,
391 arg.element_type.as_deref(),
392 &e2e_config.test_documents_dir,
393 has_error_assertion,
394 );
395 if arg.arg_type == "json_object" {
399 if let Some(ref opts_type) = options_type {
400 bindings = bindings
401 .into_iter()
402 .map(|b| {
403 let prefix = format!("let {var_name} = ");
405 if b.starts_with(&prefix) {
406 format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
407 } else {
408 b
409 }
410 })
411 .collect();
412 }
413 }
414 if visitor_via_options && arg.arg_type == "json_object" {
417 options_arg_name = Some(var_name.clone());
418 bindings = bindings
419 .into_iter()
420 .map(|b| {
421 let prefix = format!("let {var_name}");
423 if b.starts_with(&prefix) {
424 format!("let mut {}", &b[4..])
425 } else {
426 b
427 }
428 })
429 .collect();
430 }
431 if has_error_assertion && arg.arg_type == "handle" {
435 error_context_handle_name = Some(var_name.clone());
436 }
437 for binding in &bindings {
438 let _ = writeln!(out, " {binding}");
439 }
440 let final_expr = if has_error_assertion && arg.arg_type == "handle" {
444 format!("&{var_name}")
448 } else if wrap_options_in_some && arg.arg_type == "json_object" {
449 if visitor_via_options {
450 let name = if let Some(rest) = expr.strip_prefix('&') {
453 rest.to_string()
454 } else {
455 expr.clone()
456 };
457 format!("Some({name})")
458 } else if let Some(rest) = expr.strip_prefix('&') {
459 format!("Some({rest}.clone())")
460 } else {
461 format!("Some({expr})")
462 }
463 } else {
464 expr
465 };
466 arg_exprs.push(final_expr);
467 }
468
469 if let Some(visitor_spec) = &fixture.visitor {
471 let visitor_trait = resolve_visitor_trait(rust_overrides)
474 .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
475 let _ = writeln!(out, " #[derive(Debug)]");
477 let _ = writeln!(out, " struct _TestVisitor;");
478 let _ = writeln!(out, " impl {visitor_trait} for _TestVisitor {{");
479 for (method_name, action) in &visitor_spec.callbacks {
480 emit_rust_visitor_method(out, method_name, action);
481 }
482 let _ = writeln!(out, " }}");
483 let _ = writeln!(
484 out,
485 " let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
486 );
487 if visitor_via_options {
488 let opts_name = options_arg_name.as_deref().unwrap_or("options");
490 let _ = writeln!(out, " {opts_name}.visitor = Some(visitor);");
491 } else {
492 arg_exprs.push("Some(visitor)".to_string());
494 }
495 } else {
496 arg_exprs.extend(extra_args);
499 }
500
501 let args_str = arg_exprs.join(", ");
502
503 let await_suffix = if is_async { ".await" } else { "" };
504
505 let call_expr = if let Some(factory) = client_factory {
509 let base_url_arg = if has_mock {
510 "Some(mock_server.url.clone())"
511 } else {
512 "None"
513 };
514 let _ = writeln!(
515 out,
516 " let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
517 );
518 format!("client.{function_name}({args_str})")
519 } else {
520 format!("{function_name}({args_str})")
521 };
522
523 let result_is_tree = call_config.result_var == "tree";
524 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
528 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
531 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
534
535 if has_error_assertion {
536 if let Some(ref handle_name) = error_context_handle_name {
540 let _ = writeln!(out, " let {result_var} = match {handle_name}_result {{");
541 let _ = writeln!(out, " Err(e) => Err(e),");
542 let _ = writeln!(out, " Ok({handle_name}) => {{");
543 let _ = writeln!(out, " {call_expr}{await_suffix}");
544 let _ = writeln!(out, " }}");
545 let _ = writeln!(out, " }};");
546 } else {
547 let _ = writeln!(out, " let {result_var} = {call_expr}{await_suffix};");
548 }
549 let has_non_error_assertions = fixture.assertions.iter().any(|a| {
552 !matches!(a.assertion_type.as_str(), "error" | "not_error")
553 && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
554 });
555 if returns_result && has_non_error_assertions {
558 let _ = writeln!(out, " let {result_var}_ok = {result_var}.as_ref().ok();");
560 }
561 for assertion in &fixture.assertions {
563 render_assertion(
564 out,
565 assertion,
566 result_var,
567 &module,
568 dep_name,
569 true,
570 &[],
571 field_resolver,
572 result_is_tree,
573 result_is_simple,
574 false,
575 false,
576 returns_result,
577 );
578 }
579 let _ = writeln!(out, "}}");
580 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
581 return;
582 }
583
584 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
586
587 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
592 let stream_var = "stream";
594 let chunks_var = "chunks";
596
597 let has_usable_assertion = fixture.assertions.iter().any(|a| {
603 if a.assertion_type == "not_error" || a.assertion_type == "error" {
604 return false;
605 }
606 if a.assertion_type == "method_result" {
607 let supported_checks = [
610 "equals",
611 "is_true",
612 "is_false",
613 "greater_than_or_equal",
614 "count_min",
615 "is_error",
616 "contains",
617 "not_empty",
618 "is_empty",
619 ];
620 let check = a.check.as_deref().unwrap_or("is_true");
621 if a.method.is_none() || !supported_checks.contains(&check) {
622 return false;
623 }
624 }
625 match &a.field {
626 Some(f) if !f.is_empty() => {
627 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
628 return true;
629 }
630 field_resolver.is_valid_for_result(f)
631 }
632 _ => true,
633 }
634 });
635
636 let result_binding = if is_streaming {
639 stream_var.to_string()
640 } else if has_usable_assertion {
641 result_var.to_string()
642 } else {
643 "_".to_string()
644 };
645
646 let has_field_access = fixture
650 .assertions
651 .iter()
652 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
653 let only_emptiness_checks = !has_field_access
654 && fixture.assertions.iter().all(|a| {
655 matches!(
656 a.assertion_type.as_str(),
657 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
658 )
659 });
660
661 let unwrap_suffix = if returns_result {
662 ".expect(\"should succeed\")"
663 } else {
664 ""
665 };
666 if is_streaming {
667 let _ = writeln!(out, " let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
669 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
670 "rust", stream_var, chunks_var,
671 ) {
672 let _ = writeln!(out, " {collect}");
673 }
674 } else if !returns_result || (only_emptiness_checks && !has_not_error) {
675 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
678 } else if has_not_error || !fixture.assertions.is_empty() {
679 let _ = writeln!(
680 out,
681 " let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
682 );
683 } else {
684 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
685 }
686
687 let string_assertion_types = [
693 "equals",
694 "contains",
695 "contains_all",
696 "contains_any",
697 "not_contains",
698 "starts_with",
699 "ends_with",
700 "min_length",
701 "max_length",
702 "matches_regex",
703 ];
704 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
706 for assertion in &fixture.assertions {
707 if let Some(f) = &assertion.field {
708 if !f.is_empty()
709 && string_assertion_types.contains(&assertion.assertion_type.as_str())
710 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
711 {
712 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
715 if !is_string_assertion {
716 continue;
717 }
718 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
719 let _ = writeln!(out, " {binding}");
720 unwrapped_fields.push((f.clone(), local_var));
721 }
722 }
723 }
724 }
725 }
726
727 for assertion in &fixture.assertions {
729 if assertion.assertion_type == "not_error" {
730 continue;
732 }
733 render_assertion(
734 out,
735 assertion,
736 result_var,
737 &module,
738 dep_name,
739 false,
740 &unwrapped_fields,
741 field_resolver,
742 result_is_tree,
743 result_is_simple,
744 result_is_vec,
745 result_is_option,
746 returns_result,
747 );
748 }
749
750 let _ = writeln!(out, "}}");
751 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
752}
753
754fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
762 if has_mock {
763 let var_name = if body.contains("mock_server.") {
764 "mock_server"
765 } else {
766 "_mock_server"
767 };
768 render_mock_server_setup(out, fixture, e2e_config, var_name);
769 }
770 out.push_str(body);
771}
772
773pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
775 groups
776 .iter()
777 .filter(|g| !g.fixtures.is_empty())
778 .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
779 .collect()
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn resolve_module_for_call_prefers_crate_name_override() {
788 use crate::config::CallConfig;
789 use std::collections::HashMap;
790 let mut overrides = HashMap::new();
791 overrides.insert(
792 "rust".to_string(),
793 crate::config::CallOverride {
794 crate_name: Some("custom_crate".to_string()),
795 module: Some("ignored_module".to_string()),
796 ..Default::default()
797 },
798 );
799 let call = CallConfig {
800 overrides,
801 ..Default::default()
802 };
803 let result = resolve_module_for_call(&call, "dep_name");
804 assert_eq!(result, "custom_crate");
805 }
806}