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                // When the call returns a plain type (result_is_simple) or a Tree, the
708                // assertion renderer resolves field-bearing assertions against the result
709                // variable itself (`render_count_equals_assertion` emits `result.len()`,
710                // tree pseudo-fields map to `result.root_node()`, …) rather than treating
711                // the field as a struct member. In that case the assertion always uses the
712                // result variable regardless of whether `f` is a real field on the type, so
713                // the call must bind to `result` and not `_`.
714                if result_is_simple || result_is_tree {
715                    return true;
716                }
717                field_resolver.is_valid_for_result(f)
718            }
719            _ => true,
720        }
721    });
722
723    // For streaming fixtures the stream itself is stored in `stream` and the
724    // collected list in `chunks`.  Non-streaming fixtures use result_var / `_`.
725    let result_binding = if is_streaming {
726        stream_var.to_string()
727    } else if has_usable_assertion {
728        result_var.to_string()
729    } else {
730        "_".to_string()
731    };
732
733    // Detect Option-returning functions: only skip unwrap when ALL assertions are
734    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
735    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
736    let has_field_access = fixture
737        .assertions
738        .iter()
739        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
740    let only_emptiness_checks = !has_field_access
741        && fixture.assertions.iter().all(|a| {
742            matches!(
743                a.assertion_type.as_str(),
744                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
745            )
746        });
747
748    let unwrap_suffix = if returns_result {
749        ".expect(\"should succeed\")"
750    } else {
751        ""
752    };
753    if is_streaming {
754        // Streaming: bind the raw stream, then drain it into a Vec.
755        let _ = writeln!(out, "    let {stream_var} = {call_expr}{await_suffix}{unwrap_suffix};");
756        if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
757            "rust", stream_var, chunks_var,
758        ) {
759            let _ = writeln!(out, "    {collect}");
760        }
761    } else if !returns_result || (only_emptiness_checks && !has_not_error) {
762        // Option-returning or non-Result-returning (and not a not_error check): bind raw value, no unwrap.
763        // When returns_result=true and has_not_error, fall through to emit .expect() so errors panic.
764        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
765    } else if has_not_error || !fixture.assertions.is_empty() {
766        let _ = writeln!(
767            out,
768            "    let {result_binding} = {call_expr}{await_suffix}{unwrap_suffix};"
769        );
770    } else {
771        let _ = writeln!(out, "    let {result_binding} = {call_expr}{await_suffix};");
772    }
773
774    // Emit local bindings for fields_array fields that are referenced in assertions
775    // AND whose names collide with streaming-virtual field names (e.g. `chunks`,
776    // `imports`, `structure`).  Without this, the streaming-virtual-field arm in
777    // `render_assertion` fires unconditionally on those field names — even for
778    // non-streaming fixtures — and emits `assert!(chunks.len() >= 2 as usize, ...)`
779    // referencing an undeclared variable.  Binding `let chunks = &result.chunks;`
780    // here makes the hardcoded `chunks` identifier in that arm resolve correctly.
781    //
782    // Only emit for non-streaming fixtures: streaming fixtures already drain the
783    // stream into a `chunks: Vec<_>` local via the collect snippet.
784    if !is_streaming {
785        let mut emitted_array_bindings: std::collections::HashSet<String> = std::collections::HashSet::new();
786        for assertion in &fixture.assertions {
787            if let Some(f) = &assertion.field {
788                if !f.is_empty()
789                    && field_resolver.is_array(f)
790                    && crate::codegen::streaming_assertions::is_streaming_virtual_field(f)
791                    && !emitted_array_bindings.contains(f.as_str())
792                {
793                    let accessor = field_resolver.accessor(f, "rust", result_var);
794                    let _ = writeln!(out, "    let {f} = &{accessor};");
795                    emitted_array_bindings.insert(f.clone());
796                }
797            }
798        }
799    }
800
801    // Emit Option field unwrap bindings for any fields accessed in assertions.
802    // Use FieldResolver to handle optional fields, including nested/aliased paths.
803    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
804    // `render_assertion` itself, so the call-site has no single result struct
805    // to unwrap fields off of.
806    let string_assertion_types = [
807        "equals",
808        "contains",
809        "contains_all",
810        "contains_any",
811        "not_contains",
812        "starts_with",
813        "ends_with",
814        "min_length",
815        "max_length",
816        "matches_regex",
817    ];
818    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
819    if !result_is_vec {
820        for assertion in &fixture.assertions {
821            if let Some(f) = &assertion.field {
822                if !f.is_empty()
823                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
824                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
825                {
826                    // Only unwrap optional string fields — numeric optionals (u64, usize)
827                    // don't support .as_deref() and should be compared directly.
828                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
829                    if !is_string_assertion {
830                        continue;
831                    }
832                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
833                        let _ = writeln!(out, "    {binding}");
834                        unwrapped_fields.push((f.clone(), local_var));
835                    }
836                }
837            }
838        }
839    }
840
841    // Render assertions.
842    for assertion in &fixture.assertions {
843        if assertion.assertion_type == "not_error" {
844            // Already handled by .expect() above.
845            continue;
846        }
847        render_assertion(
848            out,
849            assertion,
850            result_var,
851            &module,
852            dep_name,
853            false,
854            &unwrapped_fields,
855            field_resolver,
856            result_is_tree,
857            result_is_simple,
858            result_is_vec,
859            result_is_option,
860            returns_result,
861        );
862    }
863
864    let _ = writeln!(out, "}}");
865    finalize_test_body(final_out, fixture, e2e_config, has_mock, &body_buf);
866}
867
868/// Emit mock-server setup (if needed) and the rendered body to the test file output.
869///
870/// The body buffer is scanned for references to `mock_server.` to decide the binding name:
871/// if any non-setup reference exists, the binding is `mock_server`; otherwise it is
872/// `_mock_server` to silence `-D unused_variables` while still keeping the server alive
873/// via Drop. Error-path fixtures typically fall into the latter case — they need the
874/// server bound to a name but never read `mock_server.url`.
875fn finalize_test_body(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, has_mock: bool, body: &str) {
876    if has_mock {
877        let var_name = if body.contains("mock_server.") {
878            "mock_server"
879        } else {
880            "_mock_server"
881        };
882        render_mock_server_setup(out, fixture, e2e_config, var_name);
883    }
884    out.push_str(body);
885}
886
887/// Collect test file names for use in build.zig and similar build scripts.
888pub fn collect_test_filenames(groups: &[FixtureGroup]) -> Vec<String> {
889    groups
890        .iter()
891        .filter(|g| !g.fixtures.is_empty())
892        .map(|g| format!("{}_test.rs", sanitize_filename(&g.category)))
893        .collect()
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    #[test]
901    fn resolve_module_for_call_prefers_crate_name_override() {
902        use crate::config::CallConfig;
903        use std::collections::HashMap;
904        let mut overrides = HashMap::new();
905        overrides.insert(
906            "rust".to_string(),
907            crate::config::CallOverride {
908                crate_name: Some("custom_crate".to_string()),
909                module: Some("ignored_module".to_string()),
910                ..Default::default()
911            },
912        );
913        let call = CallConfig {
914            overrides,
915            ..Default::default()
916        };
917        let result = resolve_module_for_call(&call, "dep_name");
918        assert_eq!(result, "custom_crate");
919    }
920
921    /// Regression test: a non-streaming fixture whose result struct has a `chunks`
922    /// field (registered in `fields_array`) must emit `let chunks = &result.chunks;`
923    /// before any assertion so the streaming-virtual-field arm's hardcoded `chunks`
924    /// identifier resolves.  Without the fix this generated
925    /// `assert!(chunks.len() >= 2 as usize, ...)` with `chunks` undeclared.
926    #[test]
927    fn fields_array_binding_emitted_before_count_min_assertion_for_non_streaming_fixture() {
928        use crate::config::CallConfig;
929        use crate::fixture::{Assertion, Fixture};
930        use std::collections::HashSet;
931
932        let mut fields_array = HashSet::new();
933        fields_array.insert("chunks".to_string());
934
935        let call = CallConfig {
936            function: "process".to_string(),
937            module: "my_crate".to_string(),
938            result_var: "result".to_string(),
939            fields_array,
940            returns_result: true,
941            streaming: Some(false),
942            ..Default::default()
943        };
944
945        let e2e_config = crate::config::E2eConfig {
946            call,
947            ..Default::default()
948        };
949
950        let fixture = Fixture {
951            id: "chunking_test".to_string(),
952            description: "Chunking produces multiple pieces".to_string(),
953            tags: Vec::new(),
954            skip: None,
955            env: None,
956            call: None,
957            input: serde_json::Value::Null,
958            mock_response: None,
959            visitor: None,
960            assertions: vec![Assertion {
961                assertion_type: "count_min".to_string(),
962                field: Some("chunks".to_string()),
963                value: Some(serde_json::Value::Number(serde_json::Number::from(2u64))),
964                values: None,
965                method: None,
966                check: None,
967                args: None,
968                return_type: None,
969            }],
970            source: String::new(),
971            http: None,
972            category: None,
973        };
974
975        let mut out = String::new();
976        render_test_function(&mut out, &fixture, &e2e_config, "my_crate", None);
977
978        assert!(
979            out.contains("let chunks = &result.chunks"),
980            "expected `let chunks = &result.chunks` binding before assertion; got:\n{out}"
981        );
982        assert!(
983            out.contains("chunks.len() >= 2"),
984            "expected count_min assertion referencing `chunks`; got:\n{out}"
985        );
986        // The binding must appear before the assertion in the output.
987        let binding_pos = out.find("let chunks = &result.chunks").unwrap();
988        let assert_pos = out.find("chunks.len() >= 2").unwrap();
989        assert!(
990            binding_pos < assert_pos,
991            "binding must appear before assertion; got:\n{out}"
992        );
993    }
994
995    /// Regression test: a `result_is_simple` call with a `count_equals` assertion whose
996    /// `field` is NOT a real field on the (plain Vec) result type must still bind the
997    /// call to the result variable.  The assertion renderer emits `result.len()` for
998    /// `result_is_simple` calls regardless of the field, so binding to `_` would leave
999    /// `result` undefined.
1000    #[test]
1001    fn result_is_simple_count_assertion_binds_to_result_variable() {
1002        use crate::config::CallConfig;
1003        use crate::fixture::{Assertion, Fixture};
1004
1005        let call = CallConfig {
1006            function: "embed_texts".to_string(),
1007            module: "my_crate".to_string(),
1008            result_var: "result".to_string(),
1009            result_is_simple: true,
1010            returns_result: true,
1011            streaming: Some(false),
1012            ..Default::default()
1013        };
1014
1015        let e2e_config = crate::config::E2eConfig {
1016            call,
1017            ..Default::default()
1018        };
1019
1020        let fixture = Fixture {
1021            id: "embed_empty".to_string(),
1022            description: "embed_texts: empty input".to_string(),
1023            tags: Vec::new(),
1024            skip: None,
1025            env: None,
1026            call: None,
1027            input: serde_json::Value::Null,
1028            mock_response: None,
1029            visitor: None,
1030            assertions: vec![
1031                Assertion {
1032                    assertion_type: "not_error".to_string(),
1033                    field: None,
1034                    value: None,
1035                    values: None,
1036                    method: None,
1037                    check: None,
1038                    args: None,
1039                    return_type: None,
1040                },
1041                Assertion {
1042                    assertion_type: "count_equals".to_string(),
1043                    field: Some("embeddings".to_string()),
1044                    value: Some(serde_json::Value::Number(serde_json::Number::from(0u64))),
1045                    values: None,
1046                    method: None,
1047                    check: None,
1048                    args: None,
1049                    return_type: None,
1050                },
1051            ],
1052            source: String::new(),
1053            http: None,
1054            category: None,
1055        };
1056
1057        let mut out = String::new();
1058        render_test_function(&mut out, &fixture, &e2e_config, "my_crate", None);
1059
1060        assert!(
1061            out.contains("let result = embed_texts"),
1062            "expected the call to bind to `result`, not `_`; got:\n{out}"
1063        );
1064        assert!(
1065            out.contains("assert_eq!(result.len(), 0"),
1066            "expected `count_equals` assertion to render `result.len()`; got:\n{out}"
1067        );
1068        assert!(
1069            !out.contains("let _ = embed_texts"),
1070            "call must not bind to `_` when an assertion references the result; got:\n{out}"
1071        );
1072    }
1073}