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 files.push(GeneratedFile {
36 path: output_base.join("Cargo.toml"),
37 content: render_cargo_toml(&crate_name, &dep_name, &crate_path),
38 generated_header: true,
39 });
40
41 for group in groups {
43 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
44
45 if fixtures.is_empty() {
46 continue;
47 }
48
49 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
50 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name);
51
52 files.push(GeneratedFile {
53 path: output_base.join("tests").join(filename),
54 content,
55 generated_header: true,
56 });
57 }
58
59 Ok(files)
60 }
61
62 fn language_name(&self) -> &'static str {
63 "rust"
64 }
65}
66
67fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
72 alef_config.crate_config.name.clone()
76}
77
78fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
79 e2e_config
80 .packages
81 .get("rust")
82 .and_then(|p| p.path.clone())
83 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
84}
85
86fn resolve_function_name(e2e_config: &E2eConfig) -> String {
87 e2e_config
88 .call
89 .overrides
90 .get("rust")
91 .and_then(|o| o.function.clone())
92 .unwrap_or_else(|| e2e_config.call.function.clone())
93}
94
95fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
96 let overrides = e2e_config.call.overrides.get("rust");
99 overrides
100 .and_then(|o| o.crate_name.clone())
101 .or_else(|| overrides.and_then(|o| o.module.clone()))
102 .unwrap_or_else(|| dep_name.to_string())
103}
104
105fn is_skipped(fixture: &Fixture, language: &str) -> bool {
106 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
107}
108
109fn render_cargo_toml(crate_name: &str, dep_name: &str, crate_path: &str) -> String {
114 let e2e_name = format!("{dep_name}-e2e-rust");
115 let dep_spec = if crate_name != dep_name {
118 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\" }}")
119 } else {
120 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
121 };
122 format!(
123 r#"[package]
124name = "{e2e_name}"
125version = "0.1.0"
126edition = "2021"
127publish = false
128
129# Standalone crate — not part of the workspace to avoid circular dependency.
130[workspace]
131
132[dependencies]
133{dep_spec}
134serde_json = "1"
135"#
136 )
137}
138
139fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, dep_name: &str) -> String {
140 let mut out = String::new();
141 let _ = writeln!(out, "//! E2e tests for category: {category}");
142 let _ = writeln!(out);
143
144 let module = resolve_module(e2e_config, dep_name);
145 let function_name = resolve_function_name(e2e_config);
146 let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
147
148 let _ = writeln!(out, "use {module}::{function_name};");
149 let _ = writeln!(out);
150
151 for fixture in fixtures {
152 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
153 let _ = writeln!(out);
154 }
155
156 if !out.ends_with('\n') {
157 out.push('\n');
158 }
159 out
160}
161
162fn render_test_function(
163 out: &mut String,
164 fixture: &Fixture,
165 e2e_config: &E2eConfig,
166 dep_name: &str,
167 field_resolver: &FieldResolver,
168) {
169 let fn_name = sanitize_ident(&fixture.id);
170 let description = &fixture.description;
171 let function_name = resolve_function_name(e2e_config);
172 let module = resolve_module(e2e_config, dep_name);
173 let result_var = &e2e_config.call.result_var;
174
175 let _ = writeln!(out, "#[test]");
176 let _ = writeln!(out, "fn test_{fn_name}() {{");
177 let _ = writeln!(out, " // {description}");
178
179 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
181
182 let mut arg_exprs: Vec<String> = Vec::new();
184 for arg in &e2e_config.call.args {
185 let value = resolve_field(&fixture.input, &arg.field);
186 let var_name = &arg.name;
187 let (bindings, expr) = render_rust_arg(var_name, value, &arg.arg_type, arg.optional, &module);
188 for binding in &bindings {
189 let _ = writeln!(out, " {binding}");
190 }
191 arg_exprs.push(expr);
192 }
193
194 let args_str = arg_exprs.join(", ");
195
196 if has_error_assertion {
197 let _ = writeln!(out, " let {result_var} = {function_name}({args_str});");
198 for assertion in &fixture.assertions {
200 render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
201 }
202 let _ = writeln!(out, "}}");
203 return;
204 }
205
206 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
208
209 if has_not_error || !fixture.assertions.is_empty() {
210 let _ = writeln!(
211 out,
212 " let {result_var} = {function_name}({args_str}).expect(\"should succeed\");"
213 );
214 } else {
215 let _ = writeln!(out, " let {result_var} = {function_name}({args_str});");
216 }
217
218 let string_assertion_types = [
221 "equals",
222 "contains",
223 "contains_all",
224 "contains_any",
225 "not_contains",
226 "starts_with",
227 "ends_with",
228 "min_length",
229 "max_length",
230 "matches_regex",
231 ];
232 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
234 if let Some(f) = &assertion.field {
235 if !f.is_empty()
236 && string_assertion_types.contains(&assertion.assertion_type.as_str())
237 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
238 {
239 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
240 let _ = writeln!(out, " {binding}");
241 unwrapped_fields.push((f.clone(), local_var));
242 }
243 }
244 }
245 }
246
247 for assertion in &fixture.assertions {
249 if assertion.assertion_type == "not_error" {
250 continue;
252 }
253 render_assertion(
254 out,
255 assertion,
256 result_var,
257 dep_name,
258 false,
259 &unwrapped_fields,
260 field_resolver,
261 );
262 }
263
264 let _ = writeln!(out, "}}");
265}
266
267fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
272 let mut current = input;
273 for part in field_path.split('.') {
274 current = current.get(part).unwrap_or(&serde_json::Value::Null);
275 }
276 current
277}
278
279fn render_rust_arg(
280 name: &str,
281 value: &serde_json::Value,
282 arg_type: &str,
283 optional: bool,
284 module: &str,
285) -> (Vec<String>, String) {
286 if arg_type == "json_object" {
287 return render_json_object_arg(name, value, optional, module);
288 }
289 let literal = json_to_rust_literal(value, arg_type);
290 if optional && value.is_null() {
291 (vec![format!("let {name} = None;")], name.to_string())
292 } else if optional {
293 (vec![format!("let {name} = Some({literal});")], name.to_string())
294 } else {
295 (vec![format!("let {name} = {literal};")], name.to_string())
296 }
297}
298
299fn render_json_object_arg(
303 name: &str,
304 value: &serde_json::Value,
305 optional: bool,
306 _module: &str,
307) -> (Vec<String>, String) {
308 if value.is_null() && optional {
309 return (vec![format!("let {name} = None;")], name.to_string());
310 }
311
312 let json_literal = json_value_to_macro_literal(value);
314 let mut lines = Vec::new();
315 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
316 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
318 if optional {
319 lines.push(format!("let {name} = Some({deser_expr});"));
320 } else {
321 lines.push(format!("let {name} = {deser_expr};"));
322 }
323 (lines, name.to_string())
324}
325
326fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
328 match value {
329 serde_json::Value::Null => "null".to_string(),
330 serde_json::Value::Bool(b) => format!("{b}"),
331 serde_json::Value::Number(n) => n.to_string(),
332 serde_json::Value::String(s) => {
333 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
334 format!("\"{escaped}\"")
335 }
336 serde_json::Value::Array(arr) => {
337 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
338 format!("[{}]", items.join(", "))
339 }
340 serde_json::Value::Object(obj) => {
341 let entries: Vec<String> = obj
342 .iter()
343 .map(|(k, v)| {
344 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
345 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
346 })
347 .collect();
348 format!("{{{}}}", entries.join(", "))
349 }
350 }
351}
352
353fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
354 match value {
355 serde_json::Value::Null => "None".to_string(),
356 serde_json::Value::Bool(b) => format!("{b}"),
357 serde_json::Value::Number(n) => {
358 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
359 if let Some(f) = n.as_f64() {
360 return format!("{f}_f64");
361 }
362 }
363 n.to_string()
364 }
365 serde_json::Value::String(s) => rust_raw_string(s),
366 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
367 let json_str = serde_json::to_string(value).unwrap_or_default();
368 let literal = rust_raw_string(&json_str);
369 format!("serde_json::from_str({literal}).unwrap()")
370 }
371 }
372}
373
374fn render_assertion(
379 out: &mut String,
380 assertion: &Assertion,
381 result_var: &str,
382 _dep_name: &str,
383 is_error_context: bool,
384 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
386) {
387 let field_access = match &assertion.field {
391 Some(f) if !f.is_empty() => {
392 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
393 local_var.clone()
394 } else {
395 field_resolver.accessor(f, "rust", result_var)
396 }
397 }
398 _ => result_var.to_string(),
399 };
400
401 let is_unwrapped = assertion
403 .field
404 .as_ref()
405 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
406
407 match assertion.assertion_type.as_str() {
408 "error" => {
409 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
410 if let Some(serde_json::Value::String(msg)) = &assertion.value {
411 let escaped = escape_rust(msg);
412 let _ = writeln!(
413 out,
414 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
415 );
416 }
417 }
418 "not_error" => {
419 }
421 "equals" => {
422 if let Some(val) = &assertion.value {
423 let expected = value_to_rust_string(val);
424 if is_error_context {
425 return;
426 }
427 let _ = writeln!(
428 out,
429 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
430 );
431 }
432 }
433 "contains" => {
434 if let Some(val) = &assertion.value {
435 let expected = value_to_rust_string(val);
436 let _ = writeln!(
437 out,
438 " assert!({field_access}.contains({expected}), \"expected to contain: {{}}\", {expected});"
439 );
440 }
441 }
442 "contains_all" => {
443 if let Some(values) = &assertion.values {
444 for val in values {
445 let expected = value_to_rust_string(val);
446 let _ = writeln!(
447 out,
448 " assert!({field_access}.contains({expected}), \"expected to contain: {{}}\", {expected});"
449 );
450 }
451 }
452 }
453 "not_contains" => {
454 if let Some(val) = &assertion.value {
455 let expected = value_to_rust_string(val);
456 let _ = writeln!(
457 out,
458 " assert!(!{field_access}.contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
459 );
460 }
461 }
462 "not_empty" => {
463 if let Some(f) = &assertion.field {
464 let resolved = field_resolver.resolve(f);
465 if !is_unwrapped && field_resolver.is_optional(resolved) {
466 let accessor = field_resolver.accessor(f, "rust", result_var);
468 let _ = writeln!(
469 out,
470 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
471 );
472 } else {
473 let _ = writeln!(
474 out,
475 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
476 );
477 }
478 } else {
479 let _ = writeln!(
480 out,
481 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
482 );
483 }
484 }
485 "is_empty" => {
486 if let Some(f) = &assertion.field {
487 let resolved = field_resolver.resolve(f);
488 if !is_unwrapped && field_resolver.is_optional(resolved) {
489 let accessor = field_resolver.accessor(f, "rust", result_var);
490 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
491 } else {
492 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
493 }
494 } else {
495 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
496 }
497 }
498 "starts_with" => {
499 if let Some(val) = &assertion.value {
500 let expected = value_to_rust_string(val);
501 let _ = writeln!(
502 out,
503 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
504 );
505 }
506 }
507 "ends_with" => {
508 if let Some(val) = &assertion.value {
509 let expected = value_to_rust_string(val);
510 let _ = writeln!(
511 out,
512 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
513 );
514 }
515 }
516 "min_length" => {
517 if let Some(val) = &assertion.value {
518 if let Some(n) = val.as_u64() {
519 let _ = writeln!(
520 out,
521 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
522 );
523 }
524 }
525 }
526 "max_length" => {
527 if let Some(val) = &assertion.value {
528 if let Some(n) = val.as_u64() {
529 let _ = writeln!(
530 out,
531 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
532 );
533 }
534 }
535 }
536 other => {
537 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
538 }
539 }
540}
541
542fn value_to_rust_string(value: &serde_json::Value) -> String {
543 match value {
544 serde_json::Value::String(s) => rust_raw_string(s),
545 other => {
546 let s = other.to_string();
547 format!("\"{s}\"")
548 }
549 }
550}