1use crate::config::E2eConfig;
7use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16pub struct RustE2eCodegen;
18
19impl super::E2eCodegen for RustE2eCodegen {
20 fn generate(
21 &self,
22 groups: &[FixtureGroup],
23 e2e_config: &E2eConfig,
24 alef_config: &AlefConfig,
25 ) -> Result<Vec<GeneratedFile>> {
26 let mut files = Vec::new();
27 let output_base = PathBuf::from(&e2e_config.output).join("rust");
28
29 let crate_name = resolve_crate_name(e2e_config, alef_config);
31 let crate_path = resolve_crate_path(e2e_config, &crate_name);
32 let dep_name = crate_name.replace('-', "_");
33
34 let needs_serde_json = e2e_config
37 .call
38 .args
39 .iter()
40 .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
41 files.push(GeneratedFile {
42 path: output_base.join("Cargo.toml"),
43 content: render_cargo_toml(&crate_name, &dep_name, &crate_path, needs_serde_json),
44 generated_header: true,
45 });
46
47 for group in groups {
49 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
50
51 if fixtures.is_empty() {
52 continue;
53 }
54
55 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
56 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name);
57
58 files.push(GeneratedFile {
59 path: output_base.join("tests").join(filename),
60 content,
61 generated_header: true,
62 });
63 }
64
65 Ok(files)
66 }
67
68 fn language_name(&self) -> &'static str {
69 "rust"
70 }
71}
72
73fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
78 alef_config.crate_config.name.clone()
82}
83
84fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
85 e2e_config
86 .packages
87 .get("rust")
88 .and_then(|p| p.path.clone())
89 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
90}
91
92fn resolve_function_name(e2e_config: &E2eConfig) -> String {
93 e2e_config
94 .call
95 .overrides
96 .get("rust")
97 .and_then(|o| o.function.clone())
98 .unwrap_or_else(|| e2e_config.call.function.clone())
99}
100
101fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
102 let overrides = e2e_config.call.overrides.get("rust");
105 overrides
106 .and_then(|o| o.crate_name.clone())
107 .or_else(|| overrides.and_then(|o| o.module.clone()))
108 .unwrap_or_else(|| dep_name.to_string())
109}
110
111fn is_skipped(fixture: &Fixture, language: &str) -> bool {
112 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
113}
114
115fn render_cargo_toml(crate_name: &str, dep_name: &str, crate_path: &str, needs_serde_json: bool) -> String {
120 let e2e_name = format!("{dep_name}-e2e-rust");
121 let dep_spec = if crate_name != dep_name {
124 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\" }}")
125 } else {
126 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
127 };
128 let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
129 format!(
130 r#"# This file is auto-generated by alef. DO NOT EDIT.
131
132[package]
133name = "{e2e_name}"
134version = "0.1.0"
135edition = "2021"
136license = "MIT"
137publish = false
138
139[dependencies]
140{dep_spec}{serde_line}
141tokio = {{ version = "1", features = ["full"] }}
142wiremock = "0.6"
143"#
144 )
145}
146
147fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, dep_name: &str) -> String {
148 let mut out = String::new();
149 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
150 let _ = writeln!(out, "//! E2e tests for category: {category}");
151 let _ = writeln!(out);
152
153 let module = resolve_module(e2e_config, dep_name);
154 let function_name = resolve_function_name(e2e_config);
155 let field_resolver = FieldResolver::new(
156 &e2e_config.fields,
157 &e2e_config.fields_optional,
158 &e2e_config.result_fields,
159 &e2e_config.fields_array,
160 );
161
162 let _ = writeln!(out, "use {module}::{function_name};");
163
164 for arg in &e2e_config.call.args {
166 if arg.arg_type == "handle" {
167 use heck::ToSnakeCase;
168 let constructor_name = format!("create_{}", arg.name.to_snake_case());
169 let _ = writeln!(out, "use {module}::{constructor_name};");
170 }
171 }
172
173 let _ = writeln!(out);
174
175 for fixture in fixtures {
176 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
177 let _ = writeln!(out);
178 }
179
180 if !out.ends_with('\n') {
181 out.push('\n');
182 }
183 out
184}
185
186fn render_test_function(
187 out: &mut String,
188 fixture: &Fixture,
189 e2e_config: &E2eConfig,
190 dep_name: &str,
191 field_resolver: &FieldResolver,
192) {
193 let fn_name = sanitize_ident(&fixture.id);
194 let description = &fixture.description;
195 let function_name = resolve_function_name(e2e_config);
196 let module = resolve_module(e2e_config, dep_name);
197 let result_var = &e2e_config.call.result_var;
198
199 let is_async = e2e_config.call.r#async;
200 if is_async {
201 let _ = writeln!(out, "#[tokio::test]");
202 let _ = writeln!(out, "async fn test_{fn_name}() {{");
203 } else {
204 let _ = writeln!(out, "#[test]");
205 let _ = writeln!(out, "fn test_{fn_name}() {{");
206 }
207 let _ = writeln!(out, " // {description}");
208
209 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
211
212 let mut arg_exprs: Vec<String> = Vec::new();
214 for arg in &e2e_config.call.args {
215 let value = resolve_field(&fixture.input, &arg.field);
216 let var_name = &arg.name;
217 let (bindings, expr) = render_rust_arg(var_name, value, &arg.arg_type, arg.optional, &module, &fixture.id);
218 for binding in &bindings {
219 let _ = writeln!(out, " {binding}");
220 }
221 arg_exprs.push(expr);
222 }
223
224 let args_str = arg_exprs.join(", ");
225
226 let await_suffix = if is_async { ".await" } else { "" };
227
228 if has_error_assertion {
229 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
230 for assertion in &fixture.assertions {
232 render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
233 }
234 let _ = writeln!(out, "}}");
235 return;
236 }
237
238 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
240
241 let has_usable_assertion = fixture.assertions.iter().any(|a| {
245 if a.assertion_type == "not_error" || a.assertion_type == "error" {
246 return false;
247 }
248 match &a.field {
249 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
250 _ => true,
251 }
252 });
253
254 let result_binding = if has_usable_assertion {
255 result_var.to_string()
256 } else {
257 "_".to_string()
258 };
259
260 if has_not_error || !fixture.assertions.is_empty() {
261 let _ = writeln!(
262 out,
263 " let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
264 );
265 } else {
266 let _ = writeln!(
267 out,
268 " let {result_binding} = {function_name}({args_str}){await_suffix};"
269 );
270 }
271
272 let string_assertion_types = [
275 "equals",
276 "contains",
277 "contains_all",
278 "contains_any",
279 "not_contains",
280 "starts_with",
281 "ends_with",
282 "min_length",
283 "max_length",
284 "matches_regex",
285 ];
286 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
288 if let Some(f) = &assertion.field {
289 if !f.is_empty()
290 && string_assertion_types.contains(&assertion.assertion_type.as_str())
291 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
292 {
293 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
296 if !is_string_assertion {
297 continue;
298 }
299 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
300 let _ = writeln!(out, " {binding}");
301 unwrapped_fields.push((f.clone(), local_var));
302 }
303 }
304 }
305 }
306
307 for assertion in &fixture.assertions {
309 if assertion.assertion_type == "not_error" {
310 continue;
312 }
313 render_assertion(
314 out,
315 assertion,
316 result_var,
317 dep_name,
318 false,
319 &unwrapped_fields,
320 field_resolver,
321 );
322 }
323
324 let _ = writeln!(out, "}}");
325}
326
327fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
332 let mut current = input;
333 for part in field_path.split('.') {
334 current = current.get(part).unwrap_or(&serde_json::Value::Null);
335 }
336 current
337}
338
339fn render_rust_arg(
340 name: &str,
341 value: &serde_json::Value,
342 arg_type: &str,
343 optional: bool,
344 module: &str,
345 fixture_id: &str,
346) -> (Vec<String>, String) {
347 if arg_type == "mock_url" {
348 let lines = vec![format!(
349 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
350 )];
351 return (lines, format!("&{name}"));
352 }
353 if arg_type == "handle" {
354 use heck::ToSnakeCase;
358 let constructor_name = format!("create_{}", name.to_snake_case());
359 let mut lines = Vec::new();
360 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
361 lines.push(format!(
362 "let {name} = {module}::{constructor_name}(None).expect(\"handle creation should succeed\");"
363 ));
364 } else {
365 let json_literal = serde_json::to_string(value).unwrap_or_default();
367 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
368 lines.push(format!(
369 "let {name}_config: {module}::CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
370 ));
371 lines.push(format!(
372 "let {name} = {module}::{constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
373 ));
374 }
375 return (lines, format!("&{name}"));
376 }
377 if arg_type == "json_object" {
378 return render_json_object_arg(name, value, optional, module);
379 }
380 if value.is_null() && !optional {
381 let default_val = match arg_type {
383 "string" => "String::new()".to_string(),
384 "int" | "integer" => "0".to_string(),
385 "float" | "number" => "0.0_f64".to_string(),
386 "bool" | "boolean" => "false".to_string(),
387 _ => "Default::default()".to_string(),
388 };
389 let expr = if arg_type == "string" {
391 format!("&{name}")
392 } else {
393 name.to_string()
394 };
395 return (vec![format!("let {name} = {default_val};")], expr);
396 }
397 let literal = json_to_rust_literal(value, arg_type);
398 let pass_by_ref = arg_type == "string";
400 let expr = |n: &str| if pass_by_ref { format!("&{n}") } else { n.to_string() };
401 if optional && value.is_null() {
402 (vec![format!("let {name} = None;")], expr(name))
403 } else if optional {
404 (vec![format!("let {name} = Some({literal});")], expr(name))
405 } else {
406 (vec![format!("let {name} = {literal};")], expr(name))
407 }
408}
409
410fn render_json_object_arg(
414 name: &str,
415 value: &serde_json::Value,
416 optional: bool,
417 _module: &str,
418) -> (Vec<String>, String) {
419 if value.is_null() && optional {
420 return (vec![format!("let {name} = None;")], name.to_string());
421 }
422
423 let normalized = super::normalize_json_keys_to_snake_case(value);
426 let json_literal = json_value_to_macro_literal(&normalized);
428 let mut lines = Vec::new();
429 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
430 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
432 if optional {
433 lines.push(format!("let {name} = Some({deser_expr});"));
434 } else {
435 lines.push(format!("let {name} = {deser_expr};"));
436 }
437 (lines, name.to_string())
438}
439
440fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
442 match value {
443 serde_json::Value::Null => "null".to_string(),
444 serde_json::Value::Bool(b) => format!("{b}"),
445 serde_json::Value::Number(n) => n.to_string(),
446 serde_json::Value::String(s) => {
447 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
448 format!("\"{escaped}\"")
449 }
450 serde_json::Value::Array(arr) => {
451 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
452 format!("[{}]", items.join(", "))
453 }
454 serde_json::Value::Object(obj) => {
455 let entries: Vec<String> = obj
456 .iter()
457 .map(|(k, v)| {
458 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
459 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
460 })
461 .collect();
462 format!("{{{}}}", entries.join(", "))
463 }
464 }
465}
466
467fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
468 match value {
469 serde_json::Value::Null => "None".to_string(),
470 serde_json::Value::Bool(b) => format!("{b}"),
471 serde_json::Value::Number(n) => {
472 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
473 if let Some(f) = n.as_f64() {
474 return format!("{f}_f64");
475 }
476 }
477 n.to_string()
478 }
479 serde_json::Value::String(s) => rust_raw_string(s),
480 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
481 let json_str = serde_json::to_string(value).unwrap_or_default();
482 let literal = rust_raw_string(&json_str);
483 format!("serde_json::from_str({literal}).unwrap()")
484 }
485 }
486}
487
488fn render_assertion(
493 out: &mut String,
494 assertion: &Assertion,
495 result_var: &str,
496 _dep_name: &str,
497 is_error_context: bool,
498 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
500) {
501 if let Some(f) = &assertion.field {
503 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
504 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
505 return;
506 }
507 }
508
509 let field_access = match &assertion.field {
513 Some(f) if !f.is_empty() => {
514 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
515 local_var.clone()
516 } else {
517 field_resolver.accessor(f, "rust", result_var)
518 }
519 }
520 _ => result_var.to_string(),
521 };
522
523 let is_unwrapped = assertion
525 .field
526 .as_ref()
527 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
528
529 match assertion.assertion_type.as_str() {
530 "error" => {
531 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
532 if let Some(serde_json::Value::String(msg)) = &assertion.value {
533 let escaped = escape_rust(msg);
534 let _ = writeln!(
535 out,
536 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
537 );
538 }
539 }
540 "not_error" => {
541 }
543 "equals" => {
544 if let Some(val) = &assertion.value {
545 let expected = value_to_rust_string(val);
546 if is_error_context {
547 return;
548 }
549 if val.is_string() {
552 let _ = writeln!(
553 out,
554 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
555 );
556 } else {
557 let is_opt = assertion.field.as_ref().is_some_and(|f| {
559 let resolved = field_resolver.resolve(f);
560 field_resolver.is_optional(resolved)
561 });
562 if is_opt
563 && !unwrapped_fields
564 .iter()
565 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
566 {
567 let _ = writeln!(
568 out,
569 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
570 );
571 } else {
572 let _ = writeln!(
573 out,
574 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
575 );
576 }
577 }
578 }
579 }
580 "contains" => {
581 if let Some(val) = &assertion.value {
582 let expected = value_to_rust_string(val);
583 let line = format!(
584 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
585 );
586 let _ = writeln!(out, "{line}");
587 }
588 }
589 "contains_all" => {
590 if let Some(values) = &assertion.values {
591 for val in values {
592 let expected = value_to_rust_string(val);
593 let line = format!(
594 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
595 );
596 let _ = writeln!(out, "{line}");
597 }
598 }
599 }
600 "not_contains" => {
601 if let Some(val) = &assertion.value {
602 let expected = value_to_rust_string(val);
603 let line = format!(
604 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
605 );
606 let _ = writeln!(out, "{line}");
607 }
608 }
609 "not_empty" => {
610 if let Some(f) = &assertion.field {
611 let resolved = field_resolver.resolve(f);
612 if !is_unwrapped && field_resolver.is_optional(resolved) {
613 let accessor = field_resolver.accessor(f, "rust", result_var);
615 let _ = writeln!(
616 out,
617 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
618 );
619 } else {
620 let _ = writeln!(
621 out,
622 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
623 );
624 }
625 } else {
626 let _ = writeln!(
627 out,
628 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
629 );
630 }
631 }
632 "is_empty" => {
633 if let Some(f) = &assertion.field {
634 let resolved = field_resolver.resolve(f);
635 if !is_unwrapped && field_resolver.is_optional(resolved) {
636 let accessor = field_resolver.accessor(f, "rust", result_var);
637 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
638 } else {
639 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
640 }
641 } else {
642 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
643 }
644 }
645 "contains_any" => {
646 if let Some(values) = &assertion.values {
647 let checks: Vec<String> = values
648 .iter()
649 .map(|v| {
650 let expected = value_to_rust_string(v);
651 format!("{field_access}.contains({expected})")
652 })
653 .collect();
654 let joined = checks.join(" || ");
655 let _ = writeln!(
656 out,
657 " assert!({joined}, \"expected to contain at least one of the specified values\");"
658 );
659 }
660 }
661 "greater_than" => {
662 if let Some(val) = &assertion.value {
663 if val.as_f64().is_some_and(|n| n < 0.0) {
665 let _ = writeln!(
666 out,
667 " // skipped: greater_than with negative value is always true for unsigned types"
668 );
669 } else {
670 let lit = numeric_literal(val);
671 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
672 }
673 }
674 }
675 "less_than" => {
676 if let Some(val) = &assertion.value {
677 let lit = numeric_literal(val);
678 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
679 }
680 }
681 "greater_than_or_equal" => {
682 if let Some(val) = &assertion.value {
683 let lit = numeric_literal(val);
684 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
685 }
686 }
687 "less_than_or_equal" => {
688 if let Some(val) = &assertion.value {
689 let lit = numeric_literal(val);
690 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
691 }
692 }
693 "starts_with" => {
694 if let Some(val) = &assertion.value {
695 let expected = value_to_rust_string(val);
696 let _ = writeln!(
697 out,
698 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
699 );
700 }
701 }
702 "ends_with" => {
703 if let Some(val) = &assertion.value {
704 let expected = value_to_rust_string(val);
705 let _ = writeln!(
706 out,
707 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
708 );
709 }
710 }
711 "min_length" => {
712 if let Some(val) = &assertion.value {
713 if let Some(n) = val.as_u64() {
714 let _ = writeln!(
715 out,
716 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
717 );
718 }
719 }
720 }
721 "max_length" => {
722 if let Some(val) = &assertion.value {
723 if let Some(n) = val.as_u64() {
724 let _ = writeln!(
725 out,
726 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
727 );
728 }
729 }
730 }
731 "count_min" => {
732 if let Some(val) = &assertion.value {
733 if let Some(n) = val.as_u64() {
734 let _ = writeln!(
735 out,
736 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
737 );
738 }
739 }
740 }
741 other => {
742 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
743 }
744 }
745}
746
747fn numeric_literal(value: &serde_json::Value) -> String {
753 if let Some(n) = value.as_f64() {
754 if n.fract() == 0.0 {
755 return format!("{}", n as i64);
758 }
759 return format!("{n}_f64");
760 }
761 value.to_string()
763}
764
765fn value_to_rust_string(value: &serde_json::Value) -> String {
766 match value {
767 serde_json::Value::String(s) => rust_raw_string(s),
768 serde_json::Value::Bool(b) => format!("{b}"),
769 serde_json::Value::Number(n) => n.to_string(),
770 other => {
771 let s = other.to_string();
772 format!("\"{s}\"")
773 }
774 }
775}