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
81 let file_has_call_based = fixtures.iter().any(|f| {
84 if f.mock_response.is_some() {
85 return true;
86 }
87 if f.http.is_none() && f.mock_response.is_none() {
88 let call_config = e2e_config.resolve_call_for_fixture(
89 f.call.as_deref(),
90 &f.id,
91 &f.resolved_category(),
92 &f.tags,
93 &f.input,
94 );
95 let fn_name = resolve_function_name_for_call(call_config);
96 return !fn_name.is_empty();
97 }
98 false
99 });
100
101 let rust_call_override = e2e_config.call.overrides.get("rust");
106 let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
107
108 if file_has_call_based && client_factory.is_none() {
110 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
111 for fixture in fixtures.iter().filter(|f| {
112 if f.mock_response.is_some() {
113 return true;
114 }
115 if f.http.is_none() && f.mock_response.is_none() {
116 let call_config = e2e_config.resolve_call_for_fixture(
117 f.call.as_deref(),
118 &f.id,
119 &f.resolved_category(),
120 &f.tags,
121 &f.input,
122 );
123 let fn_name = resolve_function_name_for_call(call_config);
124 return !fn_name.is_empty();
125 }
126 false
127 }) {
128 let call_config = e2e_config.resolve_call_for_fixture(
129 fixture.call.as_deref(),
130 &fixture.id,
131 &fixture.resolved_category(),
132 &fixture.tags,
133 &fixture.input,
134 );
135 let fn_name = resolve_function_name_for_call(call_config);
136 let mod_name = resolve_module_for_call(call_config, dep_name);
137 imported.insert((mod_name, fn_name));
138 }
139 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
141 for (mod_name, fn_name) in &imported {
142 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
143 }
144 for (mod_name, fns) in &by_module {
145 if fns.len() == 1 {
146 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
147 } else {
148 let joined = fns.join(", ");
149 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
150 }
151 }
152 }
153
154 let mut body_buf = String::new();
162 for fixture in fixtures {
163 render_test_function(&mut body_buf, fixture, e2e_config, dep_name, client_factory);
164 let _ = writeln!(body_buf);
165 }
166
167 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
169 if has_handle_args && body_references_symbol(&body_buf, "CrawlConfig") {
170 let _ = writeln!(out, "use {module}::CrawlConfig;");
171 }
172 for arg in &e2e_config.call.args {
173 if arg.arg_type == "handle" {
174 use heck::ToSnakeCase;
175 let constructor_name = format!("create_{}", arg.name.to_snake_case());
176 if body_references_symbol(&body_buf, &constructor_name) {
177 let _ = writeln!(out, "use {module}::{constructor_name};");
178 }
179 }
180 }
181
182 if client_factory.is_some() && file_has_call_based {
185 let trait_imports: Vec<String> = e2e_config
186 .call
187 .overrides
188 .get("rust")
189 .map(|o| o.trait_imports.clone())
190 .unwrap_or_default();
191 for trait_name in &trait_imports {
192 let _ = writeln!(out, "use {module}::{trait_name};");
193 }
194 }
195
196 let file_needs_mock = needs_mock_server
198 && fixtures
199 .iter()
200 .any(|f| f.mock_response.is_some() || f.needs_mock_server());
201 if file_needs_mock {
202 let _ = writeln!(out, "mod common;");
203 let _ = writeln!(out, "mod mock_server;");
204 let _ = writeln!(out, "#[allow(unused_imports)]");
205 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
206 }
207
208 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
215 if file_needs_visitor {
216 let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
217 panic!(
218 "category '{}': fixture declares a visitor block but \
219 `[e2e.call.overrides.rust] visitor_trait` is not configured",
220 category
221 )
222 });
223 let _ = writeln!(
224 out,
225 "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
226 );
227 }
228
229 if file_has_call_based {
234 let rust_options_type = e2e_config
235 .call
236 .overrides
237 .get("rust")
238 .and_then(|o| o.options_type.as_deref());
239 if let Some(opts_type) = rust_options_type {
240 let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
243 if has_json_object_arg && body_references_symbol(&body_buf, opts_type) {
244 let _ = writeln!(out, "use {module}::{opts_type};");
245 }
246 }
247 }
248
249 if file_has_call_based {
253 let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
254 for fixture in fixtures.iter().filter(|f| {
255 if f.mock_response.is_some() {
256 return true;
257 }
258 if f.http.is_none() && f.mock_response.is_none() {
259 let call_config = e2e_config.resolve_call_for_fixture(
260 f.call.as_deref(),
261 &f.id,
262 &f.resolved_category(),
263 &f.tags,
264 &f.input,
265 );
266 let fn_name = resolve_function_name_for_call(call_config);
267 return !fn_name.is_empty();
268 }
269 false
270 }) {
271 let call_config = e2e_config.resolve_call_for_fixture(
272 fixture.call.as_deref(),
273 &fixture.id,
274 &fixture.resolved_category(),
275 &fixture.tags,
276 &fixture.input,
277 );
278 for arg in &call_config.args {
279 if arg.arg_type == "json_object" {
280 if let Some(ref elem_type) = arg.element_type {
281 element_types.insert(elem_type.clone());
282 }
283 }
284 }
285 }
286 for elem_type in &element_types {
287 if matches!(
290 elem_type.as_str(),
291 "String"
292 | "str"
293 | "bool"
294 | "i8"
295 | "i16"
296 | "i32"
297 | "i64"
298 | "i128"
299 | "isize"
300 | "u8"
301 | "u16"
302 | "u32"
303 | "u64"
304 | "u128"
305 | "usize"
306 | "f32"
307 | "f64"
308 | "char"
309 ) {
310 continue;
311 }
312 if body_references_symbol(&body_buf, elem_type) {
313 let _ = writeln!(out, "use {module}::{elem_type};");
314 }
315 }
316 }
317
318 let _ = writeln!(out);
319 out.push_str(&body_buf);
320
321 if !out.ends_with('\n') {
322 out.push('\n');
323 }
324 out
325}
326
327pub fn render_test_function(
328 out: &mut String,
329 fixture: &Fixture,
330 e2e_config: &E2eConfig,
331 dep_name: &str,
332 client_factory: Option<&str>,
333) {
334 if fixture.http.is_some() {
336 render_http_test_function(out, fixture, dep_name);
337 return;
338 }
339
340 if fixture.http.is_none() && fixture.mock_response.is_none() {
346 let call_config = e2e_config.resolve_call_for_fixture(
347 fixture.call.as_deref(),
348 &fixture.id,
349 &fixture.resolved_category(),
350 &fixture.tags,
351 &fixture.input,
352 );
353 let resolved_fn_name = resolve_function_name_for_call(call_config);
354 if resolved_fn_name.is_empty() {
355 let fn_name = crate::escape::sanitize_ident(&fixture.id);
356 let description = &fixture.description;
357 let _ = writeln!(out, "#[tokio::test]");
358 let _ = writeln!(out, "async fn test_{fn_name}() {{");
359 let _ = writeln!(out, " // {description}");
360 let _ = writeln!(
361 out,
362 " // TODO: implement when a callable API is available for this fixture type."
363 );
364 let _ = writeln!(out, "}}");
365 return;
366 }
367 }
369
370 let fn_name = crate::escape::sanitize_ident(&fixture.id);
371 let description = &fixture.description;
372 let call_config = e2e_config.resolve_call_for_fixture(
373 fixture.call.as_deref(),
374 &fixture.id,
375 &fixture.resolved_category(),
376 &fixture.tags,
377 &fixture.input,
378 );
379 let call_field_resolver = FieldResolver::new(
383 e2e_config.effective_fields(call_config),
384 e2e_config.effective_fields_optional(call_config),
385 e2e_config.effective_result_fields(call_config),
386 e2e_config.effective_fields_array(call_config),
387 e2e_config.effective_fields_method_calls(call_config),
388 );
389 let field_resolver = &call_field_resolver;
390 let function_name = resolve_function_name_for_call(call_config);
391 let module = resolve_module_for_call(call_config, dep_name);
392 let result_var = &call_config.result_var;
393 let has_mock = fixture.mock_response.is_some();
394
395 let rust_overrides = call_config.overrides.get("rust");
397
398 let returns_result = rust_overrides
401 .and_then(|o| o.returns_result)
402 .unwrap_or(if client_factory.is_some() {
403 true
404 } else {
405 call_config.returns_result
406 });
407
408 let is_async = call_config.r#async || has_mock;
410 if is_async {
411 let _ = writeln!(out, "#[tokio::test]");
412 let _ = writeln!(out, "async fn test_{fn_name}() {{");
413 } else {
414 let _ = writeln!(out, "#[test]");
415 let _ = writeln!(out, "fn test_{fn_name}() {{");
416 }
417 let _ = writeln!(out, " // {description}");
418
419 let final_out = out;
425 let mut body_buf = String::new();
426 let out = &mut body_buf;
427
428 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
430
431 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
433 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
434 let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
438
439 let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
444
445 let mut arg_exprs: Vec<String> = Vec::new();
447 let mut options_arg_name: Option<String> = None;
449 let mut error_context_handle_name: Option<String> = None;
452 for arg in &call_config.args {
453 let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
454 let var_name = &arg.name;
455 let (mut bindings, expr) = render_rust_arg(
456 var_name,
457 value,
458 &arg.arg_type,
459 arg.optional,
460 &module,
461 &fixture.id,
462 if has_mock {
463 Some("mock_server.url.as_str()")
464 } else {
465 None
466 },
467 arg.owned,
468 arg.element_type.as_deref(),
469 &e2e_config.test_documents_dir,
470 has_error_assertion,
471 );
472 if arg.arg_type == "json_object" {
476 if let Some(ref opts_type) = options_type {
477 bindings = bindings
478 .into_iter()
479 .map(|b| {
480 let prefix = format!("let {var_name} = ");
482 if b.starts_with(&prefix) {
483 format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
484 } else {
485 b
486 }
487 })
488 .collect();
489 }
490 }
491 if visitor_via_options && arg.arg_type == "json_object" {
494 options_arg_name = Some(var_name.clone());
495 bindings = bindings
496 .into_iter()
497 .map(|b| {
498 let prefix = format!("let {var_name}");
500 if b.starts_with(&prefix) {
501 format!("let mut {}", &b[4..])
502 } else {
503 b
504 }
505 })
506 .collect();
507 }
508 if has_error_assertion && arg.arg_type == "handle" {
512 error_context_handle_name = Some(var_name.clone());
513 }
514 for binding in &bindings {
515 let _ = writeln!(out, " {binding}");
516 }
517 let final_expr = if has_error_assertion && arg.arg_type == "handle" {
521 format!("&{var_name}")
525 } else if wrap_options_in_some && arg.arg_type == "json_object" {
526 if visitor_via_options {
527 let name = if let Some(rest) = expr.strip_prefix('&') {
530 rest.to_string()
531 } else {
532 expr.clone()
533 };
534 format!("Some({name})")
535 } else if let Some(rest) = expr.strip_prefix('&') {
536 format!("Some({rest}.clone())")
537 } else {
538 format!("Some({expr})")
539 }
540 } else {
541 expr
542 };
543 arg_exprs.push(final_expr);
544 }
545
546 if let Some(visitor_spec) = &fixture.visitor {
548 let visitor_trait = resolve_visitor_trait(rust_overrides)
551 .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
552 let _ = writeln!(out, " #[derive(Debug)]");
554 let _ = writeln!(out, " struct _TestVisitor;");
555 let _ = writeln!(out, " impl {visitor_trait} for _TestVisitor {{");
556 for (method_name, action) in &visitor_spec.callbacks {
557 emit_rust_visitor_method(out, method_name, action);
558 }
559 let _ = writeln!(out, " }}");
560 let _ = writeln!(
561 out,
562 " let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
563 );
564 if visitor_via_options {
565 let opts_name = options_arg_name.as_deref().unwrap_or("options");
567 let _ = writeln!(out, " {opts_name}.visitor = Some(visitor);");
568 } else {
569 arg_exprs.push("Some(visitor)".to_string());
571 }
572 } else {
573 arg_exprs.extend(extra_args);
576 }
577
578 let args_str = arg_exprs.join(", ");
579
580 let await_suffix = if is_async { ".await" } else { "" };
581
582 let call_expr = if let Some(factory) = client_factory {
586 let base_url_arg = if has_mock {
587 "Some(mock_server.url.clone())"
588 } else {
589 "None"
590 };
591 let _ = writeln!(
592 out,
593 " let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
594 );
595 format!("client.{function_name}({args_str})")
596 } else {
597 format!("{function_name}({args_str})")
598 };
599
600 let result_is_tree = call_config.result_var == "tree";
601 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
605 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
608 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
611
612 if has_error_assertion {
613 if let Some(ref handle_name) = error_context_handle_name {
617 let _ = writeln!(out, " let {result_var} = match {handle_name}_result {{");
618 let _ = writeln!(out, " Err(e) => Err(e),");
619 let _ = writeln!(out, " Ok({handle_name}) => {{");
620 let _ = writeln!(out, " {call_expr}{await_suffix}");
621 let _ = writeln!(out, " }}");
622 let _ = writeln!(out, " }};");
623 } else {
624 let _ = writeln!(out, " let {result_var} = {call_expr}{await_suffix};");
625 }
626 let has_non_error_assertions = fixture.assertions.iter().any(|a| {
629 !matches!(a.assertion_type.as_str(), "error" | "not_error")
630 && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
631 });
632 if returns_result && has_non_error_assertions {
635 let _ = writeln!(out, " let {result_var}_ok = {result_var}.as_ref().ok();");
637 }
638 for assertion in &fixture.assertions {
640 render_assertion(
641 out,
642 assertion,
643 result_var,
644 &module,
645 dep_name,
646 true,
647 &[],
648 field_resolver,
649 result_is_tree,
650 result_is_simple,
651 false,
652 false,
653 returns_result,
654 );
655 }
656 let _ = writeln!(out, "}}");
657 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
658 return;
659 }
660
661 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
663
664 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
669 let stream_var = "stream";
671 let chunks_var = "chunks";
673
674 let has_usable_assertion = fixture.assertions.iter().any(|a| {
680 if a.assertion_type == "not_error" || a.assertion_type == "error" {
681 return false;
682 }
683 if a.assertion_type == "method_result" {
684 let supported_checks = [
687 "equals",
688 "is_true",
689 "is_false",
690 "greater_than_or_equal",
691 "count_min",
692 "is_error",
693 "contains",
694 "not_empty",
695 "is_empty",
696 ];
697 let check = a.check.as_deref().unwrap_or("is_true");
698 if a.method.is_none() || !supported_checks.contains(&check) {
699 return false;
700 }
701 }
702 match &a.field {
703 Some(f) if !f.is_empty() => {
704 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
705 return true;
706 }
707 field_resolver.is_valid_for_result(f)
708 }
709 _ => true,
710 }
711 });
712
713 let result_binding = if is_streaming {
716 stream_var.to_string()
717 } else if has_usable_assertion {
718 result_var.to_string()
719 } else {
720 "_".to_string()
721 };
722
723 let has_field_access = fixture
727 .assertions
728 .iter()
729 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
730 let only_emptiness_checks = !has_field_access
731 && fixture.assertions.iter().all(|a| {
732 matches!(
733 a.assertion_type.as_str(),
734 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
735 )
736 });
737
738 let unwrap_suffix = if returns_result {
739 ".expect(\"should succeed\")"
740 } else {
741 ""
742 };
743 if is_streaming {
744 let _ = writeln!(out, " let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
746 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
747 "rust", stream_var, chunks_var,
748 ) {
749 let _ = writeln!(out, " {collect}");
750 }
751 } else if !returns_result || (only_emptiness_checks && !has_not_error) {
752 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
755 } else if has_not_error || !fixture.assertions.is_empty() {
756 let _ = writeln!(
757 out,
758 " let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
759 );
760 } else {
761 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
762 }
763
764 let string_assertion_types = [
770 "equals",
771 "contains",
772 "contains_all",
773 "contains_any",
774 "not_contains",
775 "starts_with",
776 "ends_with",
777 "min_length",
778 "max_length",
779 "matches_regex",
780 ];
781 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
783 for assertion in &fixture.assertions {
784 if let Some(f) = &assertion.field {
785 if !f.is_empty()
786 && string_assertion_types.contains(&assertion.assertion_type.as_str())
787 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
788 {
789 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
792 if !is_string_assertion {
793 continue;
794 }
795 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
796 let _ = writeln!(out, " {binding}");
797 unwrapped_fields.push((f.clone(), local_var));
798 }
799 }
800 }
801 }
802 }
803
804 for assertion in &fixture.assertions {
806 if assertion.assertion_type == "not_error" {
807 continue;
809 }
810 render_assertion(
811 out,
812 assertion,
813 result_var,
814 &module,
815 dep_name,
816 false,
817 &unwrapped_fields,
818 field_resolver,
819 result_is_tree,
820 result_is_simple,
821 result_is_vec,
822 result_is_option,
823 returns_result,
824 );
825 }
826
827 let _ = writeln!(out, "}}");
828 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
829}
830
831fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
839 if has_mock {
840 let var_name = if body.contains("mock_server.") {
841 "mock_server"
842 } else {
843 "_mock_server"
844 };
845 render_mock_server_setup(out, fixture, e2e_config, var_name);
846 }
847 out.push_str(body);
848}
849
850pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
852 groups
853 .iter()
854 .filter(|g| !g.fixtures.is_empty())
855 .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
856 .collect()
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 #[test]
864 fn resolve_module_for_call_prefers_crate_name_override() {
865 use crate::config::CallConfig;
866 use std::collections::HashMap;
867 let mut overrides = HashMap::new();
868 overrides.insert(
869 "rust".to_string(),
870 crate::config::CallOverride {
871 crate_name: Some("custom_crate".to_string()),
872 module: Some("ignored_module".to_string()),
873 ..Default::default()
874 },
875 );
876 let call = CallConfig {
877 overrides,
878 ..Default::default()
879 };
880 let result = resolve_module_for_call(&call, "dep_name");
881 assert_eq!(result, "custom_crate");
882 }
883}