Skip to main content

alef_e2e/codegen/
rust.rs

1//! Rust e2e test code generator.
2//!
3//! Generates `e2e/rust/Cargo.toml` and `tests/{category}_test.rs` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use 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
16/// Rust e2e test code generator.
17pub 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        // Resolve crate name and path from config.
30        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        // Cargo.toml
35        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        // Per-category test files.
42        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
67// ---------------------------------------------------------------------------
68// Config resolution helpers
69// ---------------------------------------------------------------------------
70
71fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
72    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
73    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
74    // import identifier, not the Cargo package name.
75    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    // For Rust, the module name is the crate identifier (underscores).
97    // Priority: override.crate_name > override.module > dep_name
98    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
109// ---------------------------------------------------------------------------
110// Rendering
111// ---------------------------------------------------------------------------
112
113fn render_cargo_toml(crate_name: &str, dep_name: &str, crate_path: &str) -> String {
114    let e2e_name = format!("{dep_name}-e2e-rust");
115    // When the crate name has hyphens, Cargo needs `package = "name-with-hyphens"`
116    // because the dep key uses underscores (Rust identifier).
117    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    // Check if any assertion is an error assertion.
180    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
181
182    // Emit input variable bindings from args config.
183    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        // Render error assertions.
199        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    // Non-error path: unwrap the result.
207    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    // Emit Option field unwrap bindings for any fields accessed in assertions.
219    // Use FieldResolver to handle optional fields, including nested/aliased paths.
220    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(); // (fixture_field, local_var)
233    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    // Render assertions.
248    for assertion in &fixture.assertions {
249        if assertion.assertion_type == "not_error" {
250            // Already handled by .expect() above.
251            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
267// ---------------------------------------------------------------------------
268// Argument rendering
269// ---------------------------------------------------------------------------
270
271fn 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
299/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
300/// and deserialize it through serde at runtime. Type inference from the function signature
301/// determines the concrete type, keeping the generator generic.
302fn 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    // Build the json! macro invocation from the fixture object.
313    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    // Deserialize to a concrete type inferred from the function signature.
317    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
326/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
327fn 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
374// ---------------------------------------------------------------------------
375// Assertion rendering
376// ---------------------------------------------------------------------------
377
378fn 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)], // (fixture_field, local_var)
385    field_resolver: &FieldResolver,
386) {
387    // Determine field access expression:
388    // 1. If the field was unwrapped to a local var, use that local var name.
389    // 2. Otherwise, use the field resolver to generate the accessor.
390    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    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
402    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            // Handled at call site; nothing extra needed here.
420        }
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                    // Non-string optional field (e.g., Option<Struct>): use is_some()
467                    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}