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