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