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(f.call.as_deref());
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(f.call.as_deref());
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(fixture.call.as_deref());
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 && fixtures.iter().any(|f| f.mock_response.is_some());
153 if file_needs_mock {
154 let _ = writeln!(out, "mod mock_server;");
155 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
156 }
157
158 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
163 if file_needs_visitor {
164 let visitor_trait = resolve_visitor_trait(&module);
165 let _ = writeln!(
166 out,
167 "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
168 );
169 }
170
171 if file_has_call_based {
176 let rust_options_type = e2e_config
177 .call
178 .overrides
179 .get("rust")
180 .and_then(|o| o.options_type.as_deref());
181 if let Some(opts_type) = rust_options_type {
182 let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
185 if has_json_object_arg {
186 let _ = writeln!(out, "use {module}::{opts_type};");
187 }
188 }
189 }
190
191 if file_has_call_based {
195 let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
196 for fixture in fixtures.iter().filter(|f| {
197 if f.mock_response.is_some() {
198 return true;
199 }
200 if f.http.is_none() && f.mock_response.is_none() {
201 let call_config = e2e_config.resolve_call(f.call.as_deref());
202 let fn_name = resolve_function_name_for_call(call_config);
203 return !fn_name.is_empty();
204 }
205 false
206 }) {
207 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
208 for arg in &call_config.args {
209 if arg.arg_type == "json_object" {
210 if let Some(ref elem_type) = arg.element_type {
211 element_types.insert(elem_type.clone());
212 }
213 }
214 }
215 }
216 for elem_type in &element_types {
217 let _ = writeln!(out, "use {module}::{elem_type};");
218 }
219 }
220
221 let _ = writeln!(out);
222
223 for fixture in fixtures {
224 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver, client_factory);
225 let _ = writeln!(out);
226 }
227
228 if !out.ends_with('\n') {
229 out.push('\n');
230 }
231 out
232}
233
234pub fn render_test_function(
235 out: &mut String,
236 fixture: &Fixture,
237 e2e_config: &E2eConfig,
238 dep_name: &str,
239 field_resolver: &FieldResolver,
240 client_factory: Option<&str>,
241) {
242 if fixture.http.is_some() {
244 render_http_test_function(out, fixture, dep_name);
245 return;
246 }
247
248 if fixture.http.is_none() && fixture.mock_response.is_none() {
254 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
255 let resolved_fn_name = resolve_function_name_for_call(call_config);
256 if resolved_fn_name.is_empty() {
257 let fn_name = crate::escape::sanitize_ident(&fixture.id);
258 let description = &fixture.description;
259 let _ = writeln!(out, "#[tokio::test]");
260 let _ = writeln!(out, "async fn test_{fn_name}() {{");
261 let _ = writeln!(out, " // {description}");
262 let _ = writeln!(
263 out,
264 " // TODO: implement when a callable API is available for this fixture type."
265 );
266 let _ = writeln!(out, "}}");
267 return;
268 }
269 }
271
272 let fn_name = crate::escape::sanitize_ident(&fixture.id);
273 let description = &fixture.description;
274 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
275 let function_name = resolve_function_name_for_call(call_config);
276 let module = resolve_module_for_call(call_config, dep_name);
277 let result_var = &call_config.result_var;
278 let has_mock = fixture.mock_response.is_some();
279
280 let rust_overrides = call_config.overrides.get("rust");
282
283 let returns_result = rust_overrides
286 .and_then(|o| o.returns_result)
287 .unwrap_or(if client_factory.is_some() {
288 true
289 } else {
290 call_config.returns_result
291 });
292
293 let is_async = call_config.r#async || has_mock;
295 if is_async {
296 let _ = writeln!(out, "#[tokio::test]");
297 let _ = writeln!(out, "async fn test_{fn_name}() {{");
298 } else {
299 let _ = writeln!(out, "#[test]");
300 let _ = writeln!(out, "fn test_{fn_name}() {{");
301 }
302 let _ = writeln!(out, " // {description}");
303
304 if has_mock {
307 render_mock_server_setup(out, fixture, e2e_config);
308 }
309
310 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
312
313 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
315 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
316 let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
320
321 let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
326
327 let mut arg_exprs: Vec<String> = Vec::new();
329 let mut options_arg_name: Option<String> = None;
331 for arg in &call_config.args {
332 let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
333 let var_name = &arg.name;
334 let (mut bindings, expr) = render_rust_arg(
335 var_name,
336 value,
337 &arg.arg_type,
338 arg.optional,
339 &module,
340 &fixture.id,
341 if has_mock {
342 Some("mock_server.url.as_str()")
343 } else {
344 None
345 },
346 arg.owned,
347 arg.element_type.as_deref(),
348 );
349 if arg.arg_type == "json_object" {
353 if let Some(ref opts_type) = options_type {
354 bindings = bindings
355 .into_iter()
356 .map(|b| {
357 let prefix = format!("let {var_name} = ");
359 if b.starts_with(&prefix) {
360 format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
361 } else {
362 b
363 }
364 })
365 .collect();
366 }
367 }
368 if visitor_via_options && arg.arg_type == "json_object" {
371 options_arg_name = Some(var_name.clone());
372 bindings = bindings
373 .into_iter()
374 .map(|b| {
375 let prefix = format!("let {var_name}");
377 if b.starts_with(&prefix) {
378 format!("let mut {}", &b[4..])
379 } else {
380 b
381 }
382 })
383 .collect();
384 }
385 for binding in &bindings {
386 let _ = writeln!(out, " {binding}");
387 }
388 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
392 if visitor_via_options {
393 let name = if let Some(rest) = expr.strip_prefix('&') {
396 rest.to_string()
397 } else {
398 expr.clone()
399 };
400 format!("Some({name})")
401 } else if let Some(rest) = expr.strip_prefix('&') {
402 format!("Some({rest}.clone())")
403 } else {
404 format!("Some({expr})")
405 }
406 } else {
407 expr
408 };
409 arg_exprs.push(final_expr);
410 }
411
412 if let Some(visitor_spec) = &fixture.visitor {
414 let _ = writeln!(out, " #[derive(Debug)]");
416 let _ = writeln!(out, " struct _TestVisitor;");
417 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
418 for (method_name, action) in &visitor_spec.callbacks {
419 emit_rust_visitor_method(out, method_name, action);
420 }
421 let _ = writeln!(out, " }}");
422 let _ = writeln!(
423 out,
424 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
425 );
426 if visitor_via_options {
427 let opts_name = options_arg_name.as_deref().unwrap_or("options");
429 let _ = writeln!(out, " {opts_name}.visitor = Some(visitor);");
430 } else {
431 arg_exprs.push("Some(visitor)".to_string());
433 }
434 } else {
435 arg_exprs.extend(extra_args);
438 }
439
440 let args_str = arg_exprs.join(", ");
441
442 let await_suffix = if is_async { ".await" } else { "" };
443
444 let call_expr = if let Some(factory) = client_factory {
448 let base_url_arg = if has_mock {
449 "Some(mock_server.url.clone())"
450 } else {
451 "None"
452 };
453 let _ = writeln!(
454 out,
455 " let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
456 );
457 format!("client.{function_name}({args_str})")
458 } else {
459 format!("{function_name}({args_str})")
460 };
461
462 let result_is_tree = call_config.result_var == "tree";
463 let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
467 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
470 let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
473
474 if has_error_assertion {
475 let _ = writeln!(out, " let {result_var} = {call_expr}{await_suffix};");
476 let has_non_error_assertions = fixture
478 .assertions
479 .iter()
480 .any(|a| !matches!(a.assertion_type.as_str(), "error" | "not_error"));
481 if returns_result && has_non_error_assertions {
484 let _ = writeln!(out, " let {result_var}_ok = {result_var}.as_ref().ok();");
486 }
487 for assertion in &fixture.assertions {
489 render_assertion(
490 out,
491 assertion,
492 result_var,
493 &module,
494 dep_name,
495 true,
496 &[],
497 field_resolver,
498 result_is_tree,
499 result_is_simple,
500 false,
501 false,
502 returns_result,
503 );
504 }
505 let _ = writeln!(out, "}}");
506 return;
507 }
508
509 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
511
512 let has_usable_assertion = fixture.assertions.iter().any(|a| {
516 if a.assertion_type == "not_error" || a.assertion_type == "error" {
517 return false;
518 }
519 if a.assertion_type == "method_result" {
520 let supported_checks = [
523 "equals",
524 "is_true",
525 "is_false",
526 "greater_than_or_equal",
527 "count_min",
528 "is_error",
529 "contains",
530 "not_empty",
531 "is_empty",
532 ];
533 let check = a.check.as_deref().unwrap_or("is_true");
534 if a.method.is_none() || !supported_checks.contains(&check) {
535 return false;
536 }
537 }
538 match &a.field {
539 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
540 _ => true,
541 }
542 });
543
544 let result_binding = if has_usable_assertion {
545 result_var.to_string()
546 } else {
547 "_".to_string()
548 };
549
550 let has_field_access = fixture
554 .assertions
555 .iter()
556 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
557 let only_emptiness_checks = !has_field_access
558 && fixture.assertions.iter().all(|a| {
559 matches!(
560 a.assertion_type.as_str(),
561 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
562 )
563 });
564
565 let unwrap_suffix = if returns_result {
566 ".expect(\"should succeed\")"
567 } else {
568 ""
569 };
570 if !returns_result || (only_emptiness_checks && !has_not_error) {
571 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
574 } else if has_not_error || !fixture.assertions.is_empty() {
575 let _ = writeln!(
576 out,
577 " let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
578 );
579 } else {
580 let _ = writeln!(out, " let {result_binding} = {call_expr}{await_suffix};");
581 }
582
583 let string_assertion_types = [
589 "equals",
590 "contains",
591 "contains_all",
592 "contains_any",
593 "not_contains",
594 "starts_with",
595 "ends_with",
596 "min_length",
597 "max_length",
598 "matches_regex",
599 ];
600 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
602 for assertion in &fixture.assertions {
603 if let Some(f) = &assertion.field {
604 if !f.is_empty()
605 && string_assertion_types.contains(&assertion.assertion_type.as_str())
606 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
607 {
608 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
611 if !is_string_assertion {
612 continue;
613 }
614 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
615 let _ = writeln!(out, " {binding}");
616 unwrapped_fields.push((f.clone(), local_var));
617 }
618 }
619 }
620 }
621 }
622
623 for assertion in &fixture.assertions {
625 if assertion.assertion_type == "not_error" {
626 continue;
628 }
629 render_assertion(
630 out,
631 assertion,
632 result_var,
633 &module,
634 dep_name,
635 false,
636 &unwrapped_fields,
637 field_resolver,
638 result_is_tree,
639 result_is_simple,
640 result_is_vec,
641 result_is_option,
642 returns_result,
643 );
644 }
645
646 let _ = writeln!(out, "}}");
647}
648
649pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
651 groups
652 .iter()
653 .filter(|g| !g.fixtures.is_empty())
654 .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
655 .collect()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn resolve_module_for_call_prefers_crate_name_override() {
664 use crate::config::CallConfig;
665 use std::collections::HashMap;
666 let mut overrides = HashMap::new();
667 overrides.insert(
668 "rust".to_string(),
669 crate::config::CallOverride {
670 crate_name: Some("custom_crate".to_string()),
671 module: Some("ignored_module".to_string()),
672 ..Default::default()
673 },
674 );
675 let call = CallConfig {
676 overrides,
677 ..Default::default()
678 };
679 let result = resolve_module_for_call(&call, "dep_name");
680 assert_eq!(result, "custom_crate");
681 }
682}