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"
136publish = false
137
138[dependencies]
139{dep_spec}{serde_line}
140tokio = {{ version = "1", features = ["full"] }}
141wiremock = "0.6"
142"#
143 )
144}
145
146fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, dep_name: &str) -> String {
147 let mut out = String::new();
148 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
149 let _ = writeln!(out, "//! E2e tests for category: {category}");
150 let _ = writeln!(out);
151
152 let module = resolve_module(e2e_config, dep_name);
153 let function_name = resolve_function_name(e2e_config);
154 let field_resolver = FieldResolver::new(
155 &e2e_config.fields,
156 &e2e_config.fields_optional,
157 &e2e_config.result_fields,
158 &e2e_config.fields_array,
159 );
160
161 let _ = writeln!(out, "use {module}::{function_name};");
162
163 for arg in &e2e_config.call.args {
165 if arg.arg_type == "handle" {
166 use heck::ToSnakeCase;
167 let constructor_name = format!("create_{}", arg.name.to_snake_case());
168 let _ = writeln!(out, "use {module}::{constructor_name};");
169 }
170 }
171
172 let _ = writeln!(out);
173
174 for fixture in fixtures {
175 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
176 let _ = writeln!(out);
177 }
178
179 if !out.ends_with('\n') {
180 out.push('\n');
181 }
182 out
183}
184
185fn render_test_function(
186 out: &mut String,
187 fixture: &Fixture,
188 e2e_config: &E2eConfig,
189 dep_name: &str,
190 field_resolver: &FieldResolver,
191) {
192 let fn_name = sanitize_ident(&fixture.id);
193 let description = &fixture.description;
194 let function_name = resolve_function_name(e2e_config);
195 let module = resolve_module(e2e_config, dep_name);
196 let result_var = &e2e_config.call.result_var;
197
198 let is_async = e2e_config.call.r#async;
199 if is_async {
200 let _ = writeln!(out, "#[tokio::test]");
201 let _ = writeln!(out, "async fn test_{fn_name}() {{");
202 } else {
203 let _ = writeln!(out, "#[test]");
204 let _ = writeln!(out, "fn test_{fn_name}() {{");
205 }
206 let _ = writeln!(out, " // {description}");
207
208 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
210
211 let mut arg_exprs: Vec<String> = Vec::new();
213 for arg in &e2e_config.call.args {
214 let value = resolve_field(&fixture.input, &arg.field);
215 let var_name = &arg.name;
216 let (bindings, expr) = render_rust_arg(var_name, value, &arg.arg_type, arg.optional, &module, &fixture.id);
217 for binding in &bindings {
218 let _ = writeln!(out, " {binding}");
219 }
220 arg_exprs.push(expr);
221 }
222
223 let args_str = arg_exprs.join(", ");
224
225 let await_suffix = if is_async { ".await" } else { "" };
226
227 if has_error_assertion {
228 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
229 for assertion in &fixture.assertions {
231 render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
232 }
233 let _ = writeln!(out, "}}");
234 return;
235 }
236
237 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
239
240 let has_usable_assertion = fixture.assertions.iter().any(|a| {
244 if a.assertion_type == "not_error" || a.assertion_type == "error" {
245 return false;
246 }
247 match &a.field {
248 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
249 _ => true,
250 }
251 });
252
253 let result_binding = if has_usable_assertion {
254 result_var.to_string()
255 } else {
256 "_".to_string()
257 };
258
259 if has_not_error || !fixture.assertions.is_empty() {
260 let _ = writeln!(
261 out,
262 " let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
263 );
264 } else {
265 let _ = writeln!(
266 out,
267 " let {result_binding} = {function_name}({args_str}){await_suffix};"
268 );
269 }
270
271 let string_assertion_types = [
274 "equals",
275 "contains",
276 "contains_all",
277 "contains_any",
278 "not_contains",
279 "starts_with",
280 "ends_with",
281 "min_length",
282 "max_length",
283 "matches_regex",
284 ];
285 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
287 if let Some(f) = &assertion.field {
288 if !f.is_empty()
289 && string_assertion_types.contains(&assertion.assertion_type.as_str())
290 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
291 {
292 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
295 if !is_string_assertion {
296 continue;
297 }
298 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
299 let _ = writeln!(out, " {binding}");
300 unwrapped_fields.push((f.clone(), local_var));
301 }
302 }
303 }
304 }
305
306 for assertion in &fixture.assertions {
308 if assertion.assertion_type == "not_error" {
309 continue;
311 }
312 render_assertion(
313 out,
314 assertion,
315 result_var,
316 dep_name,
317 false,
318 &unwrapped_fields,
319 field_resolver,
320 );
321 }
322
323 let _ = writeln!(out, "}}");
324}
325
326fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
331 let mut current = input;
332 for part in field_path.split('.') {
333 current = current.get(part).unwrap_or(&serde_json::Value::Null);
334 }
335 current
336}
337
338fn render_rust_arg(
339 name: &str,
340 value: &serde_json::Value,
341 arg_type: &str,
342 optional: bool,
343 module: &str,
344 fixture_id: &str,
345) -> (Vec<String>, String) {
346 if arg_type == "mock_url" {
347 let lines = vec![format!(
348 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
349 )];
350 return (lines, format!("&{name}"));
351 }
352 if arg_type == "handle" {
353 use heck::ToSnakeCase;
357 let constructor_name = format!("create_{}", name.to_snake_case());
358 let mut lines = Vec::new();
359 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
360 lines.push(format!(
361 "let {name} = {module}::{constructor_name}(None).expect(\"handle creation should succeed\");"
362 ));
363 } else {
364 let json_literal = serde_json::to_string(value).unwrap_or_default();
366 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
367 lines.push(format!(
368 "let {name}_config: {module}::CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
369 ));
370 lines.push(format!(
371 "let {name} = {module}::{constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
372 ));
373 }
374 return (lines, format!("&{name}"));
375 }
376 if arg_type == "json_object" {
377 return render_json_object_arg(name, value, optional, module);
378 }
379 if value.is_null() && !optional {
380 let default_val = match arg_type {
382 "string" => "String::new()".to_string(),
383 "int" | "integer" => "0".to_string(),
384 "float" | "number" => "0.0_f64".to_string(),
385 "bool" | "boolean" => "false".to_string(),
386 _ => "Default::default()".to_string(),
387 };
388 let expr = if arg_type == "string" {
390 format!("&{name}")
391 } else {
392 name.to_string()
393 };
394 return (vec![format!("let {name} = {default_val};")], expr);
395 }
396 let literal = json_to_rust_literal(value, arg_type);
397 let pass_by_ref = arg_type == "string";
399 let expr = |n: &str| if pass_by_ref { format!("&{n}") } else { n.to_string() };
400 if optional && value.is_null() {
401 (vec![format!("let {name} = None;")], expr(name))
402 } else if optional {
403 (vec![format!("let {name} = Some({literal});")], expr(name))
404 } else {
405 (vec![format!("let {name} = {literal};")], expr(name))
406 }
407}
408
409fn render_json_object_arg(
413 name: &str,
414 value: &serde_json::Value,
415 optional: bool,
416 _module: &str,
417) -> (Vec<String>, String) {
418 if value.is_null() && optional {
419 return (vec![format!("let {name} = None;")], name.to_string());
420 }
421
422 let normalized = super::normalize_json_keys_to_snake_case(value);
425 let json_literal = json_value_to_macro_literal(&normalized);
427 let mut lines = Vec::new();
428 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
429 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
431 if optional {
432 lines.push(format!("let {name} = Some({deser_expr});"));
433 } else {
434 lines.push(format!("let {name} = {deser_expr};"));
435 }
436 (lines, name.to_string())
437}
438
439fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
441 match value {
442 serde_json::Value::Null => "null".to_string(),
443 serde_json::Value::Bool(b) => format!("{b}"),
444 serde_json::Value::Number(n) => n.to_string(),
445 serde_json::Value::String(s) => {
446 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
447 format!("\"{escaped}\"")
448 }
449 serde_json::Value::Array(arr) => {
450 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
451 format!("[{}]", items.join(", "))
452 }
453 serde_json::Value::Object(obj) => {
454 let entries: Vec<String> = obj
455 .iter()
456 .map(|(k, v)| {
457 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
458 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
459 })
460 .collect();
461 format!("{{{}}}", entries.join(", "))
462 }
463 }
464}
465
466fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
467 match value {
468 serde_json::Value::Null => "None".to_string(),
469 serde_json::Value::Bool(b) => format!("{b}"),
470 serde_json::Value::Number(n) => {
471 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
472 if let Some(f) = n.as_f64() {
473 return format!("{f}_f64");
474 }
475 }
476 n.to_string()
477 }
478 serde_json::Value::String(s) => rust_raw_string(s),
479 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
480 let json_str = serde_json::to_string(value).unwrap_or_default();
481 let literal = rust_raw_string(&json_str);
482 format!("serde_json::from_str({literal}).unwrap()")
483 }
484 }
485}
486
487fn render_assertion(
492 out: &mut String,
493 assertion: &Assertion,
494 result_var: &str,
495 _dep_name: &str,
496 is_error_context: bool,
497 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
499) {
500 if let Some(f) = &assertion.field {
502 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
503 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
504 return;
505 }
506 }
507
508 let field_access = match &assertion.field {
512 Some(f) if !f.is_empty() => {
513 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
514 local_var.clone()
515 } else {
516 field_resolver.accessor(f, "rust", result_var)
517 }
518 }
519 _ => result_var.to_string(),
520 };
521
522 let is_unwrapped = assertion
524 .field
525 .as_ref()
526 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
527
528 match assertion.assertion_type.as_str() {
529 "error" => {
530 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
531 if let Some(serde_json::Value::String(msg)) = &assertion.value {
532 let escaped = escape_rust(msg);
533 let _ = writeln!(
534 out,
535 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
536 );
537 }
538 }
539 "not_error" => {
540 }
542 "equals" => {
543 if let Some(val) = &assertion.value {
544 let expected = value_to_rust_string(val);
545 if is_error_context {
546 return;
547 }
548 if val.is_string() {
551 let _ = writeln!(
552 out,
553 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
554 );
555 } else {
556 let is_opt = assertion.field.as_ref().is_some_and(|f| {
558 let resolved = field_resolver.resolve(f);
559 field_resolver.is_optional(resolved)
560 });
561 if is_opt
562 && !unwrapped_fields
563 .iter()
564 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
565 {
566 let _ = writeln!(
567 out,
568 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
569 );
570 } else {
571 let _ = writeln!(
572 out,
573 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
574 );
575 }
576 }
577 }
578 }
579 "contains" => {
580 if let Some(val) = &assertion.value {
581 let expected = value_to_rust_string(val);
582 let line = format!(
583 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
584 );
585 let _ = writeln!(out, "{line}");
586 }
587 }
588 "contains_all" => {
589 if let Some(values) = &assertion.values {
590 for val in values {
591 let expected = value_to_rust_string(val);
592 let line = format!(
593 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
594 );
595 let _ = writeln!(out, "{line}");
596 }
597 }
598 }
599 "not_contains" => {
600 if let Some(val) = &assertion.value {
601 let expected = value_to_rust_string(val);
602 let line = format!(
603 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
604 );
605 let _ = writeln!(out, "{line}");
606 }
607 }
608 "not_empty" => {
609 if let Some(f) = &assertion.field {
610 let resolved = field_resolver.resolve(f);
611 if !is_unwrapped && field_resolver.is_optional(resolved) {
612 let accessor = field_resolver.accessor(f, "rust", result_var);
614 let _ = writeln!(
615 out,
616 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
617 );
618 } else {
619 let _ = writeln!(
620 out,
621 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
622 );
623 }
624 } else {
625 let _ = writeln!(
626 out,
627 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
628 );
629 }
630 }
631 "is_empty" => {
632 if let Some(f) = &assertion.field {
633 let resolved = field_resolver.resolve(f);
634 if !is_unwrapped && field_resolver.is_optional(resolved) {
635 let accessor = field_resolver.accessor(f, "rust", result_var);
636 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
637 } else {
638 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
639 }
640 } else {
641 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
642 }
643 }
644 "contains_any" => {
645 if let Some(values) = &assertion.values {
646 let checks: Vec<String> = values
647 .iter()
648 .map(|v| {
649 let expected = value_to_rust_string(v);
650 format!("{field_access}.contains({expected})")
651 })
652 .collect();
653 let joined = checks.join(" || ");
654 let _ = writeln!(
655 out,
656 " assert!({joined}, \"expected to contain at least one of the specified values\");"
657 );
658 }
659 }
660 "greater_than" => {
661 if let Some(val) = &assertion.value {
662 if val.as_f64().is_some_and(|n| n < 0.0) {
664 let _ = writeln!(
665 out,
666 " // skipped: greater_than with negative value is always true for unsigned types"
667 );
668 } else {
669 let lit = numeric_literal(val);
670 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
671 }
672 }
673 }
674 "less_than" => {
675 if let Some(val) = &assertion.value {
676 let lit = numeric_literal(val);
677 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
678 }
679 }
680 "greater_than_or_equal" => {
681 if let Some(val) = &assertion.value {
682 let lit = numeric_literal(val);
683 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
684 }
685 }
686 "less_than_or_equal" => {
687 if let Some(val) = &assertion.value {
688 let lit = numeric_literal(val);
689 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
690 }
691 }
692 "starts_with" => {
693 if let Some(val) = &assertion.value {
694 let expected = value_to_rust_string(val);
695 let _ = writeln!(
696 out,
697 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
698 );
699 }
700 }
701 "ends_with" => {
702 if let Some(val) = &assertion.value {
703 let expected = value_to_rust_string(val);
704 let _ = writeln!(
705 out,
706 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
707 );
708 }
709 }
710 "min_length" => {
711 if let Some(val) = &assertion.value {
712 if let Some(n) = val.as_u64() {
713 let _ = writeln!(
714 out,
715 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
716 );
717 }
718 }
719 }
720 "max_length" => {
721 if let Some(val) = &assertion.value {
722 if let Some(n) = val.as_u64() {
723 let _ = writeln!(
724 out,
725 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
726 );
727 }
728 }
729 }
730 "count_min" => {
731 if let Some(val) = &assertion.value {
732 if let Some(n) = val.as_u64() {
733 let _ = writeln!(
734 out,
735 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
736 );
737 }
738 }
739 }
740 other => {
741 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
742 }
743 }
744}
745
746fn numeric_literal(value: &serde_json::Value) -> String {
752 if let Some(n) = value.as_f64() {
753 if n.fract() == 0.0 {
754 return format!("{}", n as i64);
757 }
758 return format!("{n}_f64");
759 }
760 value.to_string()
762}
763
764fn value_to_rust_string(value: &serde_json::Value) -> String {
765 match value {
766 serde_json::Value::String(s) => rust_raw_string(s),
767 serde_json::Value::Bool(b) => format!("{b}"),
768 serde_json::Value::Number(n) => n.to_string(),
769 other => {
770 let s = other.to_string();
771 format!("\"{s}\"")
772 }
773 }
774}