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_call_based = fixtures.iter().any(|f| {
91 if f.mock_response.is_some() {
92 return true;
93 }
94 if f.http.is_none() && f.mock_response.is_none() {
95 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
96 let fn_name = resolve_function_name_for_call(call_config);
97 return !fn_name.is_empty();
98 }
99 false
100 });
101
102 let rust_call_override = e2e_config.call.overrides.get("rust");
107 let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
108
109 if file_has_call_based && client_factory.is_none() {
111 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
112 for fixture in fixtures.iter().filter(|f| {
113 if f.mock_response.is_some() {
114 return true;
115 }
116 if f.http.is_none() && f.mock_response.is_none() {
117 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
118 let fn_name = resolve_function_name_for_call(call_config);
119 return !fn_name.is_empty();
120 }
121 false
122 }) {
123 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
124 let fn_name = resolve_function_name_for_call(call_config);
125 let mod_name = resolve_module_for_call(call_config, dep_name);
126 imported.insert((mod_name, fn_name));
127 }
128 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
130 for (mod_name, fn_name) in &imported {
131 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
132 }
133 for (mod_name, fns) in &by_module {
134 if fns.len() == 1 {
135 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
136 } else {
137 let joined = fns.join(", ");
138 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
139 }
140 }
141 }
142
143 let mut body_buf = String::new();
151 for fixture in fixtures {
152 render_test_function(
153 &mut body_buf,
154 fixture,
155 e2e_config,
156 dep_name,
157 &field_resolver,
158 client_factory,
159 );
160 let _ = writeln!(body_buf);
161 }
162
163 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
165 if has_handle_args && body_references_symbol(&body_buf, "CrawlConfig") {
166 let _ = writeln!(out, "use {module}::CrawlConfig;");
167 }
168 for arg in &e2e_config.call.args {
169 if arg.arg_type == "handle" {
170 use heck::ToSnakeCase;
171 let constructor_name = format!("create_{}", arg.name.to_snake_case());
172 if body_references_symbol(&body_buf, &constructor_name) {
173 let _ = writeln!(out, "use {module}::{constructor_name};");
174 }
175 }
176 }
177
178 if client_factory.is_some() && file_has_call_based {
181 let trait_imports: Vec<String> = e2e_config
182 .call
183 .overrides
184 .get("rust")
185 .map(|o| o.trait_imports.clone())
186 .unwrap_or_default();
187 for trait_name in &trait_imports {
188 let _ = writeln!(out, "use {module}::{trait_name};");
189 }
190 }
191
192 let file_needs_mock = needs_mock_server
194 && fixtures
195 .iter()
196 .any(|f| f.mock_response.is_some() || f.needs_mock_server());
197 if file_needs_mock {
198 let _ = writeln!(out, "mod common;");
199 let _ = writeln!(out, "mod mock_server;");
200 let _ = writeln!(out, "#[allow(unused_imports)]");
201 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
202 }
203
204 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
211 if file_needs_visitor {
212 let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
213 panic!(
214 "category '{}': fixture declares a visitor block but \
215 `[e2e.call.overrides.rust] visitor_trait` is not configured",
216 category
217 )
218 });
219 let _ = writeln!(
220 out,
221 "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
222 );
223 }
224
225 if file_has_call_based {
230 let rust_options_type = e2e_config
231 .call
232 .overrides
233 .get("rust")
234 .and_then(|o| o.options_type.as_deref());
235 if let Some(opts_type) = rust_options_type {
236 let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
239 if has_json_object_arg && body_references_symbol(&body_buf, opts_type) {
240 let _ = writeln!(out, "use {module}::{opts_type};");
241 }
242 }
243 }
244
245 if file_has_call_based {
249 let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
250 for fixture in fixtures.iter().filter(|f| {
251 if f.mock_response.is_some() {
252 return true;
253 }
254 if f.http.is_none() && f.mock_response.is_none() {
255 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
256 let fn_name = resolve_function_name_for_call(call_config);
257 return !fn_name.is_empty();
258 }
259 false
260 }) {
261 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
262 for arg in &call_config.args {
263 if arg.arg_type == "json_object" {
264 if let Some(ref elem_type) = arg.element_type {
265 element_types.insert(elem_type.clone());
266 }
267 }
268 }
269 }
270 for elem_type in &element_types {
271 if matches!(
274 elem_type.as_str(),
275 "String"
276 | "str"
277 | "bool"
278 | "i8"
279 | "i16"
280 | "i32"
281 | "i64"
282 | "i128"
283 | "isize"
284 | "u8"
285 | "u16"
286 | "u32"
287 | "u64"
288 | "u128"
289 | "usize"
290 | "f32"
291 | "f64"
292 | "char"
293 ) {
294 continue;
295 }
296 if body_references_symbol(&body_buf, elem_type) {
297 let _ = writeln!(out, "use {module}::{elem_type};");
298 }
299 }
300 }
301
302 let _ = writeln!(out);
303 out.push_str(&body_buf);
304
305 if !out.ends_with('\n') {
306 out.push('\n');
307 }
308 out
309}
310
311pub fn render_test_function(
312 out: &mut String,
313 fixture: &Fixture,
314 e2e_config: &E2eConfig,
315 dep_name: &str,
316 field_resolver: &FieldResolver,
317 client_factory: Option<&str>,
318) {
319 if fixture.http.is_some() {
321 render_http_test_function(out, fixture, dep_name);
322 return;
323 }
324
325 if fixture.http.is_none() && fixture.mock_response.is_none() {
331 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
332 let resolved_fn_name = resolve_function_name_for_call(call_config);
333 if resolved_fn_name.is_empty() {
334 let fn_name = crate::escape::sanitize_ident(&fixture.id);
335 let description = &fixture.description;
336 let _ = writeln!(out, "#[tokio::test]");
337 let _ = writeln!(out, "async fn test_{fn_name}() {{");
338 let _ = writeln!(out, " // {description}");
339 let _ = writeln!(
340 out,
341 " // TODO: implement when a callable API is available for this fixture type."
342 );
343 let _ = writeln!(out, "}}");
344 return;
345 }
346 }
348
349 let fn_name = crate::escape::sanitize_ident(&fixture.id);
350 let description = &fixture.description;
351 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
352 let function_name = resolve_function_name_for_call(call_config);
353 let module = resolve_module_for_call(call_config, dep_name);
354 let result_var = &call_config.result_var;
355 let has_mock = fixture.mock_response.is_some();
356
357 let rust_overrides = call_config.overrides.get("rust");
359
360 let returns_result = rust_overrides
363 .and_then(|o| o.returns_result)
364 .unwrap_or(if client_factory.is_some() {
365 true
366 } else {
367 call_config.returns_result
368 });
369
370 let is_async = call_config.r#async || has_mock;
372 if is_async {
373 let _ = writeln!(out, "#[tokio::test]");
374 let _ = writeln!(out, "async fn test_{fn_name}() {{");
375 } else {
376 let _ = writeln!(out, "#[test]");
377 let _ = writeln!(out, "fn test_{fn_name}() {{");
378 }
379 let _ = writeln!(out, " // {description}");
380
381 let final_out = out;
387 let mut body_buf = String::new();
388 let out = &mut body_buf;
389
390 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
392
393 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
395 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
396 let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
400
401 let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
406
407 let mut arg_exprs: Vec<String> = Vec::new();
409 let mut options_arg_name: Option<String> = None;
411 let mut error_context_handle_name: Option<String> = None;
414 for arg in &call_config.args {
415 let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
416 let var_name = &arg.name;
417 let (mut bindings, expr) = render_rust_arg(
418 var_name,
419 value,
420 &arg.arg_type,
421 arg.optional,
422 &module,
423 &fixture.id,
424 if has_mock {
425 Some("mock_server.url.as_str()")
426 } else {
427 None
428 },
429 arg.owned,
430 arg.element_type.as_deref(),
431 &e2e_config.test_documents_dir,
432 has_error_assertion,
433 );
434 if arg.arg_type == "json_object" {
438 if let Some(ref opts_type) = options_type {
439 bindings = bindings
440 .into_iter()
441 .map(|b| {
442 let prefix = format!("let {var_name} = ");
444 if b.starts_with(&prefix) {
445 format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
446 } else {
447 b
448 }
449 })
450 .collect();
451 }
452 }
453 if visitor_via_options && arg.arg_type == "json_object" {
456 options_arg_name = Some(var_name.clone());
457 bindings = bindings
458 .into_iter()
459 .map(|b| {
460 let prefix = format!("let {var_name}");
462 if b.starts_with(&prefix) {
463 format!("let mut {}", &b[4..])
464 } else {
465 b
466 }
467 })
468 .collect();
469 }
470 if has_error_assertion && arg.arg_type == "handle" {
474 error_context_handle_name = Some(var_name.clone());
475 }
476 for binding in &bindings {
477 let _ = writeln!(out, " {binding}");
478 }
479 let final_expr = if has_error_assertion && arg.arg_type == "handle" {
483 format!("&{var_name}")
487 } else if wrap_options_in_some && arg.arg_type == "json_object" {
488 if visitor_via_options {
489 let name = if let Some(rest) = expr.strip_prefix('&') {
492 rest.to_string()
493 } else {
494 expr.clone()
495 };
496 format!("Some({name})")
497 } else if let Some(rest) = expr.strip_prefix('&') {
498 format!("Some({rest}.clone())")
499 } else {
500 format!("Some({expr})")
501 }
502 } else {
503 expr
504 };
505 arg_exprs.push(final_expr);
506 }
507
508 if let Some(visitor_spec) = &fixture.visitor {
510 let visitor_trait = resolve_visitor_trait(rust_overrides)
513 .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
514 let _ = writeln!(out, " #[derive(Debug)]");
516 let _ = writeln!(out, " struct _TestVisitor;");
517 let _ = writeln!(out, " impl {visitor_trait} for _TestVisitor {{");
518 for (method_name, action) in &visitor_spec.callbacks {
519 emit_rust_visitor_method(out, method_name, action);
520 }
521 let _ = writeln!(out, " }}");
522 let _ = writeln!(
523 out,
524 " let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
525 );
526 if visitor_via_options {
527 let opts_name = options_arg_name.as_deref().unwrap_or("options");
529 let _ = writeln!(out, " {opts_name}.visitor = Some(visitor);");
530 } else {
531 arg_exprs.push("Some(visitor)".to_string());
533 }
534 } else {
535 arg_exprs.extend(extra_args);
538 }
539
540 let args_str = arg_exprs.join(", ");
541
542 let await_suffix = if is_async { ".await" } else { "" };
543
544 let call_expr = if let Some(factory) = client_factory {
548 let base_url_arg = if has_mock {
549 "Some(mock_server.url.clone())"
550 } else {
551 "None"
552 };
553 let _ = writeln!(
554 out,
555 " let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
556 );
557 format!("client.{function_name}({args_str})")
558 } else {
559 format!("{function_name}({args_str})")
560 };
561
562 let result_is_tree = call_config.result_var == "tree";
563 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
567 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
570 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
573
574 if has_error_assertion {
575 if let Some(ref handle_name) = error_context_handle_name {
579 let _ = writeln!(out, " let {result_var} = match {handle_name}_result {{");
580 let _ = writeln!(out, " Err(e) => Err(e),");
581 let _ = writeln!(out, " Ok({handle_name}) => {{");
582 let _ = writeln!(out, " {call_expr}{await_suffix}");
583 let _ = writeln!(out, " }}");
584 let _ = writeln!(out, " }};");
585 } else {
586 let _ = writeln!(out, " let {result_var} = {call_expr}{await_suffix};");
587 }
588 let has_non_error_assertions = fixture.assertions.iter().any(|a| {
591 !matches!(a.assertion_type.as_str(), "error" | "not_error")
592 && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
593 });
594 if returns_result && has_non_error_assertions {
597 let _ = writeln!(out, " let {result_var}_ok = {result_var}.as_ref().ok();");
599 }
600 for assertion in &fixture.assertions {
602 render_assertion(
603 out,
604 assertion,
605 result_var,
606 &module,
607 dep_name,
608 true,
609 &[],
610 field_resolver,
611 result_is_tree,
612 result_is_simple,
613 false,
614 false,
615 returns_result,
616 );
617 }
618 let _ = writeln!(out, "}}");
619 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
620 return;
621 }
622
623 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
625
626 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
631 let stream_var = "stream";
633 let chunks_var = "chunks";
635
636 let has_usable_assertion = fixture.assertions.iter().any(|a| {
642 if a.assertion_type == "not_error" || a.assertion_type == "error" {
643 return false;
644 }
645 if a.assertion_type == "method_result" {
646 let supported_checks = [
649 "equals",
650 "is_true",
651 "is_false",
652 "greater_than_or_equal",
653 "count_min",
654 "is_error",
655 "contains",
656 "not_empty",
657 "is_empty",
658 ];
659 let check = a.check.as_deref().unwrap_or("is_true");
660 if a.method.is_none() || !supported_checks.contains(&check) {
661 return false;
662 }
663 }
664 match &a.field {
665 Some(f) if !f.is_empty() => {
666 if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
667 return true;
668 }
669 field_resolver.is_valid_for_result(f)
670 }
671 _ => true,
672 }
673 });
674
675 let result_binding = if is_streaming {
678 stream_var.to_string()
679 } else if has_usable_assertion {
680 result_var.to_string()
681 } else {
682 "_".to_string()
683 };
684
685 let has_field_access = fixture
689 .assertions
690 .iter()
691 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
692 let only_emptiness_checks = !has_field_access
693 && fixture.assertions.iter().all(|a| {
694 matches!(
695 a.assertion_type.as_str(),
696 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
697 )
698 });
699
700 let unwrap_suffix = if returns_result {
701 ".expect(\"should succeed\")"
702 } else {
703 ""
704 };
705 if is_streaming {
706 let _ = writeln!(out, " let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
708 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
709 "rust", stream_var, chunks_var,
710 ) {
711 let _ = writeln!(out, " {collect}");
712 }
713 } else if !returns_result || (only_emptiness_checks && !has_not_error) {
714 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
717 } else if has_not_error || !fixture.assertions.is_empty() {
718 let _ = writeln!(
719 out,
720 " let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
721 );
722 } else {
723 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
724 }
725
726 let string_assertion_types = [
732 "equals",
733 "contains",
734 "contains_all",
735 "contains_any",
736 "not_contains",
737 "starts_with",
738 "ends_with",
739 "min_length",
740 "max_length",
741 "matches_regex",
742 ];
743 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
745 for assertion in &fixture.assertions {
746 if let Some(f) = &assertion.field {
747 if !f.is_empty()
748 && string_assertion_types.contains(&assertion.assertion_type.as_str())
749 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
750 {
751 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
754 if !is_string_assertion {
755 continue;
756 }
757 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
758 let _ = writeln!(out, " {binding}");
759 unwrapped_fields.push((f.clone(), local_var));
760 }
761 }
762 }
763 }
764 }
765
766 for assertion in &fixture.assertions {
768 if assertion.assertion_type == "not_error" {
769 continue;
771 }
772 render_assertion(
773 out,
774 assertion,
775 result_var,
776 &module,
777 dep_name,
778 false,
779 &unwrapped_fields,
780 field_resolver,
781 result_is_tree,
782 result_is_simple,
783 result_is_vec,
784 result_is_option,
785 returns_result,
786 );
787 }
788
789 let _ = writeln!(out, "}}");
790 finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
791}
792
793fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
801 if has_mock {
802 let var_name = if body.contains("mock_server.") {
803 "mock_server"
804 } else {
805 "_mock_server"
806 };
807 render_mock_server_setup(out, fixture, e2e_config, var_name);
808 }
809 out.push_str(body);
810}
811
812pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
814 groups
815 .iter()
816 .filter(|g| !g.fixtures.is_empty())
817 .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
818 .collect()
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824
825 #[test]
826 fn resolve_module_for_call_prefers_crate_name_override() {
827 use crate::config::CallConfig;
828 use std::collections::HashMap;
829 let mut overrides = HashMap::new();
830 overrides.insert(
831 "rust".to_string(),
832 crate::config::CallOverride {
833 crate_name: Some("custom_crate".to_string()),
834 module: Some("ignored_module".to_string()),
835 ..Default::default()
836 },
837 );
838 let call = CallConfig {
839 overrides,
840 ..Default::default()
841 };
842 let result = resolve_module_for_call(&call, "dep_name");
843 assert_eq!(result, "custom_crate");
844 }
845}