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.effective_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        // Check if any fixture uses json_object args (needs serde_json dep).
36        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
42        // Check if any fixture in any group requires a mock HTTP server.
43        let needs_mock_server = groups
44            .iter()
45            .flat_map(|g| g.fixtures.iter())
46            .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
47
48        let crate_version = resolve_crate_version(e2e_config);
49        files.push(GeneratedFile {
50            path: output_base.join("Cargo.toml"),
51            content: render_cargo_toml(
52                &crate_name,
53                &dep_name,
54                &crate_path,
55                needs_serde_json,
56                needs_mock_server,
57                e2e_config.dep_mode,
58                crate_version.as_deref(),
59            ),
60            generated_header: true,
61        });
62
63        // Generate mock_server.rs when at least one fixture uses mock_response.
64        if needs_mock_server {
65            files.push(GeneratedFile {
66                path: output_base.join("tests").join("mock_server.rs"),
67                content: render_mock_server_module(),
68                generated_header: true,
69            });
70        }
71
72        // Per-category test files.
73        for group in groups {
74            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
75
76            if fixtures.is_empty() {
77                continue;
78            }
79
80            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
81            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
82
83            files.push(GeneratedFile {
84                path: output_base.join("tests").join(filename),
85                content,
86                generated_header: true,
87            });
88        }
89
90        Ok(files)
91    }
92
93    fn language_name(&self) -> &'static str {
94        "rust"
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Config resolution helpers
100// ---------------------------------------------------------------------------
101
102fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
103    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
104    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
105    // import identifier, not the Cargo package name.
106    alef_config.crate_config.name.clone()
107}
108
109fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
110    e2e_config
111        .resolve_package("rust")
112        .and_then(|p| p.path.clone())
113        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
114}
115
116fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
117    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
118}
119
120fn resolve_function_name(e2e_config: &E2eConfig) -> String {
121    e2e_config
122        .call
123        .overrides
124        .get("rust")
125        .and_then(|o| o.function.clone())
126        .unwrap_or_else(|| e2e_config.call.function.clone())
127}
128
129fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
130    // For Rust, the module name is the crate identifier (underscores).
131    // Priority: override.crate_name > override.module > dep_name
132    let overrides = e2e_config.call.overrides.get("rust");
133    overrides
134        .and_then(|o| o.crate_name.clone())
135        .or_else(|| overrides.and_then(|o| o.module.clone()))
136        .unwrap_or_else(|| dep_name.to_string())
137}
138
139fn is_skipped(fixture: &Fixture, language: &str) -> bool {
140    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
141}
142
143// ---------------------------------------------------------------------------
144// Rendering
145// ---------------------------------------------------------------------------
146
147fn render_cargo_toml(
148    crate_name: &str,
149    dep_name: &str,
150    crate_path: &str,
151    needs_serde_json: bool,
152    needs_mock_server: bool,
153    dep_mode: crate::config::DependencyMode,
154    version: Option<&str>,
155) -> String {
156    let e2e_name = format!("{dep_name}-e2e-rust");
157    let dep_spec = match dep_mode {
158        crate::config::DependencyMode::Registry => {
159            let ver = version.unwrap_or("0.1.0");
160            if crate_name != dep_name {
161                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\" }}")
162            } else {
163                format!("{dep_name} = \"{ver}\"")
164            }
165        }
166        crate::config::DependencyMode::Local => {
167            // When the crate name has hyphens, Cargo needs `package = "name-with-hyphens"`
168            // because the dep key uses underscores (Rust identifier).
169            if crate_name != dep_name {
170                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\" }}")
171            } else {
172                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
173            }
174        }
175    };
176    let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
177    // When using registry mode the generated Cargo.toml lives inside a directory
178    // that may be auto-discovered as part of a parent Cargo workspace.  Adding an
179    // empty [workspace] table tells Cargo that this crate is its own standalone
180    // workspace and opts out of any parent workspace discovery.
181    // Always add [workspace] — even in local mode the e2e crate lives outside
182    // the parent workspace members list and needs its own workspace declaration.
183    let workspace_section = "\n[workspace]\n";
184    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
185    let mock_lines = if needs_mock_server {
186        "\naxum = \"0.8\"\ntokio-stream = \"0.1\""
187    } else {
188        ""
189    };
190    let mut machete_ignored: Vec<&str> = Vec::new();
191    if needs_serde_json {
192        machete_ignored.push("\"serde_json\"");
193    }
194    if needs_mock_server {
195        machete_ignored.push("\"axum\"");
196        machete_ignored.push("\"tokio-stream\"");
197    }
198    let machete_section = if machete_ignored.is_empty() {
199        String::new()
200    } else {
201        format!(
202            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
203            machete_ignored.join(", ")
204        )
205    };
206    format!(
207        r#"# This file is auto-generated by alef. DO NOT EDIT.
208{workspace_section}
209[package]
210name = "{e2e_name}"
211version = "0.1.0"
212edition = "2021"
213license = "MIT"
214publish = false
215
216[dependencies]
217{dep_spec}{serde_line}{mock_lines}
218tokio = {{ version = "1", features = ["full"] }}
219{machete_section}"#
220    )
221}
222
223fn render_test_file(
224    category: &str,
225    fixtures: &[&Fixture],
226    e2e_config: &E2eConfig,
227    dep_name: &str,
228    needs_mock_server: bool,
229) -> String {
230    let mut out = String::new();
231    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
232    let _ = writeln!(out, "//! E2e tests for category: {category}");
233    let _ = writeln!(out);
234
235    let module = resolve_module(e2e_config, dep_name);
236    let function_name = resolve_function_name(e2e_config);
237    let field_resolver = FieldResolver::new(
238        &e2e_config.fields,
239        &e2e_config.fields_optional,
240        &e2e_config.result_fields,
241        &e2e_config.fields_array,
242    );
243
244    let _ = writeln!(out, "use {module}::{function_name};");
245
246    // Import handle constructor functions and the config type they use.
247    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
248    if has_handle_args {
249        let _ = writeln!(out, "use {module}::CrawlConfig;");
250    }
251    for arg in &e2e_config.call.args {
252        if arg.arg_type == "handle" {
253            use heck::ToSnakeCase;
254            let constructor_name = format!("create_{}", arg.name.to_snake_case());
255            let _ = writeln!(out, "use {module}::{constructor_name};");
256        }
257    }
258
259    // Import mock_server module when any fixture in this file uses mock_response.
260    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
261    if file_needs_mock {
262        let _ = writeln!(out, "mod mock_server;");
263        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
264    }
265
266    let _ = writeln!(out);
267
268    for fixture in fixtures {
269        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
270        let _ = writeln!(out);
271    }
272
273    if !out.ends_with('\n') {
274        out.push('\n');
275    }
276    out
277}
278
279fn render_test_function(
280    out: &mut String,
281    fixture: &Fixture,
282    e2e_config: &E2eConfig,
283    dep_name: &str,
284    field_resolver: &FieldResolver,
285) {
286    let fn_name = sanitize_ident(&fixture.id);
287    let description = &fixture.description;
288    let function_name = resolve_function_name(e2e_config);
289    let module = resolve_module(e2e_config, dep_name);
290    let result_var = &e2e_config.call.result_var;
291    let has_mock = fixture.needs_mock_server();
292
293    // Tests with a mock server are always async (Axum requires a Tokio runtime).
294    let is_async = e2e_config.call.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    // Emit mock server setup before building arguments so arg expressions can
305    // reference `mock_server.url` when needed.
306    if has_mock {
307        render_mock_server_setup(out, fixture, e2e_config);
308    }
309
310    // Check if any assertion is an error assertion.
311    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
312
313    // Emit input variable bindings from args config.
314    let mut arg_exprs: Vec<String> = Vec::new();
315    for arg in &e2e_config.call.args {
316        let value = resolve_field(&fixture.input, &arg.field);
317        let var_name = &arg.name;
318        let (bindings, expr) = render_rust_arg(
319            var_name,
320            value,
321            &arg.arg_type,
322            arg.optional,
323            &module,
324            &fixture.id,
325            if has_mock { Some("mock_server.url.as_str()") } else { None },
326        );
327        for binding in &bindings {
328            let _ = writeln!(out, "    {binding}");
329        }
330        arg_exprs.push(expr);
331    }
332
333    let args_str = arg_exprs.join(", ");
334
335    let await_suffix = if is_async { ".await" } else { "" };
336
337    if has_error_assertion {
338        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
339        // Render error assertions.
340        for assertion in &fixture.assertions {
341            render_assertion(out, assertion, result_var, dep_name, true, &[], field_resolver);
342        }
343        let _ = writeln!(out, "}}");
344        return;
345    }
346
347    // Non-error path: unwrap the result.
348    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
349
350    // Check if any assertion actually uses the result variable.
351    // If all assertions are skipped (field not on result type), use `_` to avoid
352    // Rust's "variable never used" warning.
353    let has_usable_assertion = fixture.assertions.iter().any(|a| {
354        if a.assertion_type == "not_error" || a.assertion_type == "error" {
355            return false;
356        }
357        match &a.field {
358            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
359            _ => true,
360        }
361    });
362
363    let result_binding = if has_usable_assertion {
364        result_var.to_string()
365    } else {
366        "_".to_string()
367    };
368
369    if has_not_error || !fixture.assertions.is_empty() {
370        let _ = writeln!(
371            out,
372            "    let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
373        );
374    } else {
375        let _ = writeln!(
376            out,
377            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
378        );
379    }
380
381    // Emit Option field unwrap bindings for any fields accessed in assertions.
382    // Use FieldResolver to handle optional fields, including nested/aliased paths.
383    let string_assertion_types = [
384        "equals",
385        "contains",
386        "contains_all",
387        "contains_any",
388        "not_contains",
389        "starts_with",
390        "ends_with",
391        "min_length",
392        "max_length",
393        "matches_regex",
394    ];
395    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
396    for assertion in &fixture.assertions {
397        if let Some(f) = &assertion.field {
398            if !f.is_empty()
399                && string_assertion_types.contains(&assertion.assertion_type.as_str())
400                && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
401            {
402                // Only unwrap optional string fields — numeric optionals (u64, usize)
403                // don't support .as_deref() and should be compared directly.
404                let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
405                if !is_string_assertion {
406                    continue;
407                }
408                if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
409                    let _ = writeln!(out, "    {binding}");
410                    unwrapped_fields.push((f.clone(), local_var));
411                }
412            }
413        }
414    }
415
416    // Render assertions.
417    for assertion in &fixture.assertions {
418        if assertion.assertion_type == "not_error" {
419            // Already handled by .expect() above.
420            continue;
421        }
422        render_assertion(
423            out,
424            assertion,
425            result_var,
426            dep_name,
427            false,
428            &unwrapped_fields,
429            field_resolver,
430        );
431    }
432
433    let _ = writeln!(out, "}}");
434}
435
436// ---------------------------------------------------------------------------
437// Argument rendering
438// ---------------------------------------------------------------------------
439
440fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
441    let mut current = input;
442    for part in field_path.split('.') {
443        current = current.get(part).unwrap_or(&serde_json::Value::Null);
444    }
445    current
446}
447
448fn render_rust_arg(
449    name: &str,
450    value: &serde_json::Value,
451    arg_type: &str,
452    optional: bool,
453    module: &str,
454    fixture_id: &str,
455    mock_base_url: Option<&str>,
456) -> (Vec<String>, String) {
457    if arg_type == "mock_url" {
458        let lines = vec![format!(
459            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
460        )];
461        return (lines, format!("&{name}"));
462    }
463    // When the arg is a base_url and a mock server is running, use the mock server URL.
464    if arg_type == "base_url" {
465        if let Some(url_expr) = mock_base_url {
466            return (vec![], url_expr.to_string());
467        }
468        // No mock server: fall through to string handling below.
469    }
470    if arg_type == "handle" {
471        // Generate a create_engine (or equivalent) call and pass the config.
472        // If the fixture has input.config, serialize it as a json_object and pass it;
473        // otherwise pass None.
474        use heck::ToSnakeCase;
475        let constructor_name = format!("create_{}", name.to_snake_case());
476        let mut lines = Vec::new();
477        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
478            lines.push(format!(
479                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
480            ));
481        } else {
482            // Serialize the config JSON and deserialize at runtime.
483            let json_literal = serde_json::to_string(value).unwrap_or_default();
484            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
485            lines.push(format!(
486                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
487            ));
488            lines.push(format!(
489                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
490            ));
491        }
492        return (lines, format!("&{name}"));
493    }
494    if arg_type == "json_object" {
495        return render_json_object_arg(name, value, optional, module);
496    }
497    if value.is_null() && !optional {
498        // Required arg with no fixture value: use a language-appropriate default.
499        let default_val = match arg_type {
500            "string" => "String::new()".to_string(),
501            "int" | "integer" => "0".to_string(),
502            "float" | "number" => "0.0_f64".to_string(),
503            "bool" | "boolean" => "false".to_string(),
504            _ => "Default::default()".to_string(),
505        };
506        // String args are passed by reference in Rust.
507        let expr = if arg_type == "string" {
508            format!("&{name}")
509        } else {
510            name.to_string()
511        };
512        return (vec![format!("let {name} = {default_val};")], expr);
513    }
514    let literal = json_to_rust_literal(value, arg_type);
515    // String args are passed by reference in Rust.
516    let pass_by_ref = arg_type == "string";
517    let expr = |n: &str| if pass_by_ref { format!("&{n}") } else { n.to_string() };
518    if optional && value.is_null() {
519        (vec![format!("let {name} = None;")], expr(name))
520    } else if optional {
521        (vec![format!("let {name} = Some({literal});")], expr(name))
522    } else {
523        (vec![format!("let {name} = {literal};")], expr(name))
524    }
525}
526
527/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
528/// and deserialize it through serde at runtime. Type inference from the function signature
529/// determines the concrete type, keeping the generator generic.
530fn render_json_object_arg(
531    name: &str,
532    value: &serde_json::Value,
533    optional: bool,
534    _module: &str,
535) -> (Vec<String>, String) {
536    if value.is_null() && optional {
537        return (vec![format!("let {name} = None;")], name.to_string());
538    }
539
540    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
541    // Normalize keys before building the json! literal so deserialization succeeds.
542    let normalized = super::normalize_json_keys_to_snake_case(value);
543    // Build the json! macro invocation from the fixture object.
544    let json_literal = json_value_to_macro_literal(&normalized);
545    let mut lines = Vec::new();
546    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
547    // Deserialize to a concrete type inferred from the function signature.
548    let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
549    if optional {
550        lines.push(format!("let {name} = Some({deser_expr});"));
551    } else {
552        lines.push(format!("let {name} = {deser_expr};"));
553    }
554    (lines, name.to_string())
555}
556
557/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
558fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
559    match value {
560        serde_json::Value::Null => "null".to_string(),
561        serde_json::Value::Bool(b) => format!("{b}"),
562        serde_json::Value::Number(n) => n.to_string(),
563        serde_json::Value::String(s) => {
564            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
565            format!("\"{escaped}\"")
566        }
567        serde_json::Value::Array(arr) => {
568            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
569            format!("[{}]", items.join(", "))
570        }
571        serde_json::Value::Object(obj) => {
572            let entries: Vec<String> = obj
573                .iter()
574                .map(|(k, v)| {
575                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
576                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
577                })
578                .collect();
579            format!("{{{}}}", entries.join(", "))
580        }
581    }
582}
583
584fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
585    match value {
586        serde_json::Value::Null => "None".to_string(),
587        serde_json::Value::Bool(b) => format!("{b}"),
588        serde_json::Value::Number(n) => {
589            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
590                if let Some(f) = n.as_f64() {
591                    return format!("{f}_f64");
592                }
593            }
594            n.to_string()
595        }
596        serde_json::Value::String(s) => rust_raw_string(s),
597        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
598            let json_str = serde_json::to_string(value).unwrap_or_default();
599            let literal = rust_raw_string(&json_str);
600            format!("serde_json::from_str({literal}).unwrap()")
601        }
602    }
603}
604
605// ---------------------------------------------------------------------------
606// Mock server helpers
607// ---------------------------------------------------------------------------
608
609/// Emit mock server setup lines into a test function body.
610///
611/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
612/// the server.  The resulting `mock_server` variable is in scope for the rest
613/// of the test function.
614fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
615    let mock = match fixture.mock_response.as_ref() {
616        Some(m) => m,
617        None => return,
618    };
619
620    // Resolve the HTTP path and method from the call config.
621    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
622    let path = call_config
623        .path
624        .as_deref()
625        .unwrap_or("/");
626    let method = call_config
627        .method
628        .as_deref()
629        .unwrap_or("POST");
630
631    let status = mock.status;
632
633    if let Some(chunks) = &mock.stream_chunks {
634        // Streaming SSE response.
635        let _ = writeln!(out, "    let mock_route = MockRoute {{");
636        let _ = writeln!(out, "        path: \"{path}\",");
637        let _ = writeln!(out, "        method: \"{method}\",");
638        let _ = writeln!(out, "        status: {status},");
639        let _ = writeln!(out, "        body: String::new(),");
640        let _ = writeln!(out, "        stream_chunks: vec![");
641        for chunk in chunks {
642            let chunk_str = match chunk {
643                serde_json::Value::String(s) => rust_raw_string(s),
644                other => {
645                    let s = serde_json::to_string(other).unwrap_or_default();
646                    rust_raw_string(&s)
647                }
648            };
649            let _ = writeln!(out, "            {chunk_str}.to_string(),");
650        }
651        let _ = writeln!(out, "        ],");
652        let _ = writeln!(out, "    }};");
653    } else {
654        // Non-streaming JSON response.
655        let body_str = match &mock.body {
656            Some(b) => {
657                let s = serde_json::to_string(b).unwrap_or_default();
658                rust_raw_string(&s)
659            }
660            None => rust_raw_string("{}"),
661        };
662        let _ = writeln!(out, "    let mock_route = MockRoute {{");
663        let _ = writeln!(out, "        path: \"{path}\",");
664        let _ = writeln!(out, "        method: \"{method}\",");
665        let _ = writeln!(out, "        status: {status},");
666        let _ = writeln!(out, "        body: {body_str}.to_string(),");
667        let _ = writeln!(out, "        stream_chunks: vec![],");
668        let _ = writeln!(out, "    }};");
669    }
670
671    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
672}
673
674/// Generate the complete `mock_server.rs` module source.
675fn render_mock_server_module() -> String {
676    // This is parameterized Axum mock server code identical in structure to
677    // liter-llm's mock_server.rs but without any project-specific imports.
678    r#"// This file is auto-generated by alef. DO NOT EDIT.
679//
680// Minimal axum-based mock HTTP server for e2e tests.
681
682use std::net::SocketAddr;
683use std::sync::Arc;
684
685use axum::Router;
686use axum::body::Body;
687use axum::extract::State;
688use axum::http::{Request, StatusCode};
689use axum::response::{IntoResponse, Response};
690use tokio::net::TcpListener;
691
692/// A single mock route: match by path + method, return a configured response.
693#[derive(Clone, Debug)]
694pub struct MockRoute {
695    /// URL path to match, e.g. `"/v1/chat/completions"`.
696    pub path: &'static str,
697    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
698    pub method: &'static str,
699    /// HTTP status code to return.
700    pub status: u16,
701    /// Response body JSON string (used when `stream_chunks` is empty).
702    pub body: String,
703    /// Ordered SSE data payloads for streaming responses.
704    /// Each entry becomes `data: <chunk>\n\n` in the response.
705    /// A final `data: [DONE]\n\n` is always appended.
706    pub stream_chunks: Vec<String>,
707}
708
709struct ServerState {
710    routes: Vec<MockRoute>,
711}
712
713pub struct MockServer {
714    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
715    pub url: String,
716    handle: tokio::task::JoinHandle<()>,
717}
718
719impl MockServer {
720    /// Start a mock server with the given routes.  Binds to a random port on
721    /// localhost and returns immediately once the server is listening.
722    pub async fn start(routes: Vec<MockRoute>) -> Self {
723        let state = Arc::new(ServerState { routes });
724
725        let app = Router::new().fallback(handle_request).with_state(state);
726
727        let listener = TcpListener::bind("127.0.0.1:0")
728            .await
729            .expect("Failed to bind mock server port");
730        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
731        let url = format!("http://{addr}");
732
733        let handle = tokio::spawn(async move {
734            axum::serve(listener, app).await.expect("Mock server failed");
735        });
736
737        MockServer { url, handle }
738    }
739
740    /// Stop the mock server.
741    pub fn shutdown(self) {
742        self.handle.abort();
743    }
744}
745
746impl Drop for MockServer {
747    fn drop(&mut self) {
748        self.handle.abort();
749    }
750}
751
752async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
753    let path = req.uri().path().to_owned();
754    let method = req.method().as_str().to_uppercase();
755
756    for route in &state.routes {
757        if route.path == path && route.method.to_uppercase() == method {
758            let status =
759                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
760
761            if !route.stream_chunks.is_empty() {
762                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
763                let mut sse = String::new();
764                for chunk in &route.stream_chunks {
765                    sse.push_str("data: ");
766                    sse.push_str(chunk);
767                    sse.push_str("\n\n");
768                }
769                sse.push_str("data: [DONE]\n\n");
770
771                return Response::builder()
772                    .status(status)
773                    .header("content-type", "text/event-stream")
774                    .header("cache-control", "no-cache")
775                    .body(Body::from(sse))
776                    .unwrap()
777                    .into_response();
778            }
779
780            return Response::builder()
781                .status(status)
782                .header("content-type", "application/json")
783                .body(Body::from(route.body.clone()))
784                .unwrap()
785                .into_response();
786        }
787    }
788
789    // No matching route → 404.
790    Response::builder()
791        .status(StatusCode::NOT_FOUND)
792        .body(Body::from(format!("No mock route for {method} {path}")))
793        .unwrap()
794        .into_response()
795}
796"#
797    .to_string()
798}
799
800// ---------------------------------------------------------------------------
801// Assertion rendering
802// ---------------------------------------------------------------------------
803
804fn render_assertion(
805    out: &mut String,
806    assertion: &Assertion,
807    result_var: &str,
808    _dep_name: &str,
809    is_error_context: bool,
810    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
811    field_resolver: &FieldResolver,
812) {
813    // Skip assertions on fields that don't exist on the result type.
814    if let Some(f) = &assertion.field {
815        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
816            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
817            return;
818        }
819    }
820
821    // Determine field access expression:
822    // 1. If the field was unwrapped to a local var, use that local var name.
823    // 2. Otherwise, use the field resolver to generate the accessor.
824    let field_access = match &assertion.field {
825        Some(f) if !f.is_empty() => {
826            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
827                local_var.clone()
828            } else {
829                field_resolver.accessor(f, "rust", result_var)
830            }
831        }
832        _ => result_var.to_string(),
833    };
834
835    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
836    let is_unwrapped = assertion
837        .field
838        .as_ref()
839        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
840
841    match assertion.assertion_type.as_str() {
842        "error" => {
843            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
844            if let Some(serde_json::Value::String(msg)) = &assertion.value {
845                let escaped = escape_rust(msg);
846                let _ = writeln!(
847                    out,
848                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
849                );
850            }
851        }
852        "not_error" => {
853            // Handled at call site; nothing extra needed here.
854        }
855        "equals" => {
856            if let Some(val) = &assertion.value {
857                let expected = value_to_rust_string(val);
858                if is_error_context {
859                    return;
860                }
861                // For string equality, trim trailing whitespace to handle trailing newlines
862                // from the converter.
863                if val.is_string() {
864                    let _ = writeln!(
865                        out,
866                        "    assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
867                    );
868                } else if val.is_boolean() {
869                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
870                    if val.as_bool() == Some(true) {
871                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
872                    } else {
873                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
874                    }
875                } else {
876                    // Wrap expected value in Some() for optional fields.
877                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
878                        let resolved = field_resolver.resolve(f);
879                        field_resolver.is_optional(resolved)
880                    });
881                    if is_opt
882                        && !unwrapped_fields
883                            .iter()
884                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
885                    {
886                        let _ = writeln!(
887                            out,
888                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
889                        );
890                    } else {
891                        let _ = writeln!(
892                            out,
893                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
894                        );
895                    }
896                }
897            }
898        }
899        "contains" => {
900            if let Some(val) = &assertion.value {
901                let expected = value_to_rust_string(val);
902                let line = format!(
903                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
904                );
905                let _ = writeln!(out, "{line}");
906            }
907        }
908        "contains_all" => {
909            if let Some(values) = &assertion.values {
910                for val in values {
911                    let expected = value_to_rust_string(val);
912                    let line = format!(
913                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
914                    );
915                    let _ = writeln!(out, "{line}");
916                }
917            }
918        }
919        "not_contains" => {
920            if let Some(val) = &assertion.value {
921                let expected = value_to_rust_string(val);
922                let line = format!(
923                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
924                );
925                let _ = writeln!(out, "{line}");
926            }
927        }
928        "not_empty" => {
929            if let Some(f) = &assertion.field {
930                let resolved = field_resolver.resolve(f);
931                if !is_unwrapped && field_resolver.is_optional(resolved) {
932                    // Non-string optional field (e.g., Option<Struct>): use is_some()
933                    let accessor = field_resolver.accessor(f, "rust", result_var);
934                    let _ = writeln!(
935                        out,
936                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
937                    );
938                } else {
939                    let _ = writeln!(
940                        out,
941                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
942                    );
943                }
944            } else {
945                let _ = writeln!(
946                    out,
947                    "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
948                );
949            }
950        }
951        "is_empty" => {
952            if let Some(f) = &assertion.field {
953                let resolved = field_resolver.resolve(f);
954                if !is_unwrapped && field_resolver.is_optional(resolved) {
955                    let accessor = field_resolver.accessor(f, "rust", result_var);
956                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
957                } else {
958                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
959                }
960            } else {
961                let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
962            }
963        }
964        "contains_any" => {
965            if let Some(values) = &assertion.values {
966                let checks: Vec<String> = values
967                    .iter()
968                    .map(|v| {
969                        let expected = value_to_rust_string(v);
970                        format!("{field_access}.contains({expected})")
971                    })
972                    .collect();
973                let joined = checks.join(" || ");
974                let _ = writeln!(
975                    out,
976                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
977                );
978            }
979        }
980        "greater_than" => {
981            if let Some(val) = &assertion.value {
982                // Skip comparisons with negative values against unsigned types (.len() etc.)
983                if val.as_f64().is_some_and(|n| n < 0.0) {
984                    let _ = writeln!(
985                        out,
986                        "    // skipped: greater_than with negative value is always true for unsigned types"
987                    );
988                } else if val.as_u64() == Some(0) {
989                    // Clippy prefers !is_empty() over len() > 0
990                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
991                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
992                } else {
993                    let lit = numeric_literal(val);
994                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
995                }
996            }
997        }
998        "less_than" => {
999            if let Some(val) = &assertion.value {
1000                let lit = numeric_literal(val);
1001                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
1002            }
1003        }
1004        "greater_than_or_equal" => {
1005            if let Some(val) = &assertion.value {
1006                if val.as_u64() == Some(1) {
1007                    // Clippy prefers !is_empty() over len() >= 1
1008                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1009                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
1010                } else {
1011                    let lit = numeric_literal(val);
1012                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1013                }
1014            }
1015        }
1016        "less_than_or_equal" => {
1017            if let Some(val) = &assertion.value {
1018                let lit = numeric_literal(val);
1019                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1020            }
1021        }
1022        "starts_with" => {
1023            if let Some(val) = &assertion.value {
1024                let expected = value_to_rust_string(val);
1025                let _ = writeln!(
1026                    out,
1027                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1028                );
1029            }
1030        }
1031        "ends_with" => {
1032            if let Some(val) = &assertion.value {
1033                let expected = value_to_rust_string(val);
1034                let _ = writeln!(
1035                    out,
1036                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1037                );
1038            }
1039        }
1040        "min_length" => {
1041            if let Some(val) = &assertion.value {
1042                if let Some(n) = val.as_u64() {
1043                    let _ = writeln!(
1044                        out,
1045                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1046                    );
1047                }
1048            }
1049        }
1050        "max_length" => {
1051            if let Some(val) = &assertion.value {
1052                if let Some(n) = val.as_u64() {
1053                    let _ = writeln!(
1054                        out,
1055                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1056                    );
1057                }
1058            }
1059        }
1060        "count_min" => {
1061            if let Some(val) = &assertion.value {
1062                if let Some(n) = val.as_u64() {
1063                    if n <= 1 {
1064                        // Clippy prefers !is_empty() over len() >= 1
1065                        let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1066                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
1067                    } else {
1068                        let _ = writeln!(
1069                            out,
1070                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1071                        );
1072                    }
1073                }
1074            }
1075        }
1076        other => {
1077            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
1078        }
1079    }
1080}
1081
1082/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
1083///
1084/// Whole numbers (no fractional part) are emitted as bare integer literals so
1085/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
1086/// Numbers with a fractional component get the `_f64` suffix.
1087fn numeric_literal(value: &serde_json::Value) -> String {
1088    if let Some(n) = value.as_f64() {
1089        if n.fract() == 0.0 {
1090            // Whole number — emit without a type suffix so Rust can infer the
1091            // correct integer type from context (usize, u64, i64, …).
1092            return format!("{}", n as i64);
1093        }
1094        return format!("{n}_f64");
1095    }
1096    // Fallback: use the raw JSON representation.
1097    value.to_string()
1098}
1099
1100fn value_to_rust_string(value: &serde_json::Value) -> String {
1101    match value {
1102        serde_json::Value::String(s) => rust_raw_string(s),
1103        serde_json::Value::Bool(b) => format!("{b}"),
1104        serde_json::Value::Number(n) => n.to_string(),
1105        other => {
1106            let s = other.to_string();
1107            format!("\"{s}\"")
1108        }
1109    }
1110}