Skip to main content

alef_e2e/codegen/rust/
test_file.rs

1//! Per-category test file generation for Rust e2e tests.
2
3use std::fmt::Write as FmtWrite;
4
5use crate::config::E2eConfig;
6use crate::escape::sanitize_filename;
7use crate::field_access::FieldResolver;
8use crate::fixture::{Fixture, FixtureGroup};
9
10use super::args::{emit_rust_visitor_method, render_rust_arg, resolve_visitor_trait};
11use super::assertions::render_assertion;
12use super::http::render_http_test_function;
13use super::mock_server::render_mock_server_setup;
14
15pub(super) fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
16    call_config
17        .overrides
18        .get("rust")
19        .and_then(|o| o.function.clone())
20        .unwrap_or_else(|| call_config.function.clone())
21}
22
23pub(super) fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
24    resolve_module_for_call(&e2e_config.call, dep_name)
25}
26
27pub(super) fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
28    // For Rust, the module name is the crate identifier (underscores).
29    // Priority: override.crate_name > override.module > dep_name
30    let overrides = call_config.overrides.get("rust");
31    overrides
32        .and_then(|o| o.crate_name.clone())
33        .or_else(|| overrides.and_then(|o| o.module.clone()))
34        .unwrap_or_else(|| dep_name.to_string())
35}
36
37pub(super) fn is_skipped(fixture: &Fixture, language: &str) -> bool {
38    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
39}
40
41pub fn render_test_file(
42    category: &str,
43    fixtures: &[&Fixture],
44    e2e_config: &E2eConfig,
45    dep_name: &str,
46    needs_mock_server: bool,
47) -> String {
48    let mut out = String::new();
49    out.push_str(&alef_core::hash::header(alef_core::hash::CommentStyle::DoubleSlash));
50    let _ = writeln!(out, "//! E2e tests for category: {category}");
51    let _ = writeln!(out);
52
53    let module = resolve_module(e2e_config, dep_name);
54    let field_resolver = FieldResolver::new(
55        &e2e_config.fields,
56        &e2e_config.fields_optional,
57        &e2e_config.result_fields,
58        &e2e_config.fields_array,
59        &e2e_config.fields_method_calls,
60    );
61
62    // Check if this file has http-fixture tests (separate from call-based tests).
63    let file_has_http = fixtures.iter().any(|f| f.http.is_some());
64    // Call-based: has mock_response OR is a plain function-call fixture (no http, no mock) with a
65    // configured function name. Pure schema/spec stubs (function name empty) use the stub path.
66    let file_has_call_based = fixtures.iter().any(|f| {
67        if f.mock_response.is_some() {
68            return true;
69        }
70        if f.http.is_none() && f.mock_response.is_none() {
71            let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
72            let fn_name = resolve_function_name_for_call(call_config);
73            return !fn_name.is_empty();
74        }
75        false
76    });
77
78    // Collect all unique (module, function) pairs needed across call-based fixtures only.
79    // Resolve client_factory from the default call's rust override. When set, the generated tests
80    // create a client via `module::factory(...)` and call methods on it rather than importing and
81    // calling free functions. In that case we skip the function `use` imports entirely.
82    let rust_call_override = e2e_config.call.overrides.get("rust");
83    let client_factory = rust_call_override.and_then(|o| o.client_factory.as_deref());
84
85    // Http fixtures and pure stub fixtures use different code paths and don't import the call function.
86    if file_has_call_based && client_factory.is_none() {
87        let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
88        for fixture in fixtures.iter().filter(|f| {
89            if f.mock_response.is_some() {
90                return true;
91            }
92            if f.http.is_none() && f.mock_response.is_none() {
93                let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
94                let fn_name = resolve_function_name_for_call(call_config);
95                return !fn_name.is_empty();
96            }
97            false
98        }) {
99            let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
100            let fn_name = resolve_function_name_for_call(call_config);
101            let mod_name = resolve_module_for_call(call_config, dep_name);
102            imported.insert((mod_name, fn_name));
103        }
104        // Emit use statements, grouping by module when possible.
105        let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
106        for (mod_name, fn_name) in &imported {
107            by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
108        }
109        for (mod_name, fns) in &by_module {
110            if fns.len() == 1 {
111                let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
112            } else {
113                let joined = fns.join(", ");
114                let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
115            }
116        }
117    }
118
119    // Http fixtures use App + RequestContext for integration tests.
120    if file_has_http {
121        let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
122    }
123
124    // Import handle constructor functions and the config type they use.
125    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
126    if has_handle_args {
127        let _ = writeln!(out, "use {module}::CrawlConfig;");
128    }
129    for arg in &e2e_config.call.args {
130        if arg.arg_type == "handle" {
131            use heck::ToSnakeCase;
132            let constructor_name = format!("create_{}", arg.name.to_snake_case());
133            let _ = writeln!(out, "use {module}::{constructor_name};");
134        }
135    }
136
137    // When client_factory is set, emit trait imports required to call methods on the client object.
138    // Traits like LlmClient, FileClient, etc. must be in scope for method dispatch to work.
139    if client_factory.is_some() && file_has_call_based {
140        let trait_imports: Vec<String> = e2e_config
141            .call
142            .overrides
143            .get("rust")
144            .map(|o| o.trait_imports.clone())
145            .unwrap_or_default();
146        for trait_name in &trait_imports {
147            let _ = writeln!(out, "use {module}::{trait_name};");
148        }
149    }
150
151    // Import mock_server and common modules when any fixture in this file uses mock_response.
152    let file_needs_mock = needs_mock_server
153        && fixtures
154            .iter()
155            .any(|f| f.mock_response.is_some() || f.needs_mock_server());
156    if file_needs_mock {
157        let _ = writeln!(out, "mod common;");
158        let _ = writeln!(out, "mod mock_server;");
159        let _ = writeln!(out, "#[allow(unused_imports)]");
160        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
161    }
162
163    // Import the visitor trait, result enum, and node context when any fixture
164    // in this file declares a `visitor` block. Without these, the inline
165    // `impl <visitor_trait> for _TestVisitor` block fails to resolve.
166    // Visitor types live in the `visitor` sub-module of the crate, not the crate root.
167    // The trait name is read from `[e2e.call.overrides.rust] visitor_trait`; omitting it
168    // while a fixture declares a visitor is a configuration error.
169    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
170    if file_needs_visitor {
171        let visitor_trait = resolve_visitor_trait(rust_call_override).unwrap_or_else(|| {
172            panic!(
173                "category '{}': fixture declares a visitor block but \
174                 `[e2e.call.overrides.rust] visitor_trait` is not configured",
175                category
176            )
177        });
178        let _ = writeln!(
179            out,
180            "use {module}::visitor::{{{visitor_trait}, NodeContext, VisitResult}};"
181        );
182    }
183
184    // When the rust override specifies an `options_type` (e.g. `ConversionOptions`),
185    // type annotations are emitted on json_object bindings so that `Default::default()`
186    // and `serde_json::from_value(…)` can be resolved without a trailing positional arg.
187    // Import the named type so it is in scope in every test function in this file.
188    if file_has_call_based {
189        let rust_options_type = e2e_config
190            .call
191            .overrides
192            .get("rust")
193            .and_then(|o| o.options_type.as_deref());
194        if let Some(opts_type) = rust_options_type {
195            // Only emit if the call has a json_object arg (the type annotation is only
196            // added to json_object bindings).
197            let has_json_object_arg = e2e_config.call.args.iter().any(|a| a.arg_type == "json_object");
198            if has_json_object_arg {
199                let _ = writeln!(out, "use {module}::{opts_type};");
200            }
201        }
202    }
203
204    // Collect and import element types from json_object args that have an element_type specified.
205    // These types are used in serde_json::from_value::<Vec<{elem}>>() for batch operations.
206    // Collect from all calls used in call-based fixtures (not just the default call).
207    if file_has_call_based {
208        let mut element_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
209        for fixture in fixtures.iter().filter(|f| {
210            if f.mock_response.is_some() {
211                return true;
212            }
213            if f.http.is_none() && f.mock_response.is_none() {
214                let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
215                let fn_name = resolve_function_name_for_call(call_config);
216                return !fn_name.is_empty();
217            }
218            false
219        }) {
220            let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
221            for arg in &call_config.args {
222                if arg.arg_type == "json_object" {
223                    if let Some(ref elem_type) = arg.element_type {
224                        element_types.insert(elem_type.clone());
225                    }
226                }
227            }
228        }
229        for elem_type in &element_types {
230            // Skip primitive / std types — they're already in scope via the Rust prelude
231            // and emitting `use kreuzberg::String;` (etc.) would fail with E0432.
232            if matches!(
233                elem_type.as_str(),
234                "String"
235                    | "str"
236                    | "bool"
237                    | "i8"
238                    | "i16"
239                    | "i32"
240                    | "i64"
241                    | "i128"
242                    | "isize"
243                    | "u8"
244                    | "u16"
245                    | "u32"
246                    | "u64"
247                    | "u128"
248                    | "usize"
249                    | "f32"
250                    | "f64"
251                    | "char"
252            ) {
253                continue;
254            }
255            let _ = writeln!(out, "use {module}::{elem_type};");
256        }
257    }
258
259    let _ = writeln!(out);
260
261    for fixture in fixtures {
262        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver, client_factory);
263        let _ = writeln!(out);
264    }
265
266    if !out.ends_with('\n') {
267        out.push('\n');
268    }
269    out
270}
271
272pub fn render_test_function(
273    out: &mut String,
274    fixture: &Fixture,
275    e2e_config: &E2eConfig,
276    dep_name: &str,
277    field_resolver: &FieldResolver,
278    client_factory: Option<&str>,
279) {
280    // Http fixtures get their own integration test code path.
281    if fixture.http.is_some() {
282        render_http_test_function(out, fixture, dep_name);
283        return;
284    }
285
286    // Fixtures that have neither `http` nor `mock_response` may be either:
287    //  - schema/spec validation fixtures (asyncapi, grpc, graphql_schema, …) with no callable
288    //    function → emit a TODO stub so the suite compiles and preserves test count.
289    //  - plain function-call fixtures (e.g. kreuzberg::extract_file) with a configured
290    //    `[e2e.call]` → fall through to the real function-call code path below.
291    if fixture.http.is_none() && fixture.mock_response.is_none() {
292        let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
293        let resolved_fn_name = resolve_function_name_for_call(call_config);
294        if resolved_fn_name.is_empty() {
295            let fn_name = crate::escape::sanitize_ident(&fixture.id);
296            let description = &fixture.description;
297            let _ = writeln!(out, "#[tokio::test]");
298            let _ = writeln!(out, "async fn test_{fn_name}() {{");
299            let _ = writeln!(out, "    // {description}");
300            let _ = writeln!(
301                out,
302                "    // TODO: implement when a callable API is available for this fixture type."
303            );
304            let _ = writeln!(out, "}}");
305            return;
306        }
307        // Non-empty function name: fall through to emit a real function call below.
308    }
309
310    let fn_name = crate::escape::sanitize_ident(&fixture.id);
311    let description = &fixture.description;
312    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
313    let function_name = resolve_function_name_for_call(call_config);
314    let module = resolve_module_for_call(call_config, dep_name);
315    let result_var = &call_config.result_var;
316    let has_mock = fixture.mock_response.is_some();
317
318    // Resolve Rust-specific overrides early since we need them for returns_result.
319    let rust_overrides = call_config.overrides.get("rust");
320
321    // Determine if this call returns Result<T, E>. Per-rust override takes precedence.
322    // When client_factory is set, methods always return Result<T>.
323    let returns_result = rust_overrides
324        .and_then(|o| o.returns_result)
325        .unwrap_or(if client_factory.is_some() {
326            true
327        } else {
328            call_config.returns_result
329        });
330
331    // Tests with a mock server are always async (Axum requires a Tokio runtime).
332    let is_async = call_config.r#async || has_mock;
333    if is_async {
334        let _ = writeln!(out, "#[tokio::test]");
335        let _ = writeln!(out, "async fn test_{fn_name}() {{");
336    } else {
337        let _ = writeln!(out, "#[test]");
338        let _ = writeln!(out, "fn test_{fn_name}() {{");
339    }
340    let _ = writeln!(out, "    // {description}");
341
342    // Render the rest of the function body into a side buffer so we can post-process
343    // the `mock_server` binding name: if the body never reads `mock_server.url` (typical
344    // for error-path fixtures that only need the server held alive via Drop), we rename
345    // the binding to `_mock_server` to silence `-D unused_variables`. The original `out`
346    // is renamed to `final_out`; the inner code below writes to `out` (the body buffer).
347    let final_out = out;
348    let mut body_buf = String::new();
349    let out = &mut body_buf;
350
351    // Check if any assertion is an error assertion.
352    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
353
354    // Extract additional overrides for argument shaping.
355    let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
356    let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
357    // options_type from the rust override (e.g. "ConversionOptions") — used to annotate
358    // `Default::default()` and `serde_json::from_value(…)` bindings so Rust can infer
359    // the concrete type without a trailing positional argument to guide inference.
360    let options_type: Option<String> = rust_overrides.and_then(|o| o.options_type.clone());
361
362    // When the fixture declares a visitor that is passed via an options-field (the
363    // html-to-markdown core `convert` API accepts visitor only through
364    // `ConversionOptions.visitor`), the options binding must be `mut` so we can
365    // assign the visitor field before the call.
366    let visitor_via_options = fixture.visitor.is_some() && rust_overrides.is_none_or(|o| o.visitor_function.is_none());
367
368    // Emit input variable bindings from args config.
369    let mut arg_exprs: Vec<String> = Vec::new();
370    // Track the name of the json_object options arg so we can inject the visitor later.
371    let mut options_arg_name: Option<String> = None;
372    // When has_error_assertion is true and a handle arg is present, track its name so we
373    // can wrap the main call in a match that propagates engine-creation failures as Err.
374    let mut error_context_handle_name: Option<String> = None;
375    for arg in &call_config.args {
376        let value = crate::codegen::resolve_field(&fixture.input, &arg.field);
377        let var_name = &arg.name;
378        let (mut bindings, expr) = render_rust_arg(
379            var_name,
380            value,
381            &arg.arg_type,
382            arg.optional,
383            &module,
384            &fixture.id,
385            if has_mock {
386                Some("mock_server.url.as_str()")
387            } else {
388                None
389            },
390            arg.owned,
391            arg.element_type.as_deref(),
392            &e2e_config.test_documents_dir,
393            has_error_assertion,
394        );
395        // Add explicit type annotation to json_object bindings so Rust can resolve
396        // `Default::default()` and `serde_json::from_value(…)` without a trailing
397        // positional argument to guide inference.
398        if arg.arg_type == "json_object" {
399            if let Some(ref opts_type) = options_type {
400                bindings = bindings
401                    .into_iter()
402                    .map(|b| {
403                        // `let {name} = …` → `let {name}: {opts_type} = …`
404                        let prefix = format!("let {var_name} = ");
405                        if b.starts_with(&prefix) {
406                            format!("let {var_name}: {opts_type} = {}", &b[prefix.len()..])
407                        } else {
408                            b
409                        }
410                    })
411                    .collect();
412            }
413        }
414        // When the visitor will be injected via the options field, the options binding
415        // must be declared `mut` so we can assign `options.visitor = Some(visitor)`.
416        if visitor_via_options && arg.arg_type == "json_object" {
417            options_arg_name = Some(var_name.clone());
418            bindings = bindings
419                .into_iter()
420                .map(|b| {
421                    // `let {name}` → `let mut {name}`
422                    let prefix = format!("let {var_name}");
423                    if b.starts_with(&prefix) {
424                        format!("let mut {}", &b[4..])
425                    } else {
426                        b
427                    }
428                })
429                .collect();
430        }
431        // When in error context and the arg is a handle, the binding emitted by
432        // render_rust_arg is `{name}_result` (a Result). Track the handle name so
433        // the error-context call site can emit a match wrapper.
434        if has_error_assertion && arg.arg_type == "handle" {
435            error_context_handle_name = Some(var_name.clone());
436        }
437        for binding in &bindings {
438            let _ = writeln!(out, "    {binding}");
439        }
440        // For functions whose options slot is owned `Option<T>` rather than `&T`,
441        // wrap the json_object expression in `Some(...).clone()` so it matches
442        // the parameter shape. Other arg types pass through unchanged.
443        let final_expr = if has_error_assertion && arg.arg_type == "handle" {
444            // In error context, the handle binding is `{name}_result` (a Result).
445            // The actual call will be emitted inside a `match {name}_result { Ok({name}) => ...}`
446            // wrapper, so the call expression still uses `&{name}` (the unwrapped handle).
447            format!("&{var_name}")
448        } else if wrap_options_in_some && arg.arg_type == "json_object" {
449            if visitor_via_options {
450                // Visitor will be injected into options before the call; pass by move
451                // (no .clone() needed).
452                let name = if let Some(rest) = expr.strip_prefix('&') {
453                    rest.to_string()
454                } else {
455                    expr.clone()
456                };
457                format!("Some({name})")
458            } else if let Some(rest) = expr.strip_prefix('&') {
459                format!("Some({rest}.clone())")
460            } else {
461                format!("Some({expr})")
462            }
463        } else {
464            expr
465        };
466        arg_exprs.push(final_expr);
467    }
468
469    // Emit visitor if present in fixture.
470    if let Some(visitor_spec) = &fixture.visitor {
471        // The visitor trait name must be configured via
472        // `[e2e.call.overrides.rust] visitor_trait`; we propagate the error to the caller.
473        let visitor_trait = resolve_visitor_trait(rust_overrides)
474            .expect("visitor_trait must be set in [e2e.call.overrides.rust] when a fixture declares a visitor block");
475        // The visitor trait requires `std::fmt::Debug`; derive it on the inline struct.
476        let _ = writeln!(out, "    #[derive(Debug)]");
477        let _ = writeln!(out, "    struct _TestVisitor;");
478        let _ = writeln!(out, "    impl {visitor_trait} for _TestVisitor {{");
479        for (method_name, action) in &visitor_spec.callbacks {
480            emit_rust_visitor_method(out, method_name, action);
481        }
482        let _ = writeln!(out, "    }}");
483        let _ = writeln!(
484            out,
485            "    let visitor = std::sync::Arc::new(std::sync::Mutex::new(_TestVisitor));"
486        );
487        if visitor_via_options {
488            // Inject the visitor via the options field rather than as a positional arg.
489            let opts_name = options_arg_name.as_deref().unwrap_or("options");
490            let _ = writeln!(out, "    {opts_name}.visitor = Some(visitor);");
491        } else {
492            // Binding uses a visitor_function override that takes visitor as positional arg.
493            arg_exprs.push("Some(visitor)".to_string());
494        }
495    } else {
496        // No fixture-supplied visitor: append any extra positional args declared in
497        // the rust override (e.g. trailing `None` for an Option<VisitorParam> slot).
498        arg_exprs.extend(extra_args);
499    }
500
501    let args_str = arg_exprs.join(", ");
502
503    let await_suffix = if is_async { ".await" } else { "" };
504
505    // When client_factory is configured, emit a `create_client` call and dispatch
506    // methods on the returned client object instead of calling free functions.
507    // The mock server URL (when present) is passed as `base_url`; otherwise `None`.
508    let call_expr = if let Some(factory) = client_factory {
509        let base_url_arg = if has_mock {
510            "Some(mock_server.url.clone())"
511        } else {
512            "None"
513        };
514        let _ = writeln!(
515            out,
516            "    let client = {module}::{factory}(\"test-key\".to_string(), {base_url_arg}, None, None, None).unwrap();"
517        );
518        format!("client.{function_name}({args_str})")
519    } else {
520        format!("{function_name}({args_str})")
521    };
522
523    let result_is_tree = call_config.result_var == "tree";
524    // When the call config or rust override sets result_is_simple, the function
525    // returns a plain type (String, Vec<T>, etc.) — field-access assertions use
526    // the result var directly.
527    let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
528    // When result_is_vec is set, the function returns Vec<T>. Field-path assertions
529    // are wrapped in `.iter().all(|r| ...)` so every element is checked.
530    let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
531    // When result_is_option is set, the function returns Option<T>. Field-path
532    // assertions unwrap first via `.as_ref().expect("Option should be Some")`.
533    let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
534
535    if has_error_assertion {
536        // When a handle (engine) arg is present, the engine creation itself may fail.
537        // Wrap the primary call in a match so engine-creation errors propagate as Err
538        // instead of panicking via .expect().
539        if let Some(ref handle_name) = error_context_handle_name {
540            let _ = writeln!(out, "    let {result_var} = match {handle_name}_result {{");
541            let _ = writeln!(out, "        Err(e) => Err(e),");
542            let _ = writeln!(out, "        Ok({handle_name}) => {{");
543            let _ = writeln!(out, "            {call_expr}{await_suffix}");
544            let _ = writeln!(out, "        }}");
545            let _ = writeln!(out, "    }};");
546        } else {
547            let _ = writeln!(out, "    let {result_var} = {call_expr}{await_suffix};");
548        }
549        // Check if any assertion accesses fields on the Ok value (not error-path fields).
550        // Assertions on `error.*` fields access the Err value and do not need `result_ok`.
551        let has_non_error_assertions = fixture.assertions.iter().any(|a| {
552            !matches!(a.assertion_type.as_str(), "error" | "not_error")
553                && !a.field.as_ref().is_some_and(|f| f.starts_with("error."))
554        });
555        // When returns_result=true and there are field assertions (non-error), we need to
556        // handle the Result wrapper: unwrap Ok for field assertions, extract Err for error assertions.
557        if returns_result && has_non_error_assertions {
558            // Emit a temporary binding for the unwrapped Ok value.
559            let _ = writeln!(out, "    let {result_var}_ok = {result_var}.as_ref().ok();");
560        }
561        // Render error assertions.
562        for assertion in &fixture.assertions {
563            render_assertion(
564                out,
565                assertion,
566                result_var,
567                &module,
568                dep_name,
569                true,
570                &[],
571                field_resolver,
572                result_is_tree,
573                result_is_simple,
574                false,
575                false,
576                returns_result,
577            );
578        }
579        let _ = writeln!(out, "}}");
580        finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
581        return;
582    }
583
584    // Non-error path: unwrap the result.
585    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
586
587    // Streaming detection (call-level `streaming` opt-out is honored).
588    // Shared helper auto-detects from unambiguous streaming-only field names
589    // (chunks, stream_content, …) — but not from ambiguous fields like `usage`
590    // or `finish_reason` that also exist on non-streaming response shapes.
591    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
592    // Name of the stream-level variable (the raw stream returned by the call).
593    let stream_var = "stream";
594    // Name of the collected-list variable produced by draining the stream.
595    let chunks_var = "chunks";
596
597    // Check if any assertion actually uses the result variable.
598    // If all assertions are skipped (field not on result type), use `_` to avoid
599    // Rust's "variable never used" warning.
600    // For streaming fixtures: streaming virtual fields count as usable — they resolve
601    // against the `chunks` collected variable rather than the result type.
602    let has_usable_assertion = fixture.assertions.iter().any(|a| {
603        if a.assertion_type == "not_error" || a.assertion_type == "error" {
604            return false;
605        }
606        if a.assertion_type == "method_result" {
607            // method_result assertions that would generate only a TODO comment don't use the
608            // result variable. These are: missing `method` field, or unsupported `check` type.
609            let supported_checks = [
610                "equals",
611                "is_true",
612                "is_false",
613                "greater_than_or_equal",
614                "count_min",
615                "is_error",
616                "contains",
617                "not_empty",
618                "is_empty",
619            ];
620            let check = a.check.as_deref().unwrap_or("is_true");
621            if a.method.is_none() || !supported_checks.contains(&check) {
622                return false;
623            }
624        }
625        match &a.field {
626            Some(f) if !f.is_empty() => {
627                if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
628                    return true;
629                }
630                field_resolver.is_valid_for_result(f)
631            }
632            _ => true,
633        }
634    });
635
636    // For streaming fixtures the stream itself is stored in `stream` and the
637    // collected list in `chunks`.  Non-streaming fixtures use result_var / `_`.
638    let result_binding = if is_streaming {
639        stream_var.to_string()
640    } else if has_usable_assertion {
641        result_var.to_string()
642    } else {
643        "_".to_string()
644    };
645
646    // Detect Option-returning functions: only skip unwrap when ALL assertions are
647    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
648    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
649    let has_field_access = fixture
650        .assertions
651        .iter()
652        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
653    let only_emptiness_checks = !has_field_access
654        && fixture.assertions.iter().all(|a| {
655            matches!(
656                a.assertion_type.as_str(),
657                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
658            )
659        });
660
661    let unwrap_suffix = if returns_result {
662        ".expect(\"should succeed\")"
663    } else {
664        ""
665    };
666    if is_streaming {
667        // Streaming: bind the raw stream, then drain it into a Vec.
668        let _ = writeln!(out, "    let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
669        if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
670            "rust", stream_var, chunks_var,
671        ) {
672            let _ = writeln!(out, "    {collect}");
673        }
674    } else if !returns_result || (only_emptiness_checks && !has_not_error) {
675        // Option-returning or non-Result-returning (and not a not_error check): bind raw value, no unwrap.
676        // When returns_result=true and has_not_error, fall through to emit .expect() so errors panic.
677        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
678    } else if has_not_error || !fixture.assertions.is_empty() {
679        let _ = writeln!(
680            out,
681            "    let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
682        );
683    } else {
684        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
685    }
686
687    // Emit Option field unwrap bindings for any fields accessed in assertions.
688    // Use FieldResolver to handle optional fields, including nested/aliased paths.
689    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
690    // `render_assertion` itself, so the call-site has no single result struct
691    // to unwrap fields off of.
692    let string_assertion_types = [
693        "equals",
694        "contains",
695        "contains_all",
696        "contains_any",
697        "not_contains",
698        "starts_with",
699        "ends_with",
700        "min_length",
701        "max_length",
702        "matches_regex",
703    ];
704    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
705    if !result_is_vec {
706        for assertion in &fixture.assertions {
707            if let Some(f) = &assertion.field {
708                if !f.is_empty()
709                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
710                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
711                {
712                    // Only unwrap optional string fields — numeric optionals (u64, usize)
713                    // don't support .as_deref() and should be compared directly.
714                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
715                    if !is_string_assertion {
716                        continue;
717                    }
718                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
719                        let _ = writeln!(out, "    {binding}");
720                        unwrapped_fields.push((f.clone(), local_var));
721                    }
722                }
723            }
724        }
725    }
726
727    // Render assertions.
728    for assertion in &fixture.assertions {
729        if assertion.assertion_type == "not_error" {
730            // Already handled by .expect() above.
731            continue;
732        }
733        render_assertion(
734            out,
735            assertion,
736            result_var,
737            &module,
738            dep_name,
739            false,
740            &unwrapped_fields,
741            field_resolver,
742            result_is_tree,
743            result_is_simple,
744            result_is_vec,
745            result_is_option,
746            returns_result,
747        );
748    }
749
750    let _ = writeln!(out, "}}");
751    finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
752}
753
754/// Emit mock-server setup (if needed) and the rendered body to the test file output.
755///
756/// The body buffer is scanned for references to `mock_server.` to decide the binding name:
757/// if any non-setup reference exists, the binding is `mock_server`; otherwise it is
758/// `_mock_server` to silence `-D unused_variables` while still keeping the server alive
759/// via Drop. Error-path fixtures typically fall into the latter case — they need the
760/// server bound to a name but never read `mock_server.url`.
761fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
762    if has_mock {
763        let var_name = if body.contains("mock_server.") {
764            "mock_server"
765        } else {
766            "_mock_server"
767        };
768        render_mock_server_setup(out, fixture, e2e_config, var_name);
769    }
770    out.push_str(body);
771}
772
773/// Collect test file names for use in build.zig and similar build scripts.
774pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
775    groups
776        .iter()
777        .filter(|g| !g.fixtures.is_empty())
778        .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
779        .collect()
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn resolve_module_for_call_prefers_crate_name_override() {
788        use crate::config::CallConfig;
789        use std::collections::HashMap;
790        let mut overrides = HashMap::new();
791        overrides.insert(
792            "rust".to_string(),
793            crate::config::CallOverride {
794                crate_name: Some("custom_crate".to_string()),
795                module: Some("ignored_module".to_string()),
796                ..Default::default()
797            },
798        );
799        let call = CallConfig {
800            overrides,
801            ..Default::default()
802        };
803        let result = resolve_module_for_call(&call, "dep_name");
804        assert_eq!(result, "custom_crate");
805    }
806}