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